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
- func CLIReleaseAssetURL(version, arch string) string
- func DeleteDeployment(id string) error
- func InstanceAlreadyGone(err error) bool
- func ProviderResourceName(identifier string) (string, error)
- func RenderBootstrap(p BootstrapParams) (string, error)
- func RenderTeardown(p TeardownParams) (string, error)
- func SaveDeployment(d *Deployment) error
- func StepRank(step string) int
- type AzureProvisioner
- func (p *AzureProvisioner) ConfigureFirewall(ctx context.Context, prov Provisioned, rules []FirewallRule) error
- func (p *AzureProvisioner) Destroy(ctx context.Context, prov Provisioned) error
- func (p *AzureProvisioner) FindInstanceByName(ctx context.Context, name, region string) (string, error)
- func (p *AzureProvisioner) Provision(ctx context.Context, spec Spec) (Provisioned, error)
- type BootstrapParams
- type DBEngine
- type Deployment
- type DigitalOceanProvisioner
- func (p *DigitalOceanProvisioner) ConfigureFirewall(ctx context.Context, prov Provisioned, rules []FirewallRule) error
- func (p *DigitalOceanProvisioner) Destroy(ctx context.Context, prov Provisioned) error
- func (p *DigitalOceanProvisioner) FindInstanceByName(ctx context.Context, name, region string) (string, error)
- func (p *DigitalOceanProvisioner) Provision(ctx context.Context, spec Spec) (Provisioned, error)
- type FirewallRule
- type GCPProvisioner
- func (p *GCPProvisioner) ConfigureFirewall(ctx context.Context, prov Provisioned, rules []FirewallRule) error
- func (p *GCPProvisioner) Destroy(ctx context.Context, prov Provisioned) error
- func (p *GCPProvisioner) FindInstanceByName(ctx context.Context, name, region string) (string, error)
- func (p *GCPProvisioner) Provision(ctx context.Context, spec Spec) (Provisioned, error)
- type HetznerProvisioner
- func (p *HetznerProvisioner) ConfigureFirewall(ctx context.Context, prov Provisioned, rules []FirewallRule) error
- func (p *HetznerProvisioner) Destroy(ctx context.Context, prov Provisioned) error
- func (p *HetznerProvisioner) FindInstanceByName(ctx context.Context, name, region string) (string, error)
- func (p *HetznerProvisioner) Provision(ctx context.Context, spec Spec) (Provisioned, error)
- type InstanceRef
- type LinodeProvisioner
- func (p *LinodeProvisioner) ConfigureFirewall(ctx context.Context, prov Provisioned, rules []FirewallRule) error
- func (p *LinodeProvisioner) Destroy(ctx context.Context, prov Provisioned) error
- func (p *LinodeProvisioner) FindInstanceByName(ctx context.Context, name, region string) (string, error)
- func (p *LinodeProvisioner) Provision(ctx context.Context, spec Spec) (Provisioned, error)
- type Provider
- type ProviderConfig
- type Provisioned
- type Provisioner
- type RemoteRunner
- type SSHProvisioner
- func (p *SSHProvisioner) ConfigureFirewall(ctx context.Context, prov Provisioned, rules []FirewallRule) error
- func (p *SSHProvisioner) Destroy(ctx context.Context, prov Provisioned) error
- func (p *SSHProvisioner) FindInstanceByName(ctx context.Context, name, region string) (string, error)
- func (p *SSHProvisioner) Provision(ctx context.Context, spec Spec) (Provisioned, error)
- type SSHRunner
- func (r *SSHRunner) Capture(ctx context.Context, command string) (string, error)
- func (r *SSHRunner) CopyDir(ctx context.Context, localDir, remotePath string) error
- func (r *SSHRunner) Ping(ctx context.Context) error
- func (r *SSHRunner) RunCommand(ctx context.Context, command string) error
- func (r *SSHRunner) RunScript(ctx context.Context, label, script string) error
- func (r *SSHRunner) SetSudo(sudo bool)
- type ScalewayProvisioner
- func (p *ScalewayProvisioner) ConfigureFirewall(ctx context.Context, prov Provisioned, rules []FirewallRule) error
- func (p *ScalewayProvisioner) Destroy(ctx context.Context, prov Provisioned) error
- func (p *ScalewayProvisioner) FindInstanceByName(ctx context.Context, name, region string) (string, error)
- func (p *ScalewayProvisioner) Provision(ctx context.Context, spec Spec) (Provisioned, error)
- type Spec
- type Target
- type TeardownParams
- type VultrProvisioner
- func (p *VultrProvisioner) ConfigureFirewall(ctx context.Context, prov Provisioned, rules []FirewallRule) error
- func (p *VultrProvisioner) Destroy(ctx context.Context, prov Provisioned) error
- func (p *VultrProvisioner) FindInstanceByName(ctx context.Context, name, region string) (string, error)
- func (p *VultrProvisioner) Provision(ctx context.Context, spec Spec) (Provisioned, error)
Constants ¶
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.
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.
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
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 ¶
DeleteDeployment removes a deployment's state file. Not-found is not an error.
func InstanceAlreadyGone ¶ added in v1.5.4
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
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
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.
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 (p *DigitalOceanProvisioner) Destroy(ctx context.Context, prov Provisioned) error
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
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
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 (*SSHRunner) Capture ¶
Capture runs a remote command and returns its stdout (trimmed). Used for health checks and status probes.
func (*SSHRunner) CopyDir ¶
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) RunCommand ¶
RunCommand runs a single remote command, streaming its output to stderr.
func (*SSHRunner) RunScript ¶
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.
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)