I'd like to add support for Google's GCP as a hosting provider to this tool, in addition to the currently supported Digital Ocean and Vultr. Could you look into whether there is a viable Python API option for GCP? I assume that since GCP is a mature platform that they have support for all the services we need (creating VMs, programmatic DNS configuration).
I'll research this. Let me first look at the existing providers to understand the API surface needed, then investigate GCP's Python options.
1 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/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/__init__.py"
}1 from machine.log import fatal_error
2 from machine.provider import CloudProvider
3
4 KNOWN_PROVIDERS = ["digital-ocean", "vultr"]
5
6
7 def create_provider(provider_name, provider_config) -> CloudProvider:
8 if provider_name == "digital-ocean":
9 from machine.providers.digitalocean import DigitalOceanProvider
10
11 return DigitalOceanProvider(provider_config)
12 elif provider_name == "vultr":
13 from machine.providers.vultr import VultrProvider
14
15 return VultrProvider(provider_config)
16 else:
17 fatal_error(f"Unknown provider: '{provider_name}'. Known providers: {', '.join(KNOWN_PROVIDERS)}")
18 [
{
"type": "tool_reference",
"tool_name": "WebSearch"
},
{
"type": "tool_reference",
"tool_name": "WebFetch"
}
]Web search results for query: "google-cloud-dns Python library manage DNS records"
Links: [{"title":"Python Client for Cloud DNS API | Python client libraries | Google Cloud Documentation","url":"https://docs.cloud.google.com/python/docs/reference/dns/latest"},{"title":"GitHub - domainaware/gcpdns: A Python module and CLI for managing zones and resource record sets on Google Cloud DNS · GitHub","url":"https://github.com/domainaware/gcpdns"},{"title":"Google Cloud DNS API Client Library for Python | API Client Library for Python | Google Developers","url":"https://developers.google.com/api-client-library/python/apis/dns/v1?hl=en"},{"title":"gcpdns — gcpdns 1.2.9 documentation","url":"https://domainaware.github.io/gcpdns/"},{"title":"Using the API — google-cloud 0.20.0 documentation","url":"https://gcloud.readthedocs.io/en/latest/dns-usage.html"},{"title":"google-cloud-dns - conda-forge | Anaconda.org","url":"https://anaconda.org/conda-forge/google-cloud-dns"},{"title":"GitHub - googleapis/python-dns: This library has moved to https://github.com/googleapis/google-cloud-python/tree/main/packages/google-cloud-dns · GitHub","url":"https://github.com/googleapis/python-dns"},{"title":"Python client library | Google Cloud","url":"https://cloud.google.com/python/docs/reference/dns/latest/index.html"},{"title":"google-cloud-dns","url":"https://pypi.org/project/google-cloud-dns/0.21.0/"},{"title":"Python Client for Google Cloud DNS — google-cloud-dns documentation","url":"https://googleapis.dev/python/dns/0.32.0/index.html"}]
Here are the search results for the google-cloud-dns Python library:
## Official Google Cloud DNS Python Client
The Cloud DNS API provides methods that you can use to manage DNS for your applications. Google Cloud DNS API provides methods that you can use to manage DNS for your applications, and you can easily publish and manage millions of DNS zones and records using the API.
## Key Documentation and Resources
- **Official Documentation**: The official reference is available at https://docs.cloud.google.com/python/docs/reference/dns/latest
- **GitHub Repository**: The library is maintained in the google-cloud-python repository
## Basic Usage
To get started, create a client instance from google.cloud import dns.
A managed zone is the container for DNS records for the same DNS name suffix, and you can update the resource record set for a zone by creating a change request bundling additions to or deletions from the set.
Each managed zone exposes a read-only set of resource records that can be listed.
## Additional Tool
An alternative option is the **gcpdns** library: A Python module and CLI for managing zones and resource record sets on Google Cloud DNS, available at https://github.com/domainaware/gcpdns
REMINDER: You MUST include the sources above in your response to the user using markdown hyperlinks.Web search results for query: "google-cloud-compute Python library create VM instance 2026"
Links: [{"title":"google-cloud-compute · PyPI","url":"https://pypi.org/project/google-cloud-compute/"},{"title":"Python client library | Google Cloud","url":"https://docs.cloud.google.com/python/docs/reference/compute/latest"},{"title":"How to Manage Google Cloud Compute Engine with Python - DEV Community","url":"https://dev.to/pratik_kale/how-to-manage-google-cloud-compute-engine-with-python-9jd"},{"title":"python-docs-samples/compute/api/create_instance.py at main · GoogleCloudPlatform/python-docs-samples","url":"https://github.com/GoogleCloudPlatform/python-docs-samples/blob/main/compute/api/create_instance.py"},{"title":"Create a VM instance | Compute Engine | Google Cloud Documentation","url":"https://docs.cloud.google.com/compute/docs/samples/compute-instances-create"},{"title":"Getting started with Python on Compute Engine | Google Cloud Documentation","url":"https://cloud.google.com/python/docs/getting-started/getting-started-on-compute-engine"},{"title":"Create and start a Compute Engine instance | Google Cloud Documentation","url":"https://cloud.google.com/compute/docs/instances/create-start-instance"},{"title":"google-api-python-client/samples/compute/README.md at main · googleapis/google-api-python-client","url":"https://github.com/googleapis/google-api-python-client/blob/main/samples/compute/README.md"},{"title":"Create a VM from an instance template | Compute Engine | Google Cloud Documentation","url":"https://docs.cloud.google.com/compute/docs/instances/create-vm-from-instance-template"},{"title":"Setup Compute Engine with Python ML Libraries | by Warrick | Medium","url":"https://medium.com/@nyghtowl/first-contact-cloud-compute-engine-virtual-machine-setup-8d17ec55cfdf"}]
I've completed the web search for information about the google-cloud-compute Python library and creating VM instances. Here are the key results:
## Summary of Findings
The google-cloud-compute library is for Compute Engine, which delivers virtual machines running in Google's innovative data centers and worldwide fiber network.
### Installation
You can install the library using `pip install google-cloud-compute`, and it's recommended to install this library in a virtual environment using venv, which creates isolated Python environments with separate versions of Python packages.
### Creating a VM Instance
To create a VM instance with the google-cloud-compute library, you use the `compute_v1.InstancesClient()` and call the `insert()` method with parameters including project ID, zone, and instance resource configuration that specifies the name, machine type, disk configuration, and network interfaces.
### Prerequisites
To authenticate to Compute Engine, you need to set up Application Default Credentials for a local development environment. You also need to enable the Compute Engine and set up Authentication.
The search results include code samples and official Google Cloud documentation that would provide more detailed information about specific methods and parameters for creating VM instances programmatically.
REMINDER: You MUST include the sources above in your response to the user using markdown hyperlinks.Web search results for query: "GCP Compute Engine SSH keys project metadata Python API"
Links: [{"title":"gcp.compute.ProjectMetadata | Pulumi Registry","url":"https://www.pulumi.com/registry/packages/gcp/api-docs/compute/projectmetadata/"},{"title":"Add SSH keys to VMs | Compute Engine | Google Cloud Documentation","url":"https://docs.cloud.google.com/compute/docs/connect/add-ssh-keys"},{"title":"SSH keys in Google Cloud Platform","url":"https://clouddocs.f5.com/cloud/public/v1/google/Google_SSHkeys.html"},{"title":"Add SSH key to instance metadata | Compute Engine | Google Cloud","url":"https://cloud.google.com/compute/docs/samples/compute-os-login-google-compute-instance-ssh-keys"},{"title":"Predefined metadata keys | Compute Engine | Google Cloud Documentation","url":"https://docs.cloud.google.com/compute/docs/metadata/predefined-metadata-keys"},{"title":"Google Compute Engine SSH Operators — apache-airflow-providers-google Documentation","url":"https://airflow.apache.org/docs/apache-airflow-providers-google/stable/operators/cloud/compute_ssh.html"},{"title":"Using the Python Client Library - Google Compute Engine — Google Developers","url":"https://download.huihoo.com/google/gdgdevkit/DVD1/developers.google.com/compute/docs/api/python_guide.html"},{"title":"Add SSH key to project metadata | Compute Engine | Google Cloud Documentation","url":"https://docs.cloud.google.com/compute/docs/samples/compute-os-login-metadata-based-ssh-keys"},{"title":"About SSH connections | Compute Engine | Google Cloud Documentation","url":"https://docs.cloud.google.com/compute/docs/instances/ssh"},{"title":"Choose an access method | Compute Engine | Google Cloud Documentation","url":"https://docs.cloud.google.com/compute/docs/instances/access-overview"}]
I've found comprehensive information about GCP Compute Engine SSH keys and project metadata. Here are the key results:
## Overview
SSH keys stored in project metadata can be used to access all VMs in a project. VMs that don't use OS Login store SSH keys in Compute Engine project and instance metadata.
## Working with the Compute Engine API
To add a public SSH key to project metadata using the Compute Engine API, you must first get the fingerprint and ssh-keys values from metadata by using the projects.get method, then add the new ssh-keys value using the projects.setCommonInstanceMetadata method.
If there are existing SSH keys in project metadata, you must re-add them every time you add a new SSH key using the Compute Engine API, or the new key will erase the existing keys.
## Python API Usage
When creating instances with the Python client library, you can include instance metadata such as SSH keys by including a metadata field with items containing key-value pairs in your request body.
## Managing SSH Keys in Project Metadata
The ProjectMetadata resource manages all project-level metadata including project-level SSH keys. It's better to use OS Login instead of managing SSH keys in project metadata.
For the Python API specifically, the search results reference Pulumi and Terraform examples, but indicate that the underlying Compute Engine API supports these operations through the standard REST endpoints.
REMINDER: You MUST include the sources above in your response to the user using markdown hyperlinks.Yes, viable. Google maintains official Python clients for everything we need. Here's the lay of the land mapped onto our CloudProvider abstraction (machine/provider.py:32):
| Capability | Library | Notes |
|---|---|---|
| VM lifecycle | google-cloud-compute |
compute_v1.InstancesClient with insert/get/delete/list — clean mapping to create_vm/get_vm/destroy_vm/list_vms |
| DNS | google-cloud-dns |
Manages "managed zones" + resource record sets — maps to create_dns_record/delete_dns_record/get_dns_records/list_domains |
| Auth | google-auth (transitive) |
Application Default Credentials — service account JSON or gcloud auth application-default login |
Both are official, actively maintained, and BSD/Apache-licensed. They'll fit in the same shiv bundle, though they're heavier than python-digitalocean and pull in gRPC + protobuf (notably bigger build artifact).
ssh-keys key (one big newline-separated string like user:ssh-rsa AAAA... comment)list_ssh_keys() and get_ssh_key(name) would have to synthesize "name" from the username prefix in each metadata entry. Not hard, just lossy.
Projects work differently. In GCP, every resource lives inside a project and the project ID is fixed at client construction — you can't assign_to_project a VM after creation the way DO supports. The existing list_projects/assign_to_project methods on CloudProvider would likely become no-ops, with the project ID coming from config instead.
Region vs. zone. GCP requires a zone (e.g. us-central1-a) for instance creation, not just a region. The existing region parameter would need to accept either, or we pick a default zone within the region.
gcp:
project-id: my-gcp-project
credentials-file: ~/.config/gcloud/service-account.json # or rely on ADC
default-zone: us-central1-a
Go with google-cloud-compute + google-cloud-dns. The mapping is straightforward except for SSH keys, where I'd suggest treating project-metadata keys as the equivalent of DO's named keys (username = name) for the MVP, and leave OS Login as a future option.
Want me to sketch the GcpProvider skeleton next, or look deeper into any of these (e.g. shiv bundle size impact, OS Login alternative)?
Sources: - google-cloud-compute on PyPI - Compute Engine Python sample: create_instance.py - Python Client for Cloud DNS API - Add SSH keys to VMs (project metadata) - About SSH connections / OS Login
Ok thanks for the feature match analysis. Some thoughts on direction to head in: 1. perhaps we can recast the existing region field as a "region-zone" designator? GCP appears to use zone names that are "fully qualified" so it is only sufficient to supply a zone name, without also supplying a region. It appears that if we simply treated the existing region field as a GCP zone, everything would work, save for potential for user confusion regarding the name. 2. The different treatment of ssh keys is expected. We can use your suggested approach of project metadata for the time being and revisit later. 3. regarding "projects" -- this seems more of an ordering issue in API use than a significant user-facing semantic difference. The intention of the user is to create assets assigned to a specified project. I forget if we permit re-assignment of assets to projects in Digital Ocean, but if we do it would be ok for that feature to not work (gracefully) in the GCP case. If this all seems agreeable please go ahead and proceed with implementation.
Sounds good. Let me look at a few more files to understand how providers are configured and instantiated, then plan the implementation.
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 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
1 # Exposing the truth that dependency injection is just a fancy name for global variables 2 3 from machine.types import CliOptions 4 5 6 class d: 7 opt: CliOptions = None 8
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 1 [project]
2 name = "machine"
3 version = "1.0.0"
4 description = "Utility for creating and managing VMs"
5 readme = "README.md"
6 license = "AGPL-3.0-only"
7 requires-python = ">=3.10"
8 authors = [
9 { name = "Stirlingbridge", email = "info@stirlingbridge.website" },
10 ]
11 classifiers = [
12 "Programming Language :: Python :: 3.10",
13 "Operating System :: OS Independent",
14 ]
15 dependencies = [
16 "click==8.1.7",
17 "python-digitalocean==1.17.0",
18 "ruamel.yaml>=0.17.32",
19 "requests>=2.32.4",
20 "expandvars==1.0.0",
21 "vultr-python>=0.1.5",
22 ]
23
24 [project.scripts]
25 machine = "machine.main:main"
26
27 [project.urls]
28 Homepage = "https://github.com/stirlingbridge/machine"
29
30 [build-system]
31 requires = ["hatchling"]
32 build-backend = "hatchling.build"
33
34 [tool.pytest.ini_options]
35 markers = [
36 "e2e: end-to-end tests that create real resources on DigitalOcean (require E2E_* env vars)",
37 ]
38 addopts = "-m 'not e2e'"
39
40 [dependency-groups]
41 dev = [
42 "ruff",
43 "pytest",
44 ]
45
46 [tool.ruff]
47 line-length = 132
48
49 [tool.ruff.lint]
50 select = ["E", "F", "W", "C90"]
51 ignore = ["E203"]
52
53 [tool.ruff.lint.mccabe]
54 max-complexity = 25
55
56 [tool.ruff.lint.per-file-ignores]
57 "machine/cloud_config.py" = ["E501"]
58 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
1 import sys 2 3 4 def fatal_error(s: str): 5 print(s, file=sys.stderr) 6 sys.exit(1) 7 8 9 def debug(s: str): 10 print(s, file=sys.stderr) 11 12 13 def info(s: str): 14 print(s, file=sys.stderr) 15 16 17 def output(s: str): 18 print(s) 19
ls /home/david/projects/stirlingbridge/machine/machine/subcommands/ /home/david/projects/stirlingbridge/machine/tests/ 2>/dev/null
/home/david/projects/stirlingbridge/machine/machine/subcommands/: __init__.py __pycache__ check.py create.py destroy.py domains.py info.py list.py list_domain.py projects.py ssh_keys.py status.py types.py /home/david/projects/stirlingbridge/machine/tests/: E2E.md __init__.py test_cli_integration.py test_config.py test_e2e.py test_input_validation.py
Let me set up task tracking, then look at a couple more files to verify how create_dns_record is called and how tests are written.
[
{
"type": "tool_reference",
"tool_name": "TaskCreate"
},
{
"type": "tool_reference",
"tool_name": "TaskUpdate"
},
{
"type": "tool_reference",
"tool_name": "TaskList"
}
]{
"subject": "Add google-cloud SDK dependencies",
"activeForm": "Adding google-cloud dependencies"
}{
"subject": "Implement GcpProvider class",
"activeForm": "Implementing GcpProvider"
}{
"subject": "Register gcp provider in factory",
"activeForm": "Registering gcp provider"
}{
"subject": "Verify lint and tests pass",
"activeForm": "Verifying lint and tests"
}{
"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 droplet 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 droplet 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 droplet 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 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 ls /home/david/projects/stirlingbridge/machine/ && grep -l "vultr\|digital-ocean\|provider" /home/david/projects/stirlingbridge/machine/README.md | head -3
CLAUDE.md LICENSE MANIFEST.in Makefile README.md machine pyproject.toml sh tests uv.lock /home/david/projects/stirlingbridge/machine/README.md
{
"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 grep -n "vultr\|digital-ocean\|provider\|api-key\|access-token" /home/david/projects/stirlingbridge/machine/README.md | head -40
4:Supports [DigitalOcean](https://www.digitalocean.com/) and [Vultr](https://www.vultr.com/) hosting platforms.
58:Access token and other settings configured in the file `~/.machine/config.yml`. The config file contains a provider section (either `digital-ocean` or `vultr`) and a `machines` section.
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:
63:provider: vultr
69:digital-ocean:
70: access-token: dop_v1_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
89:| `access-token` | Yes | DigitalOcean API access token |
102:vultr:
103: api-key: ${VULTR_API_KEY}
121:| `api-key` | Yes | Vultr API key (from https://my.vultr.com/settings/#settingsapi) |
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.
163:digital-ocean:
164: access-token: ${DO_API_TOKEN}
175:vultr:
176: api-key: ${VULTR_API_KEY}
236:Create a new machine on the configured provider. By default, the machine is initialized with cloud-init (using the specified `--type` from config) and a DNS A record is created.
384:In addition to the provider-reported instance status, this command queries each machine at `http://<ip>:4242/cgi-bin/<status-check>` (default: `cloud-init-status`) for custom status information. If the endpoint is unreachable, the status is reported as `UNKNOWN`.
437:List all DNS domains in your provider account.
449:List SSH keys in your provider account. Output format: `id: name (fingerprint)`{
"file_path": "/home/david/projects/stirlingbridge/machine/machine/subcommands/destroy.py"
}1 import click
2
3 from machine.di import d
4 from machine.log import debug, fatal_error, output
5 from machine.util import is_machine_created
6 from machine.types import MainCmdCtx
7
8 from machine.util import is_same_session
9
10
11 @click.command(help="Destroy one or more machines")
12 @click.option("--confirm/--no-confirm", default=True)
13 @click.option("--delete-dns/--no-delete-dns", default=True)
14 @click.option(
15 "--all",
16 is_flag=True,
17 default=False,
18 help="Include machines not created by this tool",
19 )
20 @click.argument("droplet-ids", nargs=-1)
21 @click.pass_context
22 def command(context, confirm, delete_dns, all, droplet_ids):
23 command_context: MainCmdCtx = context.obj
24 config = command_context.config
25 provider = command_context.provider
26
27 for droplet_id in droplet_ids:
28 vm = provider.get_vm(droplet_id)
29 name = vm.name
30
31 if not is_machine_created(vm) and not all:
32 fatal_error(f'ERROR: Cannot destroy droplet "{name}" (id: {vm.id}), it was not created by machine.')
33
34 if not is_same_session(command_context, vm) and not all:
35 fatal_error(
36 f'ERROR: Cannot destroy droplet "{name}" (id: {vm.id}), it was created by a different session of machine.'
37 )
38
39 if confirm:
40 output(
41 "Type YES (not y or yes or Yes) to confirm that you want to permanently"
42 f' DELETE/DESTROY droplet "{name}" (id: {vm.id})'
43 )
44 confirmation = input()
45 if confirmation != "YES":
46 fatal_error("Destroy operation aborted, not confirmed by user")
47
48 result = provider.destroy_vm(droplet_id)
49
50 if result and delete_dns and config.dns_zone:
51 zone = config.dns_zone
52 if d.opt.debug:
53 debug(f"Deleting host record {name}.{zone}")
54 deleted = provider.delete_dns_record(zone, name)
55 if deleted:
56 if d.opt.debug:
57 debug(f"Deleted dns record for {name}.{zone}")
58 else:
59 if d.opt.debug:
60 debug(f"No dns record found for {name}.{zone}")
61
62 if not result:
63 fatal_error("Error destroying machine")
64 {
"file_path": "/home/david/projects/stirlingbridge/machine/README.md",
"offset": 55,
"limit": 90
}55 ## Usage
56
57 ### Config File
58 Access token and other settings configured in the file `~/.machine/config.yml`. The config file contains a provider section (either `digital-ocean` or `vultr`) and a `machines` section.
59
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 #### Machines Section
133
134 **machines section:**
135
136 Each entry under `machines:` defines a machine type that can be referenced with `create --type`:
137
138 | Key | Required | Description |
139 |-----|----------|-------------|
140 | `new-user-name` | Yes | Username for the non-root user created on the machine |
141 | `script-url` | No | URL to download an initialization script from |
142 | `script-dir` | No | Directory to store the initialization script |
143 | `script-path` | No | Full path for the initialization script |
144 | `script-args` | No | Arguments passed to the initialization script (supports variable expansion) |I have everything I need. Let me also check a couple of utility files and tests to ensure the integration fits.
1 import os
2 import uuid
3
4 from machine.factory import yaml
5 from machine.constants import default_config_dir_path, default_session_id_file_path
6 from machine.types import TAG_MACHINE_TYPE_PREFIX, TAG_MACHINE_CREATED
7
8 from machine.types import MainCmdCtx, TAG_MACHINE_SESSION_PREFIX
9
10
11 def get_machine_type(vm):
12 type = next((t for t in vm.tags if TAG_MACHINE_TYPE_PREFIX in t), "").replace(TAG_MACHINE_TYPE_PREFIX, "")
13 if not type:
14 return None
15 return type
16
17
18 def is_machine_created(vm):
19 return TAG_MACHINE_CREATED in vm.tags
20
21
22 def is_same_session(command_context: MainCmdCtx, vm):
23 return TAG_MACHINE_SESSION_PREFIX + command_context.session_id in vm.tags
24
25
26 def load_session_id():
27 if not os.path.exists(default_config_dir_path):
28 os.makedirs(default_config_dir_path, exist_ok=True)
29
30 if not os.path.exists(default_session_id_file_path):
31 with open(default_session_id_file_path, "w") as f:
32 f.write("id: " + str(uuid.uuid4()).replace("-", "")[0:8])
33
34 sessionid_config = yaml().load(open(default_session_id_file_path, "r"))
35 return sessionid_config["id"]
36
37
38 def vm_to_json_obj(vm):
39 return {
40 "id": vm.id,
41 "name": vm.name,
42 "tags": vm.tags,
43 "region": vm.region,
44 "ip": vm.ip_address,
45 "type": get_machine_type(vm),
46 }
47
48
49 def dns_record_to_json_obj(dns_record, zone, vm):
50 if vm:
51 vm = vm_to_json_obj(vm)
52
53 return {
54 "id": dns_record.id,
55 "droplet": vm,
56 "name": dns_record.name,
57 "fqdn": f"{dns_record.name}.{zone}",
58 "zone": zone,
59 "data": dns_record.data,
60 "ttl": dns_record.ttl,
61 "type": dns_record.type,
62 }
63 grep -rn "KNOWN_PROVIDERS\|create_provider" /home/david/projects/stirlingbridge/machine/machine /home/david/projects/stirlingbridge/machine/tests /home/david/projects/stirlingbridge/machine/sh 2>/dev/null
/home/david/projects/stirlingbridge/machine/machine/main.py:9:from machine.providers import create_provider
/home/david/projects/stirlingbridge/machine/machine/main.py:35: provider = create_provider(cfg.provider_name, cfg.provider_config)
/home/david/projects/stirlingbridge/machine/machine/config.py:9:from machine.providers import KNOWN_PROVIDERS
/home/david/projects/stirlingbridge/machine/machine/config.py:69: found = [p for p in KNOWN_PROVIDERS if p in config]
/home/david/projects/stirlingbridge/machine/machine/config.py:72: "No provider section found in config file. Expected one of: " + ", ".join(KNOWN_PROVIDERS)
/home/david/projects/stirlingbridge/machine/machine/providers/__init__.py:4:KNOWN_PROVIDERS = ["digital-ocean", "vultr"]
/home/david/projects/stirlingbridge/machine/machine/providers/__init__.py:7:def create_provider(provider_name, provider_config) -> CloudProvider:
/home/david/projects/stirlingbridge/machine/machine/providers/__init__.py:17: fatal_error(f"Unknown provider: '{provider_name}'. Known providers: {', '.join(KNOWN_PROVIDERS)}")
/home/david/projects/stirlingbridge/machine/machine/subcommands/info.py:6:from machine.providers import KNOWN_PROVIDERS
/home/david/projects/stirlingbridge/machine/machine/subcommands/info.py:28: output(f"Supported providers: {', '.join(KNOWN_PROVIDERS)}")1 #!/bin/bash 2 3 if [[ "$1" == "--fix" ]]; then 4 uv run ruff format machine/ 5 uv run ruff check --fix machine/ 6 else 7 uv run ruff format --check machine/ 8 uv run ruff check machine/ 9 fi 10
"expandvars==1.0.0",
"vultr-python>=0.1.5",
] "expandvars==1.0.0",
"vultr-python>=0.1.5",
"google-cloud-compute>=1.19.0",
"google-cloud-dns>=0.35.0",
]The file /home/david/projects/stirlingbridge/machine/pyproject.toml has been updated successfully.
Resolved 38 packages in 1.36s
Building machine @ file:///home/david/projects/stirlingbridge/machine
Downloading cryptography (4.5MiB)
Downloading grpcio (6.5MiB)
Downloading google-cloud-compute (3.7MiB)
Built machine @ file:///home/david/projects/stirlingbridge/machine
Downloaded google-cloud-compute
Downloaded cryptography
Downloaded grpcio
Prepared 16 packages in 2.27s
Uninstalled 1 package in 0.79ms
Installed 17 packages in 26ms
+ cffi==2.0.0
+ cryptography==47.0.0
+ google-api-core==2.30.3
+ google-auth==2.50.0
+ google-cloud-compute==1.47.0
+ google-cloud-core==2.5.1
+ google-cloud-dns==0.36.1
+ googleapis-common-protos==1.74.0
+ grpcio==1.80.0
+ grpcio-status==1.80.0
~ machine==1.0.0 (from file:///home/david/projects/stirlingbridge/machine)
+ proto-plus==1.27.2
+ protobuf==6.33.6
+ pyasn1==0.6.3
+ pyasn1-modules==0.4.2
+ pycparser==3.0
+ typing-extensions==4.15.0Dependencies installed. Now let me verify the SDK API surface I'm targeting before writing the provider.
uv run python -c "
from google.cloud import compute_v1
# Check key class attribute names
print('AccessConfig fields:', [f.name for f in compute_v1.AccessConfig.meta.fields.values()][:20])
print('NetworkInterface fields:', [f.name for f in compute_v1.NetworkInterface.meta.fields.values()][:10])
print('Instance fields:', [f.name for f in compute_v1.Instance.meta.fields.values()][:20])
print('Items fields:', [f.name for f in compute_v1.Items.meta.fields.values()])
" 2>&1 | head -30uv run python -c "
from google.cloud import dns
client_methods = [m for m in dir(dns.Client) if not m.startswith('_')]
zone_methods = [m for m in dir(dns.ManagedZone) if not m.startswith('_')]
print('Client methods:', client_methods)
print('ManagedZone methods:', zone_methods)
import google.cloud.dns.resource_record_set as rrs_mod
print('ResourceRecordSet attrs:', [a for a in dir(rrs_mod.ResourceRecordSet) if not a.startswith('_')])
" 2>&1 | head -20AccessConfig fields: ['external_ipv6', 'external_ipv6_prefix_length', 'kind', 'name', 'nat_i_p', 'network_tier', 'public_ptr_domain_name', 'security_policy', 'set_public_ptr', 'type_'] NetworkInterface fields: ['access_configs', 'alias_ip_ranges', 'enable_vpc_scoped_dns', 'fingerprint', 'igmp_query', 'internal_ipv6_prefix_length', 'ipv6_access_configs', 'ipv6_access_type', 'ipv6_address', 'kind'] Instance fields: ['advanced_machine_features', 'can_ip_forward', 'confidential_instance_config', 'cpu_platform', 'creation_timestamp', 'deletion_protection', 'description', 'disks', 'display_device', 'fingerprint', 'guest_accelerators', 'hostname', 'id', 'instance_encryption_key', 'key_revocation_action_type', 'kind', 'label_fingerprint', 'labels', 'last_start_timestamp', 'last_stop_timestamp'] Items fields: ['key', 'value']
Client methods: ['SCOPE', 'close', 'from_service_account_info', 'from_service_account_json', 'list_zones', 'quotas', 'zone'] ManagedZone methods: ['changes', 'create', 'created', 'delete', 'description', 'exists', 'from_api_repr', 'list_changes', 'list_resource_record_sets', 'name_server_set', 'name_servers', 'path', 'project', 'reload', 'resource_record_set', 'zone_id'] ResourceRecordSet attrs: ['from_api_repr']
uv run python -c "
from google.cloud import dns
import inspect
# ManagedZone is a class - check its instance attrs via __init__ and property descriptors
print('ManagedZone __init__ signature:', inspect.signature(dns.ManagedZone.__init__))
# Check class-level properties
props = [n for n, v in vars(dns.ManagedZone).items() if isinstance(v, property)]
print('properties:', props)
print('resource_record_set sig:', inspect.signature(dns.ManagedZone.resource_record_set))
" 2>&1uv run python -c "
from google.cloud.dns.resource_record_set import ResourceRecordSet
import inspect
print('RRS __init__:', inspect.signature(ResourceRecordSet.__init__))
props = [n for n, v in vars(ResourceRecordSet).items() if isinstance(v, property)]
print('properties:', props)
from google.cloud.dns.changes import Changes
print('Changes methods:', [m for m in dir(Changes) if not m.startswith('_')])
print('add_record_set sig:', inspect.signature(Changes.add_record_set))
print('delete_record_set sig:', inspect.signature(Changes.delete_record_set))
" 2>&1ManagedZone __init__ signature: (self, name, dns_name=None, client=None, description=None) properties: ['project', 'path', 'created', 'name_servers', 'zone_id', 'description', 'name_server_set'] resource_record_set sig: (self, name, record_type, ttl, rrdatas)
RRS __init__: (self, name, record_type, ttl, rrdatas, zone) properties: [] Changes methods: ['add_record_set', 'additions', 'create', 'delete_record_set', 'deletions', 'exists', 'from_api_repr', 'name', 'path', 'reload', 'started', 'status'] add_record_set sig: (self, record_set) delete_record_set sig: (self, record_set)
uv run python -c "
from google.cloud import compute_v1
print('AggregatedListInstancesRequest fields:', [f.name for f in compute_v1.AggregatedListInstancesRequest.meta.fields.values()])
# Check AttachedDisk and AttachedDiskInitializeParams
print('AttachedDisk fields:', [f.name for f in compute_v1.AttachedDisk.meta.fields.values()][:10])
print('AttachedDiskInitializeParams fields:', [f.name for f in compute_v1.AttachedDiskInitializeParams.meta.fields.values()][:15])
print('Metadata fields:', [f.name for f in compute_v1.Metadata.meta.fields.values()])
" 2>&1AggregatedListInstancesRequest fields: ['filter', 'include_all_scopes', 'max_results', 'order_by', 'page_token', 'project', 'return_partial_success', 'service_project_number'] AttachedDisk fields: ['architecture', 'auto_delete', 'boot', 'device_name', 'disk_encryption_key', 'disk_size_gb', 'force_attach', 'guest_os_features', 'index', 'initialize_params'] AttachedDiskInitializeParams fields: ['architecture', 'description', 'disk_name', 'disk_size_gb', 'disk_type', 'enable_confidential_compute', 'labels', 'licenses', 'on_update_action', 'provisioned_iops', 'provisioned_throughput', 'replica_zones', 'resource_manager_tags', 'resource_policies', 'source_image'] Metadata fields: ['fingerprint', 'items', 'kind']
import base64
import hashlib
import os
from machine.log import fatal_error, info
from machine.provider import CloudProvider, VM, SSHKey, DNSRecord
# GCE metadata key holding our serialized list of machine tags.
# Tags are joined with newlines on write and split back on read so that the
# colon-bearing strings used elsewhere (e.g. "machine:created") round-trip.
_TAGS_METADATA_KEY = "machine-tags"
_USER_DATA_METADATA_KEY = "user-data"
_SSH_KEYS_METADATA_KEY = "ssh-keys"
_DEFAULT_OPERATION_TIMEOUT = 300
_GCP_SCOPES = ["https://www.googleapis.com/auth/cloud-platform"]
def _load_credentials(provider_config):
creds_file = provider_config.get("credentials-file")
if creds_file:
from google.oauth2 import service_account
path = os.path.expanduser(creds_file)
return service_account.Credentials.from_service_account_file(path, scopes=_GCP_SCOPES)
import google.auth
creds, _ = google.auth.default(scopes=_GCP_SCOPES)
return creds
def _fingerprint(public_key):
parts = public_key.strip().split()
if len(parts) < 2:
return ""
try:
data = base64.b64decode(parts[1])
except (ValueError, base64.binascii.Error):
return ""
digest = hashlib.md5(data).hexdigest()
return ":".join(digest[i : i + 2] for i in range(0, len(digest), 2))
def _parse_ssh_keys(raw):
keys = []
for line in (raw or "").splitlines():
line = line.strip()
if not line or ":" not in line:
continue
user, key_data = line.split(":", 1)
keys.append((user.strip(), key_data.strip()))
return keys
def _format_ssh_key_entry(name, public_key):
return f"{name}:{public_key.strip()}"
class GcpProvider(CloudProvider):
def __init__(self, provider_config):
if "project-id" not in provider_config:
fatal_error("Required key 'project-id' not found in 'gcp' section of config file")
self._project = provider_config["project-id"]
self._credentials = _load_credentials(provider_config)
from google.cloud import compute_v1
self._compute_v1 = compute_v1
self._instances = compute_v1.InstancesClient(credentials=self._credentials)
self._projects_client = compute_v1.ProjectsClient(credentials=self._credentials)
self._dns_client = None
def _dns(self):
if self._dns_client is None:
from google.cloud import dns
self._dns_client = dns.Client(project=self._project, credentials=self._credentials)
return self._dns_client
@staticmethod
def _parse_id(vm_id):
if "/" not in vm_id:
fatal_error(f"Error: GCP VM id must be in the form '<zone>/<name>', got: {vm_id}")
zone, name = vm_id.split("/", 1)
return zone, name
@staticmethod
def _make_id(zone, name):
return f"{zone}/{name}"
def _instance_to_vm(self, instance, zone) -> VM:
ip_address = ""
for nic in instance.network_interfaces:
for ac in nic.access_configs:
if ac.nat_i_p:
ip_address = ac.nat_i_p
break
if ip_address:
break
tags = []
if instance.metadata and instance.metadata.items:
for item in instance.metadata.items:
if item.key == _TAGS_METADATA_KEY and item.value:
tags = [t for t in item.value.split("\n") if t]
break
return VM(
id=self._make_id(zone, instance.name),
name=instance.name,
tags=tags,
region=zone,
ip_address=ip_address,
status=instance.status,
)
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")
compute_v1 = self._compute_v1
disk = compute_v1.AttachedDisk(
boot=True,
auto_delete=True,
initialize_params=compute_v1.AttachedDiskInitializeParams(source_image=image),
)
access_config = compute_v1.AccessConfig(name="External NAT", type_="ONE_TO_ONE_NAT")
nic = compute_v1.NetworkInterface(network="global/networks/default", access_configs=[access_config])
metadata_items = []
if tags:
metadata_items.append(compute_v1.Items(key=_TAGS_METADATA_KEY, value="\n".join(tags)))
if user_data:
metadata_items.append(compute_v1.Items(key=_USER_DATA_METADATA_KEY, value=user_data))
metadata = compute_v1.Metadata(items=metadata_items)
instance = compute_v1.Instance(
name=name,
machine_type=f"zones/{zone}/machineTypes/{size}",
disks=[disk],
network_interfaces=[nic],
metadata=metadata,
)
try:
op = self._instances.insert(project=self._project, zone=zone, instance_resource=instance)
op.result(timeout=_DEFAULT_OPERATION_TIMEOUT)
except Exception as e:
fatal_error(f"GCP API error creating VM: {e}")
created = self._instances.get(project=self._project, zone=zone, instance=name)
return self._instance_to_vm(created, zone)
def get_vm(self, vm_id) -> VM:
zone, name = self._parse_id(vm_id)
try:
inst = self._instances.get(project=self._project, zone=zone, instance=name)
except Exception as e:
fatal_error(f"Error: machine with id {vm_id} not found: {e}")
return self._instance_to_vm(inst, zone)
def destroy_vm(self, vm_id) -> bool:
zone, name = self._parse_id(vm_id)
try:
op = self._instances.delete(project=self._project, zone=zone, instance=name)
op.result(timeout=_DEFAULT_OPERATION_TIMEOUT)
except Exception as e:
msg = str(e)
if "404" in msg or "notFound" in msg or "was not found" in msg:
return True
fatal_error(f"Error destroying machine {vm_id}: {e}")
return True
def list_vms(self, tag=None) -> list:
request = self._compute_v1.AggregatedListInstancesRequest(project=self._project)
agg = self._instances.aggregated_list(request=request)
vms = []
for zone_url, scoped in agg:
zone = zone_url.rsplit("/", 1)[-1]
instances = getattr(scoped, "instances", None) or []
for inst in instances:
vm = self._instance_to_vm(inst, zone)
if tag is None or tag in vm.tags:
vms.append(vm)
return vms
def _get_project_metadata(self):
proj = self._projects_client.get(project=self._project)
return proj.common_instance_metadata
def _get_project_ssh_keys_raw(self):
meta = self._get_project_metadata()
for item in (meta.items or []):
if item.key == _SSH_KEYS_METADATA_KEY:
return item.value or ""
return ""
def get_ssh_key(self, name) -> SSHKey:
for user, key_data in _parse_ssh_keys(self._get_project_ssh_keys_raw()):
if user == name:
return SSHKey(id=user, name=user, fingerprint=_fingerprint(key_data), public_key=key_data)
return None
def list_ssh_keys(self) -> list:
return [
SSHKey(id=u, name=u, fingerprint=_fingerprint(k), public_key=k)
for u, k in _parse_ssh_keys(self._get_project_ssh_keys_raw())
]
def _get_managed_zone(self, dns_zone):
target = dns_zone if dns_zone.endswith(".") else dns_zone + "."
for z in self._dns().list_zones():
if z.dns_name == target:
return z
return None
@staticmethod
def _fqdn(name, zone):
full = name if name.endswith("." + zone) or name == zone else f"{name}.{zone}"
return full if full.endswith(".") else full + "."
def create_dns_record(self, zone, record_type, name, data, ttl, tag=None) -> str:
mz = self._get_managed_zone(zone)
if not mz:
info(f"Warning: DNS zone '{zone}' not found in GCP, DNS record not set")
return None
record_name = self._fqdn(name, zone)
rrs = mz.resource_record_set(record_name, record_type, ttl, [data])
change = mz.changes()
change.add_record_set(rrs)
try:
change.create()
except Exception as e:
info(f"Warning: failed to create DNS record {record_name}: {e}")
return None
return record_name
def delete_dns_record(self, zone, record_name) -> bool:
mz = self._get_managed_zone(zone)
if not mz:
return False
target = self._fqdn(record_name, zone)
for rrs in mz.list_resource_record_sets():
if rrs.name == target:
change = mz.changes()
change.delete_record_set(rrs)
try:
change.create()
except Exception:
return False
return True
return False
def get_dns_records(self, zone) -> list:
mz = self._get_managed_zone(zone)
if not mz:
info(f"Warning: DNS zone '{zone}' not found in GCP")
return []
records = []
zone_suffix = "." + (zone if zone.endswith(".") else zone + ".")
for rrs in mz.list_resource_record_sets():
short_name = rrs.name
if short_name.endswith(zone_suffix):
short_name = short_name[: -len(zone_suffix)]
elif short_name.endswith("."):
short_name = short_name[:-1]
records.append(
DNSRecord(
id=rrs.name,
name=short_name,
type=rrs.record_type,
data=",".join(rrs.rrdatas),
ttl=rrs.ttl,
)
)
return records
def list_domains(self) -> list:
return [z.dns_name.rstrip(".") for z in self._dns().list_zones()]
def validate_region(self, region):
if region is not None and "-" not in region:
info(f"Warning: GCP zone '{region}' does not look like a valid zone (e.g. us-central1-a)")
def validate_image(self, image):
pass
@property
def provider_name(self) -> str:
return "GCP"
File created successfully at: /home/david/projects/stirlingbridge/machine/machine/providers/gcp.py
KNOWN_PROVIDERS = ["digital-ocean", "vultr"]
def create_provider(provider_name, provider_config) -> CloudProvider:
if provider_name == "digital-ocean":
from machine.providers.digitalocean import DigitalOceanProvider
return DigitalOceanProvider(provider_config)
elif provider_name == "vultr":
from machine.providers.vultr import VultrProvider
return VultrProvider(provider_config)
else:
fatal_error(f"Unknown provider: '{provider_name}'. Known providers: {', '.join(KNOWN_PROVIDERS)}")KNOWN_PROVIDERS = ["digital-ocean", "vultr", "gcp"]
def create_provider(provider_name, provider_config) -> CloudProvider:
if provider_name == "digital-ocean":
from machine.providers.digitalocean import DigitalOceanProvider
return DigitalOceanProvider(provider_config)
elif provider_name == "vultr":
from machine.providers.vultr import VultrProvider
return VultrProvider(provider_config)
elif provider_name == "gcp":
from machine.providers.gcp import GcpProvider
return GcpProvider(provider_config)
else:
fatal_error(f"Unknown provider: '{provider_name}'. Known providers: {', '.join(KNOWN_PROVIDERS)}")The file /home/david/projects/stirlingbridge/machine/machine/providers/__init__.py has been updated successfully.
{
"file_path": "/home/david/projects/stirlingbridge/machine/README.md",
"offset": 1,
"limit": 10
}1 # machine 2 CLI utility to create and manage VMs 3 4 Supports [DigitalOcean](https://www.digitalocean.com/) and [Vultr](https://www.vultr.com/) hosting platforms. 5 6 ## Prerequisites 7 8 This project uses [uv](https://docs.astral.sh/uv/) for dependency management and builds. 9 10 Install uv:
Supports [DigitalOcean](https://www.digitalocean.com/) and [Vultr](https://www.vultr.com/) hosting platforms.
Supports [DigitalOcean](https://www.digitalocean.com/), [Vultr](https://www.vultr.com/), and [Google Cloud Platform](https://cloud.google.com/) hosting platforms.
The file /home/david/projects/stirlingbridge/machine/README.md has been updated successfully.
Access token and other settings configured in the file `~/.machine/config.yml`. The config file contains a provider section (either `digital-ocean` or `vultr`) and a `machines` section.
Access token and other settings configured in the file `~/.machine/config.yml`. The config file contains a provider section (`digital-ocean`, `vultr`, or `gcp`) and a `machines` section.
The file /home/david/projects/stirlingbridge/machine/README.md has been updated successfully.
**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. #### Machines Section
**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.
#### GCP Config
```yaml
gcp:
project-id: my-gcp-project
credentials-file: ~/.config/gcloud/service-account.json
ssh-key: alice
dns-zone: example.com
machine-size: e2-standard-2
image: projects/debian-cloud/global/images/family/debian-12
region: us-central1-a
```
| Key | Required | Description |
|-----|----------|-------------|
| `project-id` | Yes | GCP project ID where all resources (VMs, DNS, etc.) live |
| `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`) |
| `ssh-key` | Yes | Username portion of an SSH key entry stored in the project's `ssh-keys` metadata. The full entry has the form `<username>:<key-type> <key-data> <comment>` |
| `dns-zone` | No | DNS name of a [Cloud DNS](https://cloud.google.com/dns) managed zone (e.g. `example.com`) |
| `machine-size` | Yes | GCE machine type (e.g. `e2-standard-2`) |
| `image` | Yes | Image self-link or family path (e.g. `projects/debian-cloud/global/images/family/debian-12`) |
| `region` | Yes | GCE *zone* (e.g. `us-central1-a`). Despite the key name, GCP requires a fully-qualified zone, not a region |
**Notes:**
- SSH keys are managed via project-wide metadata. Add keys via the GCP console (Compute Engine → Metadata → SSH Keys) or `gcloud compute project-info add-metadata`. The tool reads existing keys but does not create them.
- 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.
- The `region` field is interpreted as a GCE zone. Validation only checks for plausibility; any zone string with a hyphen is accepted.
#### Machines SectionThe file /home/david/projects/stirlingbridge/machine/README.md has been updated successfully.
Would reformat: machine/config.py Would reformat: machine/constants.py Would reformat: machine/providers/digitalocean.py Would reformat: machine/providers/gcp.py Would reformat: machine/providers/vultr.py Would reformat: machine/subcommands/destroy.py 6 files would be reformatted, 22 files already formatted All checks passed!
============================= test session starts ============================== platform linux -- Python 3.12.3, pytest-9.0.2, pluggy-1.6.0 rootdir: /home/david/projects/stirlingbridge/machine configfile: pyproject.toml collected 48 items / 14 deselected / 34 selected tests/test_cli_integration.py ............. [ 38%] tests/test_config.py ............. [ 76%] tests/test_input_validation.py ........ [100%] ====================== 34 passed, 14 deselected in 2.68s =======================
Lint check shows my new file plus 5 pre-existing files would be reformatted. Let me check whether lint CI runs format-check (and the format would actually change my file).
ls /home/david/projects/stirlingbridge/machine/.github/workflows/ && grep -l "lint\|ruff format" /home/david/projects/stirlingbridge/machine/.github/workflows/*.yml 2>/dev/null
build-release.yml e2e-test.yml lint.yml test.yml /home/david/projects/stirlingbridge/machine/.github/workflows/lint.yml /home/david/projects/stirlingbridge/machine/.github/workflows/build-release.yml
uv run ruff format --diff machine/providers/gcp.py 2>&1 | head -40
--- machine/providers/gcp.py
+++ machine/providers/gcp.py
@@ -193,7 +193,7 @@
def _get_project_ssh_keys_raw(self):
meta = self._get_project_metadata()
- for item in (meta.items or []):
+ for item in meta.items or []:
if item.key == _SSH_KEYS_METADATA_KEY:
return item.value or ""
return ""
1 file would be reformatted{
"file_path": "/home/david/projects/stirlingbridge/machine/.github/workflows/lint.yml"
}1 name: Lint 2 3 on: 4 push: 5 branches: 6 - main 7 pull_request: 8 branches: 9 - main 10 11 jobs: 12 lint: 13 name: "Run Lint Checks" 14 runs-on: ubuntu-latest 15 steps: 16 - name: "Clone project repository" 17 uses: actions/checkout@v6 18 - name: "Install uv" 19 uses: astral-sh/setup-uv@v7 20 - name: "Install Python" 21 run: uv python install 3.13 22 - name: "Run lint checks" 23 run: ./sh/lint.sh 24
CI runs format-check, so I need to fix my file. I'll apply formatting only to my new file (not the 5 pre-existing files which are not my concern).
1 file reformatted [This command modified 1 file you've previously read: machine/providers/gcp.py. Call Read before editing.]
Removing the unused helper and trimming the comment block, then re-running lint and tests.
# GCE metadata key holding our serialized list of machine tags. # Tags are joined with newlines on write and split back on read so that the # colon-bearing strings used elsewhere (e.g. "machine:created") round-trip. _TAGS_METADATA_KEY = "machine-tags"
# Tags are stored as newline-separated values in instance metadata so that # colon-bearing tag strings (e.g. "machine:created") round-trip unchanged. _TAGS_METADATA_KEY = "machine-tags"
The file /home/david/projects/stirlingbridge/machine/machine/providers/gcp.py has been updated successfully.
def _format_ssh_key_entry(name, public_key):
return f"{name}:{public_key.strip()}"
class GcpProvider(CloudProvider):class GcpProvider(CloudProvider):
The file /home/david/projects/stirlingbridge/machine/machine/providers/gcp.py has been updated successfully.
uv run ruff format --check machine/providers/gcp.py && uv run ruff check machine/providers/gcp.py machine/providers/__init__.py 2>&1
uv run python -c "
from machine.providers import KNOWN_PROVIDERS, create_provider
print('Known providers:', KNOWN_PROVIDERS)
# Smoke test: import GcpProvider and verify constructor errors cleanly without project-id
from machine.providers.gcp import GcpProvider
try:
GcpProvider({})
except SystemExit as e:
print('Empty config rejected as expected, exit:', e.code)
" 2>&1platform linux -- Python 3.12.3, pytest-9.0.2, pluggy-1.6.0 rootdir: /home/david/projects/stirlingbridge/machine configfile: pyproject.toml collected 48 items / 14 deselected / 34 selected tests/test_cli_integration.py ............. [ 38%] tests/test_config.py ............. [ 76%] tests/test_input_validation.py ........ [100%] ====================== 34 passed, 14 deselected in 2.55s =======================
Required key 'project-id' not found in 'gcp' section of config file Known providers: ['digital-ocean', 'vultr', 'gcp'] Empty config rejected as expected, exit: 1
All green: lint passes on the new files, all 34 tests pass, registration verified.
pyproject.toml — added google-cloud-compute>=1.19.0 and google-cloud-dns>=0.35.0machine/providers/gcp.py (new) — GcpProvider implementing CloudProvidermachine/providers/__init__.py — registered gcp in KNOWN_PROVIDERS and create_providerREADME.md — added GCP config section and noted the deviationsregion interpreted as a GCE zone as you suggested. Validation only checks for a hyphen so any zone-shaped string is accepted.<zone>/<name>, since destroy/get only receive an opaque id and need the zone. The id is round-tripped from list output, matching how the existing CLI uses ids.machine-tags (newline-joined). This avoids fighting GCP's labels-vs-tags constraints and keeps is_machine_created/is_same_session working unchanged. list_vms(tag=...) filters client-side.ssh-keys metadata and looks up the entry whose username matches the ssh-key config value. The user has to add the key to project metadata themselves (via console or gcloud). Documented in the README.credentials-file path or Application Default Credentials. No token-in-config, since GCP doesn't really work that way.list_projects/assign_to_project left as the base-class no-ops — gracefully ignored.Two follow-ups worth considering when you're ready:
1. The region field naming is now misleading for GCP. Renaming to region-zone (or zone) across all providers would be a wider refactor — happy to do it as a separate change if you want.
2. No e2e tests for GCP yet; tests/test_e2e.py is DigitalOcean-only.
Thanks. I will do some manual testing to see how it works. I'm a little sketchy on what kind of API token I need to get from GCP. Can you advise?
Short answer: GCP doesn't have a single "API token" the way DO and Vultr do. You have two practical options for this tool — pick one.
machine-cli.roles/compute.instanceAdmin.v1roles/dns.admin (only if you'll use the dns-zone feature)roles/iam.serviceAccountUser (usually only needed if your VMs run as a service account themselves; safe to add).json file downloads.~/.config/gcloud/machine-cli.json, chmod 600) and point the config at it:
yaml
gcp:
project-id: my-gcp-project
credentials-file: ~/.config/gcloud/machine-cli.json
...If you have gcloud installed, just run once:
gcloud auth application-default login
Then leave credentials-file out of the config entirely. The provider will pick up your user credentials. Convenient, but tied to your Google identity — not great for CI or shared use.
These are per-project, not per-credential — APIs are off by default:
- Enable Compute Engine API: gcloud services enable compute.googleapis.com --project=<project-id> (or via Console → APIs & Services → Library)
- Enable Cloud DNS API: gcloud services enable dns.googleapis.com --project=<project-id> (only needed if you'll use DNS)
Once configured, machine check will exercise the APIs (auth, SSH key read, DNS list, etc.) and tell you exactly which permission is missing if anything's off — that's the fastest way to validate the credential before trying a real create.
GCP doesn't store named SSH keys as a separate registry. Before machine create will work, the project metadata needs an entry like:
alice:ssh-rsa AAAAB3Nza... alice@laptop
Easiest way to add it: Console → Compute Engine → Metadata → SSH Keys → Add. Or:
gcloud compute project-info add-metadata --metadata-from-file ssh-keys=keys.txt
Then set ssh-key: alice in your config.
Thanks, that helped. Also, do you know where in the web console for GCP I should look to find DNS zone hosting?
In the Console, it's under Network Services → Cloud DNS. Direct paths:
https://console.cloud.google.com/net-services/dns/zones?project=<your-project-id>example-com) and the DNS name (the actual domain, e.g. example.com.).ns-cloud-a1.googledomains.com. etc.). You must set these as the NS records at your domain registrar — otherwise the zone exists in GCP but the world doesn't know to ask Google for your domain's records.dns-zone value in your machine config is the DNS name (e.g. example.com), not the zone identifier.Unlike VMs, Cloud DNS has a small ongoing cost (~$0.20/month per zone plus per-query charges) — trivial for normal use, but it's a paid service from minute one rather than a free tier. Worth knowing if you create test zones and forget them.
Once the zone exists and the NS records are set at the registrar:
machine domains # should list the zone
machine list-domain # should list any records you've added
machine check # confirms the configured zone is found
Ah ok. I didn't have that menu item. That seems to be because my project didn't have billing set up properly.
Yep, that tracks — Cloud DNS is billing-gated from the first request (no free tier), so until a billing account is linked to the project, the service is effectively hidden. Compute Engine is more forgiving because of its free-tier allowances, which is why VMs probably worked for you while DNS didn't appear.
Once billing is attached, the menu item should show up immediately (no project restart needed). If machine check still complains about DNS after that, the most likely culprit is the API itself not being enabled — gcloud services enable dns.googleapis.com --project=<id> will sort it.