From f2b6097ded0bbd064fb1ea5a50229994831ecf73 Mon Sep 17 00:00:00 2001 From: Jorge Gallegos Date: Fri, 29 Aug 2025 13:35:53 -0700 Subject: [PATCH 01/10] Initial naive implementation of gcloud-ssh This is just a plain implementation, nothing fancy pure dumb SSH calls per task, which makes this really slow. Going to make this faster with pure SSH first. --- plugins/connection/gcloud.py | 470 +++++++++++++++++++++++++++++++++++ 1 file changed, 470 insertions(+) create mode 100644 plugins/connection/gcloud.py diff --git a/plugins/connection/gcloud.py b/plugins/connection/gcloud.py new file mode 100644 index 00000000..e27a8b8d --- /dev/null +++ b/plugins/connection/gcloud.py @@ -0,0 +1,470 @@ +# Copyright (c) 2025 Red Hat +# GNU General Public License v3.0+ https://www.gnu.org/licenses/gpl-3.0.txt + +from __future__ import absolute_import, annotations + +import os +import os.path as ospath +import pty +import shlex +import shutil +import subprocess +import typing as T + +import ansible.errors as errors +import ansible.module_utils.common.text.converters as converters +import ansible.plugins.connection as connection +import ansible.utils.display as display + +DOCUMENTATION = """ + author: Jorge A Gallegos + short_description: Run tasks via Google Cloud's CLI + description: + - use the `gcloud` CLI command to connect and copy files + - see https://cloud.google.com/sdk/gcloud/reference/compute/ssh and + https://cloud.google.com/sdk/gcloud/reference/compute/scp for details + - this connection plugin relies on `gcloud` to be available in your + PATH and for authentication to be done prior to usage + + options: + instance: + required: true + description: + - The name of the instance to connect to. + type: string + vars: + - name: inventory_hostname + - name: ansible_host + - name: ansible_ssh_host + - name: ansible_gcloud_instance + remote_user: + required: true + description: + - The user to log in as. + type: string + vars: + - name: ansible_user + - name: ansible_ssh_user + - name: ansible_gcloud_user + env: + - name: ANSIBLE_REMOTE_USER + - name: ANSIBLE_GCLOUD_REMOTE_USER + ini: + - section: defaults + key: remote_user + - section: gcloud + key: remote_user + cli: + - name: user + keyword: + - name: remote_user + gcloud_executable: + description: + - Path to the gcloud executable, defaults to whatever is found in + the PATH environment variable + type: string + vars: + - name: ansible_gcloud_executable + ini: + - section: gcloud + key: executable + cli: + - name: gcloud_executable + option: --gcloud-executable + configuration: + description: + - File name of the configuration to use for this command invocation + type: string + vars: + - name: ansible_gcloud_configuration + ini: + - section: gcloud + key: configuration + env: + - name: CLOUDSDK_ACTIVE_CONFIG_NAME + cli: + - name: configuration + option: --gcloud-configuration + project: + required: true + description: + - The Google Cloud project ID to use for this invocation. + If omitted, then the current project is assumed + type: string + vars: + - name: ansible_gcloud_project + ini: + - section: gcloud + key: project + env: + - name: CLOUDSDK_PROJECT_ID + cli: + - name: project + option: --gcloud-project + zone: + required: true + description: + - Configures the zone to use when connecting + type: string + vars: + - name: ansible_gcloud_zone + ini: + - section: gcloud + key: zone + env: + - name: CLOUDSDK_COMPUTE_ZONE + cli: + - name: zone + option: --gcloud-zone + private_key_file: + description: + - The path to the SSH key file. By default, + this is ~/.ssh/google_compute_engine + type: string + vars: + - name: ansible_private_key_file + - name: ansible_ssh_private_key_file + - name: ansible_gcloud_private_key_file + env: + - name: ANSIBLE_PRIVATE_KEY_FILE + - name: ANSIBLE_GCLOUD_PRIVATE_KEY_FILE + ini: + - section: defaults + key: private_key_file + - section: gcloud + key: private_key_file + cli: + - name: private_key_file + option: --gcloud-private-key + default: ~/.ssh/google_compute_engine + use_tty: + description: add -tt to ssh commands to force tty allocation + type: bool + default: true + ini: + - section: ssh_connection + key: usetty + - section: gcloud + key: usetty + env: + - name: ANSIBLE_SSH_USE_TTY + vars: + - name: ansible_ssh_use_tty + - name: ansible_gcloud_use_tty + timeout: + description: + - This is the default amount of time we will wait while establishing + a connection. + - This also controls how long we can wait to access reading the + connection once established. + default: 10 + type: int + env: + - name: ANSIBLE_TIMEOUT + - name: ANSIBLE_SSH_TIMEOUT + - name: ANSIBLE_GCLOUD_TIMEOUT + ini: + - section: defaults + key: timeout + - section: ssh_connection + key: timeout + - section: gcloud + key: timeout + vars: + - name: ansible_ssh_timeout + - name: ansible_gcloud_timeout + cli: + - name: timeout + ssh_args: + description: Arguments to pass to all SSH CLI tools. + type: string + default: '-C -o ControlMaster=auto -o ControlPersist=60s' + ini: + - section: ssh_connection + key: ssh_args + - section: gcloud + key: ssh_args + env: + - name: ANSIBLE_SSH_ARGS + vars: + - name: ansible_ssh_args + ssh_extra_args: + description: + - Extra arguments exclusive to SSH + type: string + vars: + - name: ansible_ssh_extra_args + env: + - name: ANSIBLE_SSH_EXTRA_ARGS + ini: + - section: ssh_connection + key: ssh_extra_args + - section: gcloud + key: ssh_extra_args + cli: + - name: ssh_extra_args + scp_extra_args: + description: Extra exclusive to SCP + type: string + vars: + - name: ansible_scp_extra_args + env: + - name: ANSIBLE_SCP_EXTRA_ARGS + ini: + - section: ssh_connection + key: scp_extra_args + - section: gcloud + key: scp_extra_args + cli: + - name: scp_extra_args +""" + +D = display.Display() +DEFAULT_TIMEOUT: int = 10 +DEFAULT_GCLOUD: str | None = shutil.which("gcloud") + + +class Connection(connection.ConnectionBase): + """Connections via `gcloud compute ssh`""" + + gcloud_executable: str | None = None + + has_pipelining = False + transport = "gcloud-ssh" # type: ignore[override] + + def __init__(self, *args: T.Any, **kwargs: T.Any) -> None: + + super(Connection, self).__init__(*args, **kwargs) + + exec: str | None = self.get_option("gcloud_executable") + if exec is None: + self.gcloud_executable = DEFAULT_GCLOUD + else: + self.gcloud_executable = exec + + if self.gcloud_executable is None: + raise errors.AnsiblePluginError( + "Plugin Error: no gcloud binary found in $PATH and " + "no executable defined in ansible config" + ) + + def _connect(self) -> Connection: + """connect to the instance using gcloud compute ssh""" + + return self + + def close(self) -> None: + """mark connection as closed""" + + self._connected = False + + def _build_flags_for(self, what: str) -> T.List[str]: + flags: T.List[str] = [] + args: str = self.ssh_args or "" + + args += f" -o ConnectTimeout={self.timeout} " + + if what == "ssh": + args += self.ssh_extra_args or "" + elif what == "scp": + args += self.scp_extra_args or "" + + pieces: T.List[str] = shlex.split(args.strip()) + flag: str = "" + for piece in pieces: + if flag == "" and piece.startswith("-"): + # start of a flag + flag = piece + continue + elif flag != "" and piece.startswith("-"): + # we encountered a flag after another flag + # append the previous flag first and continue + flags.append(f"--{what}-flag={flag}") + flag = piece + continue + elif not piece.startswith("-"): + # this is the argument to the ongoing flag + # concatenate and append + flag += " " + piece + flags.append(f"--{what}-flag={flag}") + flag = "" + else: + pass + + # if there are any remnant flags, add them + if flag != "": + flags.append(f"--{what}-flag={flag}") + + D.vvvvv(f"flags: {flags}", host=self.host) + + return flags + + def _build_command( + self, + what: str, + cmd: str, + in_path: str | None = None, # only used for scp + out_path: str | None = None, # only used for scp + ) -> T.List[str]: + parts: T.List[str] + + parts = [ + self.gcloud_executable or "", # to silence pyright + "compute", + what, + f"--project={self.gcp_project}", + f"--zone={self.gcp_zone}", + f"--ssh-key-file={self.private_key_file}", + "--quiet", # no prompts + "--no-user-output-enabled", # no extra gcloud output + "--tunnel-through-iap", + ] + if self.gcp_configuration is not None: + parts.append(f"--configuration={self.gcp_configuration}") + + parts.extend(self._build_flags_for(what)) + + # handle options for ssh only + if what == "ssh": + if self.use_tty: + parts.append("--ssh-flag=-tt") + parts.extend([f"{self.user}@{self.host}", "--", cmd]) + + elif what == "scp": + parts.append("--compress") + if cmd == "put": + parts.append(str(in_path)) + parts.append(f"{self.user}@{self.host}:{out_path}") + elif cmd == "fetch": + parts.append(f"{self.user}@{self.host}:{in_path}") + parts.append(str(out_path)) + + return parts + + def _run( + self, + cmd: list[str], + in_data: bytes | None, + sudoable: bool = True, + checkrc: bool = True, + ) -> tuple[int, bytes, bytes]: + + D.vvv(f"EXEC: {shlex.join(cmd)}", host=self.host) + + D.vvvvv("running command with Popen()", host=self.host) + # I could just not open a pty and be done with it, because I am + # not writing the whole pipelining and keyboard interaction just now. + # It may come at a later date so I am just laying the groundwork + p: subprocess.Popen[bytes] | None = None + master_fd: int = 0 + slave_fd: int = 0 + if in_data is None: # attempt to open a pty + try: + master_fd, slave_fd = pty.openpty() + p = subprocess.Popen( + cmd, + stdin=slave_fd, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + ) + # we don't need the input half to be open + os.close(slave_fd) + D.vvvvv("created pty subprocess", host=self.host) + except OSError: + D.vvvvv("failed to create pty", host=self.host) + + if p is None: # fallback to non-pty + try: + p = subprocess.Popen( + cmd, + stdin=subprocess.PIPE, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + ) + D.vvvvv("created non-pty subprocess", host=self.host) + except OSError as ose: + raise errors.AnsibleError( + "Unable to execute ssh command on controller." + ) from ose + D.vvvvv("done running command with Popen()", host=self.host) + + D.vvvvv("getting output with communicate()", host=self.host) + if p is not None: + # output: t.Tuple[t.Optional[bytes], t.Optional[bytes]] = p.communicate() + stdout, stderr = p.communicate() + D.vvvvv("done getting output with communicate()", host=self.host) + + # close the last half of the pty if created + if master_fd != 0: + os.close(master_fd) + + D.debug(f"stdout: >>>>>{stdout}<<<<<", host=self.host) + D.debug(f"stderr: >>>>>{stderr}<<<<<", host=self.host) + return (p.returncode, stdout, stderr) + + def exec_command( + self, cmd: str, in_data: bytes | None = None, sudoable: bool = True + ) -> tuple[int, bytes, bytes]: + """run a command on the remote instance""" + + super(Connection, self).exec_command(cmd, in_data=in_data, sudoable=sudoable) + + self.host: str | None = self.get_option("instance") + self.user: str | None = self.get_option("remote_user") + self.ssh_args: str | None = self.get_option("ssh_args") + self.ssh_extra_args: str | None = self.get_option("ssh_extra_args") + self.scp_extra_args: str | None = self.get_option("scp_extra_args") + self.private_key_file: str | None = self.get_option("private_key_file") + self.use_tty: bool | None = self.get_option("use_tty") + if self.private_key_file is not None: # to silence pyright + self.private_key_file = ospath.abspath( + ospath.expanduser(self.private_key_file) + ) + self.gcp_configuration: str | None = self.get_option("configuration") + self.gcp_project: str | None = self.get_option("project") + self.gcp_zone: str | None = self.get_option("zone") + self.timeout: int = int(self.get_option("timeout") or DEFAULT_TIMEOUT) + + display.Display().vvv( + f"GCLOUD SSH CONNECTION FOR USER: {self.user}", host=self.host + ) + + full_cmd: T.List[str] = self._build_command("ssh", cmd) + + return self._run(cmd=full_cmd, in_data=in_data, sudoable=sudoable) + + def put_file(self, in_path: str, out_path: str) -> tuple[int, bytes, bytes]: # type: ignore[override] + """uploads a file to the cloud instance""" + + super(Connection, self).put_file(in_path, out_path) + + self.host: str | None = self.get_option("instance") + self.user: str | None = self.get_option("remote_user") + self.ssh_args: str | None = self.get_option("ssh_args") + self.scp_extra_args: str | None = self.get_option("scp_extra_args") + self.private_key_file: str | None = self.get_option("private_key_file") + if self.private_key_file is not None: # to silence pyright + self.private_key_file = ospath.abspath( + ospath.expanduser(self.private_key_file) + ) + self.gcp_configuration: str | None = self.get_option("configuration") + self.gcp_project: str | None = self.get_option("project") + self.gcp_zone: str | None = self.get_option("zone") + self.timeout: int = int(self.get_option("timeout") or DEFAULT_TIMEOUT) + + D.vvv(f"PUT: {in_path} TO {out_path}", host=self.host) + if not ospath.exists( + converters.to_bytes(in_path, errors="surrogate_or_strict") + ): + raise errors.AnsibleFileNotFound( + f"File or module does not exist: {converters.to_native(in_path)}" + ) + + full_cmd: T.List[str] = self._build_command("scp", "put", in_path, out_path) + + return self._run(full_cmd, None) + + def fetch_file(self, in_path: str, out_path: str) -> None: + """downloads a file from the cloud instance""" + + super(Connection, self).fetch_file(in_path, out_path) From 361da2921fd91cf2d54998777cdd43922ff29a46 Mon Sep 17 00:00:00 2001 From: Jorge Gallegos Date: Tue, 2 Sep 2025 11:39:34 -0700 Subject: [PATCH 02/10] The Union syntax for type hints is py3.10+ whereas the typing module Optional is supported since py3.2 --- plugins/connection/gcloud.py | 54 ++++++++++++++++++------------------ 1 file changed, 27 insertions(+), 27 deletions(-) diff --git a/plugins/connection/gcloud.py b/plugins/connection/gcloud.py index e27a8b8d..4101254b 100644 --- a/plugins/connection/gcloud.py +++ b/plugins/connection/gcloud.py @@ -1,4 +1,4 @@ -# Copyright (c) 2025 Red Hat +# Copyright (c) 202/ Red Hat # GNU General Public License v3.0+ https://www.gnu.org/licenses/gpl-3.0.txt from __future__ import absolute_import, annotations @@ -221,13 +221,13 @@ DOCUMENTATION = """ D = display.Display() DEFAULT_TIMEOUT: int = 10 -DEFAULT_GCLOUD: str | None = shutil.which("gcloud") +DEFAULT_GCLOUD: T.Optional[str] = shutil.which("gcloud") class Connection(connection.ConnectionBase): """Connections via `gcloud compute ssh`""" - gcloud_executable: str | None = None + gcloud_executable: T.Optional[str] = None has_pipelining = False transport = "gcloud-ssh" # type: ignore[override] @@ -236,7 +236,7 @@ class Connection(connection.ConnectionBase): super(Connection, self).__init__(*args, **kwargs) - exec: str | None = self.get_option("gcloud_executable") + exec: T.Optional[str] = self.get_option("gcloud_executable") if exec is None: self.gcloud_executable = DEFAULT_GCLOUD else: @@ -303,8 +303,8 @@ class Connection(connection.ConnectionBase): self, what: str, cmd: str, - in_path: str | None = None, # only used for scp - out_path: str | None = None, # only used for scp + in_path: T.Optional[str] = None, # only used for scp + out_path: T.Optional[str] = None, # only used for scp ) -> T.List[str]: parts: T.List[str] @@ -344,7 +344,7 @@ class Connection(connection.ConnectionBase): def _run( self, cmd: list[str], - in_data: bytes | None, + in_data: T.Optional[bytes], sudoable: bool = True, checkrc: bool = True, ) -> tuple[int, bytes, bytes]: @@ -355,7 +355,7 @@ class Connection(connection.ConnectionBase): # I could just not open a pty and be done with it, because I am # not writing the whole pipelining and keyboard interaction just now. # It may come at a later date so I am just laying the groundwork - p: subprocess.Popen[bytes] | None = None + p: T.Optional[subprocess.Popen[bytes]] = None master_fd: int = 0 slave_fd: int = 0 if in_data is None: # attempt to open a pty @@ -403,26 +403,26 @@ class Connection(connection.ConnectionBase): return (p.returncode, stdout, stderr) def exec_command( - self, cmd: str, in_data: bytes | None = None, sudoable: bool = True + self, cmd: str, in_data: T.Optional[bytes] = None, sudoable: bool = True ) -> tuple[int, bytes, bytes]: """run a command on the remote instance""" super(Connection, self).exec_command(cmd, in_data=in_data, sudoable=sudoable) - self.host: str | None = self.get_option("instance") - self.user: str | None = self.get_option("remote_user") - self.ssh_args: str | None = self.get_option("ssh_args") - self.ssh_extra_args: str | None = self.get_option("ssh_extra_args") - self.scp_extra_args: str | None = self.get_option("scp_extra_args") - self.private_key_file: str | None = self.get_option("private_key_file") - self.use_tty: bool | None = self.get_option("use_tty") + self.host: T.Optional[str] = self.get_option("instance") + self.user: T.Optional[str] = self.get_option("remote_user") + self.ssh_args: T.Optional[str] = self.get_option("ssh_args") + self.ssh_extra_args: T.Optional[str] = self.get_option("ssh_extra_args") + self.scp_extra_args: T.Optional[str] = self.get_option("scp_extra_args") + self.private_key_file: T.Optional[str] = self.get_option("private_key_file") + self.use_tty: bool = bool(self.get_option("use_tty")) if self.private_key_file is not None: # to silence pyright self.private_key_file = ospath.abspath( ospath.expanduser(self.private_key_file) ) - self.gcp_configuration: str | None = self.get_option("configuration") - self.gcp_project: str | None = self.get_option("project") - self.gcp_zone: str | None = self.get_option("zone") + self.gcp_configuration: T.Optional[str] = self.get_option("configuration") + self.gcp_project: T.Optional[str] = self.get_option("project") + self.gcp_zone: T.Optional[str] = self.get_option("zone") self.timeout: int = int(self.get_option("timeout") or DEFAULT_TIMEOUT) display.Display().vvv( @@ -438,18 +438,18 @@ class Connection(connection.ConnectionBase): super(Connection, self).put_file(in_path, out_path) - self.host: str | None = self.get_option("instance") - self.user: str | None = self.get_option("remote_user") - self.ssh_args: str | None = self.get_option("ssh_args") - self.scp_extra_args: str | None = self.get_option("scp_extra_args") - self.private_key_file: str | None = self.get_option("private_key_file") + self.host: T.Optional[str] = self.get_option("instance") + self.user: T.Optional[str] = self.get_option("remote_user") + self.ssh_args: T.Optional[str] = self.get_option("ssh_args") + self.scp_extra_args: T.Optional[str] = self.get_option("scp_extra_args") + self.private_key_file: T.Optional[str] = self.get_option("private_key_file") if self.private_key_file is not None: # to silence pyright self.private_key_file = ospath.abspath( ospath.expanduser(self.private_key_file) ) - self.gcp_configuration: str | None = self.get_option("configuration") - self.gcp_project: str | None = self.get_option("project") - self.gcp_zone: str | None = self.get_option("zone") + self.gcp_configuration: T.Optional[str] = self.get_option("configuration") + self.gcp_project: T.Optional[str] = self.get_option("project") + self.gcp_zone: T.Optional[str] = self.get_option("zone") self.timeout: int = int(self.get_option("timeout") or DEFAULT_TIMEOUT) D.vvv(f"PUT: {in_path} TO {out_path}", host=self.host) From 5889c6e667468d4772b039c964e6022b470b4362 Mon Sep 17 00:00:00 2001 From: Jorge Gallegos Date: Tue, 2 Sep 2025 12:00:14 -0700 Subject: [PATCH 03/10] Forgot the fetch file method --- plugins/connection/gcloud.py | 23 ++++++++++++++++------- 1 file changed, 16 insertions(+), 7 deletions(-) diff --git a/plugins/connection/gcloud.py b/plugins/connection/gcloud.py index 4101254b..4614af63 100644 --- a/plugins/connection/gcloud.py +++ b/plugins/connection/gcloud.py @@ -335,7 +335,7 @@ class Connection(connection.ConnectionBase): if cmd == "put": parts.append(str(in_path)) parts.append(f"{self.user}@{self.host}:{out_path}") - elif cmd == "fetch": + elif cmd == "get": parts.append(f"{self.user}@{self.host}:{in_path}") parts.append(str(out_path)) @@ -433,10 +433,10 @@ class Connection(connection.ConnectionBase): return self._run(cmd=full_cmd, in_data=in_data, sudoable=sudoable) - def put_file(self, in_path: str, out_path: str) -> tuple[int, bytes, bytes]: # type: ignore[override] - """uploads a file to the cloud instance""" - - super(Connection, self).put_file(in_path, out_path) + def _transfer_file( + self, in_path: str, out_path: str, cmd: str + ) -> tuple[int, bytes, bytes]: + """Common reads for both put and fetch""" self.host: T.Optional[str] = self.get_option("instance") self.user: T.Optional[str] = self.get_option("remote_user") @@ -452,7 +452,7 @@ class Connection(connection.ConnectionBase): self.gcp_zone: T.Optional[str] = self.get_option("zone") self.timeout: int = int(self.get_option("timeout") or DEFAULT_TIMEOUT) - D.vvv(f"PUT: {in_path} TO {out_path}", host=self.host) + D.vvv(f"{cmd.upper()}: {in_path} TO {out_path}", host=self.host) if not ospath.exists( converters.to_bytes(in_path, errors="surrogate_or_strict") ): @@ -460,11 +460,20 @@ class Connection(connection.ConnectionBase): f"File or module does not exist: {converters.to_native(in_path)}" ) - full_cmd: T.List[str] = self._build_command("scp", "put", in_path, out_path) + full_cmd: T.List[str] = self._build_command("scp", cmd, in_path, out_path) return self._run(full_cmd, None) + def put_file(self, in_path: str, out_path: str) -> tuple[int, bytes, bytes]: # type: ignore[override] + """uploads a file to the cloud instance""" + + super(Connection, self).put_file(in_path, out_path) + + return self._transfer_file(in_path, out_path, "put") + def fetch_file(self, in_path: str, out_path: str) -> None: """downloads a file from the cloud instance""" super(Connection, self).fetch_file(in_path, out_path) + + return self._transfer_file(in_path, out_path, "get") From d6e4860ebaf42dbf3efecaffdc6654292005de1c Mon Sep 17 00:00:00 2001 From: Jorge Gallegos Date: Tue, 2 Sep 2025 17:32:36 -0700 Subject: [PATCH 04/10] Almost a complete rewrite to piggyback on ssh.py This decreased the size of the plugin considerably, and I don't have to account for every little corner case in the upstream ssh.py plugin. --- plugins/connection/gcloud.py | 479 ----------------------------------- plugins/connection/iap.py | 360 ++++++++++++++++++++++++++ 2 files changed, 360 insertions(+), 479 deletions(-) delete mode 100644 plugins/connection/gcloud.py create mode 100644 plugins/connection/iap.py diff --git a/plugins/connection/gcloud.py b/plugins/connection/gcloud.py deleted file mode 100644 index 4614af63..00000000 --- a/plugins/connection/gcloud.py +++ /dev/null @@ -1,479 +0,0 @@ -# Copyright (c) 202/ Red Hat -# GNU General Public License v3.0+ https://www.gnu.org/licenses/gpl-3.0.txt - -from __future__ import absolute_import, annotations - -import os -import os.path as ospath -import pty -import shlex -import shutil -import subprocess -import typing as T - -import ansible.errors as errors -import ansible.module_utils.common.text.converters as converters -import ansible.plugins.connection as connection -import ansible.utils.display as display - -DOCUMENTATION = """ - author: Jorge A Gallegos - short_description: Run tasks via Google Cloud's CLI - description: - - use the `gcloud` CLI command to connect and copy files - - see https://cloud.google.com/sdk/gcloud/reference/compute/ssh and - https://cloud.google.com/sdk/gcloud/reference/compute/scp for details - - this connection plugin relies on `gcloud` to be available in your - PATH and for authentication to be done prior to usage - - options: - instance: - required: true - description: - - The name of the instance to connect to. - type: string - vars: - - name: inventory_hostname - - name: ansible_host - - name: ansible_ssh_host - - name: ansible_gcloud_instance - remote_user: - required: true - description: - - The user to log in as. - type: string - vars: - - name: ansible_user - - name: ansible_ssh_user - - name: ansible_gcloud_user - env: - - name: ANSIBLE_REMOTE_USER - - name: ANSIBLE_GCLOUD_REMOTE_USER - ini: - - section: defaults - key: remote_user - - section: gcloud - key: remote_user - cli: - - name: user - keyword: - - name: remote_user - gcloud_executable: - description: - - Path to the gcloud executable, defaults to whatever is found in - the PATH environment variable - type: string - vars: - - name: ansible_gcloud_executable - ini: - - section: gcloud - key: executable - cli: - - name: gcloud_executable - option: --gcloud-executable - configuration: - description: - - File name of the configuration to use for this command invocation - type: string - vars: - - name: ansible_gcloud_configuration - ini: - - section: gcloud - key: configuration - env: - - name: CLOUDSDK_ACTIVE_CONFIG_NAME - cli: - - name: configuration - option: --gcloud-configuration - project: - required: true - description: - - The Google Cloud project ID to use for this invocation. - If omitted, then the current project is assumed - type: string - vars: - - name: ansible_gcloud_project - ini: - - section: gcloud - key: project - env: - - name: CLOUDSDK_PROJECT_ID - cli: - - name: project - option: --gcloud-project - zone: - required: true - description: - - Configures the zone to use when connecting - type: string - vars: - - name: ansible_gcloud_zone - ini: - - section: gcloud - key: zone - env: - - name: CLOUDSDK_COMPUTE_ZONE - cli: - - name: zone - option: --gcloud-zone - private_key_file: - description: - - The path to the SSH key file. By default, - this is ~/.ssh/google_compute_engine - type: string - vars: - - name: ansible_private_key_file - - name: ansible_ssh_private_key_file - - name: ansible_gcloud_private_key_file - env: - - name: ANSIBLE_PRIVATE_KEY_FILE - - name: ANSIBLE_GCLOUD_PRIVATE_KEY_FILE - ini: - - section: defaults - key: private_key_file - - section: gcloud - key: private_key_file - cli: - - name: private_key_file - option: --gcloud-private-key - default: ~/.ssh/google_compute_engine - use_tty: - description: add -tt to ssh commands to force tty allocation - type: bool - default: true - ini: - - section: ssh_connection - key: usetty - - section: gcloud - key: usetty - env: - - name: ANSIBLE_SSH_USE_TTY - vars: - - name: ansible_ssh_use_tty - - name: ansible_gcloud_use_tty - timeout: - description: - - This is the default amount of time we will wait while establishing - a connection. - - This also controls how long we can wait to access reading the - connection once established. - default: 10 - type: int - env: - - name: ANSIBLE_TIMEOUT - - name: ANSIBLE_SSH_TIMEOUT - - name: ANSIBLE_GCLOUD_TIMEOUT - ini: - - section: defaults - key: timeout - - section: ssh_connection - key: timeout - - section: gcloud - key: timeout - vars: - - name: ansible_ssh_timeout - - name: ansible_gcloud_timeout - cli: - - name: timeout - ssh_args: - description: Arguments to pass to all SSH CLI tools. - type: string - default: '-C -o ControlMaster=auto -o ControlPersist=60s' - ini: - - section: ssh_connection - key: ssh_args - - section: gcloud - key: ssh_args - env: - - name: ANSIBLE_SSH_ARGS - vars: - - name: ansible_ssh_args - ssh_extra_args: - description: - - Extra arguments exclusive to SSH - type: string - vars: - - name: ansible_ssh_extra_args - env: - - name: ANSIBLE_SSH_EXTRA_ARGS - ini: - - section: ssh_connection - key: ssh_extra_args - - section: gcloud - key: ssh_extra_args - cli: - - name: ssh_extra_args - scp_extra_args: - description: Extra exclusive to SCP - type: string - vars: - - name: ansible_scp_extra_args - env: - - name: ANSIBLE_SCP_EXTRA_ARGS - ini: - - section: ssh_connection - key: scp_extra_args - - section: gcloud - key: scp_extra_args - cli: - - name: scp_extra_args -""" - -D = display.Display() -DEFAULT_TIMEOUT: int = 10 -DEFAULT_GCLOUD: T.Optional[str] = shutil.which("gcloud") - - -class Connection(connection.ConnectionBase): - """Connections via `gcloud compute ssh`""" - - gcloud_executable: T.Optional[str] = None - - has_pipelining = False - transport = "gcloud-ssh" # type: ignore[override] - - def __init__(self, *args: T.Any, **kwargs: T.Any) -> None: - - super(Connection, self).__init__(*args, **kwargs) - - exec: T.Optional[str] = self.get_option("gcloud_executable") - if exec is None: - self.gcloud_executable = DEFAULT_GCLOUD - else: - self.gcloud_executable = exec - - if self.gcloud_executable is None: - raise errors.AnsiblePluginError( - "Plugin Error: no gcloud binary found in $PATH and " - "no executable defined in ansible config" - ) - - def _connect(self) -> Connection: - """connect to the instance using gcloud compute ssh""" - - return self - - def close(self) -> None: - """mark connection as closed""" - - self._connected = False - - def _build_flags_for(self, what: str) -> T.List[str]: - flags: T.List[str] = [] - args: str = self.ssh_args or "" - - args += f" -o ConnectTimeout={self.timeout} " - - if what == "ssh": - args += self.ssh_extra_args or "" - elif what == "scp": - args += self.scp_extra_args or "" - - pieces: T.List[str] = shlex.split(args.strip()) - flag: str = "" - for piece in pieces: - if flag == "" and piece.startswith("-"): - # start of a flag - flag = piece - continue - elif flag != "" and piece.startswith("-"): - # we encountered a flag after another flag - # append the previous flag first and continue - flags.append(f"--{what}-flag={flag}") - flag = piece - continue - elif not piece.startswith("-"): - # this is the argument to the ongoing flag - # concatenate and append - flag += " " + piece - flags.append(f"--{what}-flag={flag}") - flag = "" - else: - pass - - # if there are any remnant flags, add them - if flag != "": - flags.append(f"--{what}-flag={flag}") - - D.vvvvv(f"flags: {flags}", host=self.host) - - return flags - - def _build_command( - self, - what: str, - cmd: str, - in_path: T.Optional[str] = None, # only used for scp - out_path: T.Optional[str] = None, # only used for scp - ) -> T.List[str]: - parts: T.List[str] - - parts = [ - self.gcloud_executable or "", # to silence pyright - "compute", - what, - f"--project={self.gcp_project}", - f"--zone={self.gcp_zone}", - f"--ssh-key-file={self.private_key_file}", - "--quiet", # no prompts - "--no-user-output-enabled", # no extra gcloud output - "--tunnel-through-iap", - ] - if self.gcp_configuration is not None: - parts.append(f"--configuration={self.gcp_configuration}") - - parts.extend(self._build_flags_for(what)) - - # handle options for ssh only - if what == "ssh": - if self.use_tty: - parts.append("--ssh-flag=-tt") - parts.extend([f"{self.user}@{self.host}", "--", cmd]) - - elif what == "scp": - parts.append("--compress") - if cmd == "put": - parts.append(str(in_path)) - parts.append(f"{self.user}@{self.host}:{out_path}") - elif cmd == "get": - parts.append(f"{self.user}@{self.host}:{in_path}") - parts.append(str(out_path)) - - return parts - - def _run( - self, - cmd: list[str], - in_data: T.Optional[bytes], - sudoable: bool = True, - checkrc: bool = True, - ) -> tuple[int, bytes, bytes]: - - D.vvv(f"EXEC: {shlex.join(cmd)}", host=self.host) - - D.vvvvv("running command with Popen()", host=self.host) - # I could just not open a pty and be done with it, because I am - # not writing the whole pipelining and keyboard interaction just now. - # It may come at a later date so I am just laying the groundwork - p: T.Optional[subprocess.Popen[bytes]] = None - master_fd: int = 0 - slave_fd: int = 0 - if in_data is None: # attempt to open a pty - try: - master_fd, slave_fd = pty.openpty() - p = subprocess.Popen( - cmd, - stdin=slave_fd, - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - ) - # we don't need the input half to be open - os.close(slave_fd) - D.vvvvv("created pty subprocess", host=self.host) - except OSError: - D.vvvvv("failed to create pty", host=self.host) - - if p is None: # fallback to non-pty - try: - p = subprocess.Popen( - cmd, - stdin=subprocess.PIPE, - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - ) - D.vvvvv("created non-pty subprocess", host=self.host) - except OSError as ose: - raise errors.AnsibleError( - "Unable to execute ssh command on controller." - ) from ose - D.vvvvv("done running command with Popen()", host=self.host) - - D.vvvvv("getting output with communicate()", host=self.host) - if p is not None: - # output: t.Tuple[t.Optional[bytes], t.Optional[bytes]] = p.communicate() - stdout, stderr = p.communicate() - D.vvvvv("done getting output with communicate()", host=self.host) - - # close the last half of the pty if created - if master_fd != 0: - os.close(master_fd) - - D.debug(f"stdout: >>>>>{stdout}<<<<<", host=self.host) - D.debug(f"stderr: >>>>>{stderr}<<<<<", host=self.host) - return (p.returncode, stdout, stderr) - - def exec_command( - self, cmd: str, in_data: T.Optional[bytes] = None, sudoable: bool = True - ) -> tuple[int, bytes, bytes]: - """run a command on the remote instance""" - - super(Connection, self).exec_command(cmd, in_data=in_data, sudoable=sudoable) - - self.host: T.Optional[str] = self.get_option("instance") - self.user: T.Optional[str] = self.get_option("remote_user") - self.ssh_args: T.Optional[str] = self.get_option("ssh_args") - self.ssh_extra_args: T.Optional[str] = self.get_option("ssh_extra_args") - self.scp_extra_args: T.Optional[str] = self.get_option("scp_extra_args") - self.private_key_file: T.Optional[str] = self.get_option("private_key_file") - self.use_tty: bool = bool(self.get_option("use_tty")) - if self.private_key_file is not None: # to silence pyright - self.private_key_file = ospath.abspath( - ospath.expanduser(self.private_key_file) - ) - self.gcp_configuration: T.Optional[str] = self.get_option("configuration") - self.gcp_project: T.Optional[str] = self.get_option("project") - self.gcp_zone: T.Optional[str] = self.get_option("zone") - self.timeout: int = int(self.get_option("timeout") or DEFAULT_TIMEOUT) - - display.Display().vvv( - f"GCLOUD SSH CONNECTION FOR USER: {self.user}", host=self.host - ) - - full_cmd: T.List[str] = self._build_command("ssh", cmd) - - return self._run(cmd=full_cmd, in_data=in_data, sudoable=sudoable) - - def _transfer_file( - self, in_path: str, out_path: str, cmd: str - ) -> tuple[int, bytes, bytes]: - """Common reads for both put and fetch""" - - self.host: T.Optional[str] = self.get_option("instance") - self.user: T.Optional[str] = self.get_option("remote_user") - self.ssh_args: T.Optional[str] = self.get_option("ssh_args") - self.scp_extra_args: T.Optional[str] = self.get_option("scp_extra_args") - self.private_key_file: T.Optional[str] = self.get_option("private_key_file") - if self.private_key_file is not None: # to silence pyright - self.private_key_file = ospath.abspath( - ospath.expanduser(self.private_key_file) - ) - self.gcp_configuration: T.Optional[str] = self.get_option("configuration") - self.gcp_project: T.Optional[str] = self.get_option("project") - self.gcp_zone: T.Optional[str] = self.get_option("zone") - self.timeout: int = int(self.get_option("timeout") or DEFAULT_TIMEOUT) - - D.vvv(f"{cmd.upper()}: {in_path} TO {out_path}", host=self.host) - if not ospath.exists( - converters.to_bytes(in_path, errors="surrogate_or_strict") - ): - raise errors.AnsibleFileNotFound( - f"File or module does not exist: {converters.to_native(in_path)}" - ) - - full_cmd: T.List[str] = self._build_command("scp", cmd, in_path, out_path) - - return self._run(full_cmd, None) - - def put_file(self, in_path: str, out_path: str) -> tuple[int, bytes, bytes]: # type: ignore[override] - """uploads a file to the cloud instance""" - - super(Connection, self).put_file(in_path, out_path) - - return self._transfer_file(in_path, out_path, "put") - - def fetch_file(self, in_path: str, out_path: str) -> None: - """downloads a file from the cloud instance""" - - super(Connection, self).fetch_file(in_path, out_path) - - return self._transfer_file(in_path, out_path, "get") diff --git a/plugins/connection/iap.py b/plugins/connection/iap.py new file mode 100644 index 00000000..8d66fdb1 --- /dev/null +++ b/plugins/connection/iap.py @@ -0,0 +1,360 @@ +# Copyright (c) 2025 Red Hat +# GNU General Public License v3.0+ https://www.gnu.org/licenses/gpl-3.0.txt + +from __future__ import absolute_import, annotations + +import os +import os.path as ospath +import re +import pty +import shlex +import select +import shutil +import subprocess +import threading +import time +import yaml +import tempfile +import typing as T + +import ansible.plugins.connection.ssh as sshconn +import ansible.errors as errors +import ansible.utils.display as display + +_my_opts = { + "gcloud_executable": { + "description": [ + "Path to the gcloud executable, defaults to whatever is found in", + "the PATH environment variable", + ], + "type": "string", + "vars": [{"name": "ansible_gcloud_executable"}], + "ini": [ + {"section": "gcloud", "key": "executable"}, + ], + }, + "gcloud_configuration": { + "description": ["Path to the gcloud configuration file if non default"], + "type": "string", + "vars": [{"name": "ansible_gcloud_configuration"}], + "ini": [{"section": "gcloud", "key": "configuration"}], + "env": [{"name": "CLOUDSDK_ACTIVE_CONFIG_NAME"}], + }, + "gcloud_project": { + "description": [ + "The Google Cloud project ID to use for this invocation.", + "If omitted, then the current project is assumed", + ], + "type": "string", + "vars": [{"name": "ansible_gcloud_project"}], + "ini": [{"section": "gcloud", "key": "project"}], + "env": [{"name": "CLOUDSDK_CORE_PROJECT"}], + }, + "gcloud_account": { + "description": ["Google cloud account to use for invocation"], + "type": "string", + "vars": [{"name": "ansible_gcloud_account"}], + "ini": [{"section": "gcloud", "key": "account"}], + "env": [{"name": "CLOUDSDK_CORE_ACCOUNT"}], + }, + "gcloud_zone": { + "description": ["The Google Cloud zone to use for the instance(s)"], + "type": "string", + "vars": [{"name": "ansible_gcloud_zone"}], + "ini": [{"section": "gcloud", "key": "zone"}], + "env": [{"name": "CLOUDSDK_COMPUTE_ZONE"}], + }, + "gcloud_access_token_file": { + "description": [ + "A file to read the access token from. ", + "The credentials of the active account (if exists) will be ignored.", + ], + "type": "string", + "vars": [{"name": "ansible_access_token_file"}], + "ini": [{"section": "gcloud", "key": "access_token_file"}], + "env": [{"name": "CLOUDSDK_AUTH_ACCESS_TOKEN_FILE"}], + }, +} + +# piggy back on top of upstream SSH plugin's docs, this somehow breaks ansible-doc but +# it still works for other ansible commands. This is expensive but I don't want to +# duplicate the entire doc string from ssh.py +_doc = yaml.safe_load(sshconn.DOCUMENTATION) +_doc["name"] = "gcloud-iap" +_doc["short_description"] = "connect using Google Cloud's Identity Aware Proxy (IAP)" +_doc["description"] = [ + "This connection plugin behaves almost like the stock SSH plugin, ", + "but it creates a new IAP process per host in the inventory so connections ", + "are tunneled through it. ", + "This plugin requires you to have set authentication prior to using it.", +] +_doc["author"] = "Jorge A Gallegos (@kad)" +# Add custom opts for this plugin +_doc["options"].update(_my_opts) +# Change default to stock SSH key used by gcloud +_doc["options"]["private_key_file"]["default"] = "~/.ssh/google_compute_engine" + +DOCUMENTATION = yaml.dump(_doc) + +D = display.Display() +DEFAULT_GCLOUD: T.Optional[str] = shutil.which("gcloud") +DEFAULT_SSH_PORT: int = 22 +PORT_REGEX = re.compile(r"\d+") + + +class IAP: + host: str + local_port: int + remote_port: int + master_fd: int + up: bool = False + process: T.Optional[subprocess.Popen] = None + thread: T.Optional[threading.Thread] = None + ready: threading.Event = threading.Event() + output: T.List[str] = [] + + def __init__( + self, + gcloud_bin: str, + host: str, + remote_port: int, + project: T.Optional[str], + account: T.Optional[str], + zone: T.Optional[str], + config: T.Optional[str] = None, + token_file: T.Optional[str] = None, + ) -> None: + + self.host = host + self.remote_port = remote_port + cmd: T.List[str] = [ + gcloud_bin, + "compute", + "start-iap-tunnel", + host, + str(self.remote_port), + ] + if config is not None: + cmd.extend( + [ + "--configuration", + shlex.quote(ospath.realpath(ospath.expanduser(config.strip()))), + ] + ) + + if project is not None: + cmd.extend( + [ + "--project", + shlex.quote(project.strip()), + ] + ) + + if account is not None: + cmd.extend( + [ + "--account", + shlex.quote(account.strip()), + ] + ) + + if zone is not None: + cmd.extend( + [ + "--zone", + shlex.quote(zone.strip()), + ] + ) + + if token_file is not None: + cmd.extend( + [ + "--access-token-file", + shlex.quote(token_file.strip()), + ] + ) + + D.vvv(f"IAP: CMD {' '.join(cmd)}", host=self.host) + + try: + # start-iap-tunnel prints 2 lines: + # - Picking local unused port [$PORT]. + # - Testing if tunnel connection works. + # and only when the terminal is a pty, a 3rd line: + # - Listening on port [$PORT]. + # The last line only displayed after the tunnel has been tested, + # that's why we use a PTY for the subprocess + self.master_fd, slave_fd = pty.openpty() + self.process = subprocess.Popen( + cmd, stdout=slave_fd, stderr=slave_fd, text=True, close_fds=True + ) + os.close(slave_fd) + self.thread = threading.Thread(target=self._monitor, daemon=True) + self.thread.start() + D.vvvvv("started IAP thread", host=self.host) + except Exception as e: + self.process = None + raise Exception from e + + def _monitor(self) -> None: + """Monitor the thread handling the IAP subprocess until it is 'up'""" + + while self.process is not None and self.process.poll() is None: + rlist, _, _ = select.select([self.master_fd], [], [], 0.1) + if rlist is not None: + try: + output = os.read(self.master_fd, 1024).decode("utf-8") + if output: + for line in output.splitlines(): + self.output.append(line) + if line.startswith("Listening on port"): + m = PORT_REGEX.search(line) + if m is not None: + self.local_port = int(m.group()) + self.up = True + D.vvv( + f"IAP: LOCAL PORT {self.local_port}", + host=self.host, + ) + except OSError: # pty is closed + break + + if self.up: # no need to monitor if already up + break + + if not self.ready.is_set(): + self.ready.set() + + os.close(self.master_fd) + + def terminate(self) -> None: + """Gracefully terminate the IAP subprocess""" + + D.vvv("IAP: STOPPING TUNNEL", host=self.host) + if self.process is not None and self.process.poll() is None: + try: + self.process.terminate() + self.process.wait(timeout=5) # wait up to 5 seconds to terminate IAP + except subprocess.TimeoutExpired: + self.process.kill() + + D.vvvvv("terminated/killed IAP", host=self.host) + + if self.thread is not None and self.thread.is_alive(): + self.thread.join(timeout=1) # joining thread back should be quick + + +class Connection(sshconn.Connection): + """ + This is pretty much the same as the upstream ssh plugin, just overloads + the connection handling to start/stop the IAP tunnel with gcloud as appropriate + """ + + iaps: dict[str, IAP] = {} + lock: threading.Lock = threading.Lock() + + gcloud_executable: T.Optional[str] = None + ssh_config: str + + transport = "gcloud-iap" # type: ignore[override] + + def __init__(self, *args: T.Any, **kwargs: T.Any) -> None: + + super(Connection, self).__init__(*args, **kwargs) + + # If the gcloud binary isn't found/configured, bail out immediately + exec: T.Optional[str] = self.get_option("gcloud_executable") + if exec is None: + self.gcloud_executable = DEFAULT_GCLOUD + else: + self.gcloud_executable = exec + + if self.gcloud_executable is None: + raise errors.AnsiblePluginError( + "Plugin Error: no gcloud binary found in $PATH and " + "no executable defined in ansible config" + ) + + # have to trick SSH to connect to localhost instead of the instances + fd, self.ssh_config = tempfile.mkstemp( + suffix="ssh_config", prefix="ansible_gcloud", text=True + ) + with open(fd, "w") as fp: + fp.write("Host *\n") + fp.write(" HostName localhost\n") + + def _connect(self) -> Connection: + """Upstream ssh is empty, overload with the stuff starting the IAP tunnel""" + + host: T.Optional[str] = self.get_option("host") + project: T.Optional[str] = self.get_option("gcloud_project") + account: T.Optional[str] = self.get_option("gcloud_account") + zone: T.Optional[str] = self.get_option("gcloud_zone") + token_file: T.Optional[str] = self.get_option("gcloud_access_token_file") + config: T.Optional[str] = self.get_option("gcloud_configuration") + port: T.Optional[int] = self.get_option("port") + + # this shouldn't happen, but still. + if host is None: + raise errors.AnsibleAssertionError("No host defined") + + with self.lock: + if host not in self.iaps: + self.iaps[host] = IAP( + str(self.gcloud_executable), + host=host, + remote_port=int(port or DEFAULT_SSH_PORT), + project=project, + zone=zone, + account=account, + config=config, + token_file=token_file, + ) + + success = self.iaps[host].ready.wait(timeout=5) + is_up: bool = False + for _ in range(3): + is_up = self.iaps[host].up + if success and is_up: + D.vvv("IAP: TUNNEL IS UP", host=host) + is_up = True + break + else: + time.sleep(0.5) + + if not is_up: + D.vvv("IAP: TUNNEL FAILURE", host=host) + for line in self.iaps[host].output: + D.vvvvv(line, host=host) + raise errors.AnsibleRuntimeError("Failure when starting IAP tunnel") + + # override port with the random IAP port + self.set_option("port", self.iaps[host].local_port) + # disable host_key_checking because it's impossible to know ports beforehand + self.set_option("host_key_checking", False) + # prepend our generated tiny ssh config to all ssh_args if not already present + if self.ssh_config not in str(self.get_option("ssh_args")): + self.set_option( + "ssh_args", f"-F {self.ssh_config} " + str(self.get_option("ssh_args")) + ) + + self._connected = True + + return self + + def close(self) -> None: + """ + Upstream only marks the connection as closed, we have to terminate + all IAP tunnels as well + """ + + # Terminate IAP + with self.lock: + for iap in self.iaps.values(): + iap.terminate() + self.iaps.clear() + + # remove ssh config + os.unlink(self.ssh_config) + + self._connected = False From 8b68a484e8eb09cf269c9ea7a6131941712dd8bc Mon Sep 17 00:00:00 2001 From: Jorge Gallegos Date: Wed, 3 Sep 2025 22:36:58 -0700 Subject: [PATCH 05/10] Adding README for plugin --- plugins/connection/README.md | 73 ++++++++++++++++++++++++++++++++++++ 1 file changed, 73 insertions(+) create mode 100644 plugins/connection/README.md diff --git a/plugins/connection/README.md b/plugins/connection/README.md new file mode 100644 index 00000000..ca828b8e --- /dev/null +++ b/plugins/connection/README.md @@ -0,0 +1,73 @@ +# Identity Aware Proxy Connection Plugin + +This plugin uses the gcloud cli [start-iap-tunnel](https://cloud.google.com/sdk/gcloud/reference/compute/start-iap-tunnel) +method to prepare TCP forwarding to your compute instances, and then uses the +builtin ansible SSH connection plugin to communicate ansible commands to the +target nodes. + +This makes it possible to start using ansible without the need to expose your +instances to the open web, or configure stringent firewall rules to ensure no +bad actors can potentially login to your infrastructure. + +## Requisites + +1. The [gcloud cli tool](https://cloud.google.com/sdk/gcloud?authuser=0) installed +2. Firewall rules in places for [IAP TCP Forwarding](https://cloud.google.com/iap/docs/using-tcp-forwarding) + +## Configuring the connection plugin + +The connection plugin can be configured by setting some values in the +`[gcloud]` section of your ansible.cfg, here's an example: + +```ini +[gcloud] +account = my-service-account@my-project.iam.gserviceaccount.com +project = my-project +region = us-central1 +zone = us-central1-a +``` + +With the above, you can now connect to all your instances in a single +`us-central1-a` zone via IAP. + +You can also couple this with the GCP dynamic inventory like so: + +```yaml +plugin: google.cloud.gcp_compute +zones: + - us-central1-a + - us-central1-b + - us-central1-c + - us-central1-f +projects: + - my-project +service_account_file: /path/to/my/service-account.json +auth_kind: serviceaccount +scopes: + - 'https://www.googleapis.com/auth/cloud-platform' + - 'https://www.googleapis.com/auth/compute.readonly' + +# Create groups from labels e.g. +keyed_groups: + - prefix: gcp + key: labels.gcp_role + +# inventory_hostname needs to be the actual name of the instance +hostnames: + - name + +# fetch zone dynamically to feed IAP plugin +compose: + ansible_gcloud_zone: zone + +# maybe add some filters +filters: + - 'status = RUNNING' + - 'labels.my-special-label:some-value' +``` + +with the above, you don't need to statically set the zone, they will be +populated accordingly. + +The rest of the connection behavior can be configured just like the builtin SSH +ansible plugin, e.g. remote user, etc. From fff8805a55fed09c1d33ea444ee545901681161f Mon Sep 17 00:00:00 2001 From: Jorge Gallegos Date: Thu, 4 Sep 2025 14:29:08 -0700 Subject: [PATCH 06/10] Set documentation the "right" way --- plugins/connection/iap.py | 588 ++++++++++++++++++++++++++++++++------ 1 file changed, 500 insertions(+), 88 deletions(-) diff --git a/plugins/connection/iap.py b/plugins/connection/iap.py index 8d66fdb1..37069867 100644 --- a/plugins/connection/iap.py +++ b/plugins/connection/iap.py @@ -1,7 +1,490 @@ # Copyright (c) 2025 Red Hat # GNU General Public License v3.0+ https://www.gnu.org/licenses/gpl-3.0.txt -from __future__ import absolute_import, annotations +# I had to duplicate (almost?) all of the documentation found in the +# ansible.plugins.connection.ssh plugin, due to how ansible-doc and ansible-test sanity +# work, they look at the lexical structure of the code. I had initially done: +# 1. load ssh.py DOCUMENTATION string into a yaml object +# 2. modify the yaml object to change defaults / add my options +# 3. set this plugin's DOCUMENTATION to the yaml.dump() of the modified object +# but that doesn't work with how the AST evaluation is done. +# here are the changes from upstream: +# 1. Changed default private_key_file default to ~/.ssh/google_compute_engine +# 2. Make host_key_checking default to False +# 3. Added known_hosts_file option pointing to ~/.ssh/google_compute_known_hosts +DOCUMENTATION = """ + name: gcloud-iap + short_description: connect via SSH through Google Cloud's Identity Aware Proxy (IAP) + description: + - This connection plugin behaves almost like the stock SSH plugin, but it creates + a new IAP process per host in the inventory so connections are tunneled through + it. + author: Jorge A Gallegos (jgallego@redhat.com) + notes: + - This plugin requires you to have configured gcloud authentication prior to using + it. You can change the active configuration used, but the plugin won't auth + for you. + - This plugin is mostly a wrapper to the ``ssh`` CLI utility and the exact behavior + of the options depends on this tool. This means that the documentation provided + here is subject to be overridden by the CLI tool itself. + - Many options default to V(None) here but that only means we do not override the + SSH tool's defaults and/or configuration. For example, if you specify the port + in this plugin it will override any C(Port) entry in your C(.ssh/config). + - The ssh CLI tool uses return code 255 as a 'connection error', this can conflict + with commands/tools that also return 255 as an error code and will look like an + 'unreachable' condition or 'connection error' to this plugin. + extends_documentation_fragment: + - connection_pipelining + options: + gcloud_executable: + description: + - Path to the gcloud executable, defaults to whatever is found in the PATH + environment variable. + type: string + vars: + - name: ansible_gcloud_executable + ini: + - section: gcloud + key: executable + gcloud_configuration: + description: + - If set, points to non-standard gcloud configuration. + type: string + vars: + - name: ansible_gcloud_configuration + ini: + - section: gcloud + key: configuration + env: + - name: CLOUDSDK_ACTIVE_CONFIG_NAME + gcloud_project: + description: + - The Google Cloud project ID to use for this invocation. + - If omitted, then the current active project is assumed. + type: string + vars: + - name: ansible_gcloud_project + ini: + - section: gcloud + key: project + env: + - name: CLOUDSDK_CORE_PROJECT + gcloud_account: + description: + - Google cloud account to use for invocation. + type: string + vars: + - name: ansible_gcloud_account + ini: + - section: gcloud + key: account + env: + - name: CLOUDSDK_CORE_ACCOUNT + gcloud_zone: + description: + - The Google Cloud zone to use for the instance(s). + type: string + vars: + - name: ansible_gcloud_zone + ini: + - section: gcloud + key: zone + env: + - name: CLOUDSDK_COMPUTE_ZONE + gcloud_access_token_file: + description: + - A file to read the access token from. + - The credentials of the active account (if exists) will be ignored. + type: string + vars: + - name: ansible_access_token_file + ini: + - section: gcloud + key: access_token_file + env: + - name: CLOUDSDK_AUTH_ACCESS_TOKEN_FILE + host: + description: Google Cloud instance name to connect to. + default: inventory_hostname + type: string + vars: + - name: inventory_hostname + - name: ansible_host + - name: ansible_gcloud_host + known_hosts_file: + description: Path to the UserKnownHosts file storing SSH fingerprints. Defaults + to the same file used by `gcloud compute ssh` + type: string + default: ~/.ssh/google_compute_known_hosts + ini: + - section: ssh_connection + key: known_hosts_file + vars: + - name: ansible_known_hosts_file + - name: ansible_ssh_known_hosts_file + host_key_checking: + description: Determines if SSH should reject or not a connection after checking + host keys. + default: false + type: boolean + ini: + - section: defaults + key: host_key_checking + - section: ssh_connection + key: host_key_checking + env: + - name: ANSIBLE_HOST_KEY_CHECKING + - name: ANSIBLE_SSH_HOST_KEY_CHECKING + vars: + - name: ansible_host_key_checking + - name: ansible_ssh_host_key_checking + password: + description: + - Authentication password for the O(remote_user). Can be supplied as CLI option. + type: string + vars: + - name: ansible_password + - name: ansible_ssh_pass + - name: ansible_ssh_password + password_mechanism: + description: Mechanism to use for handling ssh password prompt + type: string + default: ssh_askpass + choices: + - ssh_askpass + - sshpass + - disable + env: + - name: ANSIBLE_SSH_PASSWORD_MECHANISM + ini: + - section: ssh_connection + key: password_mechanism + vars: + - name: ansible_ssh_password_mechanism + sshpass_prompt: + description: + - Password prompt that C(sshpass)/C(SSH_ASKPASS) should search for. + - Supported by sshpass 1.06 and up when O(password_mechanism) set to V(sshpass). + - Defaults to C(Enter PIN for) when pkcs11_provider is set. + - Defaults to C(assword) when O(password_mechanism) set to V(ssh_askpass). + default: '' + type: string + ini: + - section: ssh_connection + key: 'sshpass_prompt' + env: + - name: ANSIBLE_SSHPASS_PROMPT + vars: + - name: ansible_sshpass_prompt + ssh_args: + description: arguments to pass to all ssh cli tools. + type: string + default: '-C -o controlmaster=auto -o controlpersist=60s' + ini: + - section: ssh_connection + key: ssh_args + env: + - name: ansible_ssh_args + vars: + - name: ansible_ssh_args + ssh_common_args: + description: common extra args for all ssh cli tools. + type: string + default: '' + ini: + - section: ssh_connection + key: ssh_common_args + env: + - name: ANSIBLE_SSH_COMMON_ARGS + vars: + - name: ansible_ssh_common_args + cli: + - name: ssh_common_args + ssh_executable: + description: + - This defines the location of the SSH binary. It defaults to V(ssh) which will + use the first SSH binary available in $PATH. + - This option is usually not required, it might be useful when access to system + SSH is restricted, or when using SSH wrappers to connect to remote hosts. + type: string + default: ssh + ini: + - key: ssh_executable + section: ssh_connection + env: + - name: ANSIBLE_SSH_EXECUTABLE + vars: + - name: ansible_ssh_executable + sftp_executable: + description: + - This defines the location of the sftp binary. It defaults to V(sftp) which + will use the first binary available in $PATH. + type: string + default: sftp + ini: + - key: sftp_executable + section: ssh_connection + env: + - name: ANSIBLE_SFTP_EXECUTABLE + vars: + - name: ansible_sftp_executable + scp_executable: + description: + - This defines the location of the scp binary. It defaults to V(scp) which will + use the first binary available in $PATH. + type: string + default: scp + ini: + - section: ssh_connection + key: scp_executable + env: + - name: ANSIBLE_SCP_EXECUTABLE + vars: + - name: ansible_scp_executable + scp_extra_args: + description: Extra exclusive to the C(scp) CLI + type: string + default: '' + ini: + - section: ssh_connection + key: scp_extra_args + env: + - name: ANSIBLE_SCP_EXTRA_ARGS + vars: + - name: ansible_scp_extra_args + cli: + - name: scp_extra_args + sftp_extra_args: + description: Extra exclusive to the C(sftp) CLI + type: string + default: '' + ini: + - section: ssh_connection + key: sftp_extra_args + vars: + - name: ansible_sftp_extra_args + env: + - name: ANSIBLE_SFTP_EXTRA_ARGS + cli: + - name: sftp_extra_args + ssh_extra_args: + description: Extra exclusive to the SSH CLI. + type: string + default: '' + vars: + - name: ansible_ssh_extra_args + env: + - name: ANSIBLE_SSH_EXTRA_ARGS + ini: + - section: ssh_connection + key: ssh_extra_args + cli: + - name: ssh_extra_args + reconnection_retries: + description: + - Number of attempts to connect. + - Ansible retries connections only if it gets an SSH error with a return code + of 255. + - Any errors with return codes other than 255 indicate an issue with program + execution. + type: integer + default: 0 + env: + - name: ANSIBLE_SSH_RETRIES + ini: + - section: connection + key: retries + - section: ssh_connection + key: retries + vars: + - name: ansible_ssh_retries + port: + description: Remote port to connect to. + type: int + ini: + - section: defaults + key: remote_port + env: + - name: ANSIBLE_REMOTE_PORT + vars: + - name: ansible_port + - name: ansible_ssh_port + keyword: + - name: port + remote_user: + description: + - User name with which to login to the remote server, normally set by the + remote_user keyword. + - If no user is supplied, Ansible will let the SSH client binary choose the + user as it normally. + type: string + ini: + - section: defaults + key: remote_user + env: + - name: ANSIBLE_REMOTE_USER + vars: + - name: ansible_user + - name: ansible_ssh_user + cli: + - name: user + keyword: + - name: remote_user + private_key_file: + description: + - Path to private key file to use for authentication. + type: string + default: '~/.ssh/google_compute_engine' + ini: + - section: defaults + key: private_key_file + - section: gcloud + key: private_key_file + env: + - name: ANSIBLE_PRIVATE_KEY_FILE + vars: + - name: ansible_private_key_file + - name: ansible_ssh_private_key_file + - name: ansible_gcloud_private_key_file + cli: + - name: private_key_file + option: '--private-key' + private_key: + description: + - Private key contents in PEM format. Requires the C(SSH_AGENT) configuration + to be enabled. + type: string + env: + - name: ANSIBLE_PRIVATE_KEY + vars: + - name: ansible_private_key + - name: ansible_ssh_private_key + private_key_passphrase: + description: + - Private key passphrase, dependent on O(private_key). + - This does NOT have any effect when used with O(private_key_file). + type: string + env: + - name: ANSIBLE_PRIVATE_KEY_PASSPHRASE + vars: + - name: ansible_private_key_passphrase + - name: ansible_ssh_private_key_passphrase + control_path: + description: + - This is the location to save SSH's ControlPath sockets, it uses SSH's variable + substitution. + - Be aware that this setting is ignored if C(-o ControlPath) is set in ssh args. + type: string + env: + - name: ANSIBLE_SSH_CONTROL_PATH + ini: + - section: ssh_connection + key: control_path + vars: + - name: ansible_control_path + control_path_dir: + default: ~/.ansible/cp + description: + - This sets the directory to use for ssh control path if the control path + setting is null. + - Also, provides the ``%(directory)s`` variable for the control path setting. + type: string + env: + - name: ANSIBLE_SSH_CONTROL_PATH_DIR + ini: + - section: ssh_connection + key: control_path_dir + vars: + - name: ansible_control_path_dir + sftp_batch_mode: + description: + - When set to C(True), sftp will be run in batch mode, allowing detection of + transfer errors. + - When set to C(False), sftp will not be run in batch mode, preventing detection + of transfer errors. + type: bool + default: true + env: + - name: ANSIBLE_SFTP_BATCH_MODE + ini: + - section: ssh_connection + key: sftp_batch_mode + vars: + - name: ansible_sftp_batch_mode + ssh_transfer_method: + description: Preferred method to use when transferring files over ssh + type: string + default: smart + choices: + sftp: This is the most reliable way to copy things with SSH. + scp: Deprecated in OpenSSH. For OpenSSH >=9.0 you must add an additional option + to enable scp C(scp_extra_args="-O"). + piped: Creates an SSH pipe with C(dd) on either side to copy the data. + smart: Tries each method in order (sftp > scp > piped), until one succeeds or + they all fail. + env: + - name: ANSIBLE_SSH_TRANSFER_METHOD + ini: + - section: ssh_connection + key: transfer_method + vars: + - name: ansible_ssh_transfer_method + use_tty: + description: add -tt to ssh commands to force tty allocation. + type: bool + default: true + env: + - name: ANSIBLE_SSH_USETTY + ini: + - section: ssh_connection + key: usetty + vars: + - name: ansible_ssh_use_tty + timeout: + description: + - This is the default amount of time we will wait while establishing an SSH + connection. + - It also controls how long we can wait to access reading the connection once + established (select on the socket). + type: integer + default: 10 + env: + - name: ANSIBLE_TIMEOUT + - name: ANSIBLE_SSH_TIMEOUT + ini: + - section: defaults + key: timeout + - section: ssh_connection + key: timeout + vars: + - name: ansible_ssh_timeout + cli: + - name: timeout + pkcs11_provider: + description: + - PKCS11 SmartCard provider such as opensc, e.g. /usr/local/lib/opensc-pkcs11.so + type: string + default: '' + env: + - name: ANSIBLE_PKCS11_PROVIDER + ini: + - section: ssh_connection + key: pkcs11_provider + vars: + - name: ansible_ssh_pkcs11_provider + verbosity: + description: + - Requested verbosity level for the SSH CLI. + default: 0 + type: int + env: + - name: ANSIBLE_SSH_VERBOSITY + ini: + - section: ssh_connection + key: verbosity + vars: + - name: ansible_ssh_verbosity +""" import os import os.path as ospath @@ -13,7 +496,6 @@ import shutil import subprocess import threading import time -import yaml import tempfile import typing as T @@ -21,81 +503,6 @@ import ansible.plugins.connection.ssh as sshconn import ansible.errors as errors import ansible.utils.display as display -_my_opts = { - "gcloud_executable": { - "description": [ - "Path to the gcloud executable, defaults to whatever is found in", - "the PATH environment variable", - ], - "type": "string", - "vars": [{"name": "ansible_gcloud_executable"}], - "ini": [ - {"section": "gcloud", "key": "executable"}, - ], - }, - "gcloud_configuration": { - "description": ["Path to the gcloud configuration file if non default"], - "type": "string", - "vars": [{"name": "ansible_gcloud_configuration"}], - "ini": [{"section": "gcloud", "key": "configuration"}], - "env": [{"name": "CLOUDSDK_ACTIVE_CONFIG_NAME"}], - }, - "gcloud_project": { - "description": [ - "The Google Cloud project ID to use for this invocation.", - "If omitted, then the current project is assumed", - ], - "type": "string", - "vars": [{"name": "ansible_gcloud_project"}], - "ini": [{"section": "gcloud", "key": "project"}], - "env": [{"name": "CLOUDSDK_CORE_PROJECT"}], - }, - "gcloud_account": { - "description": ["Google cloud account to use for invocation"], - "type": "string", - "vars": [{"name": "ansible_gcloud_account"}], - "ini": [{"section": "gcloud", "key": "account"}], - "env": [{"name": "CLOUDSDK_CORE_ACCOUNT"}], - }, - "gcloud_zone": { - "description": ["The Google Cloud zone to use for the instance(s)"], - "type": "string", - "vars": [{"name": "ansible_gcloud_zone"}], - "ini": [{"section": "gcloud", "key": "zone"}], - "env": [{"name": "CLOUDSDK_COMPUTE_ZONE"}], - }, - "gcloud_access_token_file": { - "description": [ - "A file to read the access token from. ", - "The credentials of the active account (if exists) will be ignored.", - ], - "type": "string", - "vars": [{"name": "ansible_access_token_file"}], - "ini": [{"section": "gcloud", "key": "access_token_file"}], - "env": [{"name": "CLOUDSDK_AUTH_ACCESS_TOKEN_FILE"}], - }, -} - -# piggy back on top of upstream SSH plugin's docs, this somehow breaks ansible-doc but -# it still works for other ansible commands. This is expensive but I don't want to -# duplicate the entire doc string from ssh.py -_doc = yaml.safe_load(sshconn.DOCUMENTATION) -_doc["name"] = "gcloud-iap" -_doc["short_description"] = "connect using Google Cloud's Identity Aware Proxy (IAP)" -_doc["description"] = [ - "This connection plugin behaves almost like the stock SSH plugin, ", - "but it creates a new IAP process per host in the inventory so connections ", - "are tunneled through it. ", - "This plugin requires you to have set authentication prior to using it.", -] -_doc["author"] = "Jorge A Gallegos (@kad)" -# Add custom opts for this plugin -_doc["options"].update(_my_opts) -# Change default to stock SSH key used by gcloud -_doc["options"]["private_key_file"]["default"] = "~/.ssh/google_compute_engine" - -DOCUMENTATION = yaml.dump(_doc) - D = display.Display() DEFAULT_GCLOUD: T.Optional[str] = shutil.which("gcloud") DEFAULT_SSH_PORT: int = 22 @@ -275,14 +682,6 @@ class Connection(sshconn.Connection): "no executable defined in ansible config" ) - # have to trick SSH to connect to localhost instead of the instances - fd, self.ssh_config = tempfile.mkstemp( - suffix="ssh_config", prefix="ansible_gcloud", text=True - ) - with open(fd, "w") as fp: - fp.write("Host *\n") - fp.write(" HostName localhost\n") - def _connect(self) -> Connection: """Upstream ssh is empty, overload with the stuff starting the IAP tunnel""" @@ -330,9 +729,22 @@ class Connection(sshconn.Connection): # override port with the random IAP port self.set_option("port", self.iaps[host].local_port) - # disable host_key_checking because it's impossible to know ports beforehand - self.set_option("host_key_checking", False) - # prepend our generated tiny ssh config to all ssh_args if not already present + + # read path to the supplied known hosts file + ukhf: str = ospath.abspath( + ospath.expanduser(str(self.get_option("known_hosts_file"))) + ) + # have to trick SSH to connect to localhost instead of the instances + fd, self.ssh_config = tempfile.mkstemp( + suffix="ssh_config", prefix="ansible_gcloud", text=True + ) + with open(fd, "w") as fp: + fp.write("Host *\n") + fp.write(" HostName localhost\n") # trick + fp.write(" HostKeyAlias {}\n".format(host)) # avoid multiple entries + fp.write(" UserKnownHostsFile {}\n".format(ukhf)) # as defined in opts + + # prepend our generated ssh config to all ssh_args if not already present if self.ssh_config not in str(self.get_option("ssh_args")): self.set_option( "ssh_args", f"-F {self.ssh_config} " + str(self.get_option("ssh_args")) From 0296c92c00d51ae97627b0cfae23d7eec9c366be Mon Sep 17 00:00:00 2001 From: Jorge Gallegos Date: Thu, 4 Sep 2025 18:28:04 -0700 Subject: [PATCH 07/10] Fix sanity errors --- plugins/connection/iap.py | 21 +++++++++++++-------- 1 file changed, 13 insertions(+), 8 deletions(-) diff --git a/plugins/connection/iap.py b/plugins/connection/iap.py index 37069867..89293a5b 100644 --- a/plugins/connection/iap.py +++ b/plugins/connection/iap.py @@ -1,6 +1,8 @@ # Copyright (c) 2025 Red Hat # GNU General Public License v3.0+ https://www.gnu.org/licenses/gpl-3.0.txt +from __future__ import annotations + # I had to duplicate (almost?) all of the documentation found in the # ansible.plugins.connection.ssh plugin, due to how ansible-doc and ansible-test sanity # work, they look at the lexical structure of the code. I had initially done: @@ -13,13 +15,14 @@ # 2. Make host_key_checking default to False # 3. Added known_hosts_file option pointing to ~/.ssh/google_compute_known_hosts DOCUMENTATION = """ - name: gcloud-iap + name: iap short_description: connect via SSH through Google Cloud's Identity Aware Proxy (IAP) description: - This connection plugin behaves almost like the stock SSH plugin, but it creates a new IAP process per host in the inventory so connections are tunneled through it. - author: Jorge A Gallegos (jgallego@redhat.com) + author: + - Jorge A Gallegos (@thekad) notes: - This plugin requires you to have configured gcloud authentication prior to using it. You can change the active configuration used, but the plugin won't auth @@ -487,7 +490,6 @@ DOCUMENTATION = """ """ import os -import os.path as ospath import re import pty import shlex @@ -498,10 +500,11 @@ import threading import time import tempfile import typing as T +from os import path as ospath -import ansible.plugins.connection.ssh as sshconn -import ansible.errors as errors -import ansible.utils.display as display +from ansible.plugins.connection import ssh as sshconn +from ansible import errors +from ansible.utils import display D = display.Display() DEFAULT_GCLOUD: T.Optional[str] = shutil.which("gcloud") @@ -607,6 +610,7 @@ class IAP: """Monitor the thread handling the IAP subprocess until it is 'up'""" while self.process is not None and self.process.poll() is None: + # pylint: disable=disallowed-name rlist, _, _ = select.select([self.master_fd], [], [], 0.1) if rlist is not None: try: @@ -692,6 +696,7 @@ class Connection(sshconn.Connection): token_file: T.Optional[str] = self.get_option("gcloud_access_token_file") config: T.Optional[str] = self.get_option("gcloud_configuration") port: T.Optional[int] = self.get_option("port") + timeout: T.Optional[int] = self.get_option("timeout") # this shouldn't happen, but still. if host is None: @@ -710,9 +715,9 @@ class Connection(sshconn.Connection): token_file=token_file, ) - success = self.iaps[host].ready.wait(timeout=5) + success = self.iaps[host].ready.wait(timeout=timeout) is_up: bool = False - for _ in range(3): + for _ in range(3): # pylint: disable=disallowed-name is_up = self.iaps[host].up if success and is_up: D.vvv("IAP: TUNNEL IS UP", host=host) From 5745bdaac2d36f380c77aab492ac5f73754e98b2 Mon Sep 17 00:00:00 2001 From: Jorge Gallegos Date: Thu, 4 Sep 2025 19:28:23 -0700 Subject: [PATCH 08/10] Integration test for new IAP connection plugin 1. creates instances with a custom ssh keypair 2. change the connection plugin method and perform basic checks 3. cleanup --- .../targets/connection_plugin/aliases | 1 + .../connection_plugin/playbooks/setup.yml | 66 +++++++++++++++++++ .../connection_plugin/playbooks/teardown.yml | 34 ++++++++++ .../connection_plugin/playbooks/test.yml | 25 +++++++ .../targets/connection_plugin/runme.sh | 25 +++++++ .../templates/inventory.yml.j2 | 31 +++++++++ .../connection_plugin/test.gcp_compute.yml | 1 + .../targets/connection_plugin/vars.yml | 32 +++++++++ 8 files changed, 215 insertions(+) create mode 100644 tests/integration/targets/connection_plugin/aliases create mode 100644 tests/integration/targets/connection_plugin/playbooks/setup.yml create mode 100644 tests/integration/targets/connection_plugin/playbooks/teardown.yml create mode 100644 tests/integration/targets/connection_plugin/playbooks/test.yml create mode 100755 tests/integration/targets/connection_plugin/runme.sh create mode 100644 tests/integration/targets/connection_plugin/templates/inventory.yml.j2 create mode 100644 tests/integration/targets/connection_plugin/test.gcp_compute.yml create mode 100644 tests/integration/targets/connection_plugin/vars.yml diff --git a/tests/integration/targets/connection_plugin/aliases b/tests/integration/targets/connection_plugin/aliases new file mode 100644 index 00000000..26507c23 --- /dev/null +++ b/tests/integration/targets/connection_plugin/aliases @@ -0,0 +1 @@ +cloud/gcp diff --git a/tests/integration/targets/connection_plugin/playbooks/setup.yml b/tests/integration/targets/connection_plugin/playbooks/setup.yml new file mode 100644 index 00000000..128feaeb --- /dev/null +++ b/tests/integration/targets/connection_plugin/playbooks/setup.yml @@ -0,0 +1,66 @@ +--- +- name: Setup test suite + hosts: localhost + connection: local + gather_facts: false + vars_files: + - ../vars.yml + environment: + GCP_SERVICE_ACCOUNT_FILE: "{{ gcp_cred_file }}" + GCP_AUTH_KIND: "{{ gcp_cred_kind }}" + GCP_PROJECT: "{{ gcp_project }}" + tasks: + - name: SETUP | Create SSH key pair + community.crypto.openssh_keypair: + path: "{{ ansible_ssh_private_key_file }}" + type: ed25519 + register: _keypair + + - name: SETUP | Create network + google.cloud.gcp_compute_network: + name: "{{ prefix }}" + auto_create_subnetworks: true + state: present + register: _network + + - name: SETUP | Allow SSH through IAP + google.cloud.gcp_compute_firewall: + name: all-iap + state: present + source_ranges: + - 35.235.240.0/20 + allowed: + - ip_protocol: tcp + ports: + - 22 + network: "{{ _network }}" + + - name: SETUP | Create instances + google.cloud.gcp_compute_instance: + name: "{{ prefix }}-{{ item.name }}" + machine_type: "{{ gcp_machine_type }}" + disks: + - auto_delete: true + boot: true + initialize_params: + source_image: "{{ gcp_disk_image }}" + disk_type: pd-standard + network_interfaces: + - network: "{{ _network }}" + metadata: + ssh-keys: "{{ ansible_ssh_user }}:{{ _keypair.public_key }}" + labels: "{{ item.labels | default({}) }}" + hostname: "{{ item.hostname | default(omit) }}" + zone: "{{ gcp_zone }}" + state: present + loop: "{{ sut }}" + + - name: SETUP | Render dynamic inventory file + ansible.builtin.copy: + dest: ../test.gcp_compute.yml + content: "{{ lookup('template', '../templates/inventory.yml.j2') }}" + mode: preserve + + - name: SETUP | Give time for instances to be up + ansible.builtin.pause: + seconds: 30 diff --git a/tests/integration/targets/connection_plugin/playbooks/teardown.yml b/tests/integration/targets/connection_plugin/playbooks/teardown.yml new file mode 100644 index 00000000..9c598267 --- /dev/null +++ b/tests/integration/targets/connection_plugin/playbooks/teardown.yml @@ -0,0 +1,34 @@ +--- +- name: Teardown test suite + hosts: localhost + connection: local + gather_facts: false + vars_files: + - ../vars.yml + environment: + GCP_SERVICE_ACCOUNT_FILE: "{{ gcp_cred_file }}" + GCP_AUTH_KIND: "{{ gcp_cred_kind }}" + GCP_PROJECT: "{{ gcp_project }}" + tasks: + - name: TEARDOWN | Destroy instances # noqa: ignore-errors + google.cloud.gcp_compute_instance: + name: "{{ prefix }}-{{ item.name }}" + machine_type: "{{ gcp_machine_type }}" + zone: "{{ gcp_zone }}" + state: absent + loop: "{{ sut }}" + ignore_errors: true + + - name: TEARDOWN | Remove IAP firewall rule # noqa: ignore-errors + google.cloud.gcp_compute_firewall: + name: all-iap + state: absent + network: + selfLink: "networks/{{ prefix }}" + ignore_errors: true + + - name: TEARDOWN | Destroy network # noqa: ignore-errors + google.cloud.gcp_compute_network: + name: "{{ prefix }}" + state: absent + ignore_errors: true diff --git a/tests/integration/targets/connection_plugin/playbooks/test.yml b/tests/integration/targets/connection_plugin/playbooks/test.yml new file mode 100644 index 00000000..94c7fef9 --- /dev/null +++ b/tests/integration/targets/connection_plugin/playbooks/test.yml @@ -0,0 +1,25 @@ +--- +- name: Test IAP connection plugin + hosts: gcp_cluster_web:gcp_cluster_db + connection: google.cloud.iap + gather_facts: false + vars_files: + - ../vars.yml + tasks: + - name: TEST | Ping + ansible.builtin.ping: + + - name: TEST | Copy + ansible.builtin.copy: + content: "Test file test" + dest: "/tmp/{{ prefix }}.txt" + mode: "0644" + + - name: TEST | Slurp + ansible.builtin.slurp: + src: "/tmp/{{ prefix }}.txt" + register: _content + + - name: TEST | Debug + ansible.builtin.debug: + msg: "{{ _content['content'] | b64decode }}" diff --git a/tests/integration/targets/connection_plugin/runme.sh b/tests/integration/targets/connection_plugin/runme.sh new file mode 100755 index 00000000..678cb6a6 --- /dev/null +++ b/tests/integration/targets/connection_plugin/runme.sh @@ -0,0 +1,25 @@ +#!/usr/bin/env bash + +set -eux + +# test infra +ansible-playbook playbooks/setup.yml "$@" + +export ANSIBLE_INVENTORY=test.gcp_compute.yml + +ansible-inventory --graph + +RC=0 +# we want to run teardown regardless of playbook exit status, so catch the +# exit code of ansible-playbook manually +set +e +ansible-playbook -vvvvv playbooks/test.yml "$@" +RC=$? +set -e + +unset ANSIBLE_INVENTORY + +# delete test infra +ansible-playbook playbooks/teardown.yml "$@" + +exit $RC diff --git a/tests/integration/targets/connection_plugin/templates/inventory.yml.j2 b/tests/integration/targets/connection_plugin/templates/inventory.yml.j2 new file mode 100644 index 00000000..ae83258e --- /dev/null +++ b/tests/integration/targets/connection_plugin/templates/inventory.yml.j2 @@ -0,0 +1,31 @@ +--- +plugin: google.cloud.gcp_compute + +zones: +{{ gcp_zones | to_nice_yaml }} + +projects: +- {{ gcp_project }} + +auth_kind: {{ gcp_cred_kind }} + +service_account_file: {{ gcp_cred_file }} + +scopes: +- 'https://www.googleapis.com/auth/cloud-platform' +- 'https://www.googleapis.com/auth/compute.readonly' + +keyed_groups: +- prefix: gcp + key: labels + +filters: +- 'labels.test:{{ prefix }}' + +hostnames: +- name + +# set variables for the connection plugin +compose: + ansible_gcloud_zone: zone + ansible_gcloud_project: project diff --git a/tests/integration/targets/connection_plugin/test.gcp_compute.yml b/tests/integration/targets/connection_plugin/test.gcp_compute.yml new file mode 100644 index 00000000..fdffa2a0 --- /dev/null +++ b/tests/integration/targets/connection_plugin/test.gcp_compute.yml @@ -0,0 +1 @@ +# placeholder diff --git a/tests/integration/targets/connection_plugin/vars.yml b/tests/integration/targets/connection_plugin/vars.yml new file mode 100644 index 00000000..dde5fcd0 --- /dev/null +++ b/tests/integration/targets/connection_plugin/vars.yml @@ -0,0 +1,32 @@ +--- +prefix: "{{ resource_prefix | default('d3adb33f') }}" + +gcp_region: us-central1 +gcp_zones: + - "{{ gcp_region }}-a" + - "{{ gcp_region }}-b" + - "{{ gcp_region }}-c" + - "{{ gcp_region }}-f" +gcp_zone: "{{ gcp_zones | last }}" +gcp_disk_image: projects/centos-cloud/global/images/family/centos-stream-9 +gcp_machine_type: g1-small + +sut: + - name: vm1 + labels: + test: "{{ prefix }}" + cluster: web + - name: vm2 + labels: + test: "{{ prefix }}" + cluster: web + - name: vm3 + labels: + test: "{{ prefix }}" + cluster: db + +ansible_python_interpreter: /usr/bin/python3 + +# these are only useful when connection != local +ansible_ssh_user: cloud-user +ansible_ssh_private_key_file: "{{ playbook_dir }}/ssh_key" From 2fc899cd32d2b8fadb7348fd0fa93f37b99ac62f Mon Sep 17 00:00:00 2001 From: Jorge Gallegos Date: Thu, 4 Sep 2025 19:29:36 -0700 Subject: [PATCH 09/10] Unrelated change but it's better if we use an image family --- tests/integration/targets/inventory_gce/vars.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/integration/targets/inventory_gce/vars.yml b/tests/integration/targets/inventory_gce/vars.yml index 87e8d04d..7423b55c 100644 --- a/tests/integration/targets/inventory_gce/vars.yml +++ b/tests/integration/targets/inventory_gce/vars.yml @@ -6,7 +6,7 @@ gcp_zones: - "{{ gcp_region }}-c" - "{{ gcp_region }}-f" gcp_zone: "{{ gcp_zones | first }}" -gcp_disk_image: projects/centos-cloud/global/images/centos-stream-9-v20250513 +gcp_disk_image: projects/centos-cloud/global/images/family/centos-stream-9 prefix: "{{ resource_prefix | default('d3adb33f') }}" sut: From fa560f3ef419c4337456b78658eb58eb9f2397b7 Mon Sep 17 00:00:00 2001 From: Jorge Gallegos Date: Thu, 4 Sep 2025 20:25:42 -0700 Subject: [PATCH 10/10] More sanity fixes --- plugins/connection/iap.py | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/plugins/connection/iap.py b/plugins/connection/iap.py index 89293a5b..c4b1d7f0 100644 --- a/plugins/connection/iap.py +++ b/plugins/connection/iap.py @@ -419,12 +419,10 @@ DOCUMENTATION = """ type: string default: smart choices: - sftp: This is the most reliable way to copy things with SSH. - scp: Deprecated in OpenSSH. For OpenSSH >=9.0 you must add an additional option - to enable scp C(scp_extra_args="-O"). - piped: Creates an SSH pipe with C(dd) on either side to copy the data. - smart: Tries each method in order (sftp > scp > piped), until one succeeds or - they all fail. + - sftp + - scp + - piped + - smart env: - name: ANSIBLE_SSH_TRANSFER_METHOD ini: