ops-cli 2.3.1__py3-none-any.whl → 2.4.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- ops/cli/__init__.py +6 -0
- ops/cli/parser.py +2 -0
- ops/cli/playbook.py +4 -2
- ops/cli/run.py +3 -2
- ops/cli/ssh.py +240 -117
- ops/cli/sync.py +63 -33
- ops/data/ssh/ssh.teleport.config.tpl +9 -0
- ops/inventory/__init__.py +18 -0
- ops/inventory/ec2inventory.py +16 -2
- ops/inventory/plugin/cns.py +1 -0
- ops/inventory/plugin/ec2.py +4 -1
- ops/inventory/sshconfig.py +50 -39
- {ops_cli-2.3.1.dist-info → ops_cli-2.4.0.dist-info}/METADATA +55 -40
- {ops_cli-2.3.1.dist-info → ops_cli-2.4.0.dist-info}/RECORD +18 -17
- {ops_cli-2.3.1.dist-info → ops_cli-2.4.0.dist-info}/WHEEL +1 -1
- {ops_cli-2.3.1.dist-info → ops_cli-2.4.0.dist-info}/entry_points.txt +0 -0
- {ops_cli-2.3.1.dist-info → ops_cli-2.4.0.dist-info/licenses}/LICENSE +0 -0
- {ops_cli-2.3.1.dist-info → ops_cli-2.4.0.dist-info}/top_level.txt +0 -0
ops/cli/__init__.py
CHANGED
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
import os
|
|
12
12
|
from subprocess import Popen, PIPE
|
|
13
13
|
import sys
|
|
14
|
+
from shutil import which
|
|
14
15
|
|
|
15
16
|
|
|
16
17
|
def get_output(command, trim=True):
|
|
@@ -41,3 +42,8 @@ def get_config_value(config, key):
|
|
|
41
42
|
err("You must set the %s value in %s.yaml or in the cli as an extra variable: -e %s=value" %
|
|
42
43
|
(e.message, config['cluster'], e.message))
|
|
43
44
|
sys.exit(1)
|
|
45
|
+
|
|
46
|
+
def check_if_teleport_binary_installed():
|
|
47
|
+
if which("tsh") is None:
|
|
48
|
+
err('tsh binary needs to be installed for Teleport to work!')
|
|
49
|
+
sys.exit(2)
|
ops/cli/parser.py
CHANGED
|
@@ -112,5 +112,7 @@ def configure_common_ansible_args(parser):
|
|
|
112
112
|
parser.add_argument('--noscb', action='store_false', dest='use_scb',
|
|
113
113
|
help='Disable use of Shell Control Box (SCB) even if '
|
|
114
114
|
'it is enabled in the cluster config')
|
|
115
|
+
parser.add_argument('--teleport', action='store_false', dest='use_teleport',
|
|
116
|
+
help='Use Teleport for SSH')
|
|
115
117
|
|
|
116
118
|
return parser
|
ops/cli/playbook.py
CHANGED
|
@@ -73,9 +73,11 @@ class PlaybookRunner(object):
|
|
|
73
73
|
def run(self, args, extra_args):
|
|
74
74
|
logger.info("Found extra_args %s", extra_args)
|
|
75
75
|
inventory_path, ssh_config_paths = self.inventory_generator.generate()
|
|
76
|
-
|
|
76
|
+
ssh_config_generator = SshConfigGenerator(self.ops_config.package_dir)
|
|
77
|
+
ssh_config_path = ssh_config_generator.get_ssh_config_path(self.cluster_config,
|
|
77
78
|
ssh_config_paths,
|
|
78
|
-
args
|
|
79
|
+
args)
|
|
80
|
+
|
|
79
81
|
ssh_config = f"ANSIBLE_SSH_ARGS='-F {ssh_config_path}'"
|
|
80
82
|
|
|
81
83
|
ansible_config = "ANSIBLE_CONFIG=%s" % self.ops_config.ansible_config_path
|
ops/cli/run.py
CHANGED
|
@@ -68,9 +68,10 @@ class CommandRunner(object):
|
|
|
68
68
|
logger.info("Found extra_args %s", extra_args)
|
|
69
69
|
inventory_path, ssh_config_paths = self.inventory_generator.generate()
|
|
70
70
|
limit = args.host_pattern
|
|
71
|
-
|
|
71
|
+
ssh_config_generator = SshConfigGenerator(self.ops_config.package_dir)
|
|
72
|
+
ssh_config_path = ssh_config_generator.get_ssh_config_path(self.cluster_config,
|
|
72
73
|
ssh_config_paths,
|
|
73
|
-
args
|
|
74
|
+
args)
|
|
74
75
|
extra_args = ' '.join(args.extra_args)
|
|
75
76
|
command = """cd {root_dir}
|
|
76
77
|
ANSIBLE_SSH_ARGS='-F {ssh_config}' ANSIBLE_CONFIG={ansible_config_path} ansible -i {inventory_path} '{limit}' \\
|
ops/cli/ssh.py
CHANGED
|
@@ -16,7 +16,7 @@ from .parser import configure_common_arguments
|
|
|
16
16
|
from ansible.inventory.host import Host
|
|
17
17
|
from ops.inventory.sshconfig import SshConfigGenerator
|
|
18
18
|
|
|
19
|
-
from . import err
|
|
19
|
+
from . import err, check_if_teleport_binary_installed
|
|
20
20
|
import sys
|
|
21
21
|
import getpass
|
|
22
22
|
import re
|
|
@@ -75,7 +75,7 @@ class SshParserConfig(SubParserConfig):
|
|
|
75
75
|
action="store_true",
|
|
76
76
|
help="Port tunnel a machine that does not have SSH. "
|
|
77
77
|
"Implies --ipaddress, and --tunnel; requires --local and --remote"
|
|
78
|
-
|
|
78
|
+
)
|
|
79
79
|
parser.add_argument(
|
|
80
80
|
'--keygen',
|
|
81
81
|
action='store_true',
|
|
@@ -86,6 +86,11 @@ class SshParserConfig(SubParserConfig):
|
|
|
86
86
|
dest='use_scb',
|
|
87
87
|
help='Disable use of Shell Control Box (SCB) even if it is '
|
|
88
88
|
'enabled in the cluster config')
|
|
89
|
+
parser.add_argument(
|
|
90
|
+
'--teleport',
|
|
91
|
+
action='store_false',
|
|
92
|
+
dest='use_teleport',
|
|
93
|
+
help='Use Teleport for SSH')
|
|
89
94
|
parser.add_argument(
|
|
90
95
|
'--auto_scb_port',
|
|
91
96
|
action='store_true',
|
|
@@ -162,6 +167,60 @@ class SshRunner(object):
|
|
|
162
167
|
|
|
163
168
|
def run(self, args, extra_args):
|
|
164
169
|
logger.info("Found extra_args %s", extra_args)
|
|
170
|
+
|
|
171
|
+
self.check_if_keygen_arg(args)
|
|
172
|
+
self.exit_when_local_arg_not_match_regex(args)
|
|
173
|
+
self.exit_when_tunnel_used_with_incorrect_parameters(args)
|
|
174
|
+
self.exit_when_scb_enabled_but_scb_host_not_set(self.is_scb_enabled(args), self.get_scb_host(args))
|
|
175
|
+
self.exit_when_scb_proxy_used_with_incorect_parameters(args, self.get_scb_proxy_port(args))
|
|
176
|
+
|
|
177
|
+
group = "%s,&%s" % (self.cluster_name, args.role)
|
|
178
|
+
self.set_args_index(args)
|
|
179
|
+
self.set_args_if_nossh(args)
|
|
180
|
+
|
|
181
|
+
display("Expression %s matched hosts (max 10): " % group, stderr=True)
|
|
182
|
+
host_names = self.get_host_names(group, self.get_hosts_with_fallback(args, group))
|
|
183
|
+
display('\n'.join(host_names), color='blue')
|
|
184
|
+
|
|
185
|
+
host = self.resolve_host(args, host_names, group)
|
|
186
|
+
ssh_host = self.resolve_ssh_host(args, host)
|
|
187
|
+
ssh_user = args.user or self.get_ssh_user_from_config()
|
|
188
|
+
|
|
189
|
+
self.ssh_opts_extend_with_user_arg(ssh_user, args)
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
ssh_config = SshConfig(self.is_scb_enabled(args),
|
|
193
|
+
self.is_teleport_enabled(args),
|
|
194
|
+
self.get_ssh_config_prop(args),
|
|
195
|
+
ssh_user,
|
|
196
|
+
ssh_host,
|
|
197
|
+
self.get_ssh_host_dest(args, ssh_host),
|
|
198
|
+
self.get_ssh_host_bastion(args, ssh_host),
|
|
199
|
+
self.get_scb_host(args),
|
|
200
|
+
self.get_scb_ssh_host(ssh_host, args),
|
|
201
|
+
host,
|
|
202
|
+
self.get_scb_proxy_port(args))
|
|
203
|
+
|
|
204
|
+
command = self.build_ssh_command(args, ssh_config)
|
|
205
|
+
display(command, color="purple")
|
|
206
|
+
|
|
207
|
+
self.check_passwordless_wrapper()
|
|
208
|
+
|
|
209
|
+
display(
|
|
210
|
+
"SSH-ing to %s[%d] => %s" %
|
|
211
|
+
(args.role,
|
|
212
|
+
args.index,
|
|
213
|
+
host.name),
|
|
214
|
+
color="green",
|
|
215
|
+
stderr=True)
|
|
216
|
+
|
|
217
|
+
return dict(command=command)
|
|
218
|
+
|
|
219
|
+
def get_ssh_config_prop(self, args):
|
|
220
|
+
return args.ssh_config or self.ops_config.get(
|
|
221
|
+
'ssh.config') or self.ansible_inventory.get_ssh_config()
|
|
222
|
+
|
|
223
|
+
def check_if_keygen_arg(self, args):
|
|
165
224
|
if args.keygen:
|
|
166
225
|
if self.cluster_config.has_ssh_keys:
|
|
167
226
|
err('Cluster already has ssh keys, refusing to overwrite')
|
|
@@ -172,64 +231,41 @@ class SshRunner(object):
|
|
|
172
231
|
display(
|
|
173
232
|
'Trying to generate ssh keys in:\n{} and \n{}'.format(
|
|
174
233
|
pub_key_file, prv_key_file))
|
|
175
|
-
if os.path.isfile(
|
|
176
|
-
pub_key_file) or os.path.isfile(prv_key_file):
|
|
234
|
+
if os.path.isfile(pub_key_file) or os.path.isfile(prv_key_file):
|
|
177
235
|
err('Although we do not have a complete keyset, one of the files exists and we refuse to overwrite\n')
|
|
178
236
|
sys.exit(2)
|
|
179
237
|
else:
|
|
180
238
|
# generate ssh keypair. The passphrase will be the name of
|
|
181
239
|
# the cluster
|
|
182
|
-
cmd = "ssh-keygen -t rsa -b 4096 -N {} -f {}".format(
|
|
183
|
-
self.cluster_name, prv_key_file).split(' ')
|
|
240
|
+
cmd = "ssh-keygen -t rsa -b 4096 -N {} -f {}".format(self.cluster_name, prv_key_file).split(' ')
|
|
184
241
|
print(cmd)
|
|
185
242
|
call(cmd)
|
|
186
243
|
return
|
|
187
244
|
|
|
188
|
-
|
|
189
|
-
err('The --local parameter must be in the form of host-ip:port or port')
|
|
190
|
-
sys.exit(2)
|
|
191
|
-
|
|
192
|
-
if args.tunnel or args.nossh:
|
|
193
|
-
if args.local is None or args.remote is None:
|
|
194
|
-
err('When using --tunnel or --nossh both the --local and --remote parameters are required')
|
|
195
|
-
sys.exit(2)
|
|
196
|
-
|
|
197
|
-
scb_settings = self.cluster_config.get('scb', {})
|
|
198
|
-
scb_enabled = scb_settings.get('enabled') and args.use_scb
|
|
199
|
-
scb_host = scb_settings.get('host') or self.ops_config.get('scb.host')
|
|
200
|
-
scb_proxy_port = scb_settings.get('proxy_port')
|
|
201
|
-
|
|
202
|
-
if scb_enabled and not scb_host:
|
|
203
|
-
err('When scb is enabled scb_host is required!')
|
|
204
|
-
sys.exit(2)
|
|
205
|
-
|
|
206
|
-
if args.proxy:
|
|
207
|
-
if args.local is None and (args.auto_scb_port is False and not scb_proxy_port):
|
|
208
|
-
err('When using --proxy the --local parameter is required if not using '
|
|
209
|
-
'--auto_scb_port and scb.proxy_port is not configured in the cluster config')
|
|
210
|
-
sys.exit(2)
|
|
211
|
-
|
|
212
|
-
group = "%s,&%s" % (self.cluster_name, args.role)
|
|
213
|
-
|
|
245
|
+
def set_args_index(self, args):
|
|
214
246
|
args.index = args.index - 1
|
|
215
247
|
if args.index < 0:
|
|
216
248
|
args.index = 0
|
|
217
249
|
|
|
218
|
-
|
|
250
|
+
def get_hosts_with_fallback(self, args, group):
|
|
251
|
+
hosts = self.get_ansible_hosts(group)
|
|
219
252
|
if len(hosts) <= args.index:
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
253
|
+
hosts = self.ansible_inventory.get_hosts(args.role)
|
|
254
|
+
self.exit_if_hosts_null(args, hosts)
|
|
255
|
+
return hosts
|
|
256
|
+
|
|
257
|
+
def exit_if_hosts_null(self, args, hosts):
|
|
258
|
+
if not hosts:
|
|
259
|
+
display("No host found in inventory, using provided name %s" %
|
|
225
260
|
(args.role), color="purple", stderr=True)
|
|
226
261
|
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
262
|
+
def get_host_names(self, group, hosts):
|
|
263
|
+
return [host.name for host in hosts[:10]]
|
|
264
|
+
|
|
265
|
+
def get_ansible_hosts(self, group):
|
|
266
|
+
return self.ansible_inventory.get_hosts(group)
|
|
231
267
|
|
|
232
|
-
|
|
268
|
+
def resolve_host(self, args, host_names, group):
|
|
233
269
|
if host_names:
|
|
234
270
|
if args.index < len(host_names):
|
|
235
271
|
host = self.ansible_inventory.get_host(host_names[args.index])
|
|
@@ -238,85 +274,102 @@ class SshRunner(object):
|
|
|
238
274
|
"Index out of bounds for %s" %
|
|
239
275
|
(group), color="red", stderr=True)
|
|
240
276
|
return
|
|
241
|
-
if host:
|
|
242
|
-
ssh_host = host.vars.get('ansible_ssh_host') or host.name
|
|
243
277
|
else:
|
|
244
278
|
# no host found in inventory, use the role provided
|
|
245
|
-
bastion = self.ansible_inventory.get_hosts(
|
|
246
|
-
'bastion')[0].vars.get('ansible_ssh_host')
|
|
247
279
|
host = Host(name=args.role)
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
280
|
+
return host
|
|
281
|
+
|
|
282
|
+
def resolve_ssh_host(self, args, host):
|
|
283
|
+
|
|
284
|
+
if host and self.is_teleport_enabled(args):
|
|
285
|
+
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
|
|
286
|
+
|
|
287
|
+
if host and not self.is_teleport_enabled(args):
|
|
288
|
+
return host.vars.get('ansible_ssh_host') or host.name
|
|
255
289
|
|
|
290
|
+
if args.nossh:
|
|
291
|
+
return self.ansible_inventory.get_hosts(
|
|
292
|
+
'bastion')[0].vars.get('ansible_ssh_host')
|
|
293
|
+
|
|
294
|
+
bastion = self.ansible_inventory.get_hosts(
|
|
295
|
+
'bastion')[0].vars.get('ansible_ssh_host')
|
|
296
|
+
return f'{bastion}--{host.name}'
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
def set_args_if_nossh(self, args):
|
|
256
300
|
if args.nossh:
|
|
257
301
|
args.tunnel = True
|
|
258
302
|
args.ipaddress = True
|
|
259
|
-
ssh_host = self.ansible_inventory.get_hosts(
|
|
260
|
-
'bastion')[0].vars.get('ansible_ssh_host')
|
|
261
303
|
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
# else:
|
|
265
|
-
# ssh_config = args.ssh_config or self.ansible_inventory.get_ssh_config()
|
|
266
|
-
ssh_config = args.ssh_config or self.ops_config.get(
|
|
267
|
-
'ssh.config') or self.ansible_inventory.get_ssh_config()
|
|
304
|
+
def get_ssh_host_parts(self, ssh_host):
|
|
305
|
+
return ssh_host.split('--')
|
|
268
306
|
|
|
269
|
-
|
|
270
|
-
if args.ssh_dest_user
|
|
271
|
-
ssh_host_parts = ssh_host.split('--')
|
|
272
|
-
ssh_host_bastion = ssh_host_parts[0]
|
|
273
|
-
ssh_host_dest = ssh_host_parts[1] if len(ssh_host_parts) > 1 else None
|
|
307
|
+
def get_ssh_host_dest(self, args, ssh_host):
|
|
308
|
+
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
|
|
274
309
|
|
|
275
|
-
|
|
276
|
-
if
|
|
277
|
-
# scb->bastion->host vs scb->bastion
|
|
278
|
-
scb_delimiter = "--" if "--" in ssh_host else "@"
|
|
279
|
-
scb_ssh_host = f"{ssh_host}{scb_delimiter}{scb_host}"
|
|
310
|
+
def get_ssh_host_bastion(self, args, ssh_host):
|
|
311
|
+
return self.get_ssh_host_parts(ssh_host)[0] if args.ssh_dest_user else None
|
|
280
312
|
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
else:
|
|
285
|
-
host_ip = 'localhost'
|
|
286
|
-
if scb_enabled:
|
|
287
|
-
command = f"ssh -F {ssh_config} {ssh_user}@{scb_ssh_host} " \
|
|
288
|
-
f"-4 -T -L {args.local}:{host_ip}:{args.remote:d}"
|
|
289
|
-
else:
|
|
290
|
-
command = f"ssh -F {ssh_config} {ssh_host} " \
|
|
291
|
-
f"-4 -N -L {args.local}:{host_ip}:{args.remote:d}"
|
|
292
|
-
else:
|
|
293
|
-
if scb_enabled:
|
|
294
|
-
command = f"ssh -F {ssh_config} {ssh_user}@{scb_ssh_host}"
|
|
295
|
-
if args.ssh_dest_user and ssh_host_dest:
|
|
296
|
-
command = (f"ssh -F {ssh_config} -t {ssh_user}@{ssh_host_bastion}@{scb_host} "
|
|
297
|
-
f"ssh {args.ssh_dest_user}@{ssh_host_dest}")
|
|
298
|
-
else:
|
|
299
|
-
command = f"ssh -F {ssh_config} {ssh_host}"
|
|
300
|
-
if args.ssh_dest_user and ssh_host_dest:
|
|
301
|
-
command = (f"ssh -F {ssh_config} -t {ssh_user}@{ssh_host_bastion} "
|
|
302
|
-
f"ssh {args.ssh_dest_user}@{ssh_host_dest}")
|
|
303
|
-
|
|
304
|
-
if args.proxy:
|
|
305
|
-
if scb_enabled:
|
|
306
|
-
proxy_port = args.local or SshConfigGenerator.generate_ssh_scb_proxy_port(
|
|
307
|
-
self.ansible_inventory.generated_path.removesuffix("/inventory"),
|
|
308
|
-
args.auto_scb_port,
|
|
309
|
-
scb_proxy_port
|
|
310
|
-
)
|
|
311
|
-
command = f"ssh -F {ssh_config} {ssh_user}@{scb_ssh_host} " \
|
|
312
|
-
f"-4 -T -D {proxy_port} -o 'ExitOnForwardFailure yes'"
|
|
313
|
-
else:
|
|
314
|
-
command = f"ssh -F {ssh_config} {ssh_host} " \
|
|
315
|
-
f"-4 -N -D {args.local} -f -o 'ExitOnForwardFailure yes'"
|
|
313
|
+
def get_scb_ssh_host(self, ssh_host, args):
|
|
314
|
+
scb_delimiter = ("--" if "--" in ssh_host else "@") if self.is_scb_enabled(args) else None # scb->bastion->host vs scb->bastion
|
|
315
|
+
return f"{ssh_host}{scb_delimiter}{self.get_scb_host(args)}" if self.is_scb_enabled(args) else None
|
|
316
316
|
|
|
317
|
-
|
|
318
|
-
|
|
317
|
+
def ssh_opts_extend_with_user_arg(self, ssh_user, args):
|
|
318
|
+
if ssh_user and '-l' not in args.ssh_opts and not self.is_teleport_enabled(args):
|
|
319
|
+
args.ssh_opts.extend(['-l', ssh_user])
|
|
320
|
+
|
|
321
|
+
def get_ssh_user_from_config(self):
|
|
322
|
+
return self.cluster_config.get('ssh_user') or self.ops_config.get('ssh.user') or getpass.getuser()
|
|
319
323
|
|
|
324
|
+
def exit_when_local_arg_not_match_regex(self, args):
|
|
325
|
+
if args.local and not IP_HOST_REG_EX.match(args.local):
|
|
326
|
+
err('The --local parameter must be in the form of host-ip:port or port')
|
|
327
|
+
sys.exit(2)
|
|
328
|
+
|
|
329
|
+
def exit_when_tunnel_used_with_incorrect_parameters(self, args):
|
|
330
|
+
if args.tunnel or args.nossh:
|
|
331
|
+
if args.local is None or args.remote is None:
|
|
332
|
+
err('When using --tunnel or --nossh both the --local and --remote parameters are required')
|
|
333
|
+
sys.exit(2)
|
|
334
|
+
|
|
335
|
+
def exit_when_scb_proxy_used_with_incorect_parameters(self, args, scb_proxy_port):
|
|
336
|
+
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):
|
|
337
|
+
err('When using --proxy the --local parameter is required if not using '
|
|
338
|
+
'--auto_scb_port and scb.proxy_port is not configured in the cluster config')
|
|
339
|
+
sys.exit(2)
|
|
340
|
+
|
|
341
|
+
def exit_when_scb_enabled_but_scb_host_not_set(self, scb_enabled, scb_host):
|
|
342
|
+
if scb_enabled and not scb_host:
|
|
343
|
+
err('When scb is enabled scb_host is required!')
|
|
344
|
+
sys.exit(2)
|
|
345
|
+
|
|
346
|
+
def get_scb_info_if_enabled(self, args):
|
|
347
|
+
scb_settings = self.cluster_config.get('scb', {})
|
|
348
|
+
return (True,
|
|
349
|
+
scb_settings.get('host') or self.ops_config.get('scb.host'),
|
|
350
|
+
scb_settings.get('proxy_port')) \
|
|
351
|
+
if scb_settings.get('enabled') and args.use_scb \
|
|
352
|
+
else (False, None, None)
|
|
353
|
+
|
|
354
|
+
def is_scb_enabled(self, args):
|
|
355
|
+
return True if self.get_cluster_config('scb').get('enabled') and self.use_scb_arg_set(args) else False
|
|
356
|
+
|
|
357
|
+
def get_scb_host(self, args):
|
|
358
|
+
return self.get_cluster_config('scb').get('host') if self.is_scb_enabled(args) and self.use_scb_arg_set(args) else None
|
|
359
|
+
|
|
360
|
+
def get_scb_proxy_port(self, args):
|
|
361
|
+
return self.get_cluster_config('scb').get('proxy_port') if self.is_scb_enabled(args) and self.use_scb_arg_set(args) else None
|
|
362
|
+
|
|
363
|
+
def use_scb_arg_set(self, args):
|
|
364
|
+
return True if args.use_scb else False
|
|
365
|
+
|
|
366
|
+
def get_cluster_config(self, section):
|
|
367
|
+
return self.cluster_config.get(section, {})
|
|
368
|
+
|
|
369
|
+
def is_teleport_enabled(self, args):
|
|
370
|
+
return True if self.get_cluster_config('teleport').get('enabled') and args.use_teleport else False
|
|
371
|
+
|
|
372
|
+
def check_passwordless_wrapper(self):
|
|
320
373
|
# Check if optional sshpass is available and print info message
|
|
321
374
|
sshpass_path = os.path.expanduser("~/bin/sshpass")
|
|
322
375
|
if (os.path.isfile(sshpass_path) and os.access(sshpass_path, os.X_OK)):
|
|
@@ -326,12 +379,82 @@ class SshRunner(object):
|
|
|
326
379
|
display("sshpass passwordless wrapper NOT available in %s" %
|
|
327
380
|
(sshpass_path), color="purple", stderr=True)
|
|
328
381
|
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
(args.role,
|
|
332
|
-
args.index,
|
|
333
|
-
host.name),
|
|
334
|
-
color="green",
|
|
335
|
-
stderr=True)
|
|
382
|
+
def wrap_command_with_opts(self, initial_command, args):
|
|
383
|
+
return f"{initial_command} {' '.join(args.ssh_opts)}"
|
|
336
384
|
|
|
337
|
-
|
|
385
|
+
def build_ssh_command(self, args, ssh_config):
|
|
386
|
+
if args.tunnel:
|
|
387
|
+
return self.build_tunnel_command(args, ssh_config)
|
|
388
|
+
elif args.proxy:
|
|
389
|
+
return self.build_proxy_command(args, ssh_config)
|
|
390
|
+
else:
|
|
391
|
+
return self.build_regular_command(args, ssh_config)
|
|
392
|
+
|
|
393
|
+
def build_regular_command(self, args, ssh_config):
|
|
394
|
+
if ssh_config.scb_enabled:
|
|
395
|
+
command = f"ssh -F {ssh_config.ssh_config_prop} {ssh_config.ssh_user}@{ssh_config.scb_ssh_host}"
|
|
396
|
+
if args.ssh_dest_user and ssh_config.ssh_host_dest:
|
|
397
|
+
command = (f"ssh -F {ssh_config.ssh_config_prop} -t {ssh_config.ssh_user}@{ssh_config.ssh_host_bastion}@{ssh_config.scb_host} "
|
|
398
|
+
f"ssh {args.ssh_dest_user}@{ssh_config.ssh_host_dest}")
|
|
399
|
+
elif ssh_config.teleport_enabled:
|
|
400
|
+
check_if_teleport_binary_installed()
|
|
401
|
+
ssh_opts = ' '.join(args.ssh_opts) if args.ssh_opts else ''
|
|
402
|
+
return (f"tsh ssh {ssh_opts} {ssh_config.ssh_user}@{ssh_config.ssh_host}" if ssh_opts
|
|
403
|
+
else f"tsh ssh {ssh_config.ssh_user}@{ssh_config.ssh_host}")
|
|
404
|
+
else:
|
|
405
|
+
command = f"ssh -F {ssh_config.ssh_config_prop} {ssh_config.ssh_host}"
|
|
406
|
+
if args.ssh_dest_user and ssh_config.ssh_host_dest:
|
|
407
|
+
command = (f"ssh -F {ssh_config.ssh_config_prop} -t {ssh_config.ssh_user}@{ssh_config.ssh_host_bastion} "
|
|
408
|
+
f"ssh {args.ssh_dest_user}@{ssh_config.ssh_host_dest}")
|
|
409
|
+
return self.wrap_command_with_opts(command, args)
|
|
410
|
+
|
|
411
|
+
def build_tunnel_command(self, args, ssh_config):
|
|
412
|
+
if ssh_config.scb_enabled:
|
|
413
|
+
target_host = f"{ssh_config.ssh_user}@{ssh_config.scb_ssh_host}"
|
|
414
|
+
command = f"ssh -F {ssh_config.ssh_config_prop} {target_host} " \
|
|
415
|
+
f"-4 -N -L {args.local}:{self.get_host_ip(args, ssh_config.host)}:{args.remote:d}"
|
|
416
|
+
elif ssh_config.teleport_enabled:
|
|
417
|
+
check_if_teleport_binary_installed()
|
|
418
|
+
command = f"tsh ssh -L {args.local}:{self.get_host_ip(args, ssh_config.host)}:{args.remote} {ssh_config.ssh_host}"
|
|
419
|
+
else:
|
|
420
|
+
command = f"ssh -F {ssh_config.ssh_config_prop} {ssh_config.ssh_host} " \
|
|
421
|
+
f"-4 -N -L {args.local}:{self.get_host_ip(args, ssh_config.host)}:{args.remote:d}"
|
|
422
|
+
return self.wrap_command_with_opts(command, args)
|
|
423
|
+
|
|
424
|
+
def build_proxy_command(self, args, ssh_config):
|
|
425
|
+
ssh_config_generator = SshConfigGenerator(self.ops_config.package_dir)
|
|
426
|
+
if ssh_config.scb_enabled:
|
|
427
|
+
proxy_port = args.local or ssh_config_generator.generate_ssh_scb_proxy_port(
|
|
428
|
+
self.ansible_inventory.generated_path.removesuffix("/inventory"), args.auto_scb_port,
|
|
429
|
+
ssh_config.scb_proxy_port)
|
|
430
|
+
command = f"ssh -F {ssh_config.ssh_config_prop} {ssh_config.ssh_user}@{ssh_config.scb_ssh_host} " \
|
|
431
|
+
f"-4 -T -D {proxy_port} -o 'ExitOnForwardFailure yes'"
|
|
432
|
+
elif ssh_config.teleport_enabled:
|
|
433
|
+
check_if_teleport_binary_installed()
|
|
434
|
+
proxy_port = args.local or ssh_config_generator.get_random_generated_port()
|
|
435
|
+
command = f"tsh ssh -D {proxy_port} {ssh_config.ssh_host}"
|
|
436
|
+
else:
|
|
437
|
+
command = f"ssh -F {ssh_config.ssh_config_prop} {ssh_config.ssh_host} " \
|
|
438
|
+
f"-4 -N -D {args.local} -f -o 'ExitOnForwardFailure yes'"
|
|
439
|
+
return self.wrap_command_with_opts(command, args)
|
|
440
|
+
|
|
441
|
+
def get_host_ip(self, args, host):
|
|
442
|
+
return host.vars.get('private_ip_address') if args.ipaddress else 'localhost'
|
|
443
|
+
|
|
444
|
+
|
|
445
|
+
|
|
446
|
+
|
|
447
|
+
class SshConfig(object):
|
|
448
|
+
def __init__(self, scb_enabled, teleport_enabled, ssh_config_prop, ssh_user, ssh_host, ssh_host_dest,
|
|
449
|
+
ssh_host_bastion, scb_host, scb_ssh_host, host, scb_proxy_port):
|
|
450
|
+
self.scb_enabled = scb_enabled
|
|
451
|
+
self.teleport_enabled = teleport_enabled
|
|
452
|
+
self.ssh_config_prop = ssh_config_prop
|
|
453
|
+
self.ssh_user = ssh_user
|
|
454
|
+
self.ssh_host = ssh_host
|
|
455
|
+
self.ssh_host_dest = ssh_host_dest
|
|
456
|
+
self.ssh_host_bastion = ssh_host_bastion
|
|
457
|
+
self.scb_host = scb_host
|
|
458
|
+
self.scb_ssh_host = scb_ssh_host
|
|
459
|
+
self.host = host
|
|
460
|
+
self.scb_proxy_port = scb_proxy_port
|
ops/cli/sync.py
CHANGED
|
@@ -31,11 +31,15 @@ class SyncParserConfig(SubParserConfig):
|
|
|
31
31
|
parser.add_argument('--noscb', action='store_false', dest='use_scb',
|
|
32
32
|
help='Disable use of Shell Control Box (SCB) '
|
|
33
33
|
'even if it is enabled in the cluster config')
|
|
34
|
+
parser.add_argument(
|
|
35
|
+
'--teleport',
|
|
36
|
+
action='store_false',
|
|
37
|
+
dest='use_teleport',
|
|
38
|
+
help='Use Teleport for SSH')
|
|
34
39
|
parser.add_argument(
|
|
35
40
|
'opts',
|
|
36
|
-
default=['-va --progress'],
|
|
37
41
|
nargs='*',
|
|
38
|
-
help='
|
|
42
|
+
help='Sync opts')
|
|
39
43
|
|
|
40
44
|
def get_help(self):
|
|
41
45
|
return 'Sync files from/to a cluster'
|
|
@@ -54,6 +58,9 @@ class SyncParserConfig(SubParserConfig):
|
|
|
54
58
|
|
|
55
59
|
# extra rsync options
|
|
56
60
|
ops cluster.yml sync 'dcs[0]:/usr/local/demdex/conf' /tmp/configurator-data -l remote_user -- --progress
|
|
61
|
+
|
|
62
|
+
# extra sync option for Teleport (recursive download, quiet, port)
|
|
63
|
+
ops cluster.yml sync 'dcs[0]:/usr/local/demdex/conf' /tmp/configurator-data -- --recursive/port/quiet
|
|
57
64
|
"""
|
|
58
65
|
|
|
59
66
|
|
|
@@ -73,29 +80,37 @@ class SyncRunner(object):
|
|
|
73
80
|
|
|
74
81
|
def run(self, args, extra_args):
|
|
75
82
|
logger.info("Found extra_args %s", extra_args)
|
|
76
|
-
|
|
83
|
+
|
|
77
84
|
src = PathExpr(args.src)
|
|
78
85
|
dest = PathExpr(args.dest)
|
|
86
|
+
remote = self.get_remote(dest, src)
|
|
79
87
|
|
|
80
|
-
ssh_config_path = SshConfigGenerator.get_ssh_config_path(self.cluster_config,
|
|
81
|
-
ssh_config_paths,
|
|
82
|
-
args.use_scb)
|
|
83
88
|
if src.is_remote and dest.is_remote:
|
|
84
89
|
display(
|
|
85
|
-
'
|
|
90
|
+
'Two remote expressions are not allowed',
|
|
86
91
|
stderr=True,
|
|
87
92
|
color='red')
|
|
88
93
|
return
|
|
89
94
|
|
|
90
|
-
if src.is_remote:
|
|
91
|
-
remote = src
|
|
92
|
-
else:
|
|
93
|
-
remote = dest
|
|
94
|
-
|
|
95
95
|
display(
|
|
96
96
|
"Looking for hosts for pattern '%s'" %
|
|
97
97
|
remote.pattern, stderr=True)
|
|
98
98
|
|
|
99
|
+
if self.is_teleport_enabled(args):
|
|
100
|
+
check_if_teleport_binary_installed()
|
|
101
|
+
command = self.execute_teleport_scp(args, src, dest)
|
|
102
|
+
else:
|
|
103
|
+
ssh_user = self.cluster_config.get('ssh_user') or self.ops_config.get('ssh.user') or getpass.getuser()
|
|
104
|
+
if remote.remote_user:
|
|
105
|
+
ssh_user = remote.remote_user
|
|
106
|
+
elif args.user:
|
|
107
|
+
ssh_user = args.user
|
|
108
|
+
ssh_host = self.populate_remote_hosts(remote)[0]
|
|
109
|
+
command = self.execute_rsync_scp(args, src, dest, ssh_user, ssh_host, self.get_ssh_config_path(args))
|
|
110
|
+
|
|
111
|
+
return dict(command=command)
|
|
112
|
+
|
|
113
|
+
def populate_remote_hosts(self, remote):
|
|
99
114
|
remote_hosts = []
|
|
100
115
|
hosts = self.ansible_inventory.get_hosts(remote.pattern)
|
|
101
116
|
if not hosts:
|
|
@@ -106,28 +121,43 @@ class SyncRunner(object):
|
|
|
106
121
|
for host in hosts:
|
|
107
122
|
ssh_host = host.get_vars().get('ansible_ssh_host') or host
|
|
108
123
|
remote_hosts.append(ssh_host)
|
|
124
|
+
return remote_hosts
|
|
109
125
|
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
126
|
+
def get_remote(self, dest, src):
|
|
127
|
+
if src.is_remote:
|
|
128
|
+
remote = src
|
|
129
|
+
else:
|
|
130
|
+
remote = dest
|
|
131
|
+
return remote
|
|
132
|
+
|
|
133
|
+
def get_ssh_config_path(self, args):
|
|
134
|
+
ssh_config_generator = SshConfigGenerator(self.ops_config.package_dir)
|
|
135
|
+
_, ssh_config_paths = self.inventory_generator.generate()
|
|
136
|
+
return ssh_config_generator.get_ssh_config_path(self.cluster_config,
|
|
137
|
+
ssh_config_paths,
|
|
138
|
+
|
|
139
|
+
args)
|
|
140
|
+
|
|
141
|
+
def execute_teleport_scp(self, args, src, dest):
|
|
142
|
+
return 'tsh scp {opts} {from_path} {to_path}'.format(
|
|
143
|
+
from_path=src,
|
|
144
|
+
to_path=dest,
|
|
145
|
+
opts=" ".join(args.opts)
|
|
146
|
+
)
|
|
147
|
+
|
|
148
|
+
def execute_rsync_scp(self, args, src, dest, ssh_user, ssh_host, ssh_config_path):
|
|
149
|
+
from_path = src.with_user_and_path(ssh_user, ssh_host)
|
|
150
|
+
to_path = dest.with_user_and_path(ssh_user, ssh_host)
|
|
151
|
+
return 'rsync {opts} {from_path} {to_path} -e "ssh -F {ssh_config}"'.format(
|
|
152
|
+
opts=" ".join(args.opts),
|
|
153
|
+
from_path=from_path,
|
|
154
|
+
to_path=to_path,
|
|
155
|
+
ssh_config=ssh_config_path
|
|
156
|
+
)
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def is_teleport_enabled(self, args):
|
|
160
|
+
return True if self.cluster_config.get('teleport', {}).get('enabled') and args.use_teleport else False
|
|
131
161
|
|
|
132
162
|
class PathExpr(object):
|
|
133
163
|
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
# teleport.cfg
|
|
2
|
+
Host *
|
|
3
|
+
ProxyCommand "/usr/local/bin/tsh" proxy ssh --cluster=teleport-prod --proxy=teleport.adobe.net:443 %r@%n:%p
|
|
4
|
+
UserKnownHostsFile /dev/null
|
|
5
|
+
StrictHostKeyChecking no
|
|
6
|
+
ControlMaster auto
|
|
7
|
+
ControlPersist 600s
|
|
8
|
+
User {ssh_username}
|
|
9
|
+
Port 22
|
ops/inventory/__init__.py
CHANGED
|
@@ -8,4 +8,22 @@
|
|
|
8
8
|
# OF ANY KIND, either express or implied. See the License for the specific language
|
|
9
9
|
# governing permissions and limitations under the License.
|
|
10
10
|
|
|
11
|
+
import sys
|
|
12
|
+
from shutil import which
|
|
11
13
|
from .ec2inventory import Ec2Inventory
|
|
14
|
+
|
|
15
|
+
def display(msg, **kwargs):
|
|
16
|
+
# use ansible pretty printer if available
|
|
17
|
+
try:
|
|
18
|
+
from ansible.playbook.play import display
|
|
19
|
+
display.display(msg, **kwargs)
|
|
20
|
+
except ImportError:
|
|
21
|
+
print(msg)
|
|
22
|
+
|
|
23
|
+
def err(msg):
|
|
24
|
+
display(str(msg), stderr=True, color='red')
|
|
25
|
+
|
|
26
|
+
def check_if_teleport_binary_installed():
|
|
27
|
+
if which("tsh") is None:
|
|
28
|
+
err('tsh binary needs to be installed for Teleport to work!')
|
|
29
|
+
sys.exit(2)
|
ops/inventory/ec2inventory.py
CHANGED
|
@@ -17,11 +17,13 @@ from botocore.exceptions import NoRegionError, NoCredentialsError, PartialCreden
|
|
|
17
17
|
|
|
18
18
|
|
|
19
19
|
class Ec2Inventory(object):
|
|
20
|
+
|
|
21
|
+
|
|
20
22
|
@staticmethod
|
|
21
23
|
def _empty_inventory():
|
|
22
24
|
return {"_meta": {"hostvars": {}}}
|
|
23
25
|
|
|
24
|
-
def __init__(self, boto_profile, regions, filters=None, bastion_filters=None):
|
|
26
|
+
def __init__(self, boto_profile, regions, filters=None, bastion_filters=None, teleport_enabled=False):
|
|
25
27
|
|
|
26
28
|
self.filters = filters or []
|
|
27
29
|
self.regions = regions.split(',')
|
|
@@ -29,6 +31,7 @@ class Ec2Inventory(object):
|
|
|
29
31
|
self.bastion_filters = bastion_filters or []
|
|
30
32
|
self.group_callbacks = []
|
|
31
33
|
self.boto3_session = self.create_boto3_session(boto_profile)
|
|
34
|
+
self.teleport_enabled = teleport_enabled
|
|
32
35
|
|
|
33
36
|
# Inventory grouped by instance IDs, tags, security groups, regions,
|
|
34
37
|
# and availability zones
|
|
@@ -145,7 +148,11 @@ class Ec2Inventory(object):
|
|
|
145
148
|
if not dest:
|
|
146
149
|
return
|
|
147
150
|
|
|
148
|
-
if
|
|
151
|
+
if self.teleport_enabled:
|
|
152
|
+
ansible_ssh_host = self.get_tag_value(instance, ['hostname','CMDB_hostname','Adobe:FQDN'])
|
|
153
|
+
if not ansible_ssh_host:
|
|
154
|
+
ansible_ssh_host = instance.get('PrivateIpAddress')
|
|
155
|
+
elif bastion_ip and bastion_ip != instance.get('PublicIpAddress'):
|
|
149
156
|
ansible_ssh_host = bastion_ip + "--" + instance.get('PrivateIpAddress')
|
|
150
157
|
elif instance.get('PublicIpAddress'):
|
|
151
158
|
ansible_ssh_host = instance.get('PublicIpAddress')
|
|
@@ -183,6 +190,13 @@ class Ec2Inventory(object):
|
|
|
183
190
|
self.inventory["_meta"]["hostvars"][dest] = self.get_host_info_dict_from_instance(instance)
|
|
184
191
|
self.inventory["_meta"]["hostvars"][dest]['ansible_ssh_host'] = ansible_ssh_host
|
|
185
192
|
|
|
193
|
+
def get_tag_value(self, instance, tag_keys):
|
|
194
|
+
for tag_key in tag_keys:
|
|
195
|
+
for tag in instance.get('Tags', []):
|
|
196
|
+
if tag['Key'] == tag_key:
|
|
197
|
+
return tag['Value']
|
|
198
|
+
return None
|
|
199
|
+
|
|
186
200
|
def get_host_info_dict_from_instance(self, instance):
|
|
187
201
|
instance_vars = {}
|
|
188
202
|
for key, value in instance.items():
|
ops/inventory/plugin/cns.py
CHANGED
ops/inventory/plugin/ec2.py
CHANGED
|
@@ -14,6 +14,7 @@ from ops.inventory.ec2inventory import Ec2Inventory
|
|
|
14
14
|
def ec2(args):
|
|
15
15
|
filters = args.get('filters', [])
|
|
16
16
|
bastion_filters = args.get('bastion', [])
|
|
17
|
+
teleport_enabled = args.get('teleport_enabled')
|
|
17
18
|
|
|
18
19
|
if args.get('cluster') and not args.get('filters'):
|
|
19
20
|
filters = [{'Name': 'tag:cluster', 'Values': [args.get('cluster')]}]
|
|
@@ -27,4 +28,6 @@ def ec2(args):
|
|
|
27
28
|
return Ec2Inventory(boto_profile=args['boto_profile'],
|
|
28
29
|
regions=args['region'],
|
|
29
30
|
filters=filters,
|
|
30
|
-
bastion_filters=bastion_filters
|
|
31
|
+
bastion_filters=bastion_filters,
|
|
32
|
+
teleport_enabled=teleport_enabled
|
|
33
|
+
).get_as_json()
|
ops/inventory/sshconfig.py
CHANGED
|
@@ -13,16 +13,18 @@ import socketserver
|
|
|
13
13
|
from shutil import copy
|
|
14
14
|
from pathlib import Path
|
|
15
15
|
from ansible.playbook.play import display
|
|
16
|
+
from . import check_if_teleport_binary_installed
|
|
16
17
|
|
|
17
18
|
|
|
18
19
|
class SshConfigGenerator(object):
|
|
19
20
|
SSH_CONFIG_FILE = "ssh.config"
|
|
20
21
|
SSH_SCB_PROXY_TPL_FILE = "ssh.scb.proxy.config.tpl"
|
|
22
|
+
SSH_TELEPORT_PROXY_TPL_FILE = "ssh.teleport.config.tpl"
|
|
21
23
|
|
|
22
24
|
def __init__(self, package_dir):
|
|
23
25
|
self.package_dir = package_dir
|
|
24
26
|
self.ssh_data_dir = self.package_dir + '/data/ssh'
|
|
25
|
-
self.ssh_config_files = [self.SSH_CONFIG_FILE, self.SSH_SCB_PROXY_TPL_FILE]
|
|
27
|
+
self.ssh_config_files = [self.SSH_CONFIG_FILE, self.SSH_SCB_PROXY_TPL_FILE, self.SSH_TELEPORT_PROXY_TPL_FILE]
|
|
26
28
|
|
|
27
29
|
def generate(self, directory):
|
|
28
30
|
dest_ssh_config = {}
|
|
@@ -36,40 +38,39 @@ class SshConfigGenerator(object):
|
|
|
36
38
|
return [f"{self.ssh_data_dir}/{ssh_config_file}"
|
|
37
39
|
for ssh_config_file in self.ssh_config_files]
|
|
38
40
|
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
scb_settings = cluster_config.get('scb', {})
|
|
42
|
-
scb_enabled = scb_settings.get('enabled') and use_scb
|
|
41
|
+
def get_ssh_config_path(self, cluster_config, ssh_config_paths, args):
|
|
42
|
+
scb_enabled, teleport_enabled = self.get_proxy_type_information(cluster_config, args)
|
|
43
43
|
if scb_enabled:
|
|
44
44
|
ssh_config_tpl_path = ssh_config_paths.get(SshConfigGenerator.SSH_SCB_PROXY_TPL_FILE)
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
45
|
+
ssh_proxy_port = self.get_ssh_proxy_port(ssh_config_tpl_path)
|
|
46
|
+
display.display(f"Connecting via scb proxy at 127.0.0.1:{ssh_proxy_port}.\n"
|
|
47
|
+
f"This proxy should have already been started and running "
|
|
48
|
+
f"in a different terminal window.\n"
|
|
49
|
+
f"If there are connection issues double check that "
|
|
50
|
+
f"the proxy is running.",
|
|
51
|
+
color='blue',
|
|
52
|
+
stderr=True)
|
|
53
|
+
return self.generate_ssh_config_from_template(ssh_config_tpl_path, scb_proxy_port=ssh_proxy_port)
|
|
54
|
+
elif teleport_enabled:
|
|
55
|
+
check_if_teleport_binary_installed()
|
|
56
|
+
display.display(f"Using Teleport for SSH connections.\n"
|
|
57
|
+
f"Make sure you are logged in with 'tsh login'.",
|
|
58
|
+
color='blue',
|
|
59
|
+
stderr=True)
|
|
60
|
+
ssh_config_tpl_path = ssh_config_paths.get(SshConfigGenerator.SSH_TELEPORT_PROXY_TPL_FILE)
|
|
61
|
+
return self.generate_ssh_config_from_template(ssh_config_tpl_path, ssh_username=os.getlogin())
|
|
55
62
|
else:
|
|
56
|
-
|
|
57
|
-
return ssh_config_path
|
|
63
|
+
return ssh_config_paths.get(SshConfigGenerator.SSH_CONFIG_FILE)
|
|
58
64
|
|
|
59
|
-
|
|
60
|
-
def generate_ssh_scb_proxy_port(ssh_config_path, auto_scb_port, scb_config_port):
|
|
65
|
+
def generate_ssh_scb_proxy_port(self, ssh_config_path, auto_scb_port, scb_config_port):
|
|
61
66
|
ssh_config_port_path = f"{ssh_config_path}/ssh_scb_proxy_config_port"
|
|
62
67
|
if auto_scb_port:
|
|
63
|
-
|
|
64
|
-
generated_port = s.server_address[1]
|
|
65
|
-
display.display(f"Using auto generated port {generated_port} for scb proxy port",
|
|
66
|
-
color='blue',
|
|
67
|
-
stderr=True)
|
|
68
|
+
generated_port = self.get_random_generated_port()
|
|
68
69
|
else:
|
|
69
70
|
generated_port = scb_config_port
|
|
70
71
|
display.display(f"Using port {generated_port} from cluster config for scb proxy port",
|
|
71
|
-
|
|
72
|
-
|
|
72
|
+
color='blue',
|
|
73
|
+
stderr=True)
|
|
73
74
|
|
|
74
75
|
with open(ssh_config_port_path, 'w') as f:
|
|
75
76
|
f.write(str(generated_port))
|
|
@@ -77,22 +78,32 @@ class SshConfigGenerator(object):
|
|
|
77
78
|
|
|
78
79
|
return generated_port
|
|
79
80
|
|
|
80
|
-
|
|
81
|
-
@staticmethod
|
|
82
|
-
def get_ssh_scb_proxy_port(ssh_config_path):
|
|
81
|
+
def get_ssh_proxy_port(self, ssh_config_path):
|
|
83
82
|
ssh_port_path = ssh_config_path.replace("_tpl", "_port")
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
scb_proxy_port=scb_proxy_port
|
|
92
|
-
)
|
|
83
|
+
return Path(ssh_port_path).read_text()
|
|
84
|
+
|
|
85
|
+
def generate_ssh_config_from_template(self, ssh_config_tpl_path, **template_vars):
|
|
86
|
+
ssh_config_content = Path(ssh_config_tpl_path).read_text().format(**template_vars)
|
|
87
|
+
return self.generate_file_from_template(ssh_config_tpl_path, ssh_config_content)
|
|
88
|
+
|
|
89
|
+
def generate_file_from_template(self, ssh_config_tpl_path, ssh_config_content):
|
|
93
90
|
ssh_config_path = ssh_config_tpl_path.removesuffix("_tpl")
|
|
94
91
|
with open(ssh_config_path, 'w') as f:
|
|
95
92
|
f.write(ssh_config_content)
|
|
96
93
|
os.fchmod(f.fileno(), 0o644)
|
|
97
|
-
|
|
98
94
|
return ssh_config_path
|
|
95
|
+
|
|
96
|
+
def get_proxy_type_information(self, cluster_config, args):
|
|
97
|
+
scb_settings = cluster_config.get('scb', {})
|
|
98
|
+
scb_enabled = scb_settings.get('enabled') and args.use_scb
|
|
99
|
+
teleport_settings = cluster_config.get('teleport', {})
|
|
100
|
+
teleport_enabled = teleport_settings.get('enabled') and args.use_teleport
|
|
101
|
+
return scb_enabled, teleport_enabled
|
|
102
|
+
|
|
103
|
+
def get_random_generated_port(self):
|
|
104
|
+
with socketserver.TCPServer(("localhost", 0), None) as s:
|
|
105
|
+
generated_port = s.server_address[1]
|
|
106
|
+
display.display(f"Using auto generated port {generated_port} for scb proxy port",
|
|
107
|
+
color='blue',
|
|
108
|
+
stderr=True)
|
|
109
|
+
return generated_port
|
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
Metadata-Version: 2.
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
2
|
Name: ops-cli
|
|
3
|
-
Version: 2.
|
|
3
|
+
Version: 2.4.0
|
|
4
4
|
Summary: Ops - wrapper for Terraform, Ansible, and SSH for cloud automation
|
|
5
5
|
Home-page: https://github.com/adobe/ops-cli
|
|
6
6
|
Author: Adobe
|
|
@@ -26,12 +26,12 @@ License-File: LICENSE
|
|
|
26
26
|
Requires-Dist: adal==1.2.7
|
|
27
27
|
Requires-Dist: ansible==8.7.0; python_version >= "3.9"
|
|
28
28
|
Requires-Dist: ansible-core==2.15.13; python_version >= "3.9"
|
|
29
|
-
Requires-Dist: awscli==1.
|
|
29
|
+
Requires-Dist: awscli==1.42.30; python_version >= "3.9"
|
|
30
30
|
Requires-Dist: azure==4.0.0
|
|
31
31
|
Requires-Dist: azure-applicationinsights==0.1.1
|
|
32
32
|
Requires-Dist: azure-batch==4.1.3
|
|
33
33
|
Requires-Dist: azure-common==1.1.28
|
|
34
|
-
Requires-Dist: azure-core==1.
|
|
34
|
+
Requires-Dist: azure-core==1.38.0; python_version >= "3.9"
|
|
35
35
|
Requires-Dist: azure-cosmosdb-nspkg==2.0.2
|
|
36
36
|
Requires-Dist: azure-cosmosdb-table==1.0.6
|
|
37
37
|
Requires-Dist: azure-datalake-store==0.0.53
|
|
@@ -110,58 +110,55 @@ Requires-Dist: azure-storage-blob==1.5.0
|
|
|
110
110
|
Requires-Dist: azure-storage-common==1.4.2
|
|
111
111
|
Requires-Dist: azure-storage-file==1.4.0
|
|
112
112
|
Requires-Dist: azure-storage-queue==1.4.0
|
|
113
|
-
Requires-Dist:
|
|
114
|
-
Requires-Dist:
|
|
115
|
-
Requires-Dist:
|
|
116
|
-
Requires-Dist:
|
|
117
|
-
Requires-Dist:
|
|
118
|
-
Requires-Dist:
|
|
119
|
-
Requires-Dist:
|
|
120
|
-
Requires-Dist:
|
|
121
|
-
Requires-Dist:
|
|
122
|
-
Requires-Dist:
|
|
123
|
-
Requires-Dist: docutils==0.16; python_version >= "2.7" and python_version not in "3.0, 3.1, 3.2, 3.3, 3.4"
|
|
113
|
+
Requires-Dist: boto3==1.40.30; python_version >= "3.9"
|
|
114
|
+
Requires-Dist: botocore==1.40.30; python_version >= "3.9"
|
|
115
|
+
Requires-Dist: certifi==2026.1.4; python_version >= "3.7"
|
|
116
|
+
Requires-Dist: cffi==2.0.0; python_version >= "3.9"
|
|
117
|
+
Requires-Dist: charset-normalizer==3.4.4; python_version >= "3.7"
|
|
118
|
+
Requires-Dist: colorama==0.4.6; python_version >= "2.7" and python_version not in "3.0, 3.1, 3.2, 3.3, 3.4, 3.5, 3.6"
|
|
119
|
+
Requires-Dist: cryptography==46.0.4; python_version >= "3.8" and python_full_version not in "3.9.0, 3.9.1"
|
|
120
|
+
Requires-Dist: deepmerge==2.0; python_version >= "3.8"
|
|
121
|
+
Requires-Dist: docutils==0.19; python_version >= "3.7"
|
|
122
|
+
Requires-Dist: durationpy==0.10
|
|
124
123
|
Requires-Dist: gitdb==4.0.12; python_version >= "3.7"
|
|
125
|
-
Requires-Dist: gitpython==3.1.
|
|
126
|
-
Requires-Dist: google-auth==2.
|
|
124
|
+
Requires-Dist: gitpython==3.1.46; python_version >= "3.7"
|
|
125
|
+
Requires-Dist: google-auth==2.48.0; python_version >= "3.8"
|
|
127
126
|
Requires-Dist: hashmerge==0.2
|
|
128
|
-
Requires-Dist: himl==0.
|
|
127
|
+
Requires-Dist: himl==0.18.0; python_version >= "3.9"
|
|
129
128
|
Requires-Dist: hvac==1.2.1; python_full_version >= "3.6.2" and python_full_version < "4.0.0"
|
|
130
|
-
Requires-Dist: idna==3.
|
|
129
|
+
Requires-Dist: idna==3.11; python_version >= "3.8"
|
|
131
130
|
Requires-Dist: inflection==0.5.1; python_version >= "3.5"
|
|
132
131
|
Requires-Dist: isodate==0.7.2; python_version >= "3.7"
|
|
133
|
-
Requires-Dist: jinja2==3.1.
|
|
134
|
-
Requires-Dist: jmespath==1.0
|
|
135
|
-
Requires-Dist: kubernetes==
|
|
136
|
-
Requires-Dist:
|
|
137
|
-
Requires-Dist:
|
|
138
|
-
Requires-Dist: msal==1.31.1; python_version >= "3.7"
|
|
132
|
+
Requires-Dist: jinja2==3.1.6; python_version >= "3.7"
|
|
133
|
+
Requires-Dist: jmespath==1.1.0; python_version >= "3.9"
|
|
134
|
+
Requires-Dist: kubernetes==33.1.0; python_version >= "3.6"
|
|
135
|
+
Requires-Dist: markupsafe==3.0.3; python_version >= "3.9"
|
|
136
|
+
Requires-Dist: msal==1.34.0; python_version >= "3.8"
|
|
139
137
|
Requires-Dist: msrest==0.7.1; python_version >= "3.6"
|
|
140
138
|
Requires-Dist: msrestazure==0.6.4
|
|
141
|
-
Requires-Dist: oauthlib==3.
|
|
142
|
-
Requires-Dist: packaging==
|
|
139
|
+
Requires-Dist: oauthlib==3.3.1; python_version >= "3.8"
|
|
140
|
+
Requires-Dist: packaging==26.0; python_version >= "3.8"
|
|
143
141
|
Requires-Dist: passgen==1.1.1
|
|
144
142
|
Requires-Dist: pathlib2==2.3.7.post1
|
|
145
|
-
Requires-Dist: pyasn1==0.6.
|
|
146
|
-
Requires-Dist: pyasn1-modules==0.4.
|
|
147
|
-
Requires-Dist: pycparser==
|
|
143
|
+
Requires-Dist: pyasn1==0.6.2; python_version >= "3.8"
|
|
144
|
+
Requires-Dist: pyasn1-modules==0.4.2; python_version >= "3.8"
|
|
145
|
+
Requires-Dist: pycparser==3.0; python_version >= "3.10"
|
|
148
146
|
Requires-Dist: pyhcl==0.4.5
|
|
149
|
-
Requires-Dist: pyjwt[crypto]==2.
|
|
147
|
+
Requires-Dist: pyjwt[crypto]==2.11.0; python_version >= "3.9"
|
|
150
148
|
Requires-Dist: python-consul==1.1.0
|
|
151
149
|
Requires-Dist: python-dateutil==2.9.0.post0; python_version >= "2.7" and python_version not in "3.0, 3.1, 3.2"
|
|
152
150
|
Requires-Dist: pyyaml==6.0.1; python_version >= "3.6"
|
|
153
|
-
Requires-Dist: requests==2.32.
|
|
151
|
+
Requires-Dist: requests==2.32.5; python_version >= "3.9"
|
|
154
152
|
Requires-Dist: requests-oauthlib==2.0.0; python_version >= "3.4"
|
|
155
153
|
Requires-Dist: resolvelib==1.0.1
|
|
156
154
|
Requires-Dist: rsa==4.7.2; python_version >= "3.5" and python_version < "4"
|
|
157
|
-
Requires-Dist: s3transfer==0.
|
|
158
|
-
Requires-Dist: setuptools==75.8.0; python_version >= "3.9"
|
|
155
|
+
Requires-Dist: s3transfer==0.14.0; python_version >= "3.9"
|
|
159
156
|
Requires-Dist: simpledi==0.4.1
|
|
160
157
|
Requires-Dist: six==1.17.0; python_version >= "2.7" and python_version not in "3.0, 3.1, 3.2"
|
|
161
158
|
Requires-Dist: smmap==5.0.2; python_version >= "3.7"
|
|
162
|
-
Requires-Dist: typing-extensions==4.
|
|
163
|
-
Requires-Dist: urllib3==2.0
|
|
164
|
-
Requires-Dist: websocket-client==1.
|
|
159
|
+
Requires-Dist: typing-extensions==4.15.0; python_version >= "3.9"
|
|
160
|
+
Requires-Dist: urllib3==2.6.0; python_version >= "3.9"
|
|
161
|
+
Requires-Dist: websocket-client==1.9.0; python_version >= "3.9"
|
|
165
162
|
Dynamic: author
|
|
166
163
|
Dynamic: author-email
|
|
167
164
|
Dynamic: classifier
|
|
@@ -169,6 +166,7 @@ Dynamic: description
|
|
|
169
166
|
Dynamic: description-content-type
|
|
170
167
|
Dynamic: home-page
|
|
171
168
|
Dynamic: license
|
|
169
|
+
Dynamic: license-file
|
|
172
170
|
Dynamic: requires-dist
|
|
173
171
|
Dynamic: requires-python
|
|
174
172
|
Dynamic: summary
|
|
@@ -326,7 +324,7 @@ workon ops
|
|
|
326
324
|
# uninstall previous `ops` version (if you have it)
|
|
327
325
|
pip uninstall ops --yes
|
|
328
326
|
|
|
329
|
-
# install ops-cli v2.
|
|
327
|
+
# install ops-cli v2.4.0 stable release
|
|
330
328
|
pip install --upgrade ops-cli
|
|
331
329
|
```
|
|
332
330
|
|
|
@@ -342,7 +340,7 @@ You can try out `ops-cli`, by using docker. The docker image has all required pr
|
|
|
342
340
|
|
|
343
341
|
To start out a container, running the latest `ops-cli` docker image run:
|
|
344
342
|
```sh
|
|
345
|
-
docker run -it ghcr.io/adobe/ops-cli:2.
|
|
343
|
+
docker run -it ghcr.io/adobe/ops-cli:2.4.0 bash
|
|
346
344
|
```
|
|
347
345
|
|
|
348
346
|
After the container has started, you can start using `ops-cli`:
|
|
@@ -713,6 +711,23 @@ If there are connection issues double check that the proxy is running.
|
|
|
713
711
|
...
|
|
714
712
|
```
|
|
715
713
|
|
|
714
|
+
#### Teleport
|
|
715
|
+
Teleport (https://goteleport.com/) provides secretless SSH.
|
|
716
|
+
`ops` has support for using Teleport as ssh for the following operations: `ssh, tunnel, proxy, ansible play, run and sync`
|
|
717
|
+
|
|
718
|
+
In order to use Teleport an extra section needs to be added to the cluster config file:
|
|
719
|
+
***
|
|
720
|
+
```
|
|
721
|
+
inventory:
|
|
722
|
+
- plugin: cns
|
|
723
|
+
args:
|
|
724
|
+
teleport_enabled: True -> add this to existing configuration
|
|
725
|
+
|
|
726
|
+
|
|
727
|
+
teleport:
|
|
728
|
+
enabled: true -> add this whole block
|
|
729
|
+
```
|
|
730
|
+
|
|
716
731
|
### Play
|
|
717
732
|
|
|
718
733
|
Run an ansible playbook.
|
|
@@ -937,7 +952,7 @@ env LDFLAGS="-L$(brew --prefix openssl)/lib" CFLAGS="-I$(brew --prefix openssl)/
|
|
|
937
952
|
|
|
938
953
|
## Running tests
|
|
939
954
|
|
|
940
|
-
- on your machine: `
|
|
955
|
+
- on your machine: `python -m pytest tests` or `build_scripts/run_tests.sh`
|
|
941
956
|
|
|
942
957
|
# Troubleshooting
|
|
943
958
|
|
|
@@ -12,18 +12,18 @@ ops/ansible/filter_plugins/commonfilters.py,sha256=Ya1fKYAsoqYsuLbzP48Oaz2AYWfne
|
|
|
12
12
|
ops/ansible/vars_plugins/__init__.py,sha256=v9uW7YO2C7EC4MQWr_TMH_K-1ALd0uLl9vZ_h3xCQkg,596
|
|
13
13
|
ops/ansible/vars_plugins/clusterconfig.py,sha256=qmIrTp1V0gLdbtBI5A0HMmICe8z0xUq7LzO3pmIJlsI,1378
|
|
14
14
|
ops/ansible/vars_plugins/opsconfig.py,sha256=lfKCD7XRoDTW80QXEGcm3tV6ASnzoCGdxy5zpaimT4k,1615
|
|
15
|
-
ops/cli/__init__.py,sha256=
|
|
15
|
+
ops/cli/__init__.py,sha256=npLihw242EI2DzH7pkAgZnTp7GgPxh9fdHnzANuYFRQ,1562
|
|
16
16
|
ops/cli/aws.py,sha256=OsmpOVv1924_lvNESeElLgcEi1caYTnI88C7wzXie78,911
|
|
17
17
|
ops/cli/config.py,sha256=T0ZfW1bWdSr1dF4oqsvTjUtUiCr7RVm4_rFdezgGCqM,7629
|
|
18
18
|
ops/cli/config_generator.py,sha256=ARGp5kAgdMLqtrHyQb_dE9sEuM1bAnVddfWDxd_5OdA,1718
|
|
19
19
|
ops/cli/helmfile.py,sha256=2ipKyFMY5M87U1mXDQle_UsOXICdQ-0VX2f97TAg0I8,6354
|
|
20
20
|
ops/cli/inventory.py,sha256=kSqNw978_P7OrFzLng5ipzuJBh6WqtvjvLDtiNwFQ8A,3109
|
|
21
21
|
ops/cli/packer.py,sha256=GAgi6uPWO0wv3uIEwl0O2sb4fQtpCbL1561nP8Y_Lpc,2626
|
|
22
|
-
ops/cli/parser.py,sha256=
|
|
23
|
-
ops/cli/playbook.py,sha256=
|
|
24
|
-
ops/cli/run.py,sha256=
|
|
25
|
-
ops/cli/ssh.py,sha256=
|
|
26
|
-
ops/cli/sync.py,sha256=
|
|
22
|
+
ops/cli/parser.py,sha256=YGxYgMwduTnTTjNNWXYZoasBOZ9LUkE4wlx8WIsOqiE,4511
|
|
23
|
+
ops/cli/playbook.py,sha256=5WaYFgt2wRMycPoRWDRSICXPUfYRlxHwpe9GhezytR4,5426
|
|
24
|
+
ops/cli/run.py,sha256=qvhikkF4Wjbn8CH4sj_e_jI_fZ8id-Q7VmlTtnXOjxs,3446
|
|
25
|
+
ops/cli/ssh.py,sha256=nehRK6CEyfybMgC_O_04Cl1lj1dbsJj1Is2xzec9Fu8,20426
|
|
26
|
+
ops/cli/sync.py,sha256=_eImwQWm125VZ-_NPUkeXCjROH0JDp6thBgAMmt2N6E,7057
|
|
27
27
|
ops/cli/terraform.py,sha256=XeLXr_nFpdnsHdEfAFj_oiurvIhDQv-xE3L-NpMTcB0,12446
|
|
28
28
|
ops/data/ansible/ansible.cfg,sha256=LE0Ve37qwQnbM1AJfHX442yh6HMMzM1FPCTCUKZElV8,186
|
|
29
29
|
ops/data/ansible/tasks/deploy_prometheus_alert_rules.yml,sha256=JAtUk9s7r4Nd5s5BC6BzlhyZZr6aPGWVeyrd6HocDGI,888
|
|
@@ -31,29 +31,30 @@ ops/data/ansible/tasks/install_rpm.yml,sha256=DAGcuQnKeeJQd-DJByfQTL1MeNPguhJjWq
|
|
|
31
31
|
ops/data/ansible/tasks/remove_prometheus_alert_rules.yml,sha256=LBNKpuAwY3gtyOrdD3EcHDVg9fMxUK4_uocOjiFaLuY,1397
|
|
32
32
|
ops/data/ssh/ssh.config,sha256=nFgQkQ9VGLYCFKj9rm0KvuHtC-jHK5QCBFRv_XSKHxg,1208
|
|
33
33
|
ops/data/ssh/ssh.scb.proxy.config.tpl,sha256=iFoNY3N_Oz1ADTX_B2NEoK4ysMngGLmpC7uUeQUSx8E,536
|
|
34
|
+
ops/data/ssh/ssh.teleport.config.tpl,sha256=ARGcK1VONCNnFEX3P9Q3hRuulkBx6C4dOZFCr70eb5k,278
|
|
34
35
|
ops/data/ssh/ssh.tunnel.config,sha256=G12kyR8FAnoZKGNUSE01oQnwMQ8gTJJt3HXBiERRWHo,364
|
|
35
36
|
ops/data/terraform/terraformrc,sha256=500iIw-7B3IwASuuO-0xqWolbdGbwmsneitIAcVw6hE,53
|
|
36
37
|
ops/hierarchical/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
37
38
|
ops/hierarchical/composition_config_generator.py,sha256=tOW5bl7frRFJOLtFGBv0HzSFPCEAp2YViRF071sTAq4,7219
|
|
38
39
|
ops/inventory/SKMS.py,sha256=jv4nHTKbDmJBTNf3HgGyKSfAJ10akKdDxNoGOcPehfo,17616
|
|
39
|
-
ops/inventory/__init__.py,sha256=
|
|
40
|
+
ops/inventory/__init__.py,sha256=WwwekRxaxU6S_HXSqZe-2XFkW59j0CAUh1OU6xadtHo,1119
|
|
40
41
|
ops/inventory/azurerm.py,sha256=pWZqbZP9-7ChHNK1DwfSlt9hU6lcJjvNXmvduR2G5tQ,33507
|
|
41
42
|
ops/inventory/caching.py,sha256=G2sJJ7nPHCqNBz3Vh0vxrFWQZt6B_11yS50GeZ8_1Bs,2000
|
|
42
|
-
ops/inventory/ec2inventory.py,sha256=
|
|
43
|
+
ops/inventory/ec2inventory.py,sha256=AmUnCWfoYYKmk35sxfzMs0Pst35LDkqeCx2mT6PpVtU,11208
|
|
43
44
|
ops/inventory/generator.py,sha256=BcjUxMoWTnmx-P_S-1fLjqG3PUhyMyAO2S-037KG5q0,10885
|
|
44
|
-
ops/inventory/sshconfig.py,sha256=
|
|
45
|
+
ops/inventory/sshconfig.py,sha256=8j5hwTjAE9ua0mcIl0jMp8vE5cz6kNRWc9nQZ_WNbL4,5437
|
|
45
46
|
ops/inventory/plugin/__init__.py,sha256=kSOYPdE9T0Ixe6tQUYoOl_udNbFZdVatvRf6bkf92aE,725
|
|
46
47
|
ops/inventory/plugin/azr.py,sha256=8-G3mEc0WQV7ZhJ4z6X-Wo90nQ7zAzHwgLpV4l_gbjo,5910
|
|
47
|
-
ops/inventory/plugin/cns.py,sha256=
|
|
48
|
-
ops/inventory/plugin/ec2.py,sha256=
|
|
48
|
+
ops/inventory/plugin/cns.py,sha256=7VeQSaoy4v3gDSbej7TdcpxuCC6_DkH-kLRj5Z9Urv4,1833
|
|
49
|
+
ops/inventory/plugin/ec2.py,sha256=ySlR8l_uPE-RLxBBxUdaGVy1EeaBbs0zP6_BLpdoj5w,1468
|
|
49
50
|
ops/inventory/plugin/legacy_pcs.py,sha256=ii1kOkwD9PIaOlleJa3_yE95ojCoPlBPKSBu5naZhxw,1184
|
|
50
51
|
ops/inventory/plugin/skms.py,sha256=pFMpvfbXCf0KPgEOj5oAD7UzpUZ5zL1lcN4wESOjPuI,8517
|
|
51
52
|
ops/jinja/__init__.py,sha256=Nvmvb1edSAehNKYyroo26Jrxjzbu_TOCBS8Y9mMEOyA,1743
|
|
52
53
|
ops/terraform/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
53
54
|
ops/terraform/terraform_cmd_generator.py,sha256=5yfzS7aOOjVDUYHwBadFvPjWKEFJWk9SEOhTZHywlG0,21728
|
|
54
|
-
ops_cli-2.
|
|
55
|
-
ops_cli-2.
|
|
56
|
-
ops_cli-2.
|
|
57
|
-
ops_cli-2.
|
|
58
|
-
ops_cli-2.
|
|
59
|
-
ops_cli-2.
|
|
55
|
+
ops_cli-2.4.0.dist-info/licenses/LICENSE,sha256=ff5lJoiLrFF1nJn5pRJiuicRqMEqBn8hgWCd2aQGa4Q,11335
|
|
56
|
+
ops_cli-2.4.0.dist-info/METADATA,sha256=4coN_Fja0ywn0cXXVwJ_w9mpn1Xr6hMX-C9RvAPpPTU,40163
|
|
57
|
+
ops_cli-2.4.0.dist-info/WHEEL,sha256=wUyA8OaulRlbfwMtmQsvNngGrxQHAvkKcvRmdizlJi0,92
|
|
58
|
+
ops_cli-2.4.0.dist-info/entry_points.txt,sha256=maaS2Tf8WvxMXckssedK13LXegD9jgHB2AT8xiEfVpQ,37
|
|
59
|
+
ops_cli-2.4.0.dist-info/top_level.txt,sha256=enC05wWafSg8iDKIvj3gvtAtEP2kYCyN5Gmd689q-_I,4
|
|
60
|
+
ops_cli-2.4.0.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|
|
File without changes
|