deploy

package
v1.6.1 Latest Latest
Warning

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

Go to latest
Published: Aug 2, 2026 License: MIT Imports: 21 Imported by: 0

Documentation

Overview

Package deploy implements `nuzur-cli deploy` / `nuzur-cli destroy`: it provisions a Linux server, self-hosts the project's database on it (localhost-only), runs the generated API, and pairs the box back to nuzur via an outbound local agent — so the database is fully managed in nuzur with no inbound DB ports.

The package is transport-agnostic at its core: every provider runs the same bootstrap over SSH. A Provisioner supplies only the create-VM / firewall / destroy slice; the bring-your-own-server (SSH) provider implements that with no provider API and doubles as the universal path for any Linux host.

Index

Constants

View Source
const (
	ScriptBootstrap = "bootstrap"
	ScriptTeardown  = "teardown"
)

The labels RunScript is called with. They name the script in the error the caller (and the deployment record's last_error, and the terminal) reads, so a teardown failure can never be reported as a bootstrap one.

View Source
const (
	// StepPendingRecorded: the record exists and reserves a provider resource
	// NAME, written before the create call so a VM that is created and then lost
	// is still addressable.
	StepPendingRecorded = "pending_recorded"
	// StepInstanceCreated: the provider acknowledged the VM, so the record now
	// carries its instance id. The deploy is still waiting for SSH.
	StepInstanceCreated = "instance_created"
	// StepBoxRecorded: the box exists and is reachable, and the record describes
	// it fully (host, ports, workspace, connection). Everything after this point
	// is software on a server that is already billing.
	StepBoxRecorded = "box_recorded"
	// StepAgentPaired: the local agent for this box is known and registered with
	// nuzur. This is the checkpoint that makes "did the last run get far enough to
	// be reused" a fact rather than a guess.
	StepAgentPaired = "agent_paired"
	// StepFinalized: the deploy completed its record — agent, front door, data
	// manager link. A schema step may still have failed; that is reported
	// separately and does not make the DEPLOYMENT unfinished.
	StepFinalized = "finalized"
)

The checkpoints a deploy writes into Deployment.LastCompletedStep. Each is written by the same mutation that persists the step's result, so the checkpoint cannot disagree with the fields it describes.

View Source
const CLIReleaseArchX8664 = "x86_64"

CLIReleaseArchX8664 is the goreleaser architecture suffix for 64-bit Intel/AMD Linux — what every managed provider hands out unless asked otherwise, and the asset a pre-flight check probes when it wants to know whether a release was published at all (a missing tag 404s every asset under it, whatever the arch).

Variables

This section is empty.

Functions

func CLIReleaseAssetURL added in v1.6.0

func CLIReleaseAssetURL(version, arch string) string

CLIReleaseAssetURL is the GitHub release asset the box downloads the nuzur CLI from, for one version and one architecture.

It exists so that URL has ONE definition. The bootstrap template composes the same string with the arch resolved on the box (`${NUZUR_ARCH}`), and a caller that wants to check the download BEFORE paying for a VM has to ask about exactly the URL the script will use — a check aimed one character off is worse than no check, because it reports confidently about a different file. TestBootstrapTemplateUsesCLIReleaseAssetURL is what keeps the two in step; pass "${NUZUR_ARCH}" as the arch to reproduce the template's form.

The leading `v` is added here (the constant is bare, the tag carries it) and a caller that passes a tag name is absorbed rather than rendered as `.../vv1.5.2/`.

func DeleteDeployment

func DeleteDeployment(id string) error

DeleteDeployment removes a deployment's state file. Not-found is not an error.

func InstanceAlreadyGone added in v1.5.4

func InstanceAlreadyGone(err error) bool

InstanceAlreadyGone reports whether a failed delete failed because the instance is not there any more.

It is the difference between two very different closing messages. Destroying a record whose droplet had been deleted underneath it printed a DigitalOcean "404 … could not be found" and then told the user to "delete it manually to avoid charges" — sending them to hunt for a server that does not exist, on a bill it is not on. The "already gone" case is one the CLI recognises everywhere else; only the delete path lacked it.

Deliberately a phrase match: the adapters shell out to seven different provider CLIs, none of which offers a typed error, and the alternative is seven bespoke parsers. A false positive costs the reassurance that a VM is gone when it is not, so the phrases stay narrow and the caller still says which provider said it.

func ProviderResourceName added in v1.2.6

func ProviderResourceName(identifier string) (string, error)

ProviderResourceName mints the provider-side name for a deploy's resources. The deploy command calls this before provisioning so the name can be persisted ahead of the create call; adapters get it via Spec.ResourceName.

func RenderBootstrap

func RenderBootstrap(p BootstrapParams) (string, error)

RenderBootstrap produces the bootstrap shell script for a target.

func RenderTeardown added in v1.1.3

func RenderTeardown(p TeardownParams) (string, error)

RenderTeardown produces the teardown shell script for a target.

func SaveDeployment

func SaveDeployment(d *Deployment) error

SaveDeployment writes a deployment record wholesale.

NOT the production update path — MutateDeployment is, and app/ is checked for direct calls to this by TestAppWritesRecordsOnlyThroughMutateDeployment. This stays exported as the CREATION path used by tests that seed a machine's record store with records that already exist in full.

func StepRank added in v1.6.0

func StepRank(step string) int

StepRank turns a checkpoint into a comparable position, so callers can ask "did it get at least as far as X" instead of matching strings.

"" is rank 0 — a record written before checkpoints existed, or by a run that died before its first one. An UNRECOGNISED value is also rank 0: the only way to see one is a record written by a newer CLI, and the safe reading of a step this binary has never heard of is "nothing this binary knows about completed", not "further along than anything I know".

Types

type AzureProvisioner added in v1.2.5

type AzureProvisioner struct{}

func NewAzureProvisioner added in v1.2.5

func NewAzureProvisioner() *AzureProvisioner

func (*AzureProvisioner) ConfigureFirewall added in v1.2.5

func (p *AzureProvisioner) ConfigureFirewall(ctx context.Context, prov Provisioned, rules []FirewallRule) error

func (*AzureProvisioner) Destroy added in v1.2.5

func (p *AzureProvisioner) Destroy(ctx context.Context, prov Provisioned) error

func (*AzureProvisioner) FindInstanceByName added in v1.2.6

func (p *AzureProvisioner) FindInstanceByName(ctx context.Context, name, region string) (string, error)

FindInstanceByName resolves an Azure resource-group name — which for this adapter IS the instance id (see the file comment). `az group exists` answers true/false without erroring, so this is just that check.

func (*AzureProvisioner) Provision added in v1.2.5

func (p *AzureProvisioner) Provision(ctx context.Context, spec Spec) (Provisioned, error)

type BootstrapParams

type BootstrapParams struct {
	Identifier string
	DBEngine   DBEngine
	DBName     string
	DBUser     string
	// DBOnly provisions only the DB engine (--db) + the paired agent + connection
	// (and applies the schema); it skips the generated app, Docker, and Caddy. The
	// database is then managed entirely through nuzur.
	DBOnly bool
	// ExternalDB means the app/agent connect to a caller-supplied existing DB
	// (--db-dsn, local or remote, MySQL or Postgres) instead of a self-hosted one.
	// The bootstrap skips DB install + DB/user creation + backups; DBHost/
	// DBPort/DBPassword/DBParams/DBDSN carry the connection.
	ExternalDB bool
	DBHost     string
	DBPort     string
	DBPassword string // external only; self-hosted generates its own on the box
	DBParams   string // DSN query params (e.g. parseTime=true / sslmode=require)
	DBDSN      string // external only: the raw DSN used for the agent connection
	// DBSchema is the agent connection's default schema — set for Postgres (a
	// namespace like `public`), empty for MySQL where the database is the schema.
	DBSchema    string
	GRPCEnabled bool
	// JWTAuth means the generated app uses the JWT auth server, which reads its
	// signing key from config (auth.jwt.key). The generated base.yaml ships a
	// placeholder, so the bootstrap generates a real random key into prod.yaml —
	// without it token creation is broken.
	JWTAuth bool
	// Domain, when set, makes Caddy serve HTTPS/443 with an automatic Let's
	// Encrypt cert for this project's site. Empty means IP-only: the project gets
	// its own auto-assigned public port on the host IP (plain HTTP), so multiple
	// projects can coexist without domains.
	Domain string
	// Host is the box IP/hostname the CLI connected to; used to compose the
	// IP-only public URL (http://{host}:{publicPort}) written back for the report.
	Host              string
	InnoDBBufferMB    int
	ProjectDir        string // per-project dir, e.g. /etc/nuzur/{identifier} (holds secrets + url)
	ConfigDir         string // per-project config, e.g. /etc/nuzur/{identifier}/config
	RemoteSrcDir      string // where generated source was copied
	ImageName         string
	ContainerName     string
	ProvisioningToken string
	// ConnUUID/ConnName register the localhost DB as a named agent connection
	// (locally, --no-publish) so the daemon serves it by UUID; the deploy
	// command publishes the catalog to nuzur with the user's token.
	ConnUUID string
	ConnName string
	// CLIInstallCmd optionally overrides how the nuzur CLI is installed on the
	// box. When empty, the bootstrap downloads the matching Linux binary from
	// the nuzur-cli GitHub releases. A custom command must leave the binary at
	// NuzurBin.
	CLIInstallCmd string
	// CLIVersion PINS which nuzur-cli release the box installs; it defaults to the
	// version of the CLI driving this deploy. Two reasons, both structural:
	//
	//   - box CLI == driving CLI, so the agent on the box is never a different
	//     version from the CLI that paired it and published its connection;
	//   - a release published WHILE a deploy runs can no longer break it. The URL
	//     used to be `releases/latest/download/...`, which resolves at curl time to
	//     whatever Release exists at that instant — and a GitHub Release exists from
	//     the moment it is created, seconds before goreleaser finishes uploading its
	//     assets. Every in-flight deploy 404s during that window, at the very end of
	//     the expensive part (VM, Docker, database and app image all already paid
	//     for). Pinning removes the dependency on what was published minutes ago.
	//
	// A dev/unreleased version still tries the pinned URL: the bootstrap's curl
	// failure names the version AND the exact URL it tried, so the log says plainly
	// that this version has no published assets rather than failing as a generic
	// 404. --cli-install-cmd remains the escape hatch — for boxes that cannot reach
	// GitHub, and for deliberately pinning some other version.
	CLIVersion string
	// NuzurBin is the absolute path to the installed nuzur binary (used in the
	// agent systemd unit).
	NuzurBin string
	// S3* configure the generated app's file-upload endpoints (/upload, /sign).
	// Unlike the DB password, these credentials are resolved from the team's
	// ObjectStore (KMS) and passed IN, then written into prod.yaml (0600) — like
	// the external --db-dsn path, the secret does travel through the bootstrap.
	S3Enabled bool
	S3Region  string
	S3Bucket  string
	S3Key     string
	S3Secret  string
}

BootstrapParams are the values rendered into the remote bootstrap script. The DB password is intentionally NOT here — it is generated on the box so the plaintext secret never leaves the server.

type DBEngine

type DBEngine string

DBEngine is the database engine. Both MySQL and Postgres are supported as a self-hosted local tier (installed + provisioned on the box) and as an external (--db-dsn) database the app/agent connect to directly.

const (
	DBMySQL    DBEngine = "mysql"
	DBPostgres DBEngine = "postgres"
)

type Deployment

type Deployment struct {
	ID                 string   `json:"id"`
	Provider           Provider `json:"provider"`
	ProviderInstanceID string   `json:"provider_instance_id,omitempty"` // cloud VM/instance id (for destroy); empty for BYO-SSH
	// ProviderResourceName is the name nuzur minted for the VM, written to disk
	// BEFORE the provider create call. If a deploy dies during that call, the id
	// never comes back and this name is the only handle left on a VM that may be
	// running and billing — destroy resolves it via Provisioner.FindInstanceByName.
	ProviderResourceName string `json:"provider_resource_name,omitempty"`
	// Provisioning marks a deployment whose VM is still being created. It is set
	// before the create call and cleared once the deploy completes, so a record left
	// with it set is a deploy that died in flight and may have leaked a VM.
	Provisioning       bool     `json:"provisioning,omitempty"`
	Region             string   `json:"region,omitempty"` // cloud region the VM lives in
	Host               string   `json:"host"`
	User               string   `json:"user"`
	Port               int      `json:"port"`
	Identifier         string   `json:"identifier"`
	ProjectUUID        string   `json:"project_uuid"`
	ProjectVersionUUID string   `json:"project_version_uuid"`
	LocalAgentUUID     string   `json:"local_agent_uuid"`
	ConnUUID           string   `json:"conn_uuid,omitempty"`
	DBEngine           DBEngine `json:"db_engine"`
	ExternalDB         bool     `json:"external_db,omitempty"` // --db-dsn: an existing DB, not self-hosted (never dropped on destroy)
	// WorkspaceDir is the persistent app-source WORKSPACE ROOT (e.g.
	// ./nuzur-<identifier>), the directory resolveWorkspace reuses on a
	// re-deploy. Distinct from Spec.SourceDir, which is the app directory
	// INSIDE the workspace (where the Dockerfile lives) — recording that here
	// instead once sent a retried deploy generating into its own app dir. The
	// json key stays source_dir so existing records keep working.
	WorkspaceDir   string    `json:"source_dir,omitempty"`
	Domain         string    `json:"domain,omitempty"`     // set when deployed with --domain (HTTPS site)
	APIURL         string    `json:"api_url,omitempty"`    // resolved front-door URL
	PublicURL      string    `json:"public_url,omitempty"` // same as APIURL; explicit alias
	DataManagerURL string    `json:"data_manager_url,omitempty"`
	CreatedAt      time.Time `json:"created_at"`

	// LastCompletedStep is how far the LAST run of this deployment got: one of the
	// Step* constants below, or "" for a record written before checkpoints existed
	// (or by a run that died before its first checkpoint).
	//
	// It exists because every question the next run asks about a half-finished
	// deploy — did it pair an agent, did it get as far as creating the VM, is this
	// record a live deployment or the debris of one — was previously answered by
	// INFERRING from which fields happened to be empty. An empty LocalAgentUUID
	// meaning "died in flight" is that inference, and it is what turned an
	// interrupted re-deploy into a second record for the same box.
	//
	// The values are self-describing strings rather than an integer, because the
	// file is read by humans debugging exactly this situation.
	//
	// MONOTONE WITHIN A RUN, AND DELIBERATELY NOT ACROSS RUNS. A re-deploy
	// rewrites `finalized` → `box_recorded` → `agent_paired` → `finalized`, so
	// for the middle of every re-deploy this record describes a healthy, serving
	// box as half-deployed. That is the field's definition working — the last run
	// IS the one in progress — but it is worth writing down, because it is the
	// one place a concurrent reader (a second CLI, a crash-recovery path, a human
	// reading the file) can take a true value and draw a false conclusion.
	//
	// Blanking it at the start of a run was considered and rejected. "" is the
	// sentinel for "predates checkpoints", so a re-deploy interrupted after
	// blanking would be indistinguishable from an old record — losing precisely
	// the adoption decision the checkpoint exists to make, which is the bug that
	// used to mint a second record for one box. Reporting less than the truth is
	// not more honest than reporting a truth that has to be read carefully.
	// Telling "in flight" from "got this far" properly needs a second, in-flight
	// marker; it is not a different value of this one.
	//
	// Both directions are pinned: TestDeployRecordSequenceManagedFirstDeploy for
	// within a run, and TestRedeployPreservesURLsMidRun (which observes
	// box_recorded on a record seeded finalized) for across them.
	LastCompletedStep string `json:"last_completed_step,omitempty"`
	// LastError is the error the last run of this deployment ended with, or "" if
	// it finished cleanly. Cleared by the finalizing write, so a record carrying
	// one is a deployment that is currently in a bad state rather than one that
	// once was.
	LastError string `json:"last_error,omitempty"`
}

Deployment is the persisted record of one `nuzur-cli deploy`, written under ~/.config/nuzur/deployments/<id>.json. It is the source of truth for `nuzur-cli destroy` (revoke the agent, delete provider infra) and `deploy list`.

func ListDeployments

func ListDeployments() ([]Deployment, error)

ListDeployments returns all recorded deployments, newest first.

func LoadDeployment

func LoadDeployment(id string) (*Deployment, error)

LoadDeployment reads a single deployment by id.

func MutateDeployment added in v1.6.0

func MutateDeployment(id string, fn func(*Deployment)) (*Deployment, error)

MutateDeployment applies fn to the deployment's record and writes it back. It is the ONLY way the deploy pipeline may write a record.

The point is what it does NOT do: it never replaces the file with a struct the caller assembled. Every field fn does not touch keeps the value it had. The four write sites in a deploy used to each build a whole Deployment literal, which meant each of them had to remember every field of it — and when one forgot, the record silently lost the agent uuid, or the resource name, or the front-door URLs for the twenty minutes between two writes. Those were three separate bugs with one shape.

Load-or-create, and the distinction is load-bearing: a record that is MISSING is a first write, but a record that is present and unparseable is a record whose contents are unknown — quite possibly the only handle on a running, billing VM — so it is an error and is never overwritten with a fresh one.

The returned record is the one that was written, so a caller that needs the post-write state does not re-read it.

type DigitalOceanProvisioner added in v1.2.0

type DigitalOceanProvisioner struct{}

func NewDigitalOceanProvisioner added in v1.2.0

func NewDigitalOceanProvisioner() *DigitalOceanProvisioner

func (*DigitalOceanProvisioner) ConfigureFirewall added in v1.2.0

func (p *DigitalOceanProvisioner) ConfigureFirewall(ctx context.Context, prov Provisioned, rules []FirewallRule) error

func (*DigitalOceanProvisioner) Destroy added in v1.2.0

func (*DigitalOceanProvisioner) FindInstanceByName added in v1.2.6

func (p *DigitalOceanProvisioner) FindInstanceByName(ctx context.Context, name, region string) (string, error)

FindInstanceByName resolves a droplet name to its id. doctl has no name filter (only --tag-name), so this lists and matches; an empty list exits 0, so "not found" is an empty result rather than an error.

func (*DigitalOceanProvisioner) Provision added in v1.2.0

func (p *DigitalOceanProvisioner) Provision(ctx context.Context, spec Spec) (Provisioned, error)

type FirewallRule added in v1.2.0

type FirewallRule struct {
	Port    int
	PortEnd int // inclusive range end; 0 → single port
}

FirewallRule is one inbound-TCP allowance for a cloud provider firewall. A single port sets Port (PortEnd == 0); a contiguous range sets both. These mirror the box's own ufw rules (SSH + the Caddy front doors) as defense in depth — the on-box ufw remains the authoritative gate.

type GCPProvisioner added in v1.2.5

type GCPProvisioner struct{}

func NewGCPProvisioner added in v1.2.5

func NewGCPProvisioner() *GCPProvisioner

func (*GCPProvisioner) ConfigureFirewall added in v1.2.5

func (p *GCPProvisioner) ConfigureFirewall(ctx context.Context, prov Provisioned, rules []FirewallRule) error

func (*GCPProvisioner) Destroy added in v1.2.5

func (p *GCPProvisioner) Destroy(ctx context.Context, prov Provisioned) error

func (*GCPProvisioner) FindInstanceByName added in v1.2.6

func (p *GCPProvisioner) FindInstanceByName(ctx context.Context, name, region string) (string, error)

FindInstanceByName resolves a GCP instance name — which for this adapter IS the instance id (see the file comment) — confirming it exists in the zone. gcloud exits 0 with no output when the filter matches nothing.

func (*GCPProvisioner) Provision added in v1.2.5

func (p *GCPProvisioner) Provision(ctx context.Context, spec Spec) (Provisioned, error)

type HetznerProvisioner added in v1.2.0

type HetznerProvisioner struct{}

func NewHetznerProvisioner added in v1.2.0

func NewHetznerProvisioner() *HetznerProvisioner

func (*HetznerProvisioner) ConfigureFirewall added in v1.2.0

func (p *HetznerProvisioner) ConfigureFirewall(ctx context.Context, prov Provisioned, rules []FirewallRule) error

func (*HetznerProvisioner) Destroy added in v1.2.0

func (p *HetznerProvisioner) Destroy(ctx context.Context, prov Provisioned) error

func (*HetznerProvisioner) FindInstanceByName added in v1.2.6

func (p *HetznerProvisioner) FindInstanceByName(ctx context.Context, name, region string) (string, error)

FindInstanceByName resolves a Hetzner server name to its id. This lists and matches rather than using `server describe <name>`, which exits non-zero with "Server not found" — an expected outcome here, not a failure.

func (*HetznerProvisioner) Provision added in v1.2.0

func (p *HetznerProvisioner) Provision(ctx context.Context, spec Spec) (Provisioned, error)

type InstanceRef added in v1.2.6

type InstanceRef struct {
	InstanceID   string
	Region       string
	Host         string
	ResourceName string
}

InstanceRef is a VM the provider has just acknowledged. It is reported the moment it exists — before it is reachable — so the caller can persist it while the deploy is still in flight. Host may be empty for a provider that assigns the address asynchronously (Vultr); the id and name are always enough to delete it.

type LinodeProvisioner added in v1.2.5

type LinodeProvisioner struct{}

func NewLinodeProvisioner added in v1.2.5

func NewLinodeProvisioner() *LinodeProvisioner

func (*LinodeProvisioner) ConfigureFirewall added in v1.2.5

func (p *LinodeProvisioner) ConfigureFirewall(ctx context.Context, prov Provisioned, rules []FirewallRule) error

func (*LinodeProvisioner) Destroy added in v1.2.5

func (p *LinodeProvisioner) Destroy(ctx context.Context, prov Provisioned) error

func (*LinodeProvisioner) FindInstanceByName added in v1.2.6

func (p *LinodeProvisioner) FindInstanceByName(ctx context.Context, name, region string) (string, error)

FindInstanceByName resolves a linode label to its id. Linode filters server-side on --label and exits 0 with no output when nothing matches, so an empty result is "no such linode", not an error.

func (*LinodeProvisioner) Provision added in v1.2.5

func (p *LinodeProvisioner) Provision(ctx context.Context, spec Spec) (Provisioned, error)

type Provider

type Provider string

Provider identifies how the target server is obtained/managed.

const (
	// ProviderSSH is bring-your-own-server: the user supplies an existing host.
	// It doubles as the universal fallback for any Linux box.
	ProviderSSH Provider = "ssh"
	// Managed providers create the VM for the user by shelling out to the
	// provider's own (already-authenticated) CLI.
	ProviderDigitalOcean Provider = "digitalocean"
	ProviderHetzner      Provider = "hetzner"
	ProviderAWS          Provider = "aws"
	ProviderGCP          Provider = "gcp"
	ProviderAzure        Provider = "azure"
	ProviderVultr        Provider = "vultr"
	ProviderLinode       Provider = "linode"
	ProviderScaleway     Provider = "scaleway"
)

type ProviderConfig added in v1.2.0

type ProviderConfig struct {
	Region     string // provider region/location (e.g. "nyc3", "nbg1")
	Size       string // instance size/type (provider-specific; empty → adapter default)
	Image      string // OS image (empty → adapter default, an Ubuntu LTS)
	SSHKeyName string // name/id of an SSH key already registered with the provider;

}

ProviderConfig holds the managed-provisioning inputs for a cloud provider. Ignored by the BYO-SSH provider. Provider auth is deliberately NOT here — the adapters shell out to the user's already-authenticated provider CLI, so nuzur never handles provider tokens.

type Provisioned added in v1.2.0

type Provisioned struct {
	Target     Target
	InstanceID string // provider VM/instance id
	Region     string // provider region the VM lives in
}

Provisioned is the result of Provision: a reachable Target plus the identifiers a cloud teardown needs. For BYO-SSH, InstanceID/Region are empty (nothing to delete). These are persisted on the Deployment record so `destroy` can find and delete the VM later.

type Provisioner

type Provisioner interface {
	// Provision returns a reachable, SSH-ready Target. BYO-SSH validates and
	// returns the user-supplied host; a cloud provider creates a VM, waits for
	// SSH, and returns its address + instance id.
	Provision(ctx context.Context, spec Spec) (Provisioned, error)
	// ConfigureFirewall restricts inbound to the given TCP rules (SSH + the Caddy
	// front doors). BYO-SSH is a no-op (the box's ufw does it); cloud adapters
	// create a provider security group/firewall for the instance.
	ConfigureFirewall(ctx context.Context, p Provisioned, rules []FirewallRule) error
	// Destroy tears down provider-created infrastructure. BYO-SSH is a no-op
	// (the user owns the box); a cloud provider deletes the VM by instance id.
	Destroy(ctx context.Context, p Provisioned) error
	// FindInstanceByName resolves a provider-side resource name to an instance id,
	// returning "" (and no error) when nothing matches. This is the last-resort
	// recovery path: a deploy killed DURING the create call leaves a VM whose id was
	// never returned to us, and the name — minted and persisted before the call — is
	// then the only handle on it. BYO-SSH returns "" (nothing is ours to find).
	FindInstanceByName(ctx context.Context, name, region string) (string, error)
}

Provisioner is the per-provider seam: everything else (the bootstrap) is shared. For BYO-SSH these are near-trivial; a cloud adapter implements them by shelling out to the provider's CLI.

func NewProvisioner added in v1.2.0

func NewProvisioner(provider Provider) (Provisioner, error)

NewProvisioner returns the adapter for a provider. An empty provider defaults to BYO-SSH.

type RemoteRunner added in v1.6.0

type RemoteRunner interface {
	Ping(ctx context.Context) error
	RunCommand(ctx context.Context, command string) error
	// RunScript runs a script on the box. label names WHICH script, and is the
	// noun the failure is reported with — see the ScriptBootstrap/ScriptTeardown
	// constants and RunScript's own doc.
	RunScript(ctx context.Context, label, script string) error
	Capture(ctx context.Context, command string) (string, error)
	CopyDir(ctx context.Context, localDir, remotePath string) error
	// SetSudo runs privileged remote work through `sudo` (see SSHRunner.Sudo).
	// A method rather than a field so the interface can carry it.
	SetSudo(sudo bool)
}

RemoteRunner is what the deploy pipeline needs from the box it is deploying to: reach it, run things on it, read something back, and ship a directory.

It exists so the pipeline can be exercised without a real host. *SSHRunner is the production implementation and the only one in the binary; the interface is the seam, not an abstraction over several transports.

type SSHProvisioner

type SSHProvisioner struct{}

SSHProvisioner implements Provisioner for a user-supplied host. It performs no provider-API work: the box already exists, the firewall is configured on the box by the bootstrap (ufw), and teardown of the box itself is the user's.

func NewSSHProvisioner

func NewSSHProvisioner() *SSHProvisioner

func (*SSHProvisioner) ConfigureFirewall

func (p *SSHProvisioner) ConfigureFirewall(ctx context.Context, prov Provisioned, rules []FirewallRule) error

ConfigureFirewall is a no-op for BYO-SSH: the firewall (ufw, 443+22 only) is applied on the box as part of the bootstrap. Cloud adapters use this for provider-level security groups.

func (*SSHProvisioner) Destroy

func (p *SSHProvisioner) Destroy(ctx context.Context, prov Provisioned) error

Destroy is a no-op for BYO-SSH: the user owns the box. Agent revocation and local-state cleanup are handled by the destroy command, not the provisioner.

func (*SSHProvisioner) FindInstanceByName added in v1.2.6

func (p *SSHProvisioner) FindInstanceByName(ctx context.Context, name, region string) (string, error)

FindInstanceByName finds nothing for BYO-SSH: nuzur created no instance, so there is never one of ours to recover.

func (*SSHProvisioner) Provision

func (p *SSHProvisioner) Provision(ctx context.Context, spec Spec) (Provisioned, error)

type SSHRunner

type SSHRunner struct {
	Target Target
	// Sudo runs the bootstrap script through `sudo` (for non-root SSH users on
	// hosts with passwordless sudo). The DB copy still lands in a user-writable
	// path, so only the privileged bootstrap needs it.
	Sudo bool
	// Stderr, when set, receives live command stderr (progress). Defaults to
	// os.Stderr. An io.Writer rather than an *os.File so the app layer can point
	// it at the CLI's own stderr sink (outputtools.Stderr), which a test can swap.
	Stderr io.Writer
}

SSHRunner executes commands and copies files to a Target by shelling out to the system `ssh` / `scp`. This reuses the user's ssh-agent and ~/.ssh/config (matching the "shell out" approach) and avoids in-process key handling.

func NewSSHRunner

func NewSSHRunner(t Target) *SSHRunner

func (*SSHRunner) Capture

func (r *SSHRunner) Capture(ctx context.Context, command string) (string, error)

Capture runs a remote command and returns its stdout (trimmed). Used for health checks and status probes.

func (*SSHRunner) CopyDir

func (r *SSHRunner) CopyDir(ctx context.Context, localDir, remotePath string) error

CopyDir copies localDir's CONTENTS to remotePath on the host, by streaming a gzipped tar over ssh.

This used to be `scp -r`, which transfers each file in its own SFTP round-trip. A generated app is ~650 small source files, so the copy was latency-bound rather than bandwidth-bound — on a transatlantic link it crawled at a few KB/s and took many minutes to move ~4MB. One tar stream is a single round-trip, and Go source gzips ~5-10x, so the same payload ships in seconds.

`tar -C localDir .` (contents, not the directory itself) matches the old scp -r semantics: the caller passes a non-existent remotePath and expects it to become a copy of localDir.

func (*SSHRunner) Ping

func (r *SSHRunner) Ping(ctx context.Context) error

Ping verifies the host is reachable and key auth works.

func (*SSHRunner) RunCommand

func (r *SSHRunner) RunCommand(ctx context.Context, command string) error

RunCommand runs a single remote command, streaming its output to stderr.

func (*SSHRunner) RunScript

func (r *SSHRunner) RunScript(ctx context.Context, label, script string) error

RunScript pipes a script to `bash -s` (or `sudo bash -s`) on the remote host. The script runs with `set -euo pipefail` semantics if it declares them itself.

label names the script ("bootstrap", "teardown"). It is a parameter rather than a constant string because ONE runner serves both: the deploy's bootstrap and destroy's teardown. A hardcoded noun here meant `destroy` reported a dead box as `remote bootstrap script failed`, naming a step that does not exist in a destroy.

A failure carries the cause, not just the exit code. ssh writes its own diagnosis ("ssh: connect to host H port 22: Operation timed out") and the remote script writes its ("E: The package cache file is corrupted") to the stream that goes to the terminal — several lines above, and unconnected to the error the caller ends up printing. Both are worth more than the `exit status 255` they used to be replaced by, so the tail of that stream rides along in the error itself.

func (*SSHRunner) SetSudo added in v1.6.0

func (r *SSHRunner) SetSudo(sudo bool)

SetSudo satisfies RemoteRunner; the field stays exported for direct callers.

type ScalewayProvisioner added in v1.2.5

type ScalewayProvisioner struct{}

func NewScalewayProvisioner added in v1.2.5

func NewScalewayProvisioner() *ScalewayProvisioner

func (*ScalewayProvisioner) ConfigureFirewall added in v1.2.5

func (p *ScalewayProvisioner) ConfigureFirewall(ctx context.Context, prov Provisioned, rules []FirewallRule) error

func (*ScalewayProvisioner) Destroy added in v1.2.5

func (p *ScalewayProvisioner) Destroy(ctx context.Context, prov Provisioned) error

func (*ScalewayProvisioner) FindInstanceByName added in v1.2.6

func (p *ScalewayProvisioner) FindInstanceByName(ctx context.Context, name, region string) (string, error)

FindInstanceByName resolves a Scaleway server name to its id. `name=` is a PREFIX filter server-side ("server1" also matches "server100"), so the exact match has to happen here — deleting a VM whose name merely starts with ours would be destroying someone else's box.

func (*ScalewayProvisioner) Provision added in v1.2.5

func (p *ScalewayProvisioner) Provision(ctx context.Context, spec Spec) (Provisioned, error)

type Spec

type Spec struct {
	Provider           Provider
	Target             Target
	ProviderConfig     ProviderConfig // managed-provisioning inputs (cloud providers only)
	Identifier         string         // app identifier (image/service name, DB name)
	ProjectUUID        string
	ProjectVersionUUID string
	DBEngine           DBEngine
	// ProvisioningToken is minted by the caller (product IssueProvisioningToken)
	// and placed on the box for headless agent pairing.
	ProvisioningToken string
	// SourceDir is the local directory of generated app source (from the
	// go-code-gen extension) that gets copied to the box and built there.
	SourceDir string
	// ResourceName is the provider-side name for everything this deploy creates.
	// The CALLER mints it (providerResourceName) rather than the adapter, so it can
	// be written to local state BEFORE the create call: a VM whose id we never
	// learned is still findable — and so deletable — by name. Adapters given an
	// empty value mint their own, so direct and test callers still work.
	ResourceName string
	// OnInstanceCreated, when set, is called by a managed provisioner the instant the
	// provider acknowledges the VM — BEFORE waiting for it to become reachable, which
	// takes minutes. That wait is the bulk of the window in which a killed deploy
	// would otherwise strand a running, billing VM that nothing on disk knows about,
	// so this is what makes the VM recoverable. Implementations must be quick and
	// must not error the deploy.
	OnInstanceCreated func(InstanceRef)
}

Spec is the fully-resolved input to a deployment.

type Target

type Target struct {
	Host string // IP or hostname
	User string // SSH user (e.g. root)
	Port int    // SSH port (default 22)
	// KeyPath is an optional explicit private key; empty uses the ssh-agent /
	// ~/.ssh/config resolution.
	KeyPath string
}

Target is a resolved server the bootstrap runs against.

type TeardownParams added in v1.1.3

type TeardownParams struct {
	Identifier    string
	ContainerName string
	ImageName     string
	ProjectDir    string // /etc/nuzur/{identifier}
	DBEngine      DBEngine
	DBName        string
	DBUser        string
	ConnUUID      string // this project's agent connection to remove from the shared agent
	NuzurBin      string
	Purge         bool
	// IsLastProject: this is the only project left on the box, so also remove the
	// shared agent (nuzur-agent.service + pairing creds) and the main Caddyfile.
	// When false, the shared agent is kept for the surviving projects.
	IsLastProject bool
}

TeardownParams are the values rendered into the remote teardown script. It removes THIS project's artifacts (its systemd unit, container, image, its /etc/nuzur/{id} config+secrets, its Caddy site snippet, its backup cron, and its agent connection). The shared agent, MySQL, and packages are left intact unless IsLastProject is set. The database is dropped only when Purge is set.

type VultrProvisioner added in v1.2.5

type VultrProvisioner struct{}

func NewVultrProvisioner added in v1.2.5

func NewVultrProvisioner() *VultrProvisioner

func (*VultrProvisioner) ConfigureFirewall added in v1.2.5

func (p *VultrProvisioner) ConfigureFirewall(ctx context.Context, prov Provisioned, rules []FirewallRule) error

func (*VultrProvisioner) Destroy added in v1.2.5

func (p *VultrProvisioner) Destroy(ctx context.Context, prov Provisioned) error

func (*VultrProvisioner) FindInstanceByName added in v1.2.6

func (p *VultrProvisioner) FindInstanceByName(ctx context.Context, name, region string) (string, error)

FindInstanceByName resolves a Vultr label to its instance id. Vultr has no server-side label filter, so this lists and matches.

func (*VultrProvisioner) Provision added in v1.2.5

func (p *VultrProvisioner) Provision(ctx context.Context, spec Spec) (Provisioned, error)

Jump to

Keyboard shortcuts

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