provision

package
v0.2.0-beta.17 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Oct 5, 2026 License: Apache-2.0 Imports: 25 Imported by: 0

Documentation

Overview

Package provision creates and tears down cloud VMs at a provider (Hetzner, DigitalOcean, Azure, GCP) so a fresh node can join the fleet through the existing join-token enrollment flow, without an operator manually clicking through a cloud console first.

Index

Constants

This section is empty.

Variables

This section is empty.

Functions

func RenderCloudInit

func RenderCloudInit(p CloudInitParams) (string, error)

RenderCloudInit renders the user-data cloud-init runs on first boot: it installs Docker if missing (install.sh's own get.docker.com convention), then runs the agent as a systemd-managed container that joins the control plane with a one-time token. The join token and CA fingerprint go in a root-only env file, not container run args, so they don't show up in `ps`; they remain visible via `docker inspect`, a known limitation documented in docs/node-provisioning.md. The agent runs as root (--user 0:0, it needs docker.sock and NET_ADMIN), so the bind-mounted identity directory is created root-owned with mode 0700.

Types

type AWS

type AWS struct {
	// contains filtered or unexported fields
}

AWS implements Provisioner against the EC2 API (ec2API, aws_network.go), the same aws-sdk-go-v2 dependency internal/objectstore (S3) and internal/email (SES) already use. Unlike Hetzner/DigitalOcean's single bearer token and location-agnostic server id, an EC2 instance id is only valid in the region it was created in, so client builds a region-scoped client lazily per call rather than storing one fixed *ec2.Client.

func NewAWS

func NewAWS(creds AWSCredentials, shortName string) *AWS

NewAWS returns an AWS provisioner authenticating with creds. shortName namespaces the security group it creates (brand.Brand.ShortName; the product name never appears in source, so this is passed in, not looked up, the convention internal/network.WithShortName already establishes).

func NewAWSFromToken

func NewAWSFromToken(token, shortName string) (*AWS, error)

NewAWSFromToken builds an AWS provisioner from the JSON-encoded AWSCredentials a NodeProviderSecrets lookup returns.

func (*AWS) CreateServer

func (a *AWS) CreateServer(ctx context.Context, opts CreateOpts) (serverID, ipAddr string, err error)

CreateServer launches an EC2 instance: resolves the current Ubuntu AMI, the account's default VPC and subnet, ensures the shared no-inbound security group exists, and runs one instance with opts as its user-data.

func (*AWS) DeleteServer

func (a *AWS) DeleteServer(ctx context.Context, id string) error

DeleteServer terminates the EC2 instance. The shared security group ensureSecurityGroup creates or reuses is never deleted here: it's shared across every AWS-provisioned node, not created fresh per instance, so a single DeleteServer call is never its sole owner and cannot safely tell whether other nodes still depend on it. A group ensureDedicatedSSHSecurityGroup created for this one instance (CreateOpts.AllowSSHInbound) is a different story: it's tagged as dedicated at creation, so cleanupDedicatedSecurityGroups can safely delete it once confirmed no other instance references it. No key pair is created either way (there is no SSH key input yet), so there is nothing to clean up there.

func (*AWS) GetServer

func (a *AWS) GetServer(ctx context.Context, id string) (ServerStatus, string, error)

GetServer calls EC2 DescribeInstances.

func (*AWS) ListRegions

func (a *AWS) ListRegions(ctx context.Context) ([]Region, error)

ListRegions calls EC2 DescribeRegions.

func (*AWS) ListSizes

func (a *AWS) ListSizes(_ context.Context, _ string) ([]Size, error)

ListSizes returns awsCuratedSizes: a static list, not a live API call (unlike Hetzner/DigitalOcean, EC2's own catalog is too large to be a useful picker, see awsCuratedSizes). region is accepted to satisfy Provisioner but unused: every listed type is available in essentially every commercial AWS region.

type AWSCredentials

type AWSCredentials struct {
	AccessKeyID     string `json:"access_key_id,omitempty"`
	SecretAccessKey string `json:"secret_access_key,omitempty"`
	SessionToken    string `json:"session_token,omitempty"`
	Region          string `json:"region,omitempty"`
	// RoleARN, when set, is assumed via STS AssumeRole on top of
	// whichever base credentials resolve below: the base credentials
	// then only need sts:AssumeRole on this one ARN, not direct EC2
	// permissions, matching least-privilege IAM practice.
	RoleARN string `json:"role_arn,omitempty"`
	// UseAmbientCredentials, with AccessKeyID/SecretAccessKey both
	// empty, resolves this control plane's own AWS identity (env vars,
	// shared config, or an EC2 instance profile) instead of a stored
	// static key: only meaningful when the control plane itself runs on
	// AWS. Full AssumeRoleWithWebIdentity (OIDC federation, the way
	// GitHub Actions authenticates to AWS) is not implemented: it needs
	// this control plane to be its own trusted OIDC token issuer, which
	// does not exist. RoleARN plus a static key, or plus this ambient
	// chain, are the two supported ways to avoid a long-lived key with
	// direct EC2 permissions.
	UseAmbientCredentials bool `json:"use_ambient_credentials,omitempty"`
}

AWSCredentials is the JSON shape node-provider/aws's stored credential value holds under NodeProviderTokenEnvKey (store.NodeProviderSecretsKey): EC2 has no single bearer token the way Hetzner and DigitalOcean do, so that envKey carries this struct JSON-encoded instead of a raw string.

type Azure

type Azure struct {
	// contains filtered or unexported fields
}

Azure implements Provisioner against the Azure Resource Manager REST API (https://learn.microsoft.com/en-us/rest/api/compute/), OAuth2 client-credentials auth, no SDK. Unlike Hetzner/DigitalOcean's static bearer token, every request needs a fresh access token: httpClient's tokenFunc seam (httpclient.go) resolves one from ts on each call.

func NewAzure

func NewAzure(credentialJSON string) (*Azure, error)

NewAzure returns an Azure provisioner authenticating with credentialJSON (see azureCredential): a client secret by default, or workload identity federation when credentialJSON sets federated_token_file instead.

func (*Azure) CreateServer

func (a *Azure) CreateServer(ctx context.Context, opts CreateOpts) (serverID, ipAddr string, err error)

CreateServer provisions a resource group (idempotent, never deleted), a shared per-region VNet/subnet (idempotent, never deleted), then a public IP, NIC and virtual machine unique to this server. No network security group is created: a Standard SKU public IP is closed to inbound traffic by default unless an NSG explicitly allows it, so this matches the "no inbound ports" default the other providers get from their own platform defaults.

func (*Azure) DeleteServer

func (a *Azure) DeleteServer(ctx context.Context, id string) error

DeleteServer deletes only the virtual machine. Its NIC, public IP and OS disk were all created with deleteOption "Delete" (createVM, createNIC, createPublicIP), which Azure documents as cascading their deletion from this one call; the shared VNet/subnet is left in place for other servers in the same region. This cascade was not verified against a real Azure subscription, only against fakes (see azure_test.go).

func (*Azure) GetServer

func (a *Azure) GetServer(ctx context.Context, id string) (ServerStatus, string, error)

GetServer calls GET .../virtualMachines/{id} plus a second call for the public IP's address, id being the VM name (resourceGroup and subscriptionID are fixed per Azure instance, not part of id).

func (*Azure) ListRegions

func (a *Azure) ListRegions(ctx context.Context) ([]Region, error)

ListRegions calls GET /subscriptions/{sub}/locations, keeping only physical regions (Azure also lists "Logical" entries like paired DR regions that aren't valid CreateServer targets).

func (*Azure) ListSizes

func (a *Azure) ListSizes(ctx context.Context, region string) ([]Size, error)

ListSizes calls GET .../locations/{region}/vmSizes. Azure's retail pricing isn't part of this API, so every returned Size has an empty PriceMonthly, the same "not returned" case DigitalOcean/Hetzner's own Size doc comment already covers.

type CloudInitParams

type CloudInitParams struct {
	ControlPlaneAddr string
	JoinToken        string
	CAFingerprint    string
	NodeName         string
	// AgentImage overrides defaultAgentImageRepo:tag, for an operator who
	// mirrors the image elsewhere. Empty uses the default repo at
	// AgentVersion.
	AgentImage   string
	AgentVersion string
	// MeshEnabled gives the agent container the TUN device and NET_ADMIN
	// its WireGuard device needs.
	MeshEnabled bool
}

CloudInitParams is everything RenderCloudInit needs to produce a first-boot script for a freshly created server.

type CreateOpts

type CreateOpts struct {
	Region string
	Size   string
	Name   string
	// UserData is the cloud-init script run on first boot.
	UserData string
	// AllowSSHInbound opts into an inbound rule for TCP 22, off by
	// default to match this platform's "no inbound ports needed" node
	// architecture (CLAUDE.md 4.3): the agent always dials out. Only AWS
	// currently acts on this; other providers ignore it, the same way
	// AWS.ListSizes ignores the region parameter other providers use.
	AllowSSHInbound bool
}

CreateOpts describes the server CreateServer should provision.

type DigitalOcean

type DigitalOcean struct {
	// contains filtered or unexported fields
}

DigitalOcean implements Provisioner against the DigitalOcean API v2 (https://docs.digitalocean.com/reference/api/), token auth, no SDK.

func NewDigitalOcean

func NewDigitalOcean(token string) *DigitalOcean

NewDigitalOcean returns a DigitalOcean provisioner authenticating with token.

func (*DigitalOcean) CreateServer

func (d *DigitalOcean) CreateServer(ctx context.Context, opts CreateOpts) (serverID, ipAddr string, err error)

CreateServer calls POST /v2/droplets.

func (*DigitalOcean) DeleteServer

func (d *DigitalOcean) DeleteServer(ctx context.Context, id string) error

DeleteServer calls DELETE /v2/droplets/{id}.

func (*DigitalOcean) GetServer

func (d *DigitalOcean) GetServer(ctx context.Context, id string) (ServerStatus, string, error)

GetServer calls GET /v2/droplets/{id}.

func (*DigitalOcean) ListRegions

func (d *DigitalOcean) ListRegions(ctx context.Context) ([]Region, error)

ListRegions calls GET /v2/regions, following every page.

func (*DigitalOcean) ListSizes

func (d *DigitalOcean) ListSizes(ctx context.Context, region string) ([]Size, error)

ListSizes calls GET /v2/sizes, following every page. When region is non-empty, only sizes DigitalOcean lists as available in that region are returned.

type GCP

type GCP struct {
	// contains filtered or unexported fields
}

GCP implements Provisioner against the Compute Engine REST API (https://cloud.google.com/compute/docs/reference/rest/v1), a service account JSON key exchanged for an OAuth2 access token via JWT bearer auth, no SDK (golang.org/x/oauth2/google, not google.golang.org/api).

func NewGCP

func NewGCP(credentialJSON string) (*GCP, error)

NewGCP returns a GCP provisioner authenticating with credentialJSON, a service account key's raw JSON content. The project ID is read from the key itself, not a separate field.

func (*GCP) CreateServer

func (g *GCP) CreateServer(ctx context.Context, opts CreateOpts) (serverID, ipAddr string, err error)

CreateServer calls POST .../zones/{zone}/instances with an ephemeral external IP (no reserved address to clean up later) and an auto-deleting boot disk, so DeleteServer needs only the one instance delete call. The insert call is asynchronous at the operation level, but the instance resource itself exists as soon as this call returns, in PROVISIONING state; its IP isn't assigned yet, so ipAddr is always empty here, matching CreateOpts' own doc comment for a provider that assigns one later. serverID is "{zone}/{name}": unlike Azure's fixed per-account resource group, GCP's zone varies per server and every later call needs it, so it has to travel in the id.

func (*GCP) DeleteServer

func (g *GCP) DeleteServer(ctx context.Context, id string) error

DeleteServer calls DELETE .../zones/{zone}/instances/{name}. The boot disk (autoDelete: true) and the ephemeral external IP go with it; no separate cleanup call is needed.

func (*GCP) GetServer

func (g *GCP) GetServer(ctx context.Context, id string) (ServerStatus, string, error)

GetServer calls GET .../zones/{zone}/instances/{name}, id being the "{zone}/{name}" CreateServer returned.

func (*GCP) ListRegions

func (g *GCP) ListRegions(ctx context.Context) ([]Region, error)

ListRegions returns GCP zones (e.g. us-central1-a), not regions: Compute Engine places an instance in a zone, and CreateServer/ GetServer/DeleteServer all need that zone, so this interface's "region" maps to a GCE zone for this provider.

func (*GCP) ListSizes

func (g *GCP) ListSizes(ctx context.Context, zone string) ([]Size, error)

ListSizes calls GET .../zones/{zone}/machineTypes. Disk size is a separate resource in GCE, not part of a machine type, so every returned Size has Disk 0; CreateServer picks gcpBootDiskGB itself. GCP's own billing catalog is a separate API this package doesn't call, so PriceMonthly is always empty here too.

type Hetzner

type Hetzner struct {
	// contains filtered or unexported fields
}

Hetzner implements Provisioner against the Hetzner Cloud API (https://docs.hetzner.cloud), token auth, no SDK.

func NewHetzner

func NewHetzner(token string) *Hetzner

NewHetzner returns a Hetzner provisioner authenticating with token.

func (*Hetzner) CreateServer

func (h *Hetzner) CreateServer(ctx context.Context, opts CreateOpts) (serverID, ipAddr string, err error)

CreateServer calls POST /v1/servers.

func (*Hetzner) DeleteServer

func (h *Hetzner) DeleteServer(ctx context.Context, id string) error

DeleteServer calls DELETE /v1/servers/{id}.

func (*Hetzner) GetServer

func (h *Hetzner) GetServer(ctx context.Context, id string) (ServerStatus, string, error)

GetServer calls GET /v1/servers/{id}.

func (*Hetzner) ListRegions

func (h *Hetzner) ListRegions(ctx context.Context) ([]Region, error)

ListRegions calls GET /v1/locations, following every page.

func (*Hetzner) ListSizes

func (h *Hetzner) ListSizes(ctx context.Context, region string) ([]Size, error)

ListSizes calls GET /v1/server_types, following every page. When region is non-empty, only server types priced in that location are returned (Hetzner's own signal for "available there"), with that location's own monthly price attached.

type ProviderError

type ProviderError struct {
	Status int
	Body   string
}

ProviderError is a non-2xx response from a provider's API, carrying the HTTP status so callers (and tests) can distinguish "not found" from a generic failure without parsing each provider's own error body shape.

func (*ProviderError) Error

func (e *ProviderError) Error() string

type Provisioner

type Provisioner interface {
	ListRegions(ctx context.Context) ([]Region, error)
	ListSizes(ctx context.Context, region string) ([]Size, error)
	// CreateServer returns the provider's own server id and, when the
	// provider assigns one immediately, its public IPv4 address (empty
	// until GetServer reports one, for providers that assign it later).
	CreateServer(ctx context.Context, opts CreateOpts) (serverID, ipAddr string, err error)
	GetServer(ctx context.Context, id string) (status ServerStatus, ipAddr string, err error)
	DeleteServer(ctx context.Context, id string) error
}

Provisioner creates and manages servers at a cloud provider. hetzner.go, digitalocean.go, azure.go and gcp.go are the real implementations; all four talk to their provider's REST API directly over net/http, no SDK.

type Region

type Region struct {
	ID   string
	Name string
}

Region is one region/location a provider can create a server in.

type ServerStatus

type ServerStatus string

ServerStatus is a provider-reported server lifecycle state, normalized across providers into a small common vocabulary.

const (
	ServerStatusPending ServerStatus = "pending"
	ServerStatusRunning ServerStatus = "running"
	ServerStatusError   ServerStatus = "error"
)

The three server states GetServer normalizes every provider's own status string into.

type Size

type Size struct {
	ID     string
	Name   string
	VCPUs  int
	Memory int // MB
	Disk   int // GB
	// PriceMonthly is the provider's own listed monthly price, in its
	// native currency string. Empty when the provider does not return a
	// price for this size.
	PriceMonthly string
	Currency     string
}

Size is one server size/plan a provider offers.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL