mirror of
https://github.com/ansible-collections/google.cloud.git
synced 2025-09-30 05:23:29 -07:00
470 lines
16 KiB
Python
470 lines
16 KiB
Python
# 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 <jgallego@redhat.com>
|
|
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 == "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: 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 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: 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"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)
|