The current project is a Python CLI utility that deals with creation and management of VMs at a hosting provider. It supports configuration for the creation of machines via a config file. That file specifies an ssh key to be provisioned on new machines for the primary user. We have a new requirement which is to allow provisioning of more than one ssh key. Please implement this feature such that existing config file content remains backwards compatible.
machine/config.py machine/provider.py machine/cloud_config.py machine/main.py machine/types.py machine/subcommands/check.py machine/subcommands/create.py machine/subcommands/ssh_keys.py machine/providers/gcp.py machine/providers/digitalocean.py machine/providers/vultr.py tests/test_input_validation.py tests/test_cli_integration.py tests/test_e2e.py
1 import os
2 import re
3 from pathlib import Path
4 from machine.di import d
5 from machine.factory import yaml
6 from machine.log import fatal_error, debug
7 from machine.types import Config, MachineConfig
8 from machine import constants
9 from machine.providers import KNOWN_PROVIDERS
10
11 _env_var_pattern = re.compile(r"\$\{([^}]+)\}")
12
13
14 def _expand_env_vars(value):
15 if isinstance(value, str):
16
17 def _replace(match):
18 expr = match.group(1)
19 if ":-" in expr:
20 var_name, default = expr.split(":-", 1)
21 return os.environ.get(var_name, default)
22 else:
23 if expr not in os.environ:
24 fatal_error(f"Environment variable '{expr}' referenced in config is not set")
25 return os.environ[expr]
26
27 return _env_var_pattern.sub(_replace, value)
28 elif isinstance(value, dict):
29 return {k: _expand_env_vars(v) for k, v in value.items()}
30 elif isinstance(value, list):
31 return [_expand_env_vars(item) for item in value]
32 return value
33
34
35 class _loaded_config:
36 c: any = None
37
38
39 def resolve_config_file_path(config_file_name: str) -> str:
40 if not config_file_name:
41 config_file_name = constants.default_config_file_path
42 return str(Path(os.path.expanduser(config_file_name)))
43
44
45 def _load_config_data(config_file_name: str):
46 config_path = Path(resolve_config_file_path(config_file_name))
47 if not config_path.exists():
48 fatal_error(f"Error: Config file: {config_path} not found")
49 config = _expand_env_vars(yaml().load(open(config_path, "r")))
50 if d.opt.debug:
51 debug(f"Loaded config file: {config_path}")
52 debug(f"Parsed config: {config}")
53 _loaded_config.c = config
54 return config
55
56
57 def _require_key(d, key, section_name):
58 if key not in d:
59 fatal_error(f"Required key '{key}' not found in '{section_name}' section of config file")
60 return d[key]
61
62
63 def get(config_file_name: str) -> Config:
64 config = _load_config_data(config_file_name)
65
66 # Auto-detect provider from config sections
67 provider_name = config.get("provider")
68 if not provider_name:
69 found = [p for p in KNOWN_PROVIDERS if p in config]
70 if len(found) == 0:
71 fatal_error(
72 "No provider section found in config file. Expected one of: " + ", ".join(KNOWN_PROVIDERS)
73 )
74 if len(found) > 1:
75 fatal_error(
76 "Multiple provider sections found in config file. Please add a 'provider:' key to select one."
77 )
78 provider_name = found[0]
79
80 if provider_name not in config:
81 fatal_error(f"Provider '{provider_name}' specified but no '{provider_name}' section found in config file")
82
83 provider_config = config[provider_name]
84 return Config(
85 provider_name=provider_name,
86 provider_config=provider_config,
87 ssh_key=_require_key(provider_config, "ssh-key", provider_name),
88 dns_zone=provider_config.get("dns-zone"),
89 machine_size=_require_key(provider_config, "machine-size", provider_name),
90 image=_require_key(provider_config, "image", provider_name),
91 region=_require_key(provider_config, "region", provider_name),
92 project=provider_config.get("project"),
93 )
94
95
96 def get_machine(name: str) -> MachineConfig:
97 if not _loaded_config.c:
98 fatal_error("Attempt to fetch machine data before config loaded")
99 config = _loaded_config.c
100 if "machines" not in config:
101 fatal_error("Required 'machines' section not found in config file")
102 config_machines = config["machines"]
103 if name not in config_machines:
104 fatal_error(f"Machine type '{name}' not found in config file. Available types: {', '.join(config_machines.keys())}")
105 target_config = config_machines[name]
106 return MachineConfig(
107 _require_key(target_config, "new-user-name", f"machines.{name}"),
108 target_config.get("script-url"),
109 target_config.get("script-dir"),
110 target_config.get("script-path"),
111 target_config.get("script-args"),
112 )
113
114
115 def get_machines():
116 if not _loaded_config.c:
117 fatal_error("Attempt to fetch machine data before config loaded")
118 config = _loaded_config.c
119
120 if "machines" not in config:
121 fatal_error("Required 'machines' section not found in config file")
122 ret = {}
123 for name in config["machines"]:
124 ret[name] = get_machine(name)
125 return ret
126 1 from dataclasses import dataclass 2 3 TAG_MACHINE_CREATED = "machine:created" 4 TAG_MACHINE_TYPE_PREFIX = "machine:type:" 5 TAG_MACHINE_SESSION_PREFIX = "machine:session:" 6 7 8 @dataclass 9 class CliOptions: 10 debug: bool 11 quiet: bool 12 verbose: bool 13 dry_run: bool 14 15 16 @dataclass 17 class Config: 18 provider_name: str 19 provider_config: dict 20 ssh_key: str 21 dns_zone: str 22 machine_size: str 23 image: str 24 region: str 25 project: str 26 27 28 @dataclass 29 class MainCmdCtx: 30 config: Config 31 session_id: str 32 provider: object # CloudProvider instance 33 34 35 @dataclass 36 class MachineConfig: 37 new_user_name: str 38 script_url: str 39 script_dir: str 40 script_path: str 41 script_args: str 42
grep -rn "ssh_key\|ssh-key\|ssh_keys\|ssh-keys" --include="*.py" .
machine/config.py:87: ssh_key=_require_key(provider_config, "ssh-key", provider_name),
machine/subcommands/check.py:29: provider.list_ssh_keys()
machine/subcommands/check.py:38: if cfg.ssh_key:
machine/subcommands/check.py:39: key = provider.get_ssh_key(cfg.ssh_key)
machine/subcommands/check.py:40: report("SSH key", key is not None, cfg.ssh_key)
machine/providers/digitalocean.py:46: def create_vm(self, name, region, image, size, ssh_key_name, tags, user_data) -> VM:
machine/providers/digitalocean.py:47: ssh_key = self._get_do_ssh_key(ssh_key_name)
machine/providers/digitalocean.py:48: if not ssh_key:
machine/providers/digitalocean.py:49: fatal_error(f"Error: SSH key '{ssh_key_name}' not found in DigitalOcean")
machine/providers/digitalocean.py:57: ssh_keys=[ssh_key],
machine/providers/digitalocean.py:87: def get_ssh_key(self, name) -> SSHKey:
machine/providers/digitalocean.py:88: do_key = self._get_do_ssh_key(name)
machine/providers/digitalocean.py:98: def list_ssh_keys(self) -> list:
machine/providers/digitalocean.py:158: def _get_do_ssh_key(self, name):
machine/providers/gcp.py:13:_SSH_KEYS_METADATA_KEY = "ssh-keys"
machine/providers/gcp.py:45:def _parse_ssh_keys(raw):
machine/providers/gcp.py:114: def create_vm(self, name, region, image, size, ssh_key_name, tags, user_data) -> VM:
machine/providers/gcp.py:116: if not self.get_ssh_key(ssh_key_name):
machine/providers/gcp.py:117: fatal_error(f"Error: SSH key '{ssh_key_name}' not found in GCP project metadata")
machine/providers/gcp.py:189: def _get_project_ssh_keys_raw(self):
machine/providers/gcp.py:196: def get_ssh_key(self, name) -> SSHKey:
machine/providers/gcp.py:197: for user, key_data in _parse_ssh_keys(self._get_project_ssh_keys_raw()):
machine/providers/gcp.py:202: def list_ssh_keys(self) -> list:
machine/providers/gcp.py:205: for u, k in _parse_ssh_keys(self._get_project_ssh_keys_raw())
machine/subcommands/create.py:55: user_data = get_user_data(provider, config.ssh_key, fqdn, machine_config)
machine/subcommands/create.py:61: ssh_key = provider.get_ssh_key(config.ssh_key)
machine/subcommands/create.py:62: if not ssh_key:
machine/subcommands/create.py:63: fatal_error(f"Error: SSH key '{config.ssh_key}' not found in {provider.provider_name}")
machine/subcommands/create.py:82: ssh_key_name=config.ssh_key,
machine/types.py:20: ssh_key: str
machine/main.py:10:from machine.subcommands import check, create, destroy, info, list, projects, ssh_keys, domains, list_domain, types, status
machine/main.py:60:main.add_command(ssh_keys.command, "ssh-keys")
machine/subcommands/ssh_keys.py:11: keys = provider.list_ssh_keys()
machine/provider.py:34: def create_vm(self, name, region, image, size, ssh_key_name, tags, user_data) -> VM:
machine/provider.py:50: def get_ssh_key(self, name) -> SSHKey:
machine/provider.py:54: def list_ssh_keys(self) -> list:
tests/test_cli_integration.py:56: ssh-key: test-key
tests/test_cli_integration.py:77: ssh-key: test-key
tests/test_cli_integration.py:90: assert "ssh-key: test-key" in result.stdout
tests/test_cli_integration.py:99: ssh-key: test-key
tests/test_cli_integration.py:121: ssh-key: test-key
tests/test_cli_integration.py:144: ssh-key: test-key
tests/test_cli_integration.py:173: ssh-key: test-key
tests/test_cli_integration.py:205: ssh-key: "${TEST_SSH_KEY}"
tests/test_cli_integration.py:233: ssh-key: test-key
tests/test_cli_integration.py:270: ssh-key: test-key
tests/test_cli_integration.py:298: ssh-key: test-key
tests/test_cli_integration.py:326: ssh-key: test-key
machine/providers/vultr.py:37: def create_vm(self, name, region, image, size, ssh_key_name, tags, user_data) -> VM:
machine/providers/vultr.py:38: ssh_key = self._get_vultr_ssh_key(ssh_key_name)
machine/providers/vultr.py:39: if not ssh_key:
machine/providers/vultr.py:40: fatal_error(f"Error: SSH key '{ssh_key_name}' not found in Vultr")
machine/providers/vultr.py:46: "sshkey_id": [ssh_key["id"]],
machine/providers/vultr.py:94: def get_ssh_key(self, name) -> SSHKey:
machine/providers/vultr.py:95: key = self._get_vultr_ssh_key(name)
machine/providers/vultr.py:102: public_key=key.get("ssh_key", ""),
machine/providers/vultr.py:105: def list_ssh_keys(self) -> list:
machine/providers/vultr.py:115: public_key=k.get("ssh_key", ""),
machine/providers/vultr.py:179: def _get_vultr_ssh_key(self, name):
machine/cloud_config.py:8:def get_user_data(provider: CloudProvider, ssh_key_name: str, fqdn: str, machine_config: MachineConfig):
machine/cloud_config.py:16: ssh_key = provider.get_ssh_key(ssh_key_name)
machine/cloud_config.py:17: if not ssh_key:
machine/cloud_config.py:18: fatal_error(f"Error: SSH key '{ssh_key_name}' not found in {provider.provider_name}")
machine/cloud_config.py:19: ssh_public_key = ssh_key.public_key
tests/test_e2e.py:149: "ssh-key": E2E_SSH_KEY,
tests/test_e2e.py:162: "ssh-key": E2E_SSH_KEY,
tests/test_e2e.py:174: "ssh-key": E2E_SSH_KEY,
tests/test_e2e.py:311: def test_check_reports_ssh_key_pass(self, config_file, session_id):
tests/test_e2e.py:329: API call (list_ssh_keys) to fail with a permission error.
tests/test_e2e.py:343: def test_check_fails_with_bad_ssh_key(self, tmp_path, session_id):
tests/test_e2e.py:346: _write_config(cfg_path, **{"ssh-key": f"nonexistent-key-{uuid.uuid4().hex[:8]}"})
tests/test_input_validation.py:32: ssh-key: test-key
tests/test_input_validation.py:123: ssh-key: test-key
tests/test_input_validation.py:138: def test_missing_ssh_key(self, config_dir):
tests/test_input_validation.py:156: assert "ssh-key" in result.stderr
tests/test_input_validation.py:166: ssh-key: test-key
tests/test_input_validation.py:185: ssh-key: test-key1 from abc import ABC, abstractmethod 2 from dataclasses import dataclass 3 4 5 @dataclass 6 class VM: 7 id: str 8 name: str 9 tags: list 10 region: str 11 ip_address: str 12 status: str 13 14 15 @dataclass 16 class SSHKey: 17 id: str 18 name: str 19 fingerprint: str 20 public_key: str 21 22 23 @dataclass 24 class DNSRecord: 25 id: str 26 name: str 27 type: str 28 data: str 29 ttl: int 30 31 32 class CloudProvider(ABC): 33 @abstractmethod 34 def create_vm(self, name, region, image, size, ssh_key_name, tags, user_data) -> VM: 35 pass 36 37 @abstractmethod 38 def get_vm(self, vm_id) -> VM: 39 pass 40 41 @abstractmethod 42 def destroy_vm(self, vm_id) -> bool: 43 pass 44 45 @abstractmethod 46 def list_vms(self, tag=None) -> list: 47 pass 48 49 @abstractmethod 50 def get_ssh_key(self, name) -> SSHKey: 51 pass 52 53 @abstractmethod 54 def list_ssh_keys(self) -> list: 55 pass 56 57 @abstractmethod 58 def create_dns_record(self, zone, record_type, name, data, ttl, tag=None) -> str: 59 pass 60 61 @abstractmethod 62 def delete_dns_record(self, zone, record_name) -> bool: 63 pass 64 65 @abstractmethod 66 def get_dns_records(self, zone) -> list: 67 pass 68 69 @abstractmethod 70 def list_domains(self) -> list: 71 pass 72 73 def list_projects(self) -> list: 74 return [] 75 76 def assign_to_project(self, project_name, vm_id): 77 pass 78 79 def validate_region(self, region): 80 pass 81 82 def validate_image(self, image): 83 pass 84 85 @property 86 def provider_name(self) -> str: 87 return self.__class__.__name__ 88
{
"file_path": "/home/david/projects/stirlingbridge/machine/machine/cloud_config.py"
}1 from expandvars import expand
2
3 from machine.log import fatal_error
4 from machine.provider import CloudProvider
5 from machine.types import MachineConfig
6
7
8 def get_user_data(provider: CloudProvider, ssh_key_name: str, fqdn: str, machine_config: MachineConfig):
9 if not fqdn:
10 fqdn = ""
11
12 script_args = machine_config.script_args
13 if not script_args:
14 script_args = ""
15
16 ssh_key = provider.get_ssh_key(ssh_key_name)
17 if not ssh_key:
18 fatal_error(f"Error: SSH key '{ssh_key_name}' not found in {provider.provider_name}")
19 ssh_public_key = ssh_key.public_key
20 escaped_args = script_args.replace('"', '\\"')
21
22 cloud_env = {
23 "MACHINE_SCRIPT_URL": machine_config.script_url,
24 "MACHINE_SCRIPT_DIR": machine_config.script_dir,
25 "MACHINE_FQDN": fqdn,
26 }
27
28 # Exand here because otherwise escaping the vars properly for nested scripts is a guessing game
29 escaped_args = expand(escaped_args, environ=cloud_env)
30 cloud_config = f"""#cloud-config
31 users:
32 - name: {machine_config.new_user_name}
33 groups: sudo
34 shell: /bin/bash
35 sudo: ['ALL=(ALL) NOPASSWD:ALL']
36 ssh-authorized-keys:
37 - {ssh_public_key}
38 """
39 if machine_config.script_url and machine_config.script_dir and machine_config.script_path:
40 cloud_config += f"""
41 runcmd:
42 - mkdir -p {machine_config.script_dir}
43 - curl -L {machine_config.script_url} -o {machine_config.script_path}
44 - chmod +x {machine_config.script_path}
45 - [su, -c, "env {" ".join([f"{k}='{v}'" for k, v in cloud_env.items()])} {machine_config.script_path} {escaped_args}", -, {machine_config.new_user_name}]
46 """
47 return cloud_config
48 {
"file_path": "/home/david/projects/stirlingbridge/machine/machine/subcommands/create.py"
}1 import click
2 import json
3 import time
4
5 from machine.config import get_machine
6 from machine.di import d
7 from machine.log import fatal_error, info, debug, output as log_output
8 from machine.types import MainCmdCtx, TAG_MACHINE_CREATED, TAG_MACHINE_TYPE_PREFIX
9 from machine.cloud_config import get_user_data
10 from machine.util import vm_to_json_obj
11
12 from machine.types import TAG_MACHINE_SESSION_PREFIX
13
14
15 def _validate_dns_zone(provider, dns_zone):
16 available_zones = provider.list_domains()
17 if dns_zone not in available_zones:
18 zones_str = ", ".join(available_zones) if available_zones else "none"
19 fatal_error(f"Error: DNS zone '{dns_zone}' not found in {provider.provider_name}. Available zones: {zones_str}")
20
21
22 @click.command(help="Create a machine")
23 @click.option("--name", "-n", required=True, metavar="<MACHINE-NAME>", help="Name for new machine")
24 @click.option("--tag", "-t", metavar="<TAG-TEXT>", help="tag to be applied to new machine")
25 @click.option("--type", "-m", metavar="<MACHINE-TYPE>", help="create a machine of this type")
26 @click.option("--region", "-r", metavar="<REGION-CODE>", help="create a machine in this region (overrides default from config)")
27 @click.option(
28 "--machine-size", "-s", metavar="<MACHINE-SLUG>", help="create a machine of this size (overrides default from config)"
29 )
30 @click.option("--image", "-s", metavar="<IMAGE-NAME>", help="create a machine from this image (overrides default from config)")
31 @click.option("--wait-for-ip/--no-wait-for-up", default=False)
32 @click.option("--update-dns/--no-update-dns", default=True)
33 @click.option("--initialize/--no-initialize", default=True)
34 @click.option("--output", "-o", metavar="<FORMAT>", help="Output format")
35 @click.pass_context
36 def command(context, name, tag, type, region, machine_size, image, wait_for_ip, update_dns, initialize, output):
37 command_context: MainCmdCtx = context.obj
38 config = command_context.config
39 provider = command_context.provider
40
41 if update_dns and not config.dns_zone:
42 fatal_error("Error: DNS update requested but no zone configured")
43
44 if update_dns and config.dns_zone:
45 _validate_dns_zone(provider, config.dns_zone)
46
47 user_data = None
48 if initialize:
49 if not type:
50 fatal_error("Error: a machine type must be supplied")
51 machine_config = get_machine(type)
52 if not machine_config:
53 fatal_error(f"Error: machine type {type} is not defined")
54 fqdn = f"{name}.{config.dns_zone}" if config.dns_zone else None
55 user_data = get_user_data(provider, config.ssh_key, fqdn, machine_config)
56 if d.opt.debug:
57 info("user-data is:")
58 info(user_data)
59
60 # Verify SSH key exists
61 ssh_key = provider.get_ssh_key(config.ssh_key)
62 if not ssh_key:
63 fatal_error(f"Error: SSH key '{config.ssh_key}' not found in {provider.provider_name}")
64
65 provider.validate_region(region)
66 provider.validate_image(image)
67
68 tags = [
69 TAG_MACHINE_SESSION_PREFIX + command_context.session_id,
70 TAG_MACHINE_CREATED,
71 ]
72 if type:
73 tags.append(TAG_MACHINE_TYPE_PREFIX + type.lower())
74 if tag:
75 tags.append(tag)
76
77 vm = provider.create_vm(
78 name=name,
79 region=region if region is not None else config.region,
80 image=image if image is not None else config.image,
81 size=machine_size if machine_size is not None else config.machine_size,
82 ssh_key_name=config.ssh_key,
83 tags=tags,
84 user_data=user_data,
85 )
86
87 if vm.id:
88 if output != "json":
89 if d.opt.quiet:
90 log_output(f"{vm.id}")
91 else:
92 log_output(f"New machine created with id: {vm.id}")
93
94 # If requested, assign to a specified project
95 if config.project:
96 provider.assign_to_project(config.project, vm.id)
97 if d.opt.verbose:
98 info(f"Assigned machine to project: {config.project}")
99
100 # If requested, or if we are going to set a DNS record get the VM's IPv4 address
101 # Vultr returns "0.0.0.0" as main_ip while the instance is still pending,
102 # so treat that the same as no IP assigned yet.
103 ip_address = vm.ip_address if vm.ip_address != "0.0.0.0" else None
104 if (wait_for_ip or update_dns) and not ip_address:
105 while not ip_address:
106 time.sleep(1)
107 vm = provider.get_vm(vm.id)
108 ip_address = vm.ip_address if vm.ip_address != "0.0.0.0" else None
109 if d.opt.verbose:
110 log_output("Waiting for machine IP address")
111 if d.opt.quiet:
112 info(f"{ip_address}")
113 else:
114 info(f"IP Address: {ip_address}")
115
116 # If requested, and we have the IP address, create a DNS host record
117 if update_dns and ip_address and config.dns_zone:
118 zone = config.dns_zone
119 host = name
120 if d.opt.debug:
121 debug(f"Setting host record {host}.{zone} to {ip_address}")
122 record = provider.create_dns_record(
123 zone=zone,
124 record_type="A",
125 name=host,
126 data=ip_address,
127 ttl=60 * 5,
128 tag=TAG_MACHINE_CREATED,
129 )
130 if record:
131 if d.opt.verbose:
132 info(f"Created DNS record:{record}")
133 if not d.opt.quiet:
134 info(f"DNS: {host}.{zone}")
135
136 if output == "json":
137 log_output(json.dumps(vm_to_json_obj(vm)))
138 {
"file_path": "/home/david/projects/stirlingbridge/machine/machine/subcommands/check.py"
}1 import click
2
3 from machine.log import output
4 from machine.types import MainCmdCtx
5
6
7 @click.command(help="Check config validity against the provider API")
8 @click.pass_context
9 def command(context):
10 command_context: MainCmdCtx = context.obj
11 provider = command_context.provider
12 cfg = command_context.config
13 all_passed = True
14
15 def report(check_name, passed, detail=""):
16 nonlocal all_passed
17 if not passed:
18 all_passed = False
19 status = "PASS" if passed else "FAIL"
20 msg = f" {status}: {check_name}"
21 if detail:
22 msg += f" ({detail})"
23 output(msg)
24
25 output(f"Checking config for provider: {provider.provider_name}")
26
27 # 1. Check API token by making a simple read-only API call
28 try:
29 provider.list_ssh_keys()
30 report("API authentication", True)
31 except (Exception, SystemExit) as e:
32 report("API authentication", False, str(e))
33 # If auth fails, remaining checks will also fail
34 output("\nAPI authentication failed, skipping remaining checks.")
35 raise SystemExit(1)
36
37 # 2. Check SSH key exists
38 if cfg.ssh_key:
39 key = provider.get_ssh_key(cfg.ssh_key)
40 report("SSH key", key is not None, cfg.ssh_key)
41 else:
42 report("SSH key", False, "not configured")
43
44 # 3. Check DNS zone exists
45 if cfg.dns_zone:
46 try:
47 domains = provider.list_domains()
48 found = cfg.dns_zone in domains
49 report("DNS zone", found, cfg.dns_zone)
50 except Exception as e:
51 report("DNS zone", False, str(e))
52 else:
53 output(" SKIP: DNS zone (not configured)")
54
55 # 4. Check project exists
56 if cfg.project:
57 try:
58 projects = provider.list_projects()
59 if projects:
60 found = cfg.project in projects
61 report("Project", found, cfg.project)
62 else:
63 output(f" SKIP: Project (not supported by {provider.provider_name})")
64 except Exception as e:
65 report("Project", False, str(e))
66 else:
67 output(" SKIP: Project (not configured)")
68
69 # 5. Validate region
70 if cfg.region:
71 try:
72 provider.validate_region(cfg.region)
73 report("Region", True, cfg.region)
74 except SystemExit:
75 report("Region", False, cfg.region)
76 else:
77 output(" SKIP: Region (not configured)")
78
79 # 6. Validate image
80 if cfg.image:
81 try:
82 provider.validate_image(cfg.image)
83 report("Image", True, cfg.image)
84 except SystemExit:
85 report("Image", False, cfg.image)
86 else:
87 output(" SKIP: Image (not configured)")
88
89 if all_passed:
90 output("\nAll checks passed.")
91 else:
92 output("\nSome checks failed.")
93 raise SystemExit(1)
94 {
"file_path": "/home/david/projects/stirlingbridge/machine/machine/providers/digitalocean.py"
}1 import digitalocean
2
3 from machine.log import fatal_error, info
4 from machine.provider import CloudProvider, VM, SSHKey, DNSRecord
5
6
7 VALID_REGIONS = ["NYC1", "NYC3", "AMS3", "SFO2", "SFO3", "SGP1", "LON1", "FRA1", "TOR1", "BLR1", "SYD1"]
8
9 VALID_IMAGES = [
10 "almalinux-8-x64",
11 "almalinux-9-x64",
12 "centos-stream-9-x64",
13 "debian-11-x64",
14 "debian-12-x64",
15 "fedora-39-x64",
16 "fedora-40-x64",
17 "rockylinux-9-x64",
18 "rockylinux-8-x64",
19 "ubuntu-20-04-x64",
20 "ubuntu-22-04-x64",
21 "ubuntu-24-04-x64",
22 ]
23
24
25 def _droplet_to_vm(droplet) -> VM:
26 region = droplet.region
27 if isinstance(region, dict):
28 region = region.get("slug")
29 return VM(
30 id=str(droplet.id),
31 name=droplet.name,
32 tags=droplet.tags,
33 region=region,
34 ip_address=droplet.ip_address,
35 status=droplet.status,
36 )
37
38
39 class DigitalOceanProvider(CloudProvider):
40 def __init__(self, provider_config):
41 if "access-token" not in provider_config:
42 fatal_error("Required key 'access-token' not found in 'digital-ocean' section of config file")
43 self.token = provider_config["access-token"]
44 self._manager = digitalocean.Manager(token=self.token)
45
46 def create_vm(self, name, region, image, size, ssh_key_name, tags, user_data) -> VM:
47 ssh_key = self._get_do_ssh_key(ssh_key_name)
48 if not ssh_key:
49 fatal_error(f"Error: SSH key '{ssh_key_name}' not found in DigitalOcean")
50
51 droplet = digitalocean.Droplet(
52 token=self.token,
53 name=name,
54 region=region,
55 image=image,
56 size_slug=size,
57 ssh_keys=[ssh_key],
58 tags=tags,
59 user_data=user_data,
60 backups=False,
61 )
62 try:
63 droplet.create()
64 except digitalocean.DataReadError as e:
65 fatal_error(f"DigitalOcean API error creating VM: {e}")
66 return _droplet_to_vm(droplet)
67
68 def get_vm(self, vm_id) -> VM:
69 droplet = self._manager.get_droplet(vm_id)
70 return _droplet_to_vm(droplet)
71
72 def destroy_vm(self, vm_id) -> bool:
73 try:
74 droplet = self._manager.get_droplet(vm_id)
75 except digitalocean.NotFoundError:
76 fatal_error(f"Error: machine with id {vm_id} not found")
77 result = droplet.destroy()
78 return result
79
80 def list_vms(self, tag=None) -> list:
81 if tag:
82 droplets = self._manager.get_all_droplets(tag_name=tag)
83 else:
84 droplets = self._manager.get_all_droplets()
85 return [_droplet_to_vm(d) for d in droplets]
86
87 def get_ssh_key(self, name) -> SSHKey:
88 do_key = self._get_do_ssh_key(name)
89 if not do_key:
90 return None
91 return SSHKey(
92 id=str(do_key.id),
93 name=do_key.name,
94 fingerprint=do_key.fingerprint,
95 public_key=do_key.public_key,
96 )
97
98 def list_ssh_keys(self) -> list:
99 keys = self._manager.get_all_sshkeys()
100 return [
101 SSHKey(id=str(k.id), name=k.name, fingerprint=k.fingerprint, public_key=k.public_key)
102 for k in keys
103 ]
104
105 def create_dns_record(self, zone, record_type, name, data, ttl, tag=None) -> str:
106 domain = digitalocean.Domain(token=self.token, name=zone)
107 try:
108 record = domain.create_new_domain_record(type=record_type, ttl=ttl, name=name, data=data, tag=tag)
109 except digitalocean.NotFoundError:
110 info(f"Warning: DNS zone '{zone}' not found in DigitalOcean, DNS record not set")
111 return None
112 return record
113
114 def delete_dns_record(self, zone, record_name) -> bool:
115 domain = digitalocean.Domain(token=self.token, name=zone)
116 records = domain.get_records()
117 for record in records:
118 if record.name == record_name:
119 domain.delete_domain_record(id=record.id)
120 return True
121 return False
122
123 def get_dns_records(self, zone) -> list:
124 domain = digitalocean.Domain(token=self.token, name=zone)
125 records = domain.get_records()
126 return [
127 DNSRecord(id=str(r.id), name=r.name, type=r.type, data=r.data, ttl=r.ttl)
128 for r in records
129 ]
130
131 def list_domains(self) -> list:
132 domains = self._manager.get_all_domains()
133 return [d.name for d in domains]
134
135 def list_projects(self) -> list:
136 projects = self._manager.get_all_projects()
137 return [p.name for p in projects]
138
139 def assign_to_project(self, project_name, vm_id):
140 projects = self._manager.get_all_projects()
141 project = None
142 for p in projects:
143 if p.name == project_name:
144 project = p
145 break
146 if not project:
147 fatal_error(f"Error: Project {project_name} does not exist, machine created but not assigned to project")
148 project.assign_resource([f"do:droplet:{vm_id}"])
149
150 def validate_region(self, region):
151 if region is not None and region.upper() not in VALID_REGIONS:
152 fatal_error(f"Error: region {region} is not one of {VALID_REGIONS}")
153
154 def validate_image(self, image):
155 if image is not None and image not in VALID_IMAGES:
156 info(f"Warning: image {image} is not one of these known valid images: {VALID_IMAGES}")
157
158 def _get_do_ssh_key(self, name):
159 keys = self._manager.get_all_sshkeys()
160 for key in keys:
161 if key.name == name:
162 return key
163 return None
164
165 @property
166 def provider_name(self) -> str:
167 return "DigitalOcean"
168 {
"file_path": "/home/david/projects/stirlingbridge/machine/machine/providers/vultr.py"
}1 import base64
2 import time
3
4 from vultr import Vultr, VultrException
5
6 from machine.log import fatal_error, info
7 from machine.provider import CloudProvider, VM, SSHKey, DNSRecord
8
9
10 VALID_REGIONS = [
11 "ewr", "ord", "dfw", "sea", "lax", "atl", "ams", "lhr", "fra",
12 "sjc", "syd", "nrt", "cdg", "icn", "mia", "sgp", "sto",
13 "mex", "mad", "sao", "del", "hnl", "yto", "blr", "jnb",
14 "bom", "tlv",
15 ]
16
17
18 def _instance_to_vm(instance) -> VM:
19 return VM(
20 id=instance.get("id", ""),
21 name=instance.get("label", ""),
22 tags=instance.get("tags", []),
23 region=instance.get("region", ""),
24 ip_address=instance.get("main_ip", ""),
25 status=instance.get("status", ""),
26 )
27
28
29 class VultrProvider(CloudProvider):
30 def __init__(self, provider_config):
31 info("WARNING: Vultr support is experimental and has not been fully verified. Use with caution.")
32 if "api-key" not in provider_config:
33 fatal_error("Required key 'api-key' not found in 'vultr' section of config file")
34 self._api_key = provider_config["api-key"]
35 self._client = Vultr(self._api_key)
36
37 def create_vm(self, name, region, image, size, ssh_key_name, tags, user_data) -> VM:
38 ssh_key = self._get_vultr_ssh_key(ssh_key_name)
39 if not ssh_key:
40 fatal_error(f"Error: SSH key '{ssh_key_name}' not found in Vultr")
41
42 kwargs = {
43 "os_id": int(image),
44 "label": name,
45 "hostname": name,
46 "sshkey_id": [ssh_key["id"]],
47 "tags": tags,
48 "backups": "disabled",
49 }
50 if user_data:
51 kwargs["user_data"] = base64.b64encode(user_data.encode()).decode()
52
53 try:
54 result = self._client.create_instance(region, size, **kwargs)
55 except VultrException as e:
56 fatal_error(f"Error creating instance: {e}")
57
58 return _instance_to_vm(result)
59
60 def get_vm(self, vm_id) -> VM:
61 try:
62 result = self._client.get_instance(vm_id)
63 except VultrException as e:
64 fatal_error(f"Error: machine with id {vm_id} not found: {e}")
65 return _instance_to_vm(result)
66
67 def destroy_vm(self, vm_id) -> bool:
68 # Vultr returns HTTP 500 if the instance is still pending or locked
69 # (e.g. during provisioning). Retry deletion with backoff.
70 for attempt in range(24):
71 try:
72 self._client.delete_instance(vm_id)
73 return True
74 except VultrException as e:
75 error_msg = str(e)
76 if "500" in error_msg and ("not currently active" in error_msg or "currently locked" in error_msg):
77 info("Waiting for instance to become ready before destroying...")
78 time.sleep(5)
79 elif "404" in error_msg:
80 return True # already gone
81 else:
82 fatal_error(f"Error: machine with id {vm_id} not found: {e}")
83 fatal_error(f"Error: timed out waiting to destroy instance {vm_id}")
84 return False
85
86 def list_vms(self, tag=None) -> list:
87 try:
88 params = {"tag": tag} if tag else None
89 result = self._client.list_instances(params=params)
90 except VultrException as e:
91 fatal_error(f"Error listing instances: {e}")
92 return [_instance_to_vm(i) for i in result]
93
94 def get_ssh_key(self, name) -> SSHKey:
95 key = self._get_vultr_ssh_key(name)
96 if not key:
97 return None
98 return SSHKey(
99 id=key["id"],
100 name=key["name"],
101 fingerprint=key.get("fingerprint", ""),
102 public_key=key.get("ssh_key", ""),
103 )
104
105 def list_ssh_keys(self) -> list:
106 try:
107 result = self._client.list_keys()
108 except VultrException as e:
109 fatal_error(f"Error listing SSH keys: {e}")
110 return [
111 SSHKey(
112 id=k["id"],
113 name=k["name"],
114 fingerprint=k.get("fingerprint", ""),
115 public_key=k.get("ssh_key", ""),
116 )
117 for k in result
118 ]
119
120 def create_dns_record(self, zone, record_type, name, data, ttl, tag=None) -> str:
121 try:
122 result = self._client.post(
123 f"/domains/{zone}/records",
124 type=record_type, name=name, data=data, ttl=ttl,
125 )
126 except VultrException:
127 info(f"Warning: DNS zone '{zone}' not found in Vultr, DNS record not set")
128 return None
129 record = result.get("record", result)
130 return record.get("id")
131
132 def delete_dns_record(self, zone, record_name) -> bool:
133 records = self.get_dns_records(zone)
134 for record in records:
135 if record.name == record_name:
136 try:
137 self._client.delete(f"/domains/{zone}/records/{record.id}")
138 except VultrException:
139 return False
140 return True
141 return False
142
143 def get_dns_records(self, zone) -> list:
144 try:
145 result = self._client.get(f"/domains/{zone}/records")
146 except VultrException:
147 info(f"Warning: DNS zone '{zone}' not found in Vultr")
148 return []
149 records = result.get("records", [])
150 return [
151 DNSRecord(
152 id=str(r.get("id", "")),
153 name=r.get("name", ""),
154 type=r.get("type", ""),
155 data=r.get("data", ""),
156 ttl=r.get("ttl", 0),
157 )
158 for r in records
159 ]
160
161 def list_domains(self) -> list:
162 try:
163 result = self._client.get("/domains")
164 except VultrException as e:
165 fatal_error(f"Error listing domains: {e}")
166 domains = result.get("domains", [])
167 return [d.get("domain", "") for d in domains]
168
169 def validate_region(self, region):
170 if region is not None and region.lower() not in VALID_REGIONS:
171 fatal_error(f"Error: region {region} is not one of {VALID_REGIONS}")
172
173 def validate_image(self, image):
174 try:
175 int(image)
176 except (ValueError, TypeError):
177 info(f"Warning: Vultr image (os_id) should be a numeric ID. Got: {image}")
178
179 def _get_vultr_ssh_key(self, name):
180 try:
181 result = self._client.list_keys()
182 except VultrException as e:
183 fatal_error(f"Error listing SSH keys: {e}")
184 for key in result:
185 if key.get("name") == name:
186 return key
187 return None
188
189 @property
190 def provider_name(self) -> str:
191 return "Vultr"
192 {
"file_path": "/home/david/projects/stirlingbridge/machine/machine/providers/gcp.py"
}1 import base64
2 import hashlib
3 import os
4
5 from machine.log import fatal_error, info
6 from machine.provider import CloudProvider, VM, SSHKey, DNSRecord
7
8
9 # Tags are stored as newline-separated values in instance metadata so that
10 # colon-bearing tag strings (e.g. "machine:created") round-trip unchanged.
11 _TAGS_METADATA_KEY = "machine-tags"
12 _USER_DATA_METADATA_KEY = "user-data"
13 _SSH_KEYS_METADATA_KEY = "ssh-keys"
14
15 _DEFAULT_OPERATION_TIMEOUT = 300
16
17 _GCP_SCOPES = ["https://www.googleapis.com/auth/cloud-platform"]
18
19
20 def _load_credentials(provider_config):
21 creds_file = provider_config.get("credentials-file")
22 if creds_file:
23 from google.oauth2 import service_account
24
25 path = os.path.expanduser(creds_file)
26 return service_account.Credentials.from_service_account_file(path, scopes=_GCP_SCOPES)
27 import google.auth
28
29 creds, _ = google.auth.default(scopes=_GCP_SCOPES)
30 return creds
31
32
33 def _fingerprint(public_key):
34 parts = public_key.strip().split()
35 if len(parts) < 2:
36 return ""
37 try:
38 data = base64.b64decode(parts[1])
39 except (ValueError, base64.binascii.Error):
40 return ""
41 digest = hashlib.md5(data).hexdigest()
42 return ":".join(digest[i : i + 2] for i in range(0, len(digest), 2))
43
44
45 def _parse_ssh_keys(raw):
46 keys = []
47 for line in (raw or "").splitlines():
48 line = line.strip()
49 if not line or ":" not in line:
50 continue
51 user, key_data = line.split(":", 1)
52 keys.append((user.strip(), key_data.strip()))
53 return keys
54
55
56 class GcpProvider(CloudProvider):
57 def __init__(self, provider_config):
58 if "project-id" not in provider_config:
59 fatal_error("Required key 'project-id' not found in 'gcp' section of config file")
60 self._project = provider_config["project-id"]
61 self._credentials = _load_credentials(provider_config)
62
63 from google.cloud import compute_v1
64
65 self._compute_v1 = compute_v1
66 self._instances = compute_v1.InstancesClient(credentials=self._credentials)
67 self._projects_client = compute_v1.ProjectsClient(credentials=self._credentials)
68 self._dns_client = None
69
70 def _dns(self):
71 if self._dns_client is None:
72 from google.cloud import dns
73
74 self._dns_client = dns.Client(project=self._project, credentials=self._credentials)
75 return self._dns_client
76
77 @staticmethod
78 def _parse_id(vm_id):
79 if "/" not in vm_id:
80 fatal_error(f"Error: GCP VM id must be in the form '<zone>/<name>', got: {vm_id}")
81 zone, name = vm_id.split("/", 1)
82 return zone, name
83
84 @staticmethod
85 def _make_id(zone, name):
86 return f"{zone}/{name}"
87
88 def _instance_to_vm(self, instance, zone) -> VM:
89 ip_address = ""
90 for nic in instance.network_interfaces:
91 for ac in nic.access_configs:
92 if ac.nat_i_p:
93 ip_address = ac.nat_i_p
94 break
95 if ip_address:
96 break
97
98 tags = []
99 if instance.metadata and instance.metadata.items:
100 for item in instance.metadata.items:
101 if item.key == _TAGS_METADATA_KEY and item.value:
102 tags = [t for t in item.value.split("\n") if t]
103 break
104
105 return VM(
106 id=self._make_id(zone, instance.name),
107 name=instance.name,
108 tags=tags,
109 region=zone,
110 ip_address=ip_address,
111 status=instance.status,
112 )
113
114 def create_vm(self, name, region, image, size, ssh_key_name, tags, user_data) -> VM:
115 zone = region
116 if not self.get_ssh_key(ssh_key_name):
117 fatal_error(f"Error: SSH key '{ssh_key_name}' not found in GCP project metadata")
118
119 compute_v1 = self._compute_v1
120
121 disk = compute_v1.AttachedDisk(
122 boot=True,
123 auto_delete=True,
124 initialize_params=compute_v1.AttachedDiskInitializeParams(source_image=image),
125 )
126 access_config = compute_v1.AccessConfig(name="External NAT", type_="ONE_TO_ONE_NAT")
127 nic = compute_v1.NetworkInterface(network="global/networks/default", access_configs=[access_config])
128
129 metadata_items = []
130 if tags:
131 metadata_items.append(compute_v1.Items(key=_TAGS_METADATA_KEY, value="\n".join(tags)))
132 if user_data:
133 metadata_items.append(compute_v1.Items(key=_USER_DATA_METADATA_KEY, value=user_data))
134 metadata = compute_v1.Metadata(items=metadata_items)
135
136 instance = compute_v1.Instance(
137 name=name,
138 machine_type=f"zones/{zone}/machineTypes/{size}",
139 disks=[disk],
140 network_interfaces=[nic],
141 metadata=metadata,
142 )
143 try:
144 op = self._instances.insert(project=self._project, zone=zone, instance_resource=instance)
145 op.result(timeout=_DEFAULT_OPERATION_TIMEOUT)
146 except Exception as e:
147 fatal_error(f"GCP API error creating VM: {e}")
148
149 created = self._instances.get(project=self._project, zone=zone, instance=name)
150 return self._instance_to_vm(created, zone)
151
152 def get_vm(self, vm_id) -> VM:
153 zone, name = self._parse_id(vm_id)
154 try:
155 inst = self._instances.get(project=self._project, zone=zone, instance=name)
156 except Exception as e:
157 fatal_error(f"Error: machine with id {vm_id} not found: {e}")
158 return self._instance_to_vm(inst, zone)
159
160 def destroy_vm(self, vm_id) -> bool:
161 zone, name = self._parse_id(vm_id)
162 try:
163 op = self._instances.delete(project=self._project, zone=zone, instance=name)
164 op.result(timeout=_DEFAULT_OPERATION_TIMEOUT)
165 except Exception as e:
166 msg = str(e)
167 if "404" in msg or "notFound" in msg or "was not found" in msg:
168 return True
169 fatal_error(f"Error destroying machine {vm_id}: {e}")
170 return True
171
172 def list_vms(self, tag=None) -> list:
173 request = self._compute_v1.AggregatedListInstancesRequest(project=self._project)
174 agg = self._instances.aggregated_list(request=request)
175 vms = []
176 for zone_url, scoped in agg:
177 zone = zone_url.rsplit("/", 1)[-1]
178 instances = getattr(scoped, "instances", None) or []
179 for inst in instances:
180 vm = self._instance_to_vm(inst, zone)
181 if tag is None or tag in vm.tags:
182 vms.append(vm)
183 return vms
184
185 def _get_project_metadata(self):
186 proj = self._projects_client.get(project=self._project)
187 return proj.common_instance_metadata
188
189 def _get_project_ssh_keys_raw(self):
190 meta = self._get_project_metadata()
191 for item in meta.items or []:
192 if item.key == _SSH_KEYS_METADATA_KEY:
193 return item.value or ""
194 return ""
195
196 def get_ssh_key(self, name) -> SSHKey:
197 for user, key_data in _parse_ssh_keys(self._get_project_ssh_keys_raw()):
198 if user == name:
199 return SSHKey(id=user, name=user, fingerprint=_fingerprint(key_data), public_key=key_data)
200 return None
201
202 def list_ssh_keys(self) -> list:
203 return [
204 SSHKey(id=u, name=u, fingerprint=_fingerprint(k), public_key=k)
205 for u, k in _parse_ssh_keys(self._get_project_ssh_keys_raw())
206 ]
207
208 def _get_managed_zone(self, dns_zone):
209 target = dns_zone if dns_zone.endswith(".") else dns_zone + "."
210 for z in self._dns().list_zones():
211 if z.dns_name == target:
212 return z
213 return None
214
215 @staticmethod
216 def _fqdn(name, zone):
217 full = name if name.endswith("." + zone) or name == zone else f"{name}.{zone}"
218 return full if full.endswith(".") else full + "."
219
220 def create_dns_record(self, zone, record_type, name, data, ttl, tag=None) -> str:
221 mz = self._get_managed_zone(zone)
222 if not mz:
223 info(f"Warning: DNS zone '{zone}' not found in GCP, DNS record not set")
224 return None
225 record_name = self._fqdn(name, zone)
226 rrs = mz.resource_record_set(record_name, record_type, ttl, [data])
227 change = mz.changes()
228 change.add_record_set(rrs)
229 try:
230 change.create()
231 except Exception as e:
232 info(f"Warning: failed to create DNS record {record_name}: {e}")
233 return None
234 return record_name
235
236 def delete_dns_record(self, zone, record_name) -> bool:
237 mz = self._get_managed_zone(zone)
238 if not mz:
239 return False
240 target = self._fqdn(record_name, zone)
241 for rrs in mz.list_resource_record_sets():
242 if rrs.name == target:
243 change = mz.changes()
244 change.delete_record_set(rrs)
245 try:
246 change.create()
247 except Exception:
248 return False
249 return True
250 return False
251
252 def get_dns_records(self, zone) -> list:
253 mz = self._get_managed_zone(zone)
254 if not mz:
255 info(f"Warning: DNS zone '{zone}' not found in GCP")
256 return []
257 records = []
258 zone_suffix = "." + (zone if zone.endswith(".") else zone + ".")
259 for rrs in mz.list_resource_record_sets():
260 short_name = rrs.name
261 if short_name.endswith(zone_suffix):
262 short_name = short_name[: -len(zone_suffix)]
263 elif short_name.endswith("."):
264 short_name = short_name[:-1]
265 records.append(
266 DNSRecord(
267 id=rrs.name,
268 name=short_name,
269 type=rrs.record_type,
270 data=",".join(rrs.rrdatas),
271 ttl=rrs.ttl,
272 )
273 )
274 return records
275
276 def list_domains(self) -> list:
277 return [z.dns_name.rstrip(".") for z in self._dns().list_zones()]
278
279 def validate_region(self, region):
280 if region is not None and "-" not in region:
281 info(f"Warning: GCP zone '{region}' does not look like a valid zone (e.g. us-central1-a)")
282
283 def validate_image(self, image):
284 pass
285
286 @property
287 def provider_name(self) -> str:
288 return "GCP"
289 grep -rn "ssh-key\|ssh_key\|ssh key\|SSH key" --include="*.md" --include="*.yml" --include="*.yaml" --include="*.example" . 2>/dev/null; echo "---FILES---"; ls; echo "---SAMPLE CONFIGS---"; find . -name "*.yml" -o -name "*.yaml" -o -name "config*example*" 2>/dev/null | grep -v node_modules
tests/E2E.md:14:- An SSH key registered in the account
tests/E2E.md:25:| `ssh_key` | read | Looking up SSH keys by name |
tests/E2E.md:37:- An SSH key registered in the account
tests/E2E.md:49:- An SSH key entry in project metadata (the `ssh-key` config value matches the username portion — see the main README's GCP Config section)
tests/E2E.md:56:| `roles/compute.instanceAdmin.v1` | Create, list, destroy VMs and read project SSH keys |
tests/E2E.md:71:| `E2E_SSH_KEY` | Yes | Name of an SSH key registered with the provider |
tests/E2E.md:111:export E2E_SSH_KEY="my-ssh-key"
tests/E2E.md:125:export E2E_SSH_KEY="my-ssh-key"
README.md:71: ssh-key: my-ssh-key-name
README.md:90:| `ssh-key` | Yes | Name of the SSH key in your DigitalOcean account to use for new machines |
README.md:104: ssh-key: my-ssh-key-name
README.md:122:| `ssh-key` | Yes | Name of the SSH key in your Vultr account to use for new machines |
README.md:138: ssh-key: alice
README.md:149:| `ssh-key` | Yes | Username GCP associates with an SSH key in the project's `ssh-keys` metadata (see SSH key notes below) |
README.md:162:**SSH keys:**
README.md:164:GCP doesn't have a named SSH key registry like DigitalOcean or Vultr. Instead, public keys live in the project's `ssh-keys` metadata, and each one is associated with a username. The value of `ssh-key` in the config must match one of those usernames.
README.md:172: GCP derives the username from the comment portion at the end — everything before the first `@`. So `alice@laptop` and `alice@example.com` both produce the username `alice`. Set `ssh-key: alice` in the config.
README.md:177: gcloud compute project-info add-metadata --metadata-from-file ssh-keys=keys.txt
README.md:219: ssh-key: ${SSH_KEY_NAME:-my-ssh-key}
README.md:231: ssh-key: ${SSH_KEY_NAME:-my-ssh-key}
README.md:280: ssh-keys List ssh keys
README.md:331:When `--initialize` is enabled (the default), a cloud-config user-data payload is generated that creates a non-root user with sudo access, installs the SSH key, and optionally downloads and runs an initialization script.
README.md:501:#### ssh-keys
README.md:503:List SSH keys in your provider account. Output format: `id: name (fingerprint)`
.github/GCP_CI_SETUP.md:53:- An SSH key entry in project metadata (see the main README's GCP section)
---FILES---
CLAUDE.md
LICENSE
MANIFEST.in
Makefile
README.md
machine
pyproject.toml
sh
tests
uv.lock
---SAMPLE CONFIGS---
./.github/workflows/e2e-test.yml
./.github/workflows/build-release.yml
./.github/workflows/test.yml
./.github/workflows/lint.yml{
"file_path": "/home/david/projects/stirlingbridge/machine/machine/subcommands/ssh_keys.py"
}1 import click
2 from machine.log import output
3 from machine.types import MainCmdCtx
4
5
6 @click.command(help="List ssh keys")
7 @click.pass_context
8 def command(context):
9 command_context: MainCmdCtx = context.obj
10 provider = command_context.provider
11 keys = provider.list_ssh_keys()
12 for key in keys:
13 output(f"{key.id}: {key.name} ({key.fingerprint})")
14 {
"file_path": "/home/david/projects/stirlingbridge/machine/README.md",
"offset": 60,
"limit": 110
}60 If only one provider section is present, it is auto-detected. If multiple provider sections exist, add a `provider:` key to select one explicitly:
61
62 ```yaml
63 provider: vultr
64 ```
65
66 #### DigitalOcean Config
67
68 ```yaml
69 digital-ocean:
70 access-token: dop_v1_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
71 ssh-key: my-ssh-key-name
72 dns-zone: example.com
73 machine-size: s-4vcpu-8gb
74 image: ubuntu-22-04-x64
75 region: nyc3
76 project: Infrastructure
77
78 machines:
79 example:
80 new-user-name: alice
81 script-dir: /opt/setup-scripts
82 script-url: https://raw.githubusercontent.com/example/setup-machine.sh
83 script-path: /opt/setup-scripts/setup-machine.sh
84 script-args: "-y"
85 ```
86
87 | Key | Required | Description |
88 |-----|----------|-------------|
89 | `access-token` | Yes | DigitalOcean API access token |
90 | `ssh-key` | Yes | Name of the SSH key in your DigitalOcean account to use for new machines |
91 | `dns-zone` | No | DNS zone for automatic DNS record creation/deletion |
92 | `machine-size` | Yes | Default machine size slug (e.g. `s-4vcpu-8gb`) |
93 | `image` | Yes | Default image name (e.g. `ubuntu-22-04-x64`) |
94 | `region` | Yes | Default region code (e.g. `nyc3`) |
95 | `project` | No | DigitalOcean project name to assign new machines to |
96
97 Supported regions: `NYC1`, `NYC3`, `AMS3`, `SFO2`, `SFO3`, `SGP1`, `LON1`, `FRA1`, `TOR1`, `BLR1`, `SYD1`
98
99 #### Vultr Config
100
101 ```yaml
102 vultr:
103 api-key: ${VULTR_API_KEY}
104 ssh-key: my-ssh-key-name
105 dns-zone: example.com
106 machine-size: vc2-1c-1gb
107 image: 2136
108 region: ewr
109
110 machines:
111 example:
112 new-user-name: alice
113 script-dir: /opt/setup-scripts
114 script-url: https://raw.githubusercontent.com/example/setup-machine.sh
115 script-path: /opt/setup-scripts/setup-machine.sh
116 script-args: "-y"
117 ```
118
119 | Key | Required | Description |
120 |-----|----------|-------------|
121 | `api-key` | Yes | Vultr API key (from https://my.vultr.com/settings/#settingsapi) |
122 | `ssh-key` | Yes | Name of the SSH key in your Vultr account to use for new machines |
123 | `dns-zone` | No | DNS zone for automatic DNS record creation/deletion |
124 | `machine-size` | Yes | Vultr plan slug (e.g. `vc2-1c-1gb`). Use `machine list-plans` or the Vultr API to list available plans |
125 | `image` | Yes | Vultr OS ID (numeric, e.g. `2136` for Ubuntu 24.04). Use `machine list-os` or the Vultr API to list available OS IDs |
126 | `region` | Yes | Vultr region code (e.g. `ewr`) |
127
128 Supported regions: `ewr`, `ord`, `dfw`, `sea`, `lax`, `atl`, `ams`, `lhr`, `fra`, `sjc`, `syd`, `nrt`, `cdg`, `icn`, `mia`, `sgp`, `sto`, `mex`, `mad`, `sao`, `del`, `hnl`, `yto`, `blr`, `jnb`, `bom`, `tlv`
129
130 **Note:** Vultr does not have a "projects" concept, so the `project` config key and the `projects` command are not applicable when using the Vultr provider.
131
132 #### GCP Config
133
134 ```yaml
135 gcp:
136 project-id: my-gcp-project
137 credentials-file: ~/.config/gcloud/service-account.json
138 ssh-key: alice
139 dns-zone: example.com
140 machine-size: e2-standard-2
141 image: projects/debian-cloud/global/images/family/debian-12
142 region: us-central1-a
143 ```
144
145 | Key | Required | Description |
146 |-----|----------|-------------|
147 | `project-id` | Yes | GCP project ID where all resources (VMs, DNS, etc.) live |
148 | `credentials-file` | No | Path to a service account JSON key file. If omitted, [Application Default Credentials](https://cloud.google.com/docs/authentication/application-default-credentials) are used (e.g. from `gcloud auth application-default login`) |
149 | `ssh-key` | Yes | Username GCP associates with an SSH key in the project's `ssh-keys` metadata (see SSH key notes below) |
150 | `dns-zone` | No | DNS name of a [Cloud DNS](https://cloud.google.com/dns) managed zone (e.g. `example.com`) |
151 | `machine-size` | Yes | GCE machine type (e.g. `e2-standard-2`) |
152 | `image` | Yes | Image self-link or family path (e.g. `projects/debian-cloud/global/images/family/debian-12`) |
153 | `region` | Yes | GCE *zone* (e.g. `us-central1-a`). Despite the key name, GCP requires a fully-qualified zone, not a region |
154
155 **Authentication:**
156
157 `machine` supports two ways to authenticate with GCP:
158
159 - **Application Default Credentials (ADC)** — omit `credentials-file` and `machine` falls back to ADC. The simplest way to set this up is to run `gcloud auth application-default login` once; it stores credentials under `~/.config/gcloud` that `machine` picks up automatically. This is the easiest option for interactive use on a workstation that already has the `gcloud` CLI, and avoids creating and managing a long-lived key file.
160 - **Service account key file** — set `credentials-file` to the path of a service account JSON key. This is appropriate for unattended environments or when you want a dedicated, tightly-scoped identity.
161
162 **SSH keys:**
163
164 GCP doesn't have a named SSH key registry like DigitalOcean or Vultr. Instead, public keys live in the project's `ssh-keys` metadata, and each one is associated with a username. The value of `ssh-key` in the config must match one of those usernames.
165
166 Two ways to add a key:
167
168 1. **Console** (Compute Engine → Metadata → SSH Keys → Add SSH Key): paste a raw public key, e.g.
169 ```{
"file_path": "/home/david/projects/stirlingbridge/machine/tests/test_cli_integration.py",
"offset": 40,
"limit": 100
}40
41
42 class TestInfoCommand:
43 """Tests for the info diagnostic subcommand."""
44
45 @pytest.fixture()
46 def config_dir(self, tmp_path):
47 return tmp_path
48
49 def test_info_shows_config_file_path(self, config_dir):
50 config_file = config_dir / "config.yml"
51 write_config(
52 config_file,
53 """\
54 digital-ocean:
55 access-token: fake-token
56 ssh-key: test-key
57 machine-size: s-1vcpu-1gb
58 image: ubuntu-22-04-x64
59 region: nyc1
60 project: test-project
61 machines:
62 test-machine:
63 new-user-name: testuser
64 """,
65 )
66 result = run_machine("--config-file", str(config_file), "info")
67 assert result.returncode == 0
68 assert f"Config file: {config_file}" in result.stdout
69
70 def test_info_shows_config_file_contents(self, config_dir):
71 config_file = config_dir / "config.yml"
72 write_config(
73 config_file,
74 """\
75 digital-ocean:
76 access-token: fake-token
77 ssh-key: test-key
78 machine-size: s-1vcpu-1gb
79 image: ubuntu-22-04-x64
80 region: nyc1
81 project: test-project
82 machines:
83 test-machine:
84 new-user-name: testuser
85 """,
86 )
87 result = run_machine("--config-file", str(config_file), "info")
88 assert result.returncode == 0
89 assert "access-token: fake-token" in result.stdout
90 assert "ssh-key: test-key" in result.stdout
91
92 def test_info_shows_session_id(self, config_dir):
93 config_file = config_dir / "config.yml"
94 write_config(
95 config_file,
96 """\
97 digital-ocean:
98 access-token: fake-token
99 ssh-key: test-key
100 machine-size: s-1vcpu-1gb
101 image: ubuntu-22-04-x64
102 region: nyc1
103 project: test-project
104 machines:
105 test-machine:
106 new-user-name: testuser
107 """,
108 )
109 result = run_machine("--config-file", str(config_file), "info")
110 assert result.returncode == 0
111 assert "Session ID file:" in result.stdout
112 assert "Session ID:" in result.stdout
113
114 def test_info_shows_providers(self, config_dir):
115 config_file = config_dir / "config.yml"
116 write_config(
117 config_file,
118 """\
119 digital-ocean:
120 access-token: fake-token
121 ssh-key: test-key
122 machine-size: s-1vcpu-1gb
123 image: ubuntu-22-04-x64
124 region: nyc1
125 project: test-project
126 machines:
127 test-machine:
128 new-user-name: testuser
129 """,
130 )
131 result = run_machine("--config-file", str(config_file), "info")
132 assert result.returncode == 0
133 assert "digital-ocean" in result.stdout
134 assert "vultr" in result.stdout
135 assert "Active provider: digital-ocean" in result.stdout
136
137 def test_info_shows_vultr_as_active_provider(self, config_dir):
138 config_file = config_dir / "config.yml"
139 write_config({
"file_path": "/home/david/projects/stirlingbridge/machine/tests/test_input_validation.py",
"offset": 110,
"limit": 90
}110 )
111 result = run_machine("--config-file", str(config_file), "types")
112 assert result.returncode != 0
113 assert "digital-ocean" in result.stderr
114 assert "vultr" in result.stderr
115 assert "Traceback" not in result.stderr
116
117 def test_missing_access_token(self, config_dir):
118 config_file = config_dir / "config.yml"
119 write_config(
120 config_file,
121 """\
122 digital-ocean:
123 ssh-key: test-key
124 machine-size: s-1vcpu-1gb
125 image: ubuntu-22-04-x64
126 region: nyc1
127 project: test-project
128 machines:
129 web-server:
130 new-user-name: admin
131 """,
132 )
133 result = run_machine("--config-file", str(config_file), "types")
134 assert result.returncode != 0
135 assert "access-token" in result.stderr
136 assert "Traceback" not in result.stderr
137
138 def test_missing_ssh_key(self, config_dir):
139 config_file = config_dir / "config.yml"
140 write_config(
141 config_file,
142 """\
143 digital-ocean:
144 access-token: fake-token
145 machine-size: s-1vcpu-1gb
146 image: ubuntu-22-04-x64
147 region: nyc1
148 project: test-project
149 machines:
150 web-server:
151 new-user-name: admin
152 """,
153 )
154 result = run_machine("--config-file", str(config_file), "types")
155 assert result.returncode != 0
156 assert "ssh-key" in result.stderr
157 assert "Traceback" not in result.stderr
158
159 def test_missing_machines_section(self, config_dir):
160 config_file = config_dir / "config.yml"
161 write_config(
162 config_file,
163 """\
164 digital-ocean:
165 access-token: fake-token
166 ssh-key: test-key
167 machine-size: s-1vcpu-1gb
168 image: ubuntu-22-04-x64
169 region: nyc1
170 project: test-project
171 """,
172 )
173 result = run_machine("--config-file", str(config_file), "types")
174 assert result.returncode != 0
175 assert "machines" in result.stderr.lower()
176 assert "Traceback" not in result.stderr
177
178 def test_missing_new_user_name_in_machine(self, config_dir):
179 config_file = config_dir / "config.yml"
180 write_config(
181 config_file,
182 """\
183 digital-ocean:
184 access-token: fake-token
185 ssh-key: test-key
186 machine-size: s-1vcpu-1gb
187 image: ubuntu-22-04-x64
188 region: nyc1
189 project: test-project
190 machines:
191 broken-machine:
192 script-url: http://example.com/setup.sh
193 """,
194 )
195 result = run_machine(
196 "--config-file",
197 str(config_file),
198 "create",
199 "-n",{
"file_path": "/home/david/projects/stirlingbridge/machine/tests/test_input_validation.py",
"offset": 1,
"limit": 110
}1 """Tests for graceful handling of invalid user input.""" 2 3 import os 4 import subprocess 5 import textwrap 6 import pytest 7 8 9 def run_machine(*args, env_override=None): 10 """Run the machine CLI as a subprocess and return the result.""" 11 env = os.environ.copy() 12 if env_override: 13 env.update(env_override) 14 result = subprocess.run( 15 ["uv", "run", "machine"] + list(args), 16 capture_output=True, 17 text=True, 18 env=env, 19 ) 20 return result 21 22 23 def write_config(path, content): 24 with open(path, "w") as f: 25 f.write(textwrap.dedent(content)) 26 27 28 def valid_config(): 29 return """\ 30 digital-ocean: 31 access-token: fake-token 32 ssh-key: test-key 33 machine-size: s-1vcpu-1gb 34 image: ubuntu-22-04-x64 35 region: nyc1 36 project: test-project 37 machines: 38 web-server: 39 new-user-name: admin 40 db-server: 41 new-user-name: dbadmin 42 """ 43 44 45 class TestInvalidMachineType: 46 """Issue #29: invalid machine type should produce a graceful error, not a traceback.""" 47 48 @pytest.fixture() 49 def config_dir(self, tmp_path): 50 return tmp_path 51 52 def test_invalid_machine_type_error_message(self, config_dir): 53 config_file = config_dir / "config.yml" 54 write_config(config_file, valid_config()) 55 result = run_machine( 56 "--config-file", 57 str(config_file), 58 "create", 59 "-n", 60 "test-1", 61 "-m", 62 "nonexistent-type", 63 "--no-update-dns", 64 ) 65 assert result.returncode != 0 66 assert "nonexistent-type" in result.stderr 67 assert "not found" in result.stderr.lower() 68 # Should list available types 69 assert "web-server" in result.stderr 70 assert "db-server" in result.stderr 71 # Should NOT be a traceback 72 assert "Traceback" not in result.stderr 73 assert "KeyError" not in result.stderr 74 75 def test_valid_machine_type_accepted(self, config_dir): 76 """Verify that a valid machine type doesn't produce an error about the type. 77 (It will fail later trying to reach DigitalOcean, but not with a type error.)""" 78 config_file = config_dir / "config.yml" 79 write_config(config_file, valid_config()) 80 result = run_machine( 81 "--config-file", 82 str(config_file), 83 "create", 84 "-n", 85 "test-1", 86 "-m", 87 "web-server", 88 "--no-update-dns", 89 ) 90 # It will fail (no real DO token), but NOT because of machine type 91 assert "not found in config" not in result.stderr 92 93 94 class TestMissingConfigSections: 95 """Missing required config sections/keys should produce graceful errors.""" 96 97 @pytest.fixture() 98 def config_dir(self, tmp_path): 99 return tmp_path 100 101 def test_missing_provider_section(self, config_dir): 102 config_file = config_dir / "config.yml" 103 write_config( 104 config_file, 105 """\ 106 machines: 107 web-server: 108 new-user-name: admin 109 """, 110 )
{
"file_path": "/home/david/projects/stirlingbridge/machine/tests/test_input_validation.py",
"offset": 195,
"limit": 60
}195 result = run_machine( 196 "--config-file", 197 str(config_file), 198 "create", 199 "-n", 200 "test-1", 201 "-m", 202 "broken-machine", 203 "--no-update-dns", 204 ) 205 assert result.returncode != 0 206 assert "new-user-name" in result.stderr 207 assert "Traceback" not in result.stderr 208 209 210 class TestCreateNoInitialize: 211 """--no-initialize should work without a machine type and without crashing.""" 212 213 @pytest.fixture() 214 def config_dir(self, tmp_path): 215 return tmp_path 216 217 def test_no_initialize_without_type_no_crash(self, config_dir): 218 """Using --no-initialize without --type should not crash with AttributeError or NameError.""" 219 config_file = config_dir / "config.yml" 220 write_config(config_file, valid_config()) 221 result = run_machine( 222 "--config-file", 223 str(config_file), 224 "create", 225 "-n", 226 "test-1", 227 "--no-initialize", 228 "--no-update-dns", 229 ) 230 # It will fail (no real DO token), but should NOT crash with AttributeError/NameError 231 # from the bugs where user_data was undefined and type.lower() was called on None 232 assert "AttributeError" not in result.stderr 233 assert "NameError" not in result.stderr 234
ls tests/ && echo "---" && grep -ln "get_user_data\|config.get\|from machine.config\|cloud_config" tests/*.py
E2E.md __init__.py __pycache__ test_cli_integration.py test_config.py test_e2e.py test_input_validation.py --- tests/test_config.py
1 import os
2 import pytest
3 from unittest.mock import patch
4 from machine.config import _expand_env_vars
5
6
7 class TestExpandEnvVars:
8 def test_plain_string_unchanged(self):
9 assert _expand_env_vars("hello world") == "hello world"
10
11 def test_non_string_types_unchanged(self):
12 assert _expand_env_vars(42) == 42
13 assert _expand_env_vars(3.14) == 3.14
14 assert _expand_env_vars(True) is True
15 assert _expand_env_vars(None) is None
16
17 def test_simple_variable_substitution(self):
18 with patch.dict(os.environ, {"MY_VAR": "my_value"}):
19 assert _expand_env_vars("${MY_VAR}") == "my_value"
20
21 def test_variable_embedded_in_string(self):
22 with patch.dict(os.environ, {"HOST": "example.com"}):
23 assert _expand_env_vars("https://${HOST}/api") == "https://example.com/api"
24
25 def test_multiple_variables_in_string(self):
26 with patch.dict(os.environ, {"HOST": "example.com", "PORT": "8080"}):
27 assert _expand_env_vars("${HOST}:${PORT}") == "example.com:8080"
28
29 def test_default_value_when_var_unset(self):
30 env = os.environ.copy()
31 env.pop("UNSET_VAR", None)
32 with patch.dict(os.environ, env, clear=True):
33 assert _expand_env_vars("${UNSET_VAR:-fallback}") == "fallback"
34
35 def test_default_value_ignored_when_var_set(self):
36 with patch.dict(os.environ, {"MY_VAR": "actual"}):
37 assert _expand_env_vars("${MY_VAR:-fallback}") == "actual"
38
39 def test_default_value_empty_string(self):
40 env = os.environ.copy()
41 env.pop("UNSET_VAR", None)
42 with patch.dict(os.environ, env, clear=True):
43 assert _expand_env_vars("${UNSET_VAR:-}") == ""
44
45 def test_unset_variable_without_default_exits(self):
46 env = os.environ.copy()
47 env.pop("MISSING_VAR", None)
48 with patch.dict(os.environ, env, clear=True):
49 with pytest.raises(SystemExit):
50 _expand_env_vars("${MISSING_VAR}")
51
52 def test_dict_values_expanded(self):
53 with patch.dict(os.environ, {"TOKEN": "secret123"}):
54 data = {"key": "${TOKEN}", "plain": "no-change"}
55 result = _expand_env_vars(data)
56 assert result == {"key": "secret123", "plain": "no-change"}
57
58 def test_nested_dict_expanded(self):
59 with patch.dict(os.environ, {"VAL": "deep"}):
60 data = {"outer": {"inner": "${VAL}"}}
61 result = _expand_env_vars(data)
62 assert result == {"outer": {"inner": "deep"}}
63
64 def test_list_values_expanded(self):
65 with patch.dict(os.environ, {"A": "x", "B": "y"}):
66 data = ["${A}", "literal", "${B}"]
67 result = _expand_env_vars(data)
68 assert result == ["x", "literal", "y"]
69
70 def test_mixed_nested_structure(self):
71 with patch.dict(os.environ, {"V": "replaced"}):
72 data = {"list": ["${V}", "fixed"], "nested": {"k": "${V}"}}
73 result = _expand_env_vars(data)
74 assert result == {"list": ["replaced", "fixed"], "nested": {"k": "replaced"}}
75 I have a complete picture now. Let me implement the feature. The design: ssh-key in config will accept either a single name (existing behavior) or a YAML list of names — fully backward compatible.
Let me start with the core type and config parsing.
@dataclass
class Config:
provider_name: str
provider_config: dict
ssh_key: str
dns_zone: str@dataclass
class Config:
provider_name: str
provider_config: dict
ssh_keys: list
dns_zone: strThe file /home/david/projects/stirlingbridge/machine/machine/types.py has been updated successfully. (file state is current in your context — no need to Read it back)
def _require_key(d, key, section_name):
if key not in d:
fatal_error(f"Required key '{key}' not found in '{section_name}' section of config file")
return d[key]def _require_key(d, key, section_name):
if key not in d:
fatal_error(f"Required key '{key}' not found in '{section_name}' section of config file")
return d[key]
def _get_ssh_keys(provider_config, section_name) -> list:
"""Read the 'ssh-key' config value, accepting either a single name (a
string) or a list of names. Always returns a non-empty list of names."""
value = _require_key(provider_config, "ssh-key", section_name)
if isinstance(value, str):
keys = [value]
elif isinstance(value, list):
keys = [str(k) for k in value]
else:
fatal_error(f"Config key 'ssh-key' in '{section_name}' section must be a name or a list of names")
if not keys:
fatal_error(f"Config key 'ssh-key' in '{section_name}' section must specify at least one SSH key")
return keysssh_key=_require_key(provider_config, "ssh-key", provider_name),
ssh_keys=_get_ssh_keys(provider_config, provider_name),
The file /home/david/projects/stirlingbridge/machine/machine/config.py has been updated successfully. (file state is current in your context — no need to Read it back)
The file /home/david/projects/stirlingbridge/machine/machine/config.py has been updated successfully. (file state is current in your context — no need to Read it back)
def get_user_data(provider: CloudProvider, ssh_key_name: str, fqdn: str, machine_config: MachineConfig):
if not fqdn:
fqdn = ""
script_args = machine_config.script_args
if not script_args:
script_args = ""
ssh_key = provider.get_ssh_key(ssh_key_name)
if not ssh_key:
fatal_error(f"Error: SSH key '{ssh_key_name}' not found in {provider.provider_name}")
ssh_public_key = ssh_key.public_key
escaped_args = script_args.replace('"', '\\"')def get_user_data(provider: CloudProvider, ssh_key_names: list, fqdn: str, machine_config: MachineConfig):
if not fqdn:
fqdn = ""
script_args = machine_config.script_args
if not script_args:
script_args = ""
public_keys = []
for ssh_key_name in ssh_key_names:
ssh_key = provider.get_ssh_key(ssh_key_name)
if not ssh_key:
fatal_error(f"Error: SSH key '{ssh_key_name}' not found in {provider.provider_name}")
public_keys.append(ssh_key.public_key)
escaped_args = script_args.replace('"', '\\"')The file /home/david/projects/stirlingbridge/machine/machine/cloud_config.py has been updated successfully. (file state is current in your context — no need to Read it back)
escaped_args = expand(escaped_args, environ=cloud_env)
cloud_config = f"""#cloud-config
users:
- name: {machine_config.new_user_name}
groups: sudo
shell: /bin/bash
sudo: ['ALL=(ALL) NOPASSWD:ALL']
ssh-authorized-keys:
- {ssh_public_key}
""" escaped_args = expand(escaped_args, environ=cloud_env)
authorized_keys = "\n".join(f" - {key}" for key in public_keys)
cloud_config = f"""#cloud-config
users:
- name: {machine_config.new_user_name}
groups: sudo
shell: /bin/bash
sudo: ['ALL=(ALL) NOPASSWD:ALL']
ssh-authorized-keys:
{authorized_keys}
"""The file /home/david/projects/stirlingbridge/machine/machine/cloud_config.py has been updated successfully. (file state is current in your context — no need to Read it back)
user_data = get_user_data(provider, config.ssh_key, fqdn, machine_config)
if d.opt.debug:
info("user-data is:")
info(user_data)
# Verify SSH key exists
ssh_key = provider.get_ssh_key(config.ssh_key)
if not ssh_key:
fatal_error(f"Error: SSH key '{config.ssh_key}' not found in {provider.provider_name}") user_data = get_user_data(provider, config.ssh_keys, fqdn, machine_config)
if d.opt.debug:
info("user-data is:")
info(user_data)
# Verify SSH keys exist
for ssh_key_name in config.ssh_keys:
if not provider.get_ssh_key(ssh_key_name):
fatal_error(f"Error: SSH key '{ssh_key_name}' not found in {provider.provider_name}")The file /home/david/projects/stirlingbridge/machine/machine/subcommands/create.py has been updated successfully. (file state is current in your context — no need to Read it back)
ssh_key_name=config.ssh_key,
ssh_key_names=config.ssh_keys,
The file /home/david/projects/stirlingbridge/machine/machine/subcommands/create.py has been updated successfully. (file state is current in your context — no need to Read it back)
@abstractmethod
def create_vm(self, name, region, image, size, ssh_key_name, tags, user_data) -> VM:
pass @abstractmethod
def create_vm(self, name, region, image, size, ssh_key_names, tags, user_data) -> VM:
pass def create_vm(self, name, region, image, size, ssh_key_name, tags, user_data) -> VM:
ssh_key = self._get_do_ssh_key(ssh_key_name)
if not ssh_key:
fatal_error(f"Error: SSH key '{ssh_key_name}' not found in DigitalOcean")
droplet = digitalocean.Droplet(
token=self.token,
name=name,
region=region,
image=image,
size_slug=size,
ssh_keys=[ssh_key], def create_vm(self, name, region, image, size, ssh_key_names, tags, user_data) -> VM:
ssh_keys = []
for ssh_key_name in ssh_key_names:
ssh_key = self._get_do_ssh_key(ssh_key_name)
if not ssh_key:
fatal_error(f"Error: SSH key '{ssh_key_name}' not found in DigitalOcean")
ssh_keys.append(ssh_key)
droplet = digitalocean.Droplet(
token=self.token,
name=name,
region=region,
image=image,
size_slug=size,
ssh_keys=ssh_keys,The file /home/david/projects/stirlingbridge/machine/machine/provider.py has been updated successfully. (file state is current in your context — no need to Read it back)
The file /home/david/projects/stirlingbridge/machine/machine/providers/digitalocean.py has been updated successfully. (file state is current in your context — no need to Read it back)
def create_vm(self, name, region, image, size, ssh_key_name, tags, user_data) -> VM:
ssh_key = self._get_vultr_ssh_key(ssh_key_name)
if not ssh_key:
fatal_error(f"Error: SSH key '{ssh_key_name}' not found in Vultr")
kwargs = {
"os_id": int(image),
"label": name,
"hostname": name,
"sshkey_id": [ssh_key["id"]], def create_vm(self, name, region, image, size, ssh_key_names, tags, user_data) -> VM:
sshkey_ids = []
for ssh_key_name in ssh_key_names:
ssh_key = self._get_vultr_ssh_key(ssh_key_name)
if not ssh_key:
fatal_error(f"Error: SSH key '{ssh_key_name}' not found in Vultr")
sshkey_ids.append(ssh_key["id"])
kwargs = {
"os_id": int(image),
"label": name,
"hostname": name,
"sshkey_id": sshkey_ids,The file /home/david/projects/stirlingbridge/machine/machine/providers/vultr.py has been updated successfully. (file state is current in your context — no need to Read it back)
def create_vm(self, name, region, image, size, ssh_key_name, tags, user_data) -> VM:
zone = region
if not self.get_ssh_key(ssh_key_name):
fatal_error(f"Error: SSH key '{ssh_key_name}' not found in GCP project metadata") def create_vm(self, name, region, image, size, ssh_key_names, tags, user_data) -> VM:
zone = region
for ssh_key_name in ssh_key_names:
if not self.get_ssh_key(ssh_key_name):
fatal_error(f"Error: SSH key '{ssh_key_name}' not found in GCP project metadata")The file /home/david/projects/stirlingbridge/machine/machine/providers/gcp.py has been updated successfully. (file state is current in your context — no need to Read it back)
# 2. Check SSH key exists
if cfg.ssh_key:
key = provider.get_ssh_key(cfg.ssh_key)
report("SSH key", key is not None, cfg.ssh_key)
else:
report("SSH key", False, "not configured") # 2. Check SSH keys exist
if cfg.ssh_keys:
for ssh_key_name in cfg.ssh_keys:
key = provider.get_ssh_key(ssh_key_name)
report("SSH key", key is not None, ssh_key_name)
else:
report("SSH key", False, "not configured")The file /home/david/projects/stirlingbridge/machine/machine/subcommands/check.py has been updated successfully. (file state is current in your context — no need to Read it back)
C901 `command` is too complex (26 > 25)
--> machine/subcommands/create.py:36:5
|
34 | @click.option("--output", "-o", metavar="<FORMAT>", help="Output format")
35 | @click.pass_context
36 | def command(context, name, tag, type, region, machine_size, image, wait_for_ip, update_dns, initialize, output):
| ^^^^^^^
37 | command_context: MainCmdCtx = context.obj
38 | config = command_context.config
|
Found 1 error.The verification loop pushed command over the complexity limit. I'll extract it into a helper — that also reads cleaner:
def _validate_dns_zone(provider, dns_zone):
available_zones = provider.list_domains()
if dns_zone not in available_zones:
zones_str = ", ".join(available_zones) if available_zones else "none"
fatal_error(f"Error: DNS zone '{dns_zone}' not found in {provider.provider_name}. Available zones: {zones_str}")def _validate_dns_zone(provider, dns_zone):
available_zones = provider.list_domains()
if dns_zone not in available_zones:
zones_str = ", ".join(available_zones) if available_zones else "none"
fatal_error(f"Error: DNS zone '{dns_zone}' not found in {provider.provider_name}. Available zones: {zones_str}")
def _verify_ssh_keys(provider, ssh_key_names):
for ssh_key_name in ssh_key_names:
if not provider.get_ssh_key(ssh_key_name):
fatal_error(f"Error: SSH key '{ssh_key_name}' not found in {provider.provider_name}") # Verify SSH keys exist
for ssh_key_name in config.ssh_keys:
if not provider.get_ssh_key(ssh_key_name):
fatal_error(f"Error: SSH key '{ssh_key_name}' not found in {provider.provider_name}") # Verify SSH keys exist
_verify_ssh_keys(provider, config.ssh_keys)The file /home/david/projects/stirlingbridge/machine/machine/subcommands/create.py has been updated successfully. (file state is current in your context — no need to Read it back)
The file /home/david/projects/stirlingbridge/machine/machine/subcommands/create.py has been updated successfully. (file state is current in your context — no need to Read it back)
uv run ruff check machine/ 2>&1 | tail -5 && echo "===TESTS===" && uv run pytest tests/test_config.py tests/test_input_validation.py tests/test_cli_integration.py -q 2>&1 | tail -30
All checks passed! ===TESTS=== .................................. [100%] 34 passed in 4.94s
All 34 tests pass and lint is clean. Let me check main.py to confirm which commands exercise config parsing, so I can write good tests:
1 import os
2
3 import click
4
5 from machine import config
6 from machine import constants
7 from machine.di import d
8 from machine.log import output
9 from machine.providers import create_provider
10 from machine.subcommands import check, create, destroy, info, list, projects, ssh_keys, domains, list_domain, types, status
11 from machine.types import CliOptions, MainCmdCtx
12 from machine.util import load_session_id
13
14 CLICK_CONTEXT_SETTINGS = dict(help_option_names=["-h", "--help"])
15
16
17 @click.group(context_settings=CLICK_CONTEXT_SETTINGS)
18 @click.option("--debug", is_flag=True, default=False, help="Enable debug output")
19 @click.option("--quiet", is_flag=True, default=False, help="Suppress all non-essential output")
20 @click.option("--verbose", is_flag=True, default=False, help="Enable verbose output")
21 @click.option("--dry-run", is_flag=True, default=False, help="Run but do not do anything")
22 @click.option("--config-file", metavar="<PATH>", help=f"Specify the config file (default {constants.default_config_file_path})")
23 @click.option(
24 "--session-id", metavar="<ID>", default=load_session_id, help="Override the default session ID (default: from session-id.yml)"
25 )
26 @click.pass_context
27 def main(context, debug, quiet, verbose, dry_run, config_file, session_id):
28 options = CliOptions(debug, quiet, verbose, dry_run)
29 d.opt = options
30 # Skip config loading for version subcommand since it doesn't need it
31 # and should work even when no config file exists (#25)
32 if context.invoked_subcommand == "version":
33 return
34 cfg = config.get(config_file)
35 provider = create_provider(cfg.provider_name, cfg.provider_config)
36 main_context = MainCmdCtx(cfg, session_id, provider)
37 context.obj = main_context
38
39
40 @main.command()
41 @click.pass_context
42 def version(context):
43 try:
44 version_file = os.path.join(os.path.dirname(__file__), "version.txt")
45 with open(version_file) as f:
46 version_string = f.read().strip()
47 except FileNotFoundError:
48 version_string = "dev"
49 output(version_string)
50
51
52 main.add_command(check.command, "check")
53 main.add_command(create.command, "create")
54 main.add_command(destroy.command, "destroy")
55 main.add_command(domains.command, "domains")
56 main.add_command(info.command, "info")
57 main.add_command(list.command, "list")
58 main.add_command(list_domain.command, "list-domain")
59 main.add_command(projects.command, "projects")
60 main.add_command(ssh_keys.command, "ssh-keys")
61 main.add_command(types.command, "types")
62 main.add_command(status.command, "status")
63 config.get() runs for every command except version, so types exercises config parsing without needing API calls. Let me add tests:
import os import pytest from unittest.mock import patch from machine.config import _expand_env_vars
import os import pytest from unittest.mock import patch from machine.config import _expand_env_vars, _get_ssh_keys
The file /home/david/projects/stirlingbridge/machine/tests/test_config.py has been updated successfully. (file state is current in your context — no need to Read it back)
def test_mixed_nested_structure(self):
with patch.dict(os.environ, {"V": "replaced"}):
data = {"list": ["${V}", "fixed"], "nested": {"k": "${V}"}}
result = _expand_env_vars(data)
assert result == {"list": ["replaced", "fixed"], "nested": {"k": "replaced"}} def test_mixed_nested_structure(self):
with patch.dict(os.environ, {"V": "replaced"}):
data = {"list": ["${V}", "fixed"], "nested": {"k": "${V}"}}
result = _expand_env_vars(data)
assert result == {"list": ["replaced", "fixed"], "nested": {"k": "replaced"}}
class TestGetSshKeys:
"""The 'ssh-key' config value accepts either a single name or a list of
names; both forms normalize to a non-empty list."""
def test_single_name_returns_one_element_list(self):
assert _get_ssh_keys({"ssh-key": "my-key"}, "digital-ocean") == ["my-key"]
def test_list_of_names_preserved(self):
assert _get_ssh_keys({"ssh-key": ["alice", "bob"]}, "digital-ocean") == ["alice", "bob"]
def test_list_elements_coerced_to_str(self):
assert _get_ssh_keys({"ssh-key": [1, 2]}, "digital-ocean") == ["1", "2"]
def test_missing_key_exits(self):
with pytest.raises(SystemExit):
_get_ssh_keys({}, "digital-ocean")
def test_empty_list_exits(self):
with pytest.raises(SystemExit):
_get_ssh_keys({"ssh-key": []}, "digital-ocean")
def test_unsupported_type_exits(self):
with pytest.raises(SystemExit):
_get_ssh_keys({"ssh-key": {"unexpected": "mapping"}}, "digital-ocean")The file /home/david/projects/stirlingbridge/machine/tests/test_config.py has been updated successfully. (file state is current in your context — no need to Read it back)
class TestCreateNoInitialize:
class TestMultipleSshKeys:
"""Issue: the 'ssh-key' config value may be a single name or a list of
names, and existing single-name configs must keep working."""
@pytest.fixture()
def config_dir(self, tmp_path):
return tmp_path
def test_single_ssh_key_still_accepted(self, config_dir):
"""The original scalar form must remain backwards compatible."""
config_file = config_dir / "config.yml"
write_config(config_file, valid_config())
result = run_machine("--config-file", str(config_file), "types")
assert result.returncode == 0
assert "web-server" in result.stdout
def test_list_of_ssh_keys_accepted(self, config_dir):
"""A YAML list of key names should parse without a config error."""
config_file = config_dir / "config.yml"
write_config(
config_file,
"""\
digital-ocean:
access-token: fake-token
ssh-key:
- first-key
- second-key
machine-size: s-1vcpu-1gb
image: ubuntu-22-04-x64
region: nyc1
machines:
web-server:
new-user-name: admin
""",
)
result = run_machine("--config-file", str(config_file), "types")
assert result.returncode == 0
assert "web-server" in result.stdout
assert "Traceback" not in result.stderr
def test_empty_ssh_key_list_rejected(self, config_dir):
"""An explicitly empty list is not a valid configuration."""
config_file = config_dir / "config.yml"
write_config(
config_file,
"""\
digital-ocean:
access-token: fake-token
ssh-key: []
machine-size: s-1vcpu-1gb
image: ubuntu-22-04-x64
region: nyc1
machines:
web-server:
new-user-name: admin
""",
)
result = run_machine("--config-file", str(config_file), "types")
assert result.returncode != 0
assert "ssh-key" in result.stderr
assert "Traceback" not in result.stderr
class TestCreateNoInitialize:The file /home/david/projects/stirlingbridge/machine/tests/test_input_validation.py has been updated successfully. (file state is current in your context — no need to Read it back)
uv run pytest tests/test_config.py tests/test_input_validation.py tests/test_cli_integration.py -q 2>&1 | tail -15
| `access-token` | Yes | DigitalOcean API access token | | `ssh-key` | Yes | Name of the SSH key in your DigitalOcean account to use for new machines |
| `access-token` | Yes | DigitalOcean API access token | | `ssh-key` | Yes | Name of an SSH key in your DigitalOcean account to use for new machines, or a list of names (see [Multiple SSH keys](#multiple-ssh-keys)) |
| `api-key` | Yes | Vultr API key (from https://my.vultr.com/settings/#settingsapi) | | `ssh-key` | Yes | Name of the SSH key in your Vultr account to use for new machines |
| `api-key` | Yes | Vultr API key (from https://my.vultr.com/settings/#settingsapi) | | `ssh-key` | Yes | Name of an SSH key in your Vultr account to use for new machines, or a list of names (see [Multiple SSH keys](#multiple-ssh-keys)) |
The file /home/david/projects/stirlingbridge/machine/README.md has been updated successfully. (file state is current in your context — no need to Read it back)
The file /home/david/projects/stirlingbridge/machine/README.md has been updated successfully. (file state is current in your context — no need to Read it back)
| `ssh-key` | Yes | Username GCP associates with an SSH key in the project's `ssh-keys` metadata (see SSH key notes below) |
| `ssh-key` | Yes | Username GCP associates with an SSH key in the project's `ssh-keys` metadata (see SSH key notes below), or a list of usernames (see [Multiple SSH keys](#multiple-ssh-keys)) |
The file /home/david/projects/stirlingbridge/machine/README.md has been updated successfully. (file state is current in your context — no need to Read it back)
{
"file_path": "/home/david/projects/stirlingbridge/machine/README.md",
"offset": 178,
"limit": 60
}178 ```
179
180 The tool reads existing keys but does not create them.
181
182 **Other notes:**
183 - The `project` config key and re-assignment to projects are not applicable when using GCP — every resource is owned by the project specified in `project-id` at creation time.
184 - The `region` field is interpreted as a GCE zone. Validation only checks for plausibility; any zone string with a hyphen is accepted.
185
186 #### Machines Section
187
188 **machines section:**
189
190 Each entry under `machines:` defines a machine type that can be referenced with `create --type`:
191
192 | Key | Required | Description |
193 |-----|----------|-------------|
194 | `new-user-name` | Yes | Username for the non-root user created on the machine |
195 | `script-url` | No | URL to download an initialization script from |
196 | `script-dir` | No | Directory to store the initialization script |
197 | `script-path` | No | Full path for the initialization script |
198 | `script-args` | No | Arguments passed to the initialization script (supports variable expansion) |
199
200 If `script-url`, `script-dir`, and `script-path` are all provided, the script is downloaded and executed as the new user during cloud-init. The following variables are available for expansion in `script-args`:
201
202 - `$MACHINE_SCRIPT_URL` — URL of the initialization script
203 - `$MACHINE_SCRIPT_DIR` — directory path for the script
204 - `$MACHINE_FQDN` — fully qualified domain name of the machine (if DNS is configured)
205
206 #### Environment Variable Substitution
207
208 Config values support environment variable substitution using `${VAR}` syntax, similar to Docker Compose files. This is useful for keeping secrets like API tokens out of the config file.
209
210 Supported forms:
211 - `${VAR}` — substitutes the value of `VAR`; errors if the variable is not set
212 - `${VAR:-default}` — substitutes the value of `VAR`, or `default` if the variable is not set
213
214 Examples:
215 ```yaml
216 # DigitalOcean
217 digital-ocean:
218 access-token: ${DO_API_TOKEN}
219 ssh-key: ${SSH_KEY_NAME:-my-ssh-key}
220 dns-zone: example.com
221 machine-size: ${MACHINE_SIZE:-s-4vcpu-8gb}
222 image: ubuntu-22-04-x64
223 region: ${DO_REGION:-nyc3}
224 project: Infrastructure
225 ```
226
227 ```yaml
228 # Vultr
229 vultr:
230 api-key: ${VULTR_API_KEY}
231 ssh-key: ${SSH_KEY_NAME:-my-ssh-key}
232 dns-zone: example.com
233 machine-size: ${MACHINE_SIZE:-vc2-1c-1gb}
234 image: 2136
235 region: ${VULTR_REGION:-ewr}
236 ```
237 #### Environment Variable Substitution
#### Multiple SSH keys
The `ssh-key` config value accepts either a single key name or a list of names. When a list is given, every key is installed on new machines (both on the provider account's default user and, when `--initialize` is used, on the new user created by cloud-init).
```yaml
digital-ocean:
access-token: ${DO_API_TOKEN}
ssh-key:
- alice-laptop
- bob-laptop
machine-size: s-4vcpu-8gb
image: ubuntu-22-04-x64
region: nyc3
```
The single-name form is unchanged and remains fully supported:
```yaml
ssh-key: my-ssh-key-name
```
#### Environment Variable SubstitutionThe file /home/david/projects/stirlingbridge/machine/README.md has been updated successfully. (file state is current in your context — no need to Read it back)
352:When `--initialize` is enabled (the default), a cloud-config user-data payload is generated that creates a non-root user with sudo access, installs the SSH key, and optionally downloads and runs an initialization script.
creates a non-root user with sudo access, installs the SSH key, and optionally downloads and runs an initialization script.
creates a non-root user with sudo access, installs the configured SSH key(s), and optionally downloads and runs an initialization script.
The file /home/david/projects/stirlingbridge/machine/README.md has been updated successfully. (file state is current in your context — no need to Read it back)
Let me run the full test suite and lint once more, and sanity-check the generated cloud-config:
uv run ruff check machine/ tests/ 2>&1 | tail -3 && echo "===TESTS===" && uv run pytest tests/ -q --ignore=tests/test_e2e.py 2>&1 | tail -12
All checks passed! ===TESTS=== ........................................... [100%] 43 passed in 5.18s
uv run python -c "
import yaml
from machine.cloud_config import get_user_data
from machine.provider import SSHKey
from machine.types import MachineConfig
class FakeProvider:
provider_name = 'Fake'
_keys = {'alice': 'ssh-rsa AAAAalice alice@host', 'bob': 'ssh-ed25519 AAAAbob bob@host'}
def get_ssh_key(self, name):
k = self._keys.get(name)
return SSHKey(id=name, name=name, fingerprint='', public_key=k) if k else None
mc = MachineConfig('admin', None, None, None, None)
out = get_user_data(FakeProvider(), ['alice', 'bob'], 'host.example.com', mc)
print(out)
parsed = yaml.safe_load(out)
keys = parsed['users'][0]['ssh-authorized-keys']
assert keys == ['ssh-rsa AAAAalice alice@host', 'ssh-ed25519 AAAAbob bob@host'], keys
print('cloud-config parses as valid YAML; both keys present:', keys)
# single-key backwards-compat
out1 = get_user_data(FakeProvider(), ['alice'], '', mc)
assert yaml.safe_load(out1)['users'][0]['ssh-authorized-keys'] == ['ssh-rsa AAAAalice alice@host']
print('single-key form OK')
"The user doesn't want to proceed with this tool use. The tool use was rejected (eg. if it was a file edit, the new_string was NOT written to the file). STOP what you are doing and wait for the user to tell you how to proceed. Note: The user's next message may contain a correction or preference. Pay close attention — if they explain what went wrong or how they'd prefer you to work, consider saving that to memory for future sessions.
I like that test, but can you please put it in a file so it gets added to the project test suite, rather than running it ad-hoc?
1 import ruamel.yaml 2 3 4 def yaml(): 5 yaml = ruamel.yaml.YAML() 6 yaml.preserve_quotes = True 7 yaml.indent(sequence=3, offset=1) 8 return yaml 9
"""Tests for cloud-init user-data generation, including provisioning of
one or more SSH keys onto the new user."""
import pytest
from machine.cloud_config import get_user_data
from machine.factory import yaml
from machine.provider import SSHKey
from machine.types import MachineConfig
class FakeProvider:
"""Minimal CloudProvider stand-in that resolves a fixed set of keys."""
provider_name = "Fake"
_keys = {
"alice": "ssh-rsa AAAAalice alice@host",
"bob": "ssh-ed25519 AAAAbob bob@host",
}
def get_ssh_key(self, name):
public_key = self._keys.get(name)
if public_key is None:
return None
return SSHKey(id=name, name=name, fingerprint="", public_key=public_key)
def _machine_config():
return MachineConfig("admin", None, None, None, None)
def _authorized_keys(user_data):
"""Parse generated user-data and return the new user's authorized keys."""
parsed = yaml().load(user_data)
return list(parsed["users"][0]["ssh-authorized-keys"])
class TestGetUserData:
def test_single_key_installed(self):
"""The original single-name form installs exactly that key."""
user_data = get_user_data(FakeProvider(), ["alice"], "", _machine_config())
assert _authorized_keys(user_data) == ["ssh-rsa AAAAalice alice@host"]
def test_multiple_keys_installed(self):
"""A list of names installs every resolved key, in order."""
user_data = get_user_data(FakeProvider(), ["alice", "bob"], "host.example.com", _machine_config())
assert _authorized_keys(user_data) == [
"ssh-rsa AAAAalice alice@host",
"ssh-ed25519 AAAAbob bob@host",
]
def test_generated_user_data_is_valid_yaml(self):
"""Generated user-data must parse as YAML so cloud-init can consume it."""
user_data = get_user_data(FakeProvider(), ["alice", "bob"], "", _machine_config())
assert user_data.startswith("#cloud-config")
parsed = yaml().load(user_data)
assert parsed["users"][0]["name"] == "admin"
def test_unknown_key_is_fatal(self):
"""A key name the provider cannot resolve aborts with a clear error."""
with pytest.raises(SystemExit):
get_user_data(FakeProvider(), ["alice", "ghost"], "", _machine_config())
File created successfully at: /home/david/projects/stirlingbridge/machine/tests/test_cloud_config.py (file state is current in your context — no need to Read it back)
uv run ruff check tests/test_cloud_config.py 2>&1 | tail -3 && echo "===TESTS===" && uv run pytest tests/test_cloud_config.py -q 2>&1 | tail -12
uv run pytest tests/ -q --ignore=tests/test_e2e.py 2>&1 | tail -8
All 47 tests pass and lint is clean. Here's a summary of what I implemented.
The ssh-key config value now accepts either a single name (string) or a list of names. Existing single-name configs are unchanged.
ssh-key: my-key # still works exactly as before
ssh-key: # new: provision multiple keys
- alice-laptop
- bob-laptop
machine/types.py — Config.ssh_key: str → Config.ssh_keys: list.machine/config.py — new _get_ssh_keys() helper normalizes the ssh-key value to a non-empty list, accepting a string or a list. Reuses _require_key so the "missing key" error message is unchanged; rejects an empty list or an unsupported type with a graceful error.machine/cloud_config.py — get_user_data() resolves every key name and emits one ssh-authorized-keys entry per key in the generated cloud-init payload.machine/subcommands/create.py — verifies each configured key exists (extracted to _verify_ssh_keys() to stay under the ruff complexity limit) and passes the full list to the provider.machine/subcommands/check.py — reports a PASS/FAIL line per key.machine/provider.py + providers/{digitalocean,vultr,gcp}.py — create_vm()'s ssh_key_name parameter is now ssh_key_names (a list). DigitalOcean attaches all keys via ssh_keys=[...], Vultr via sshkey_id=[...], and GCP validates each name against project metadata.tests/test_config.py — TestGetSshKeys: scalar form, list form, str coercion, and rejection of missing/empty/wrong-type values.tests/test_cloud_config.py (new file) — single-key install, multi-key install in order, generated payload parses as valid YAML, and an unresolvable key aborts cleanly.tests/test_input_validation.py — TestMultipleSshKeys: end-to-end CLI checks that the scalar form still works, the list form parses, and an empty list is rejected gracefully (no traceback).README updated: the ssh-key row in all three provider tables now notes list support, plus a new Multiple SSH keys section with examples.
One behavior note worth flagging: on DigitalOcean and Vultr, the listed keys are also attached to the provider's default account user (e.g. root), not only the cloud-init user — that matches the pre-existing single-key behavior. GCP has no per-VM key attachment, so there the keys are only validated against project metadata, as before.
Thanks, that's great. We still need to add multiple keys in the e2e tests but since that will involve creating and provisioning extra keys, we'll do that as a separate task in the future.
Sounds good — that's a sensible split. I'll note the deferred e2e work so a future session knows it's a known gap.
--- name: e2e-multiple-ssh-keys-deferred description: E2E test coverage for the multiple-SSH-keys feature is a deferred future task metadata: type: project --- The multiple-SSH-keys feature (config `ssh-key` accepts a single name or a list; branch `dboreham/multiple-ssh-keys`) shipped with unit and integration test coverage but intentionally NOT e2e coverage. Adding multiple-key cases to `tests/test_e2e.py` is a separate future task. **Why:** Decided 2026-05-19. E2e multi-key cases need extra SSH keys created and provisioned at the real providers; the user chose to defer that setup to keep the feature change focused. **How to apply:** If asked to extend e2e SSH-key testing, this is the known gap — `tests/test_e2e.py` currently exercises only a single `E2E_SSH_KEY`.
File created successfully at: /home/david/.claude/projects/-home-david-projects-stirlingbridge-machine/memory/e2e-multiple-ssh-keys-deferred.md (file state is current in your context — no need to Read it back)
- [No core type changes for debug features](feedback_no_modify_core_types_for_debug.md) — Keep diagnostic commands decoupled from core dataclasses
- [No core type changes for debug features](feedback_no_modify_core_types_for_debug.md) — Keep diagnostic commands decoupled from core dataclasses - [E2E multi-SSH-key tests deferred](e2e-multiple-ssh-keys-deferred.md) — Multi-key e2e coverage is a known gap, deferred to a future task
<tool_use_error>File has not been read yet. Read it first before writing to it.</tool_use_error>
{
"file_path": "/home/david/.claude/projects/-home-david-projects-stirlingbridge-machine/memory/MEMORY.md"
}<system-reminder>This memory is 48 days old. Memories are point-in-time observations, not live state — claims about code behavior or file:line citations may be outdated. Verify against current code before asserting as fact.</system-reminder> 1 - [No core type changes for debug features](feedback_no_modify_core_types_for_debug.md) — Keep diagnostic commands decoupled from core dataclasses 2
- [No core type changes for debug features](feedback_no_modify_core_types_for_debug.md) — Keep diagnostic commands decoupled from core dataclasses
- [No core type changes for debug features](feedback_no_modify_core_types_for_debug.md) — Keep diagnostic commands decoupled from core dataclasses - [E2E multi-SSH-key tests deferred](e2e-multiple-ssh-keys-deferred.md) — Multi-key e2e coverage is a known gap, deferred to a future task
The file /home/david/.claude/projects/-home-david-projects-stirlingbridge-machine/memory/MEMORY.md has been updated successfully. (file state is current in your context — no need to Read it back)
Noted and saved to memory. The multiple-SSH-keys feature is complete with unit and integration coverage; extending tests/test_e2e.py with multi-key cases (which needs extra provider-side keys provisioned) is recorded as a deferred follow-up task.