From 9165fbb41788de31bd770730a54e6492a6f2a1b1 Mon Sep 17 00:00:00 2001 From: mstanescu_adobe Date: Wed, 13 Aug 2025 16:44:04 +0300 Subject: [PATCH 1/4] [AAM-65858] Add ops-cli Teleport support --- README.md | 2 +- src/ops/cli/parser.py | 2 + src/ops/cli/playbook.py | 6 +- src/ops/cli/run.py | 5 +- src/ops/cli/ssh.py | 352 ++++++++++++------ src/ops/cli/sync.py | 95 +++-- src/ops/data/ssh/ssh.teleport.config.tpl | 9 + src/ops/inventory/ec2inventory.py | 18 +- src/ops/inventory/plugin/cns.py | 1 + src/ops/inventory/plugin/ec2.py | 5 +- src/ops/inventory/sshconfig.py | 87 +++-- .../clusters/plugin_generator_teleport.yaml | 6 + tests/e2e/test_ssh.py | 27 ++ 13 files changed, 419 insertions(+), 196 deletions(-) create mode 100644 src/ops/data/ssh/ssh.teleport.config.tpl create mode 100644 tests/e2e/fixture/inventory/clusters/plugin_generator_teleport.yaml diff --git a/README.md b/README.md index 1b0312ee..6a952524 100644 --- a/README.md +++ b/README.md @@ -762,7 +762,7 @@ env LDFLAGS="-L$(brew --prefix openssl)/lib" CFLAGS="-I$(brew --prefix openssl)/ ## Running tests -- on your machine: `py.test tests` +- on your machine: `python -m pytest tests` or `build_scripts/run_tests.sh` # Troubleshooting diff --git a/src/ops/cli/parser.py b/src/ops/cli/parser.py index c7f672c9..8c1356ea 100644 --- a/src/ops/cli/parser.py +++ b/src/ops/cli/parser.py @@ -112,5 +112,7 @@ def configure_common_ansible_args(parser): parser.add_argument('--noscb', action='store_false', dest='use_scb', help='Disable use of Shell Control Box (SCB) even if ' 'it is enabled in the cluster config') + parser.add_argument('--teleport', action='store_false', dest='use_teleport', + help='Use Teleport for SSH') return parser diff --git a/src/ops/cli/playbook.py b/src/ops/cli/playbook.py index dd66c4a9..a0069b3d 100644 --- a/src/ops/cli/playbook.py +++ b/src/ops/cli/playbook.py @@ -73,9 +73,11 @@ def __init__(self, ops_config, root_dir, inventory_generator, def run(self, args, extra_args): logger.info("Found extra_args %s", extra_args) inventory_path, ssh_config_paths = self.inventory_generator.generate() - ssh_config_path = SshConfigGenerator.get_ssh_config_path(self.cluster_config, + ssh_config_generator = SshConfigGenerator(self.ops_config.package_dir) + ssh_config_path = ssh_config_generator.get_ssh_config_path(self.cluster_config, ssh_config_paths, - args.use_scb) + args) + ssh_config = f"ANSIBLE_SSH_ARGS='-F {ssh_config_path}'" ansible_config = "ANSIBLE_CONFIG=%s" % self.ops_config.ansible_config_path diff --git a/src/ops/cli/run.py b/src/ops/cli/run.py index fce7730a..c1296332 100644 --- a/src/ops/cli/run.py +++ b/src/ops/cli/run.py @@ -68,9 +68,10 @@ def run(self, args, extra_args): logger.info("Found extra_args %s", extra_args) inventory_path, ssh_config_paths = self.inventory_generator.generate() limit = args.host_pattern - ssh_config_path = SshConfigGenerator.get_ssh_config_path(self.cluster_config, + ssh_config_generator = SshConfigGenerator(self.ops_config.package_dir) + ssh_config_path = ssh_config_generator.get_ssh_config_path(self.cluster_config, ssh_config_paths, - args.use_scb) + args) extra_args = ' '.join(args.extra_args) command = """cd {root_dir} ANSIBLE_SSH_ARGS='-F {ssh_config}' ANSIBLE_CONFIG={ansible_config_path} ansible -i {inventory_path} '{limit}' \\ diff --git a/src/ops/cli/ssh.py b/src/ops/cli/ssh.py index d517e2cd..2740bbf0 100644 --- a/src/ops/cli/ssh.py +++ b/src/ops/cli/ssh.py @@ -75,7 +75,7 @@ def configure(self, parser): action="store_true", help="Port tunnel a machine that does not have SSH. " "Implies --ipaddress, and --tunnel; requires --local and --remote" - ) + ) parser.add_argument( '--keygen', action='store_true', @@ -86,6 +86,11 @@ def configure(self, parser): dest='use_scb', help='Disable use of Shell Control Box (SCB) even if it is ' 'enabled in the cluster config') + parser.add_argument( + '--teleport', + action='store_false', + dest='use_teleport', + help='Use Teleport for SSH') parser.add_argument( '--auto_scb_port', action='store_true', @@ -162,6 +167,60 @@ def __init__(self, cluster_config_path, cluster_config, def run(self, args, extra_args): logger.info("Found extra_args %s", extra_args) + + self.check_if_keygen_arg(args) + self.exit_when_local_arg_not_match_regex(args) + self.exit_when_tunnel_used_with_incorrect_parameters(args) + self.exit_when_scb_enabled_but_scb_host_not_set(self.is_scb_enabled(args), self.get_scb_host(args)) + self.exit_when_scb_proxy_used_with_incorect_parameters(args, self.get_scb_proxy_port(args)) + + group = "%s,&%s" % (self.cluster_name, args.role) + self.set_args_index(args) + self.set_args_if_nossh(args) + + display("Expression %s matched hosts (max 10): " % group, stderr=True) + host_names = self.get_host_names(group, self.get_hosts_with_fallback(args, group)) + display('\n'.join(host_names), color='blue') + + host = self.resolve_host(args, host_names, group) + ssh_host = self.resolve_ssh_host(args, host) + ssh_user = args.user or self.get_ssh_user_from_config() + + self.ssh_opts_extend_with_user_arg(ssh_user, args) + + + ssh_config = SshConfig(self.is_scb_enabled(args), + self.is_teleport_enabled(args), + self.get_ssh_config_prop(args), + ssh_user, + ssh_host, + self.get_ssh_host_dest(args, ssh_host), + self.get_ssh_host_bastion(args, ssh_host), + self.get_scb_host(args), + self.get_scb_ssh_host(ssh_host, args), + host, + self.get_scb_proxy_port(args)) + + command = self.build_ssh_command(args, ssh_config) + display(command, color="purple") + + self.check_passwordless_wrapper() + + display( + "SSH-ing to %s[%d] => %s" % + (args.role, + args.index, + host.name), + color="green", + stderr=True) + + return dict(command=command) + + def get_ssh_config_prop(self, args): + return args.ssh_config or self.ops_config.get( + 'ssh.config') or self.ansible_inventory.get_ssh_config() + + def check_if_keygen_arg(self, args): if args.keygen: if self.cluster_config.has_ssh_keys: err('Cluster already has ssh keys, refusing to overwrite') @@ -172,64 +231,41 @@ def run(self, args, extra_args): display( 'Trying to generate ssh keys in:\n{} and \n{}'.format( pub_key_file, prv_key_file)) - if os.path.isfile( - pub_key_file) or os.path.isfile(prv_key_file): + if os.path.isfile(pub_key_file) or os.path.isfile(prv_key_file): err('Although we do not have a complete keyset, one of the files exists and we refuse to overwrite\n') sys.exit(2) else: # generate ssh keypair. The passphrase will be the name of # the cluster - cmd = "ssh-keygen -t rsa -b 4096 -N {} -f {}".format( - self.cluster_name, prv_key_file).split(' ') + cmd = "ssh-keygen -t rsa -b 4096 -N {} -f {}".format(self.cluster_name, prv_key_file).split(' ') print(cmd) call(cmd) return - if args.local and not IP_HOST_REG_EX.match(args.local): - err('The --local parameter must be in the form of host-ip:port or port') - sys.exit(2) - - if args.tunnel or args.nossh: - if args.local is None or args.remote is None: - err('When using --tunnel or --nossh both the --local and --remote parameters are required') - sys.exit(2) - - scb_settings = self.cluster_config.get('scb', {}) - scb_enabled = scb_settings.get('enabled') and args.use_scb - scb_host = scb_settings.get('host') or self.ops_config.get('scb.host') - scb_proxy_port = scb_settings.get('proxy_port') - - if scb_enabled and not scb_host: - err('When scb is enabled scb_host is required!') - sys.exit(2) - - if args.proxy: - if args.local is None and (args.auto_scb_port is False and not scb_proxy_port): - err('When using --proxy the --local parameter is required if not using ' - '--auto_scb_port and scb.proxy_port is not configured in the cluster config') - sys.exit(2) - - group = "%s,&%s" % (self.cluster_name, args.role) - + def set_args_index(self, args): args.index = args.index - 1 if args.index < 0: args.index = 0 - hosts = self.ansible_inventory.get_hosts(group) + def get_hosts_with_fallback(self, args, group): + hosts = self.get_ansible_hosts(group) if len(hosts) <= args.index: - group = args.role - hosts = self.ansible_inventory.get_hosts(group) - if not hosts: - display( - "No host found in inventory, using provided name %s" % + hosts = self.ansible_inventory.get_hosts(args.role) + self.exit_if_hosts_null(args, hosts) + return hosts + + def exit_if_hosts_null(self, args, hosts): + if not hosts: + display("No host found in inventory, using provided name %s" % (args.role), color="purple", stderr=True) - display("Expression %s matched hosts (max 10): " % group, stderr=True) - host_names = [host.name for host in hosts] - for name in host_names[:10]: - display(name, color='blue') + def get_host_names(self, group, hosts): + return [host.name for host in hosts[:10]] + + def get_ansible_hosts(self, group): + return self.ansible_inventory.get_hosts(group) - host = None + def resolve_host(self, args, host_names, group): if host_names: if args.index < len(host_names): host = self.ansible_inventory.get_host(host_names[args.index]) @@ -238,85 +274,102 @@ def run(self, args, extra_args): "Index out of bounds for %s" % (group), color="red", stderr=True) return - if host: - ssh_host = host.vars.get('ansible_ssh_host') or host.name else: # no host found in inventory, use the role provided - bastion = self.ansible_inventory.get_hosts( - 'bastion')[0].vars.get('ansible_ssh_host') host = Host(name=args.role) - ssh_host = f'{bastion}--{host.name}' - ssh_user = self.cluster_config.get('ssh_user') or self.ops_config.get( - 'ssh.user') or getpass.getuser() - if args.user: - ssh_user = args.user - if ssh_user and '-l' not in args.ssh_opts: - args.ssh_opts.extend(['-l', ssh_user]) + return host + + def resolve_ssh_host(self, args, host): + + if host and self.is_teleport_enabled(args): + return host.vars.get('ec2_tag_hostname') or host.vars.get('ec2_tag_CMDB_hostname') or host.vars.get('ec2_tag_CMDB_hostname') or host.name + + if host and not self.is_teleport_enabled(args): + return host.vars.get('ansible_ssh_host') or host.name + if args.nossh: + return self.ansible_inventory.get_hosts( + 'bastion')[0].vars.get('ansible_ssh_host') + + bastion = self.ansible_inventory.get_hosts( + 'bastion')[0].vars.get('ansible_ssh_host') + return f'{bastion}--{host.name}' + + + def set_args_if_nossh(self, args): if args.nossh: args.tunnel = True args.ipaddress = True - ssh_host = self.ansible_inventory.get_hosts( - 'bastion')[0].vars.get('ansible_ssh_host') - # if args.tunnel or args.proxy: - # ssh_config = args.ssh_config or 'ssh.tunnel.config' - # else: - # ssh_config = args.ssh_config or self.ansible_inventory.get_ssh_config() - ssh_config = args.ssh_config or self.ops_config.get( - 'ssh.config') or self.ansible_inventory.get_ssh_config() + def get_ssh_host_parts(self, ssh_host): + return ssh_host.split('--') - ssh_host_bastion, ssh_host_dest = None, None - if args.ssh_dest_user: - ssh_host_parts = ssh_host.split('--') - ssh_host_bastion = ssh_host_parts[0] - ssh_host_dest = ssh_host_parts[1] if len(ssh_host_parts) > 1 else None + def get_ssh_host_dest(self, args, ssh_host): + return self.get_ssh_host_parts(ssh_host)[1] if args.ssh_dest_user and len(self.get_ssh_host_parts(ssh_host)) > 1 else None - scb_ssh_host = None - if scb_enabled: - # scb->bastion->host vs scb->bastion - scb_delimiter = "--" if "--" in ssh_host else "@" - scb_ssh_host = f"{ssh_host}{scb_delimiter}{scb_host}" + def get_ssh_host_bastion(self, args, ssh_host): + return self.get_ssh_host_parts(ssh_host)[0] if args.ssh_dest_user else None - if args.tunnel: - if args.ipaddress: - host_ip = host.vars.get('private_ip_address') - else: - host_ip = 'localhost' - if scb_enabled: - command = f"ssh -F {ssh_config} {ssh_user}@{scb_ssh_host} " \ - f"-4 -T -L {args.local}:{host_ip}:{args.remote:d}" - else: - command = f"ssh -F {ssh_config} {ssh_host} " \ - f"-4 -N -L {args.local}:{host_ip}:{args.remote:d}" - else: - if scb_enabled: - command = f"ssh -F {ssh_config} {ssh_user}@{scb_ssh_host}" - if args.ssh_dest_user and ssh_host_dest: - command = (f"ssh -F {ssh_config} -t {ssh_user}@{ssh_host_bastion}@{scb_host} " - f"ssh {args.ssh_dest_user}@{ssh_host_dest}") - else: - command = f"ssh -F {ssh_config} {ssh_host}" - if args.ssh_dest_user and ssh_host_dest: - command = (f"ssh -F {ssh_config} -t {ssh_user}@{ssh_host_bastion} " - f"ssh {args.ssh_dest_user}@{ssh_host_dest}") - - if args.proxy: - if scb_enabled: - proxy_port = args.local or SshConfigGenerator.generate_ssh_scb_proxy_port( - self.ansible_inventory.generated_path.removesuffix("/inventory"), - args.auto_scb_port, - scb_proxy_port - ) - command = f"ssh -F {ssh_config} {ssh_user}@{scb_ssh_host} " \ - f"-4 -T -D {proxy_port} -o 'ExitOnForwardFailure yes'" - else: - command = f"ssh -F {ssh_config} {ssh_host} " \ - f"-4 -N -D {args.local} -f -o 'ExitOnForwardFailure yes'" + def get_scb_ssh_host(self, ssh_host, args): + scb_delimiter = ("--" if "--" in ssh_host else "@") if self.is_scb_enabled(args) else None # scb->bastion->host vs scb->bastion + return f"{ssh_host}{scb_delimiter}{self.get_scb_host(args)}" if self.is_scb_enabled(args) else None - if args.ssh_opts: - command = f"{command} {' '.join(args.ssh_opts)}" + def ssh_opts_extend_with_user_arg(self, ssh_user, args): + if ssh_user and '-l' not in args.ssh_opts and not self.is_teleport_enabled(args): + args.ssh_opts.extend(['-l', ssh_user]) + + def get_ssh_user_from_config(self): + return self.cluster_config.get('ssh_user') or self.ops_config.get('ssh.user') or getpass.getuser() + def exit_when_local_arg_not_match_regex(self, args): + if args.local and not IP_HOST_REG_EX.match(args.local): + err('The --local parameter must be in the form of host-ip:port or port') + sys.exit(2) + + def exit_when_tunnel_used_with_incorrect_parameters(self, args): + if args.tunnel or args.nossh: + if args.local is None or args.remote is None: + err('When using --tunnel or --nossh both the --local and --remote parameters are required') + sys.exit(2) + + def exit_when_scb_proxy_used_with_incorect_parameters(self, args, scb_proxy_port): + if self.is_scb_enabled(args) and args.proxy and args.local is None and (args.auto_scb_port is False and not scb_proxy_port): + err('When using --proxy the --local parameter is required if not using ' + '--auto_scb_port and scb.proxy_port is not configured in the cluster config') + sys.exit(2) + + def exit_when_scb_enabled_but_scb_host_not_set(self, scb_enabled, scb_host): + if scb_enabled and not scb_host: + err('When scb is enabled scb_host is required!') + sys.exit(2) + + def get_scb_info_if_enabled(self, args): + scb_settings = self.cluster_config.get('scb', {}) + return (True, + scb_settings.get('host') or self.ops_config.get('scb.host'), + scb_settings.get('proxy_port')) \ + if scb_settings.get('enabled') and args.use_scb \ + else (False, None, None) + + def is_scb_enabled(self, args): + return True if self.get_cluster_config('scb').get('enabled') and self.use_scb_arg_set(args) else False + + def get_scb_host(self, args): + return self.get_cluster_config('scb').get('host') if self.is_scb_enabled(args) and self.use_scb_arg_set(args) else None + + def get_scb_proxy_port(self, args): + return self.get_cluster_config('scb').get('proxy_port') if self.is_scb_enabled(args) and self.use_scb_arg_set(args) else None + + def use_scb_arg_set(self, args): + return True if args.use_scb else False + + def get_cluster_config(self, section): + return self.cluster_config.get(section, {}) + + def is_teleport_enabled(self, args): + return True if self.get_cluster_config('teleport').get('enabled') and args.use_teleport else False + + def check_passwordless_wrapper(self): # Check if optional sshpass is available and print info message sshpass_path = os.path.expanduser("~/bin/sshpass") if (os.path.isfile(sshpass_path) and os.access(sshpass_path, os.X_OK)): @@ -326,12 +379,79 @@ def run(self, args, extra_args): display("sshpass passwordless wrapper NOT available in %s" % (sshpass_path), color="purple", stderr=True) - display( - "SSH-ing to %s[%d] => %s" % - (args.role, - args.index, - host.name), - color="green", - stderr=True) + def wrap_command_with_opts(self, initial_command, args): + return f"{initial_command} {' '.join(args.ssh_opts)}" - return dict(command=command) + def build_ssh_command(self, args, ssh_config): + if args.tunnel: + return self.build_tunnel_command(args, ssh_config) + elif args.proxy: + return self.build_proxy_command(args, ssh_config) + else: + return self.build_regular_command(args, ssh_config) + + def build_regular_command(self, args, ssh_config): + if ssh_config.scb_enabled: + command = f"ssh -F {ssh_config.ssh_config_prop} {ssh_config.ssh_user}@{ssh_config.scb_ssh_host}" + if args.ssh_dest_user and ssh_config.ssh_host_dest: + command = (f"ssh -F {ssh_config.ssh_config_prop} -t {ssh_config.ssh_user}@{ssh_config.ssh_host_bastion}@{ssh_config.scb_host} " + f"ssh {args.ssh_dest_user}@{ssh_config.ssh_host_dest}") + elif ssh_config.teleport_enabled: + ssh_opts = ' '.join(args.ssh_opts) if args.ssh_opts else '' + return (f"tsh ssh {ssh_opts} {ssh_config.ssh_user}@{ssh_config.ssh_host}" if ssh_opts + else f"tsh ssh {ssh_config.ssh_user}@{ssh_config.ssh_host}") + else: + command = f"ssh -F {ssh_config.ssh_config_prop} {ssh_config.ssh_host}" + if args.ssh_dest_user and ssh_config.ssh_host_dest: + command = (f"ssh -F {ssh_config.ssh_config_prop} -t {ssh_config.ssh_user}@{ssh_config.ssh_host_bastion} " + f"ssh {args.ssh_dest_user}@{ssh_config.ssh_host_dest}") + return self.wrap_command_with_opts(command, args) + + def build_tunnel_command(self, args, ssh_config): + if ssh_config.scb_enabled: + target_host = f"{ssh_config.ssh_user}@{ssh_config.scb_ssh_host}" + command = f"ssh -F {ssh_config.ssh_config_prop} {target_host} " \ + f"-4 -N -L {args.local}:{self.get_host_ip(args, ssh_config.host)}:{args.remote:d}" + elif ssh_config.teleport_enabled: + command = f"tsh ssh -L {args.local}:{self.get_host_ip(args, ssh_config.host)}:{args.remote} {ssh_config.ssh_host}" + else: + command = f"ssh -F {ssh_config.ssh_config_prop} {ssh_config.ssh_host} " \ + f"-4 -N -L {args.local}:{self.get_host_ip(args, ssh_config.host)}:{args.remote:d}" + return self.wrap_command_with_opts(command, args) + + def build_proxy_command(self, args, ssh_config): + ssh_config_generator = SshConfigGenerator(self.ops_config.package_dir) + if ssh_config.scb_enabled: + proxy_port = args.local or ssh_config_generator.generate_ssh_scb_proxy_port( + self.ansible_inventory.generated_path.removesuffix("/inventory"), args.auto_scb_port, + ssh_config.scb_proxy_port) + command = f"ssh -F {ssh_config.ssh_config_prop} {ssh_config.ssh_user}@{ssh_config.scb_ssh_host} " \ + f"-4 -T -D {proxy_port} -o 'ExitOnForwardFailure yes'" + elif ssh_config.teleport_enabled: + proxy_port = args.local or ssh_config_generator.get_random_generated_port() + command = f"tsh ssh -D {proxy_port} {ssh_config.ssh_host}" + else: + command = f"ssh -F {ssh_config.ssh_config_prop} {ssh_config.ssh_host} " \ + f"-4 -N -D {args.local} -f -o 'ExitOnForwardFailure yes'" + return self.wrap_command_with_opts(command, args) + + def get_host_ip(self, args, host): + return host.vars.get('private_ip_address') if args.ipaddress else 'localhost' + + + + +class SshConfig(object): + def __init__(self, scb_enabled, teleport_enabled, ssh_config_prop, ssh_user, ssh_host, ssh_host_dest, + ssh_host_bastion, scb_host, scb_ssh_host, host, scb_proxy_port): + self.scb_enabled = scb_enabled + self.teleport_enabled = teleport_enabled + self.ssh_config_prop = ssh_config_prop + self.ssh_user = ssh_user + self.ssh_host = ssh_host + self.ssh_host_dest = ssh_host_dest + self.ssh_host_bastion = ssh_host_bastion + self.scb_host = scb_host + self.scb_ssh_host = scb_ssh_host + self.host = host + self.scb_proxy_port = scb_proxy_port \ No newline at end of file diff --git a/src/ops/cli/sync.py b/src/ops/cli/sync.py index d701a49f..ee452c65 100644 --- a/src/ops/cli/sync.py +++ b/src/ops/cli/sync.py @@ -31,11 +31,15 @@ def configure(self, parser): parser.add_argument('--noscb', action='store_false', dest='use_scb', help='Disable use of Shell Control Box (SCB) ' 'even if it is enabled in the cluster config') + parser.add_argument( + '--teleport', + action='store_false', + dest='use_teleport', + help='Use Teleport for SSH') parser.add_argument( 'opts', - default=['-va --progress'], nargs='*', - help='Rsync opts') + help='Sync opts') def get_help(self): return 'Sync files from/to a cluster' @@ -54,6 +58,9 @@ def get_epilog(self): # extra rsync options ops cluster.yml sync 'dcs[0]:/usr/local/demdex/conf' /tmp/configurator-data -l remote_user -- --progress + + # extra sync option for Teleport (recursive download, quiet, port) + ops cluster.yml sync 'dcs[0]:/usr/local/demdex/conf' /tmp/configurator-data -- --recursive/port/quiet """ @@ -73,29 +80,36 @@ def __init__(self, cluster_config, root_dir, def run(self, args, extra_args): logger.info("Found extra_args %s", extra_args) - inventory_path, ssh_config_paths = self.inventory_generator.generate() + src = PathExpr(args.src) dest = PathExpr(args.dest) + remote = self.get_remote(dest, src) - ssh_config_path = SshConfigGenerator.get_ssh_config_path(self.cluster_config, - ssh_config_paths, - args.use_scb) if src.is_remote and dest.is_remote: display( - 'Too remote expressions are not allowed', + 'Two remote expressions are not allowed', stderr=True, color='red') return - if src.is_remote: - remote = src - else: - remote = dest - display( "Looking for hosts for pattern '%s'" % remote.pattern, stderr=True) + if self.is_teleport_enabled(args): + command = self.execute_teleport_scp(args, src, dest) + else: + ssh_user = self.cluster_config.get('ssh_user') or self.ops_config.get('ssh.user') or getpass.getuser() + if remote.remote_user: + ssh_user = remote.remote_user + elif args.user: + ssh_user = args.user + ssh_host = self.populate_remote_hosts(remote)[0] + command = self.execute_rsync_scp(args, src, dest, ssh_user, ssh_host, self.get_ssh_config_path(args)) + + return dict(command=command) + + def populate_remote_hosts(self, remote): remote_hosts = [] hosts = self.ansible_inventory.get_hosts(remote.pattern) if not hosts: @@ -106,28 +120,43 @@ def run(self, args, extra_args): for host in hosts: ssh_host = host.get_vars().get('ansible_ssh_host') or host remote_hosts.append(ssh_host) + return remote_hosts - for ssh_host in remote_hosts: - ssh_user = self.cluster_config.get('ssh_user') or self.ops_config.get( - 'ssh.user') or getpass.getuser() - if remote.remote_user: - ssh_user = remote.remote_user - elif args.user: - ssh_user = args.user - - from_path = src.with_user_and_path(ssh_user, ssh_host) - to_path = dest.with_user_and_path(ssh_user, ssh_host) - - command = 'rsync {opts} {from_path} {to_path} -e "ssh -F {ssh_config}"'.format( - opts=" ".join(args.opts), - from_path=from_path, - to_path=to_path, - ssh_config=ssh_config_path - - ) - - return dict(command=command) - + def get_remote(self, dest, src): + if src.is_remote: + remote = src + else: + remote = dest + return remote + + def get_ssh_config_path(self, args): + ssh_config_generator = SshConfigGenerator(self.ops_config.package_dir) + _, ssh_config_paths = self.inventory_generator.generate() + return ssh_config_generator.get_ssh_config_path(self.cluster_config, + ssh_config_paths, + + args) + + def execute_teleport_scp(self, args, src, dest): + return 'tsh scp {opts} {from_path} {to_path}'.format( + from_path=src, + to_path=dest, + opts=" ".join(args.opts) + ) + + def execute_rsync_scp(self, args, src, dest, ssh_user, ssh_host, ssh_config_path): + from_path = src.with_user_and_path(ssh_user, ssh_host) + to_path = dest.with_user_and_path(ssh_user, ssh_host) + return 'rsync {opts} {from_path} {to_path} -e "ssh -F {ssh_config}"'.format( + opts=" ".join(args.opts), + from_path=from_path, + to_path=to_path, + ssh_config=ssh_config_path + ) + + + def is_teleport_enabled(self, args): + return True if self.cluster_config.get('teleport', {}).get('enabled') and args.use_teleport else False class PathExpr(object): diff --git a/src/ops/data/ssh/ssh.teleport.config.tpl b/src/ops/data/ssh/ssh.teleport.config.tpl new file mode 100644 index 00000000..97c229de --- /dev/null +++ b/src/ops/data/ssh/ssh.teleport.config.tpl @@ -0,0 +1,9 @@ +# teleport.cfg +Host * + ProxyCommand "/usr/local/bin/tsh" proxy ssh --cluster=teleport-prod --proxy=teleport.adobe.net:443 %r@%n:%p + UserKnownHostsFile /dev/null + StrictHostKeyChecking no + ControlMaster auto + ControlPersist 600s + User {ssh_username} + Port 22 \ No newline at end of file diff --git a/src/ops/inventory/ec2inventory.py b/src/ops/inventory/ec2inventory.py index 7d5acf16..b474221d 100644 --- a/src/ops/inventory/ec2inventory.py +++ b/src/ops/inventory/ec2inventory.py @@ -17,11 +17,13 @@ class Ec2Inventory(object): + + @staticmethod def _empty_inventory(): return {"_meta": {"hostvars": {}}} - def __init__(self, boto_profile, regions, filters=None, bastion_filters=None): + def __init__(self, boto_profile, regions, filters=None, bastion_filters=None, teleport_enabled=False): self.filters = filters or [] self.regions = regions.split(',') @@ -29,6 +31,7 @@ def __init__(self, boto_profile, regions, filters=None, bastion_filters=None): self.bastion_filters = bastion_filters or [] self.group_callbacks = [] self.boto3_session = self.create_boto3_session(boto_profile) + self.teleport_enabled = teleport_enabled # Inventory grouped by instance IDs, tags, security groups, regions, # and availability zones @@ -145,7 +148,11 @@ def add_instance(self, bastion_ip, instance, region): if not dest: return - if bastion_ip and bastion_ip != instance.get('PublicIpAddress'): + if self.teleport_enabled: + ansible_ssh_host = self.get_tag_value(instance, ['hostname','CMDB_hostname','Adobe:FQDN']) + if not ansible_ssh_host: + ansible_ssh_host = instance.get('PrivateIpAddress') + elif bastion_ip and bastion_ip != instance.get('PublicIpAddress'): ansible_ssh_host = bastion_ip + "--" + instance.get('PrivateIpAddress') elif instance.get('PublicIpAddress'): ansible_ssh_host = instance.get('PublicIpAddress') @@ -183,6 +190,13 @@ def add_instance(self, bastion_ip, instance, region): self.inventory["_meta"]["hostvars"][dest] = self.get_host_info_dict_from_instance(instance) self.inventory["_meta"]["hostvars"][dest]['ansible_ssh_host'] = ansible_ssh_host + def get_tag_value(self, instance, tag_keys): + for tag_key in tag_keys: + for tag in instance.get('Tags', []): + if tag['Key'] == tag_key: + return tag['Value'] + return None + def get_host_info_dict_from_instance(self, instance): instance_vars = {} for key, value in instance.items(): diff --git a/src/ops/inventory/plugin/cns.py b/src/ops/inventory/plugin/cns.py index 935ca6e5..e01bcd85 100644 --- a/src/ops/inventory/plugin/cns.py +++ b/src/ops/inventory/plugin/cns.py @@ -26,6 +26,7 @@ def cns(args): jsn = ec2(dict( region=region, boto_profile=profile, + teleport_enabled=args.get('teleport_enabled', False), cache=args.get('cache', 3600 * 24), filters=[ {'Name': 'tag:cluster', 'Values': [cns_cluster]} diff --git a/src/ops/inventory/plugin/ec2.py b/src/ops/inventory/plugin/ec2.py index 2a9e2b74..cc4c2f80 100644 --- a/src/ops/inventory/plugin/ec2.py +++ b/src/ops/inventory/plugin/ec2.py @@ -14,6 +14,7 @@ def ec2(args): filters = args.get('filters', []) bastion_filters = args.get('bastion', []) + teleport_enabled = args.get('teleport_enabled') if args.get('cluster') and not args.get('filters'): filters = [{'Name': 'tag:cluster', 'Values': [args.get('cluster')]}] @@ -27,4 +28,6 @@ def ec2(args): return Ec2Inventory(boto_profile=args['boto_profile'], regions=args['region'], filters=filters, - bastion_filters=bastion_filters).get_as_json() + bastion_filters=bastion_filters, + teleport_enabled=teleport_enabled + ).get_as_json() diff --git a/src/ops/inventory/sshconfig.py b/src/ops/inventory/sshconfig.py index 38e5cab1..4bfb6cee 100644 --- a/src/ops/inventory/sshconfig.py +++ b/src/ops/inventory/sshconfig.py @@ -18,11 +18,12 @@ class SshConfigGenerator(object): SSH_CONFIG_FILE = "ssh.config" SSH_SCB_PROXY_TPL_FILE = "ssh.scb.proxy.config.tpl" + SSH_TELEPORT_PROXY_TPL_FILE = "ssh.teleport.config.tpl" def __init__(self, package_dir): self.package_dir = package_dir self.ssh_data_dir = self.package_dir + '/data/ssh' - self.ssh_config_files = [self.SSH_CONFIG_FILE, self.SSH_SCB_PROXY_TPL_FILE] + self.ssh_config_files = [self.SSH_CONFIG_FILE, self.SSH_SCB_PROXY_TPL_FILE, self.SSH_TELEPORT_PROXY_TPL_FILE] def generate(self, directory): dest_ssh_config = {} @@ -36,40 +37,38 @@ def _get_ssh_config(self): return [f"{self.ssh_data_dir}/{ssh_config_file}" for ssh_config_file in self.ssh_config_files] - @staticmethod - def get_ssh_config_path(cluster_config, ssh_config_paths, use_scb): - scb_settings = cluster_config.get('scb', {}) - scb_enabled = scb_settings.get('enabled') and use_scb + def get_ssh_config_path(self, cluster_config, ssh_config_paths, args): + scb_enabled, teleport_enabled = self.get_proxy_type_information(cluster_config, args) if scb_enabled: ssh_config_tpl_path = ssh_config_paths.get(SshConfigGenerator.SSH_SCB_PROXY_TPL_FILE) - scb_proxy_port = SshConfigGenerator.get_ssh_scb_proxy_port(ssh_config_tpl_path) - ssh_config_path = SshConfigGenerator.generate_ssh_scb_config(ssh_config_tpl_path, - scb_proxy_port) - display.display(f"Connecting via scb proxy at 127.0.0.1:{scb_proxy_port}.\n" - f"This proxy should have already been started and running " - f"in a different terminal window.\n" - f"If there are connection issues double check that " - f"the proxy is running.", - color='blue', - stderr=True) + ssh_proxy_port = self.get_ssh_proxy_port(ssh_config_tpl_path) + display.display(f"Connecting via scb proxy at 127.0.0.1:{ssh_proxy_port}.\n" + f"This proxy should have already been started and running " + f"in a different terminal window.\n" + f"If there are connection issues double check that " + f"the proxy is running.", + color='blue', + stderr=True) + return self.generate_ssh_config_from_template(ssh_config_tpl_path, ssh_proxy_port) + elif teleport_enabled: + display.display(f"Using Teleport for SSH connections.\n" + f"Make sure you are logged in with 'tsh login'.", + color='blue', + stderr=True) + ssh_config_tpl_path = ssh_config_paths.get(SshConfigGenerator.SSH_TELEPORT_PROXY_TPL_FILE) + return self.generate_ssh_config_from_template(ssh_config_tpl_path, ssh_username=os.getlogin()) else: - ssh_config_path = ssh_config_paths.get(SshConfigGenerator.SSH_CONFIG_FILE) - return ssh_config_path + return ssh_config_paths.get(SshConfigGenerator.SSH_CONFIG_FILE) - @staticmethod - def generate_ssh_scb_proxy_port(ssh_config_path, auto_scb_port, scb_config_port): + def generate_ssh_scb_proxy_port(self, ssh_config_path, auto_scb_port, scb_config_port): ssh_config_port_path = f"{ssh_config_path}/ssh_scb_proxy_config_port" if auto_scb_port: - with socketserver.TCPServer(("localhost", 0), None) as s: - generated_port = s.server_address[1] - display.display(f"Using auto generated port {generated_port} for scb proxy port", - color='blue', - stderr=True) + generated_port = self.get_random_generated_port() else: generated_port = scb_config_port display.display(f"Using port {generated_port} from cluster config for scb proxy port", - color='blue', - stderr=True) + color='blue', + stderr=True) with open(ssh_config_port_path, 'w') as f: f.write(str(generated_port)) @@ -77,22 +76,32 @@ def generate_ssh_scb_proxy_port(ssh_config_path, auto_scb_port, scb_config_port) return generated_port - - @staticmethod - def get_ssh_scb_proxy_port(ssh_config_path): + def get_ssh_proxy_port(self, ssh_config_path): ssh_port_path = ssh_config_path.replace("_tpl", "_port") - ssh_scb_proxy_port = Path(ssh_port_path).read_text() - return ssh_scb_proxy_port - - @staticmethod - def generate_ssh_scb_config(ssh_config_tpl_path, scb_proxy_port): - ssh_config_template = Path(ssh_config_tpl_path).read_text() - ssh_config_content = ssh_config_template.format( - scb_proxy_port=scb_proxy_port - ) + return Path(ssh_port_path).read_text() + + def generate_ssh_config_from_template(self, ssh_config_tpl_path, **template_vars): + ssh_config_content = Path(ssh_config_tpl_path).read_text().format(**template_vars) + return self.generate_file_from_template(ssh_config_tpl_path, ssh_config_content) + + def generate_file_from_template(self, ssh_config_tpl_path, ssh_config_content): ssh_config_path = ssh_config_tpl_path.removesuffix("_tpl") with open(ssh_config_path, 'w') as f: f.write(ssh_config_content) os.fchmod(f.fileno(), 0o644) - return ssh_config_path + + def get_proxy_type_information(self, cluster_config, args): + scb_settings = cluster_config.get('scb', {}) + scb_enabled = scb_settings.get('enabled') and args.use_scb + teleport_settings = cluster_config.get('teleport', {}) + teleport_enabled = teleport_settings.get('enabled') and args.use_teleport + return scb_enabled, teleport_enabled + + def get_random_generated_port(self): + with socketserver.TCPServer(("localhost", 0), None) as s: + generated_port = s.server_address[1] + display.display(f"Using auto generated port {generated_port} for scb proxy port", + color='blue', + stderr=True) + return generated_port \ No newline at end of file diff --git a/tests/e2e/fixture/inventory/clusters/plugin_generator_teleport.yaml b/tests/e2e/fixture/inventory/clusters/plugin_generator_teleport.yaml new file mode 100644 index 00000000..d25d2271 --- /dev/null +++ b/tests/e2e/fixture/inventory/clusters/plugin_generator_teleport.yaml @@ -0,0 +1,6 @@ + +inventory: + - plugin: test_plugin + +teleport: + enabled: true diff --git a/tests/e2e/test_ssh.py b/tests/e2e/test_ssh.py index cf708a24..8de4a189 100644 --- a/tests/e2e/test_ssh.py +++ b/tests/e2e/test_ssh.py @@ -39,6 +39,31 @@ def test_ssh_scb(): command['command']) +def tests_ssh_teleport_with_user(): + command = run(current_dir + '/fixture/inventory/clusters/plugin_generator_teleport.yaml', 'ssh', + 'bastion', '-l', 'remote_user') + assert re.match(r'tsh ssh remote_user@bastion.host', command['command']) + + +def tests_ssh_teleport_without_user(): + command = run(current_dir + '/fixture/inventory/clusters/plugin_generator_teleport.yaml', 'ssh', + 'bastion') + current_user = os.environ.get("USER") or "root" + assert re.match(r'tsh ssh {0}@bastion.host'.format(current_user), command['command']) + + +def test_ssh_proxy_teleport(): + command = run(current_dir + '/fixture/inventory/clusters/plugin_generator_teleport.yaml', 'ssh', + '--proxy', '--local', '8888', 'bastion') + assert re.match(r'tsh ssh -D 8888 bastion.host', command['command']) + +def test_ssh_tunnel_teleport(): + command = run(current_dir + '/fixture/inventory/clusters/plugin_generator_teleport.yaml', 'ssh', + '--tunnel', '--remote', '80', '--local', '8080', 'bastion') + + assert re.match(r'tsh ssh -L 8080:localhost:80 bastion.host', command['command']) + + def test_ssh_scb_noscb(): command = run(current_dir + '/fixture/inventory/clusters/plugin_generator_scb.yaml', 'ssh', '--noscb', 'bastion', '--', '-TD', '8157') @@ -77,6 +102,8 @@ def test_ssh_scb_user_noscb(): assert "scb.example.com" not in command['command'] + + if not PY3: def test_ssh_user_unicode_dash(): with pytest.raises(UnicodeDecodeError): From 5241f931a3406047a81624bd55d98971163b35d4 Mon Sep 17 00:00:00 2001 From: mstanescu_adobe Date: Thu, 14 Aug 2025 11:58:42 +0300 Subject: [PATCH 2/4] Update readme --- README.md | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/README.md b/README.md index 6a952524..05c9f2c5 100644 --- a/README.md +++ b/README.md @@ -538,6 +538,25 @@ If there are connection issues double check that the proxy is running. ... ``` +#### Teleport +Teleport (https://goteleport.com/) provides secretless SSH. +`ops` has support for using Teleport as ssh for the following operations: `ssh, tunnel, proxy, ansible play, run and sync` + +In order to use Teleport an extra section needs to be added to the cluster config file: +``` +inventory: + - plugin: cns + args: + **teleport_enabled: True** + clusters: + - region: us-east-1 + boto_profile: aam-npe + names: ['{{ cluster }}'] + +**teleport: + enabled: true** +``` + ### Play Run an ansible playbook. From d187517d435a2b6f6e840ffb7eddbe4aadb5de30 Mon Sep 17 00:00:00 2001 From: mstanescu_adobe Date: Thu, 14 Aug 2025 12:06:37 +0300 Subject: [PATCH 3/4] Cosmetic changes for readme --- README.md | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index 05c9f2c5..e35241ff 100644 --- a/README.md +++ b/README.md @@ -543,18 +543,16 @@ Teleport (https://goteleport.com/) provides secretless SSH. `ops` has support for using Teleport as ssh for the following operations: `ssh, tunnel, proxy, ansible play, run and sync` In order to use Teleport an extra section needs to be added to the cluster config file: +*** ``` inventory: - plugin: cns args: - **teleport_enabled: True** - clusters: - - region: us-east-1 - boto_profile: aam-npe - names: ['{{ cluster }}'] + teleport_enabled: True -> add this to existing configuration + -**teleport: - enabled: true** +teleport: + enabled: true -> add this whole block ``` ### Play From 8e87631a36d2b767037763c3a9e2b0b2572f8161 Mon Sep 17 00:00:00 2001 From: mstanescu Date: Fri, 7 Nov 2025 07:13:33 +0200 Subject: [PATCH 4/4] [RELEASE] - Release version 2.3.2 --- .bumpversion.cfg | 2 +- README.md | 4 ++-- setup.py | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.bumpversion.cfg b/.bumpversion.cfg index 4168bcac..35d4e4ff 100644 --- a/.bumpversion.cfg +++ b/.bumpversion.cfg @@ -1,5 +1,5 @@ [bumpversion] -current_version = 2.3.1 +current_version = 2.3.2 commit = True tag = True tag_name = {new_version} diff --git a/README.md b/README.md index e35241ff..67074e87 100644 --- a/README.md +++ b/README.md @@ -151,7 +151,7 @@ workon ops # uninstall previous `ops` version (if you have it) pip uninstall ops --yes -# install ops-cli v2.3.1 stable release +# install ops-cli v2.3.2 stable release pip install --upgrade ops-cli ``` @@ -167,7 +167,7 @@ You can try out `ops-cli`, by using docker. The docker image has all required pr To start out a container, running the latest `ops-cli` docker image run: ```sh -docker run -it ghcr.io/adobe/ops-cli:2.3.1 bash +docker run -it ghcr.io/adobe/ops-cli:2.3.2 bash ``` After the container has started, you can start using `ops-cli`: diff --git a/setup.py b/setup.py index c303f845..6885eabc 100644 --- a/setup.py +++ b/setup.py @@ -23,7 +23,7 @@ _requires = [r for r in open(os.path.sep.join((_mydir, 'requirements.txt')), "r").read().split('\n') if len(r) > 1] setup( name='ops-cli', - version='2.3.1', + version='2.3.2', description='Ops - wrapper for Terraform, Ansible, and SSH for cloud automation', long_description=_readme + '\n\n', long_description_content_type='text/markdown',