outerbounds 0.6.1__py3-none-any.whl → 0.8.0rc1__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.
@@ -8,7 +8,8 @@ import json
8
8
  import os
9
9
  from os import path
10
10
  from pathlib import Path
11
- from ..utils import kubeconfig, metaflowconfig
11
+ from ..utils import kubeconfig, metaflowconfig, ssh_utils
12
+ from ..utils.kubectl_utils import exec_in_pod, cp_to_pod
12
13
  from requests.exceptions import HTTPError
13
14
  import platform
14
15
  import subprocess
@@ -23,6 +24,7 @@ from .perimeters_cli import (
23
24
  get_perimeters_from_api_or_fail_command,
24
25
  confirm_user_has_access_to_perimeter_or_fail,
25
26
  )
27
+ import sys
26
28
 
27
29
  KUBECTL_INSTALL_MITIGATION = "Please install kubectl manually from https://kubernetes.io/docs/tasks/tools/#kubectl"
28
30
 
@@ -594,3 +596,132 @@ def show_relevant_links(config_dir=None, profile=None, perimeter_id="", output="
594
596
  else:
595
597
  click.secho("Failed to show relevant links", fg="red", err=True)
596
598
  click.secho("Error: {}".format(str(e)), fg="red", err=True)
599
+
600
+
601
+ @cli.command(help="Prepare a workstation for SSH access")
602
+ @click.option(
603
+ "--workstation-id",
604
+ default="",
605
+ help="The name of the workstation on the Outerbounds UI",
606
+ )
607
+ @click.option(
608
+ "--setup-context",
609
+ "-c",
610
+ default="local",
611
+ help="The context to use for the setup command",
612
+ type=click.Choice(["local", "remote"]),
613
+ )
614
+ @click.option(
615
+ "--output",
616
+ "-o",
617
+ default="",
618
+ help="Show output in the specified format.",
619
+ type=click.Choice(["json", ""]),
620
+ )
621
+ def prepare_for_ssh_access(workstation_id="", setup_context="", output=""):
622
+ """
623
+ Finds the pod whose WORKSTATION_NAME env var matches `workstation_name`
624
+ and opens an interactive bash shell in its "workstation" container.
625
+ """
626
+ response = {
627
+ "status": "OK",
628
+ "message": "SSH access prepared successfully",
629
+ }
630
+ try:
631
+ if setup_context == "local":
632
+ prepare_for_ssh_access_local(workstation_id)
633
+ elif setup_context == "remote":
634
+ prepare_for_ssh_access_remote()
635
+ except Exception as e:
636
+ response["message"] = str(e)
637
+ response["status"] = "FAIL"
638
+ if output == "json":
639
+ click.echo(json.dumps(response, indent=4))
640
+ else:
641
+ click.secho(f"Failed to prepare for SSH access: {str(e)}", fg="red")
642
+ click.secho("Error: {}".format(str(e)), fg="red")
643
+
644
+ if output == "json":
645
+ click.echo(json.dumps(response, indent=4))
646
+ else:
647
+ click.secho(
648
+ f"SSH access prepared successfully: {response['message']}", fg="green"
649
+ )
650
+
651
+
652
+ def prepare_for_ssh_access_local(workstation_id):
653
+ """
654
+ SSH connection requires both local instance and workstation pod to do some work.
655
+ This function takes care of the local instance. It does the following:
656
+
657
+ 1. Creates a new ssh key pair, or re-uses an existing one.
658
+ 2. Copies the public key of the key pair to the workstation.
659
+ 3. Executes the `outerbounds prepare-ws-for-ssh-access` command on the workstation. This command would setup the workstation for remote access.
660
+ 4. Ensures that the SSH config is setup with the right parameters.
661
+ """
662
+ pod_name = f"{workstation_id}-0"
663
+
664
+ private_key_path, public_key_path = ssh_utils.create_ssh_key_pair()
665
+
666
+ # Create the .ssh directory if it doesn't exist
667
+ result, stdout, stderr = exec_in_pod(
668
+ pod_name,
669
+ "ws-69304f8188e31a0745bace40b9378c6b",
670
+ "mkdir -p /home/ob-workspace/.ssh",
671
+ )
672
+ if result != 0:
673
+ raise Exception(f"Failed to create .ssh directory: {stderr}")
674
+
675
+ # Copy the public key to the workstation
676
+ result, stdout, stderr = cp_to_pod(
677
+ pod_name,
678
+ "ws-69304f8188e31a0745bace40b9378c6b",
679
+ public_key_path,
680
+ f"/home/ob-workspace/.ssh/{ssh_utils.EXPECTED_PUBLIC_KEY_NAME}",
681
+ )
682
+ if result != 0:
683
+ raise Exception(f"Failed to copy public key to workstation: {stderr}")
684
+
685
+ # 4. Exec into the pod
686
+ result, stdout, stderr = exec_in_pod(
687
+ pod_name,
688
+ "ws-69304f8188e31a0745bace40b9378c6b",
689
+ "outerbounds prepare-for-ssh-access -c remote",
690
+ )
691
+ if result != 0:
692
+ raise Exception(f"Failed to exec into workstation: {stderr}")
693
+
694
+ # 5. Add the entry to the ssh config file
695
+ ok, msg = ssh_utils.add_entry_to_ssh_config(
696
+ workstation_id, "ws-69304f8188e31a0745bace40b9378c6b", private_key_path
697
+ )
698
+ if not ok:
699
+ raise Exception(f"Failed to add entry to ssh config: {msg}")
700
+
701
+
702
+ def prepare_for_ssh_access_remote():
703
+ """
704
+ SSH connection requires both local instance and workstation pod to do some work.
705
+ This function takes care of the remote instance.
706
+
707
+ For now, it assumes:
708
+
709
+ 1. The image of the workstation already has openssh-server, netcat installed.
710
+ """
711
+
712
+ if "WORKSTATION_ID" not in os.environ:
713
+ raise Exception("This can only be run from a workstation!")
714
+
715
+ ok, message = ssh_utils.best_effort_install_ssh_netcat()
716
+ if not ok:
717
+ raise Exception(f"Failed to install ssh and netcat: {message}")
718
+
719
+ ok, msg = ssh_utils.configure_ssh_server()
720
+ if not ok:
721
+ raise Exception(f"Failed to configure ssh server: {msg}")
722
+
723
+ ok, msg = ssh_utils.ensure_public_key_registered_in_ssh_agent()
724
+ if not ok:
725
+ raise Exception(f"Failed to ensure public key registered in ssh agent: {msg}")
726
+
727
+ ssh_utils.add_env_loader_to_bashrc()
@@ -0,0 +1,68 @@
1
+ import os
2
+ import subprocess
3
+ from typing import Tuple
4
+
5
+
6
+ def exec_in_pod(pod_name: str, namespace: str, command: str) -> Tuple[int, str]:
7
+ """
8
+ Executes a command using kubectl exec in the remote pod.
9
+ """
10
+
11
+ try:
12
+ result = subprocess.run(
13
+ [
14
+ "kubectl",
15
+ "--context",
16
+ "outerbounds-workstations",
17
+ "exec",
18
+ "-i",
19
+ pod_name,
20
+ "-n",
21
+ namespace,
22
+ "-c",
23
+ "workstation",
24
+ "--",
25
+ "sh",
26
+ "-c",
27
+ command,
28
+ ],
29
+ capture_output=True,
30
+ text=True,
31
+ check=True,
32
+ )
33
+ return result.returncode, result.stdout, result.stderr
34
+ except subprocess.CalledProcessError as e:
35
+ return e.returncode, "", f"Error executing command: {e.stderr}"
36
+ except Exception as e:
37
+ return 1, "", f"Error executing command: {e}"
38
+
39
+
40
+ def cp_to_pod(
41
+ pod_name: str, namespace: str, source_on_local: str, destination_on_pod: str
42
+ ) -> Tuple[int, str]:
43
+ """
44
+ Copies a file to a pod using kubectl cp.
45
+ """
46
+ try:
47
+ result = subprocess.run(
48
+ [
49
+ "kubectl",
50
+ "--context",
51
+ "outerbounds-workstations",
52
+ "--container",
53
+ "workstation",
54
+ "cp",
55
+ source_on_local,
56
+ "-n",
57
+ namespace,
58
+ f"{pod_name}:{destination_on_pod}",
59
+ ],
60
+ capture_output=True,
61
+ text=True,
62
+ check=True,
63
+ )
64
+ return result.returncode, result.stdout, result.stderr
65
+ except subprocess.CalledProcessError as e:
66
+ return e.returncode, "", f"Error copying file: {e.stderr}"
67
+ except Exception as e:
68
+ return 1, "", f"Error copying file: {e}"
@@ -0,0 +1,540 @@
1
+ import os
2
+ import shutil
3
+ import subprocess
4
+ from typing import Tuple
5
+ from pathlib import Path
6
+ import re
7
+
8
+ EXPECTED_PUBLIC_KEY_NAME = "ob_workstation_ed25519.pem.pub"
9
+ EXPECTED_PRIVATE_KEY_NAME = "ob_workstation_ed25519.pem"
10
+
11
+
12
+ def ensure_e25519_keypair_sshkeygen(
13
+ directory,
14
+ private_key_filename=EXPECTED_PRIVATE_KEY_NAME,
15
+ public_key_filename=EXPECTED_PUBLIC_KEY_NAME,
16
+ password=None,
17
+ ):
18
+ """
19
+ Creates an Ed25519 key pair using ssh-keygen.
20
+ """
21
+
22
+ # Create directory if it doesn't exist
23
+ os.makedirs(directory, exist_ok=True)
24
+
25
+ private_key_path = os.path.join(directory, private_key_filename)
26
+ public_key_path = os.path.join(directory, public_key_filename)
27
+
28
+ # No need to regenerate keys if they already exist.
29
+ if os.path.exists(private_key_path) and os.path.exists(public_key_path):
30
+ return private_key_path, public_key_path
31
+
32
+ # Generate the Ed25519 key pair
33
+ result = subprocess.run(
34
+ ["ssh-keygen", "-t", "ed25519", "-f", private_key_path, "-N", ""],
35
+ capture_output=True,
36
+ text=True,
37
+ )
38
+ if result.returncode != 0:
39
+ raise Exception(f"Failed to generate Ed25519 key pair: {result.stderr}")
40
+
41
+ return private_key_path, public_key_path
42
+
43
+
44
+ def create_ssh_key_pair():
45
+ location = os.environ.get("METAFLOW_HOME", os.path.expanduser("~/.metaflowconfig"))
46
+ return ensure_e25519_keypair_sshkeygen(
47
+ location, EXPECTED_PRIVATE_KEY_NAME, EXPECTED_PUBLIC_KEY_NAME
48
+ )
49
+
50
+
51
+ def ensure_public_key_registered_in_ssh_agent():
52
+ """
53
+ This function ensures that the public key is registered in the ssh agent.
54
+ This means making sure that the public key is added to the list of AuthorizedKeysFile entries in the ssh_config file.
55
+ """
56
+ WORKSPACE_DIR = "/home/ob-workspace/.ssh"
57
+ AUTHORIZED_KEYS = f"{WORKSPACE_DIR}/authorized_keys"
58
+ SOURCE_KEY = f"{WORKSPACE_DIR}/{EXPECTED_PUBLIC_KEY_NAME}"
59
+
60
+ try:
61
+ # Step 2: Handle authorized_keys file
62
+ print("Setting up authorized_keys...")
63
+
64
+ # Create .ssh directory if it doesn't exist
65
+ Path(WORKSPACE_DIR).mkdir(parents=True, exist_ok=True, mode=0o700)
66
+
67
+ # Check if authorized_keys exists, if not copy from ob-workstation-key
68
+ if not os.path.exists(AUTHORIZED_KEYS):
69
+ if os.path.exists(SOURCE_KEY):
70
+ shutil.copy2(SOURCE_KEY, AUTHORIZED_KEYS)
71
+ print(f"Copied {SOURCE_KEY} to {AUTHORIZED_KEYS}")
72
+ else:
73
+ return False, f"Source key file not found: {SOURCE_KEY}"
74
+
75
+ # Set correct permissions for authorized_keys
76
+ os.chmod(AUTHORIZED_KEYS, 0o600)
77
+ return restart_ssh_service()
78
+ except Exception as e:
79
+ return False, f"Error during configuration: {str(e)}"
80
+
81
+
82
+ def restart_ssh_service():
83
+ """
84
+ This function restarts the ssh service.
85
+ """
86
+ print("Restarting SSH service...")
87
+
88
+ # Try different service names and init systems
89
+ restart_commands = [
90
+ ["systemctl", "restart", "sshd"],
91
+ ["systemctl", "restart", "ssh"],
92
+ ["service", "sshd", "restart"],
93
+ ["service", "ssh", "restart"],
94
+ ]
95
+
96
+ service_restarted = False
97
+ for cmd in restart_commands:
98
+ try:
99
+ result = subprocess.run(cmd, capture_output=True, text=True, check=True)
100
+ print(f"SSH service restarted using: {' '.join(cmd)}")
101
+ service_restarted = True
102
+ break
103
+ except (subprocess.CalledProcessError, FileNotFoundError):
104
+ continue
105
+
106
+ if not service_restarted:
107
+ print("Warning: Could not restart SSH service automatically")
108
+ print("Please restart manually with: sudo systemctl restart sshd")
109
+ return False, f"SSH service could not be restarted automatically."
110
+
111
+ return True, "SSH configuration completed successfully"
112
+
113
+
114
+ def configure_ssh_server() -> Tuple[bool, str]:
115
+ """
116
+ Configure SSH server to disable password authentication and set up authorized_keys.
117
+
118
+ Returns:
119
+ Tuple of (success: bool, message: str)
120
+ """
121
+
122
+ # Configuration paths
123
+ SSHD_CONFIG = "/etc/ssh/sshd_config"
124
+ SSHD_RUN_DIR = "/run/sshd"
125
+ HOME_DIR = "/home/ob-workspace"
126
+
127
+ try:
128
+ # Step 1: Create and configure /run/sshd directory
129
+ print("Setting up /run/sshd directory...")
130
+ Path(SSHD_RUN_DIR).mkdir(parents=True, exist_ok=True, mode=0o755)
131
+ os.chmod(SSHD_RUN_DIR, 0o755)
132
+ print(f"Created {SSHD_RUN_DIR} with permissions 755")
133
+
134
+ os.chmod(HOME_DIR, 0o755)
135
+
136
+ # Step 3: Modify sshd_config
137
+ print("Modifying SSH configuration...")
138
+
139
+ # Check if we have permission to modify sshd_config
140
+ if not os.access(SSHD_CONFIG, os.W_OK):
141
+ return False, f"No write permission for {SSHD_CONFIG}. Run with sudo."
142
+
143
+ # Create backup if requested
144
+ backup_path = f"{SSHD_CONFIG}.backup"
145
+ shutil.copy2(SSHD_CONFIG, backup_path)
146
+ print(f"Backup created: {backup_path}")
147
+
148
+ # Read current configuration
149
+ with open(SSHD_CONFIG, "r") as f:
150
+ lines = f.readlines()
151
+
152
+ # Configuration settings to apply
153
+ config_settings = {
154
+ "PasswordAuthentication": "no",
155
+ "ChallengeResponseAuthentication": "no",
156
+ "PubkeyAuthentication": "yes",
157
+ "AuthorizedKeysFile": "/home/ob-workspace/.ssh/authorized_keys .ssh/authorized_keys",
158
+ }
159
+
160
+ # Track which settings we've modified
161
+ modified_settings = set()
162
+ new_lines = []
163
+
164
+ for line in lines:
165
+ modified = False
166
+
167
+ for setting, value in config_settings.items():
168
+ # Check if this line contains the setting (commented or not)
169
+ pattern = rf"^\s*#?\s*{setting}\s+"
170
+ if re.match(pattern, line, re.IGNORECASE):
171
+ # Replace with our setting
172
+ new_lines.append(f"{setting} {value}\n")
173
+ modified_settings.add(setting)
174
+ modified = True
175
+ break
176
+
177
+ if not modified:
178
+ new_lines.append(line)
179
+
180
+ # Add any settings that weren't found in the file
181
+ for setting, value in config_settings.items():
182
+ if setting not in modified_settings:
183
+ new_lines.append(f"\n{setting} {value}\n")
184
+ print(f"Added new setting: {setting} {value}")
185
+
186
+ # Write the modified configuration
187
+ with open(SSHD_CONFIG, "w") as f:
188
+ f.writelines(new_lines)
189
+
190
+ print("Configuration file updated successfully")
191
+
192
+ # Step 4: Test configuration
193
+ print("Testing SSH configuration...")
194
+ result = subprocess.run(["sshd", "-t"], capture_output=True, text=True)
195
+
196
+ if result.returncode != 0:
197
+ return False, f"SSH configuration test failed: {result.stderr}"
198
+
199
+ print("Configuration test passed")
200
+
201
+ # Step 5: Restart SSH service if requested
202
+ return restart_ssh_service()
203
+
204
+ except Exception as e:
205
+ return False, f"Error during configuration: {str(e)}"
206
+
207
+
208
+ def add_entry_to_ssh_config(
209
+ workstation_id: str, namespace: str, private_key_path: str
210
+ ) -> Tuple[bool, str]:
211
+ """
212
+ Adds an entry to the ssh config file if one doesn't already exist.
213
+
214
+ Example Entry:
215
+ Host ws-bae81a70-0
216
+ ProxyCommand kubectl exec -i --context outerbounds-workstations ws-bae81a70-0 -n ws-69304f8188e31a0745bace40b9378c6b --container workstation -- nc localhost 22
217
+ User root
218
+ StrictHostKeyChecking no
219
+ UserKnownHostsFile /dev/null
220
+ IdentityFile ~/.ssh/id_ed25519
221
+ ControlMaster auto
222
+ ControlPath ~/.ssh/control-%h-%p-%r
223
+ ControlPersist 10m
224
+ """
225
+
226
+ with open(os.path.expanduser("~/.ssh/config"), "r") as f:
227
+ lines = f.readlines()
228
+
229
+ for line in lines:
230
+ if f"Host {workstation_id}-0" in line:
231
+ return True, "Entry already exists"
232
+
233
+ indent = " " * 2
234
+ with open(os.path.expanduser("~/.ssh/config"), "a") as f:
235
+ f.write("\n")
236
+ f.write(f"Host {workstation_id}-0\n")
237
+ f.write(
238
+ f"{indent}ProxyCommand kubectl exec -i --context outerbounds-workstations {workstation_id}-0 -n {namespace} --container workstation -- nc localhost 22\n"
239
+ )
240
+ f.write(f"{indent}User root\n")
241
+ f.write(f"{indent}StrictHostKeyChecking no\n")
242
+ f.write(f"{indent}UserKnownHostsFile /dev/null\n")
243
+ f.write(f"{indent}IdentityFile {private_key_path}\n")
244
+ f.write(f"{indent}ControlMaster auto\n")
245
+ f.write(f"{indent}ControlPath ~/.ssh/control-%h-%p-%r\n")
246
+ f.write(f"{indent}ControlPersist 10m\n")
247
+
248
+ return True, "Entry added to ssh config"
249
+
250
+
251
+ def add_env_loader_to_bashrc():
252
+ """
253
+ Adds the environment variable loader block to ~/.bashrc if not already present.
254
+ Uses marker comments to identify the block.
255
+ """
256
+
257
+ # Define the block content with marker comments
258
+ env_loader_block = """
259
+ # BEGIN WORKSTATION_ENV_LOADER
260
+ # Only proceed if WORKSTATION_ID is not already set
261
+ if [ -z "$WORKSTATION_ID" ]; then
262
+ # Read environment variables from /proc/1/environ (init process)
263
+ # This file contains null-separated env vars
264
+ if [ -r /proc/1/environ ]; then
265
+ while IFS= read -r -d '' var; do
266
+ # Extract the variable name (everything before the first '=')
267
+ var_name="${var%%=*}"
268
+
269
+ # Check if the variable name contains any of our keywords
270
+ if [[ "$var_name" == *"WORKSTATION"* ]] || \\
271
+ [[ "$var_name" == *"METAFLOW"* ]] || \\
272
+ [[ "$var_name" == *"AWS"* ]] || \\
273
+ [[ "$var_name" == *"GCP"* ]] || \\
274
+ [[ "$var_name" == *"CLOUDSDK"* ]] || \\
275
+ [[ "$var_name" == *"OBP"* ]]; then
276
+ # Export the variable to the current session
277
+ export "$var"
278
+ fi
279
+ done < /proc/1/environ
280
+ fi
281
+ fi
282
+ # END WORKSTATION_ENV_LOADER
283
+ """
284
+
285
+ # Get the path to ~/.bashrc
286
+ bashrc_path = Path.home() / ".bashrc"
287
+
288
+ # Create .bashrc if it doesn't exist
289
+ if not bashrc_path.exists():
290
+ bashrc_path.touch()
291
+ print(f"Created {bashrc_path}")
292
+
293
+ # Read the current content
294
+ try:
295
+ with open(bashrc_path, "r") as f:
296
+ current_content = f.read()
297
+ except Exception as e:
298
+ print(f"Error reading {bashrc_path}: {e}")
299
+ return False
300
+
301
+ # Check if the marker is already present
302
+ marker = "BEGIN WORKSTATION_ENV_LOADER"
303
+ if marker in current_content:
304
+ print(f"Environment loader block already present in {bashrc_path}")
305
+ return True
306
+
307
+ # Add the block to the end of the file
308
+ try:
309
+ with open(bashrc_path, "a") as f:
310
+ # Add a newline before our block if file doesn't end with one
311
+ if current_content and not current_content.endswith("\n"):
312
+ f.write("\n")
313
+ f.write(env_loader_block)
314
+ print(f"Successfully added environment loader block to {bashrc_path}")
315
+ return True
316
+ except Exception as e:
317
+ print(f"Error writing to {bashrc_path}: {e}")
318
+ return False
319
+
320
+
321
+ def best_effort_install_ssh_netcat():
322
+ """
323
+ Best effort installation of openssh-server and netcat.
324
+
325
+ Returns:
326
+ tuple: (success: bool, message: str)
327
+ """
328
+
329
+ def run_command(cmd, check=False):
330
+ """Helper to run shell commands"""
331
+ try:
332
+ result = subprocess.run(
333
+ cmd, shell=True, capture_output=True, text=True, timeout=30
334
+ )
335
+ return result.returncode == 0, result.stdout, result.stderr
336
+ except subprocess.TimeoutExpired:
337
+ return False, "", "Command timed out"
338
+ except Exception as e:
339
+ return False, "", str(e)
340
+
341
+ def check_command_exists(cmd):
342
+ """Check if a command exists in PATH"""
343
+ return shutil.which(cmd) is not None
344
+
345
+ def check_ssh_server():
346
+ """Check if SSH server is installed"""
347
+ # Check for sshd in common locations
348
+ sshd_paths = ["/usr/sbin/sshd", "/sbin/sshd"]
349
+ for path in sshd_paths:
350
+ if os.path.exists(path):
351
+ return True
352
+ # Also check if sshd is in PATH
353
+ return check_command_exists("sshd")
354
+
355
+ def check_sudo_access():
356
+ """Check if we have sudo access"""
357
+ success, _, _ = run_command("sudo -n true 2>/dev/null")
358
+ if success:
359
+ return True
360
+ # Try with -v flag (might prompt for password)
361
+ success, _, _ = run_command("sudo -v 2>/dev/null")
362
+ return success
363
+
364
+ def detect_package_manager():
365
+ """Detect which package manager is available"""
366
+ package_managers = {
367
+ "apt-get": {
368
+ "update": "apt-get update",
369
+ "install": "apt-get install -y",
370
+ "ssh_package": "openssh-server",
371
+ "nc_packages": ["netcat-openbsd", "netcat-traditional", "netcat"],
372
+ },
373
+ "apt": {
374
+ "update": "apt update",
375
+ "install": "apt install -y",
376
+ "ssh_package": "openssh-server",
377
+ "nc_packages": ["netcat-openbsd", "netcat-traditional", "netcat"],
378
+ },
379
+ "yum": {
380
+ "update": "yum makecache",
381
+ "install": "yum install -y",
382
+ "ssh_package": "openssh-server",
383
+ "nc_packages": ["nmap-ncat", "nc", "netcat"],
384
+ },
385
+ "dnf": {
386
+ "update": "dnf makecache",
387
+ "install": "dnf install -y",
388
+ "ssh_package": "openssh-server",
389
+ "nc_packages": ["nmap-ncat", "nc", "netcat"],
390
+ },
391
+ "zypper": {
392
+ "update": "zypper refresh",
393
+ "install": "zypper install -n",
394
+ "ssh_package": "openssh",
395
+ "nc_packages": ["netcat-openbsd", "gnu-netcat", "netcat"],
396
+ },
397
+ "pacman": {
398
+ "update": "pacman -Sy",
399
+ "install": "pacman -S --noconfirm",
400
+ "ssh_package": "openssh",
401
+ "nc_packages": ["gnu-netcat", "openbsd-netcat"],
402
+ },
403
+ "apk": {
404
+ "update": "apk update",
405
+ "install": "apk add --no-cache",
406
+ "ssh_package": "openssh-server",
407
+ "nc_packages": ["netcat-openbsd", "busybox"],
408
+ },
409
+ }
410
+
411
+ for pm, config in package_managers.items():
412
+ if check_command_exists(pm):
413
+ return pm, config
414
+ return None, None
415
+
416
+ def install_package(package, pm_config, use_sudo=True):
417
+ """Try to install a package"""
418
+ install_cmd = pm_config["install"]
419
+ if use_sudo:
420
+ cmd = f"sudo {install_cmd} {package}"
421
+ else:
422
+ cmd = f"{install_cmd} {package}"
423
+
424
+ success, stdout, stderr = run_command(cmd)
425
+ return success
426
+
427
+ # Step 1: Check if both are already installed
428
+ ssh_installed = check_ssh_server()
429
+ nc_installed = (
430
+ check_command_exists("nc")
431
+ or check_command_exists("netcat")
432
+ or check_command_exists("ncat")
433
+ )
434
+
435
+ if ssh_installed and nc_installed:
436
+ return True, "Both openssh-server and netcat are already installed"
437
+
438
+ # Prepare status messages
439
+ status = []
440
+ if ssh_installed:
441
+ status.append("openssh-server is already installed")
442
+ if nc_installed:
443
+ status.append("netcat is already installed")
444
+
445
+ # Step 2 & 3: Check sudo availability and access
446
+ has_sudo = check_command_exists("sudo")
447
+ has_sudo_access = has_sudo and check_sudo_access()
448
+
449
+ # Step 4: Detect package manager
450
+ pm_name, pm_config = detect_package_manager()
451
+
452
+ if not pm_name:
453
+ missing = []
454
+ if not ssh_installed:
455
+ missing.append("openssh-server")
456
+ if not nc_installed:
457
+ missing.append("netcat")
458
+ return (
459
+ False,
460
+ f"No supported package manager found. Unable to install: {', '.join(missing)}",
461
+ )
462
+
463
+ # Step 5 & 6: Try to install missing packages
464
+ use_sudo = has_sudo and has_sudo_access
465
+
466
+ # Update package manager cache first
467
+ update_cmd = pm_config["update"]
468
+ if use_sudo:
469
+ update_cmd = f"sudo {update_cmd}"
470
+
471
+ update_success, _, _ = run_command(update_cmd)
472
+ if not update_success:
473
+ status.append(
474
+ f"Warning: Failed to update package cache with '{pm_config['update']}'"
475
+ )
476
+
477
+ # Install SSH server if needed
478
+ if not ssh_installed:
479
+ ssh_package = pm_config["ssh_package"]
480
+ if install_package(ssh_package, pm_config, use_sudo):
481
+ status.append(f"Successfully installed {ssh_package}")
482
+ ssh_installed = check_ssh_server()
483
+ else:
484
+ status.append(f"Failed to install {ssh_package}")
485
+
486
+ # Install netcat if needed
487
+ if not nc_installed:
488
+ # Try different netcat package names
489
+ nc_packages = pm_config["nc_packages"]
490
+ nc_install_success = False
491
+
492
+ for nc_package in nc_packages:
493
+ if install_package(nc_package, pm_config, use_sudo):
494
+ status.append(f"Successfully installed {nc_package}")
495
+ nc_install_success = True
496
+ break
497
+
498
+ if not nc_install_success:
499
+ status.append(f"Failed to install netcat (tried: {', '.join(nc_packages)})")
500
+
501
+ nc_installed = (
502
+ check_command_exists("nc")
503
+ or check_command_exists("netcat")
504
+ or check_command_exists("ncat")
505
+ )
506
+
507
+ # Step 7: Final check and return appropriate message
508
+ ssh_final = check_ssh_server()
509
+ nc_final = (
510
+ check_command_exists("nc")
511
+ or check_command_exists("netcat")
512
+ or check_command_exists("ncat")
513
+ )
514
+
515
+ if ssh_final and nc_final:
516
+ return True, "Successfully installed all required packages. " + " ".join(status)
517
+ elif ssh_final or nc_final:
518
+ installed = []
519
+ missing = []
520
+ if ssh_final:
521
+ installed.append("openssh-server")
522
+ else:
523
+ missing.append("openssh-server")
524
+ if nc_final:
525
+ installed.append("netcat")
526
+ else:
527
+ missing.append("netcat")
528
+
529
+ msg = f"Partial success. Installed: {', '.join(installed)}. "
530
+ msg += f"Failed to install: {', '.join(missing)}. "
531
+ if not use_sudo and has_sudo:
532
+ msg += "Try running with sudo privileges."
533
+ return False, msg + " " + " ".join(status)
534
+ else:
535
+ msg = "Failed to install both openssh-server and netcat. "
536
+ if not use_sudo and has_sudo:
537
+ msg += "No sudo access available. Try running with sudo privileges. "
538
+ elif not has_sudo:
539
+ msg += "sudo is not installed. May need root access. "
540
+ return False, msg + " ".join(status)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: outerbounds
3
- Version: 0.6.1
3
+ Version: 0.8.0rc1
4
4
  Summary: More Data Science, Less Administration
5
5
  License: Proprietary
6
6
  Keywords: data science,machine learning,MLOps
@@ -14,7 +14,6 @@ Classifier: Programming Language :: Python :: 3.8
14
14
  Classifier: Programming Language :: Python :: 3.9
15
15
  Classifier: Programming Language :: Python :: 3.10
16
16
  Classifier: Programming Language :: Python :: 3.11
17
- Classifier: Programming Language :: Python :: 3.12
18
17
  Provides-Extra: azure
19
18
  Provides-Extra: gcp
20
19
  Provides-Extra: otel
@@ -28,9 +27,10 @@ Requires-Dist: google-auth (>=2.27.0,<3.0.0) ; extra == "gcp"
28
27
  Requires-Dist: google-cloud-secret-manager (>=2.20.0,<3.0.0) ; extra == "gcp"
29
28
  Requires-Dist: google-cloud-storage (>=2.14.0,<3.0.0) ; extra == "gcp"
30
29
  Requires-Dist: metaflow_checkpoint (==0.2.4)
31
- Requires-Dist: ob-metaflow (==2.17.0.1)
32
- Requires-Dist: ob-metaflow-extensions (==1.4.1)
33
- Requires-Dist: ob-metaflow-stubs (==6.0.6.1)
30
+ Requires-Dist: ob-metaflow (==2.17.1.0)
31
+ Requires-Dist: ob-metaflow-extensions (==1.4.4)
32
+ Requires-Dist: ob-metaflow-stubs (==6.0.7.0)
33
+ Requires-Dist: ob-project-utils (==0.1.16)
34
34
  Requires-Dist: opentelemetry-distro (>=0.41b0) ; extra == "otel"
35
35
  Requires-Dist: opentelemetry-exporter-otlp-proto-http (>=1.20.0) ; extra == "otel"
36
36
  Requires-Dist: opentelemetry-instrumentation-requests (>=0.41b0) ; extra == "otel"
@@ -51,14 +51,16 @@ outerbounds/command_groups/local_setup_cli.py,sha256=tuuqJRXQ_guEwOuQSIf9wkUU0yg
51
51
  outerbounds/command_groups/perimeters_cli.py,sha256=iF_Uw7ROiSctf6FgoJEy30iDBLVE1j9FKuR3shgJRmc,19050
52
52
  outerbounds/command_groups/secrets_cli.py,sha256=Vgn_aiTo76a0s5hCJhNWEOrCVhyYeivD08ooQxz0y7c,2952
53
53
  outerbounds/command_groups/tutorials_cli.py,sha256=UInFyiMqtscHFfi8YQwiY_6Sdw9quJOtRu5OukEBccw,3522
54
- outerbounds/command_groups/workstations_cli.py,sha256=V5Jbj1cVb4IRllI7fOgNgL6OekRpuFDv6CEhDb4xC6w,22016
54
+ outerbounds/command_groups/workstations_cli.py,sha256=-NTYAWg7t-xK9HBNzUA8e3WiDfDuUlG3SHgeWCogCVs,26456
55
55
  outerbounds/utils/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
56
56
  outerbounds/utils/kubeconfig.py,sha256=yvcyRXGR4AhQuqUDqmbGxEOHw5ixMFV0AZIDg1LI_Qo,7981
57
+ outerbounds/utils/kubectl_utils.py,sha256=y01xZ2AGW3CCnCbX7t9pfrhWsR9pDAyzlYiKdnZ-CK8,1916
57
58
  outerbounds/utils/metaflowconfig.py,sha256=l2vJbgPkLISU-XPGZFaC8ZKmYFyJemlD6bwB-EKUsAw,5770
58
59
  outerbounds/utils/schema.py,sha256=lMUr9kNgn9wy-sO_t_Tlxmbt63yLeN4b0xQXbDUDj4A,2331
60
+ outerbounds/utils/ssh_utils.py,sha256=A6rgi307DmBTdhDj7V7Tm-OYgMj2xRyx770t2FKs9uw,18737
59
61
  outerbounds/utils/utils.py,sha256=4Z8cszNob_8kDYCLNTrP-wWads_S_MdL3Uj3ju4mEsk,501
60
62
  outerbounds/vendor.py,sha256=gRLRJNXtZBeUpPEog0LOeIsl6GosaFFbCxUvR4bW6IQ,5093
61
- outerbounds-0.6.1.dist-info/METADATA,sha256=yZN94err7ql-nJA3N43YkqM1-zhboXj_5PASBWuNnsM,1830
62
- outerbounds-0.6.1.dist-info/WHEEL,sha256=sP946D7jFCHeNz5Iq4fL4Lu-PrWrFsgfLXbbkciIZwg,88
63
- outerbounds-0.6.1.dist-info/entry_points.txt,sha256=AP6rZg7y5SK9e9a9iVq0Fi9Q2KPjPZSwtZ6R98rLw-8,56
64
- outerbounds-0.6.1.dist-info/RECORD,,
63
+ outerbounds-0.8.0rc1.dist-info/METADATA,sha256=O5X_xftOw7SMY1dsNKCNQeed2Wx5hrQAbfObMwo01nk,1825
64
+ outerbounds-0.8.0rc1.dist-info/entry_points.txt,sha256=AP6rZg7y5SK9e9a9iVq0Fi9Q2KPjPZSwtZ6R98rLw-8,56
65
+ outerbounds-0.8.0rc1.dist-info/WHEEL,sha256=vVCvjcmxuUltf8cYhJ0sJMRDLr1XsPuxEId8YDzbyCY,88
66
+ outerbounds-0.8.0rc1.dist-info/RECORD,,
@@ -1,4 +1,4 @@
1
1
  Wheel-Version: 1.0
2
- Generator: poetry-core 1.9.0
2
+ Generator: poetry-core 1.4.0
3
3
  Root-Is-Purelib: true
4
4
  Tag: py3-none-any