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
- Variables
- func CLIReleaseAssetURL(version, osName, arch string) string
- func CLIReleaseChecksumsURL(version string) string
- func DeleteDeployment(id string) error
- func ImageTagForSHA(sha string) string
- func ImageWorkflowFile(identifier string) string
- func InstanceAlreadyGone(err error) bool
- func NextChartVersion(prior string) string
- func ProviderResourceName(identifier string) (string, error)
- func ReadChartVersion(chartYAMLPath string) (string, error)
- func RemoteChartDir(release string) string
- func RemoteValuesPath(release string) string
- func RenderBootstrap(p BootstrapParams) (string, error)
- func RenderTeardown(p TeardownParams) (string, error)
- func RequireLocalTool(name, neededFor, installHint string) error
- func ResolveImageDigest(ctx context.Context, repoRoot, repository, tag string) (string, error)
- func SaveDeployment(d *Deployment) error
- func StampChartVersion(chartYAMLPath, version string) error
- func StepRank(step string) int
- func WaitForImageBuild(ctx context.Context, opts CIWaitOptions) error
- 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 CIWaitOptions
- type ClusterTools
- func (t ClusterTools) IngressHosts(ctx context.Context, runner RemoteRunner, release, namespace string) []string
- func (t ClusterTools) IsMicroK8s() bool
- func (t ClusterTools) ReachCluster(ctx context.Context, runner RemoteRunner) error
- func (t ClusterTools) ReleaseExists(ctx context.Context, runner RemoteRunner, release, namespace string) bool
- func (t ClusterTools) ServiceEndpoint(ctx context.Context, runner RemoteRunner, release, namespace string) string
- func (t ClusterTools) UninstallRelease(ctx context.Context, runner RemoteRunner, release, namespace string) error
- func (t ClusterTools) UpdateDependencies(ctx context.Context, runner RemoteRunner, chartDir string) error
- func (t ClusterTools) UpgradeRelease(ctx context.Context, runner RemoteRunner, opts ReleaseOptions) error
- 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 GitRepo
- 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 K8sProvisioner
- func (p *K8sProvisioner) ConfigureFirewall(ctx context.Context, prov Provisioned, rules []FirewallRule) error
- func (p *K8sProvisioner) Destroy(ctx context.Context, prov Provisioned) error
- func (p *K8sProvisioner) FindInstanceByName(ctx context.Context, name, region string) (string, error)
- func (p *K8sProvisioner) Provision(ctx context.Context, spec Spec) (Provisioned, error)
- 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 ReleaseOptions
- 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" // StepReleased: the Helm release is applied and its pods are up // (ProviderK8s only). The record now names the release, the chart version // and the exact image, which is what makes `--release-only` able to repeat a // deploy without regenerating or rebuilding — and what stops the next run // re-minting a chart version that has already been published. // // It is the k8s counterpart of StepAgentPaired — both mark "the workload // this deploy exists to run is now running" — but it ranks ABOVE it rather // than equal to it. The two are mutually exclusive (each provider skips the // other's step), so no record ever carries both, and ranks have to be // strictly increasing in pipeline order for "how far did the last run get" // to be answerable by comparison alone. StepReleased = "released" // 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).
const CLIReleaseOSLinux = "Linux"
CLIReleaseOSLinux is the goreleaser OS segment for Linux assets — the value `{{ title .Os }}` renders, and the only one the box ever asks for. It is a named constant because the installer script (install.sh) composes the same URL for Darwin as well, so the OS stopped being a constant of the URL itself.
Variables ¶
var LocalCommand = func(ctx context.Context, dir, name string, args ...string) (string, error) { cmd := exec.CommandContext(ctx, name, args...) cmd.Dir = dir var stdout, stderr bytes.Buffer cmd.Stdout = &stdout cmd.Stderr = &stderr if err := cmd.Run(); err != nil { detail := strings.TrimSpace(stderr.String()) if detail == "" { detail = strings.TrimSpace(stdout.String()) } return strings.TrimSpace(stdout.String()), fmt.Errorf("%s %s: %w: %s", name, strings.Join(args, " "), err, detail) } return strings.TrimSpace(stdout.String()), nil }
LocalCommand runs a command on the machine running the CLI and returns its trimmed stdout.
Distinct from cliRunner, which the cloud adapters use, for one reason: dir. git and gh answer differently depending on which repository they are standing in, and the k8s provider runs them inside the generated workspace — which may itself be a repo, or a subdirectory of one.
A package var so tests can stub git/gh without a repository or a network. It pairs with LookLocal below; both are replaced together.
var LookLocal = func(name string) error { _, err := exec.LookPath(name) return err }
LookLocal reports whether a local binary is on PATH. A package var for the same reason as LocalCommand.
Functions ¶
func CLIReleaseAssetURL ¶ added in v1.6.0
CLIReleaseAssetURL is the GitHub release asset a machine downloads the nuzur CLI from, for one version, one OS and one architecture.
It exists so that URL has ONE definition. Three places compose it: the bootstrap template (OS fixed to Linux, arch resolved on the box as `${NUZUR_ARCH}`), the shell installer (both resolved from `uname`), and the pre-flight probe in Go. 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 and TestInstallScriptComposesTheReleaseURLs are what keep the three in step; pass the scripts' own placeholders ("${NUZUR_ARCH}", "${NUZUR_OS}") to reproduce their 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 CLIReleaseChecksumsURL ¶ added in v1.6.2
CLIReleaseChecksumsURL is the release's sha256 manifest — the file both the installer and the bootstrap verify their download against, so "the bytes I got are the bytes that were published" is answered by the publisher rather than by whoever is between us and GitHub.
The version appears TWICE and in two forms, which is the entire reason this is a function: the tag segment carries the `v` (`/download/v1.6.1/`) and goreleaser's checksum filename does not (`nuzur-cli_1.6.1_checksums.txt`). Getting that one character wrong yields a 404 that reads like a release with no checksums at all.
func DeleteDeployment ¶
DeleteDeployment removes a deployment's state file. Not-found is not an error.
func ImageTagForSHA ¶ added in v1.7.0
ImageTagForSHA is the tag the generated workflow publishes for a commit.
It must match `type=sha,format=long` in docker/metadata-action, which emits sha-<full 40-char sha>. The short form (type=sha with no format) would be sha-<12 chars> — close enough to look right in a log and wrong enough that every pull 404s.
func ImageWorkflowFile ¶ added in v1.7.0
ImageWorkflowFile is the filename the generator emits for the image build.
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 NextChartVersion ¶ added in v1.7.0
NextChartVersion returns the version to stamp into Chart.yaml for this deploy.
The chart version is not decoration. Two things depend on it moving:
- The publish workflow names the packaged .tgz after it, so a version that never changes overwrites one mutable artifact in the registry forever.
- It is stamped into the pod template labels, which is what actually rolls the pods when the image reference has not changed (a moving tag like :latest resolves to the same string every time).
So it always advances, even when nothing else about the deploy did.
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 ReadChartVersion ¶ added in v1.7.0
ReadChartVersion returns the top-level version from a Chart.yaml.
Anchored to the start of a line so a dependency's indented `version:` cannot be mistaken for the chart's own — the trap the publish workflow's `grep version: | tail -n1` walks straight into if the dependencies block is ever moved below the version.
func RemoteChartDir ¶ added in v1.7.0
RemoteChartDir is where the chart is copied on the host before helm runs. Under /tmp and namespaced by release so two concurrent deploys of different projects cannot overwrite each other's chart mid-upgrade.
func RemoteValuesPath ¶ added in v1.7.0
RemoteValuesPath is the values file deploy renders on the host.
It carries no secrets — the image reference, chart version and ingress host only — because credentials come from the operator-managed file on the node. That is also why it can live in /tmp with ordinary permissions.
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 RequireLocalTool ¶ added in v1.7.0
RequireLocalTool returns an actionable error when a tool the k8s flow needs is missing, naming what it is needed for rather than just that it is absent.
func ResolveImageDigest ¶ added in v1.7.0
ResolveImageDigest returns the immutable sha256 digest for repository:tag.
Implemented against the GitHub Packages API because ghcr.io is where this flow publishes and `gh` is already required to wait for the build — pulling in docker or crane just to read a manifest would add a dependency for one lookup. Other registries are refused explicitly rather than silently falling back to a mutable tag, which would make --pin-digest a lie.
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 StampChartVersion ¶ added in v1.7.0
StampChartVersion rewrites version and appVersion in a Chart.yaml.
Line-oriented rather than a YAML round-trip, deliberately: re-marshalling would reorder keys, drop the comments explaining what the version does, and reformat the dependencies block — turning a one-line version bump into an unreviewable diff on a file the user commits.
Both are set to the same value. Every chart in use keeps them in lockstep, and the publish workflow reads the version back out assuming as much.
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".
func WaitForImageBuild ¶ added in v1.7.0
func WaitForImageBuild(ctx context.Context, opts CIWaitOptions) error
WaitForImageBuild blocks until the image workflow for a commit succeeds.
It polls `gh run list` rather than shelling to `gh run watch` for two reasons: the run does not exist the instant a push lands (so watch would exit non-zero on a race that is not a failure), and polling lets the caller report the intermediate states, which matters when the wait is minutes long.
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
// GRPCDomain and AuthDomain are OPTIONAL extra hostnames for the same box,
// each rendered as its own site block in this project's Caddy snippet — and
// therefore each getting its own automatic Let's Encrypt cert. Both stay on
// 80/443, so neither changes the firewall.
//
// They are ADDITIONS, never a replacement. The site Domain names still routes
// everything by Content-Type (gRPC vs HTTP) on one hostname, which is the
// arrangement every existing deployment runs; with both of these empty the
// snippet is byte-identical to what it has always been.
//
// GRPCDomain fronts the gRPC port directly (h2c), so it is rendered only when
// the app actually serves gRPC. AuthDomain is a hostname ALIAS onto the same
// process — a VM runs one binary serving both the API and the JWT endpoints,
// unlike the k8s path where the auth server is a second deployment — so it
// proxies to the same HTTP port.
GRPCDomain string
AuthDomain 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 CIWaitOptions ¶ added in v1.7.0
type CIWaitOptions struct {
RepoRoot string
SHA string
Workflow string // workflow filename
Timeout time.Duration // total budget
Poll time.Duration // interval between checks
OnProgress func(string) // optional status line, called on each state change
}
CIWaitOptions bounds the wait for a CI build.
type ClusterTools ¶ added in v1.7.0
ClusterTools are the helm and kubectl entrypoints to use on a given host.
They are full command prefixes rather than binary names because microk8s bundles its own: `microk8s helm3` and `microk8s kubectl`, which read the cluster's config without a kubeconfig existing anywhere. A host with a conventional install gets plain `helm` / `kubectl` instead.
func DetectClusterTools ¶ added in v1.7.0
func DetectClusterTools(ctx context.Context, runner RemoteRunner, helmOverride, kubectlOverride string) (ClusterTools, error)
DetectClusterTools resolves the helm and kubectl entrypoints on the host.
helmOverride/kubectlOverride short-circuit the probe — for a host that runs microk8s but where the deploy targets some other cluster, or any layout the candidate list does not cover. Supplying one but not the other overrides just that half, which is deliberate: the pairing rule is a default, not a constraint on someone who knows their own box.
Candidates are probed by RUNNING them, not by looking for a binary: `microk8s helm3` is a subcommand, so `command -v` says nothing about whether it works — and an install the ssh user cannot reach (not in the microk8s group) fails here, with an actionable message, rather than several steps later behind a confusing permission error.
func (ClusterTools) IngressHosts ¶ added in v1.8.0
func (t ClusterTools) IngressHosts(ctx context.Context, runner RemoteRunner, release, namespace string) []string
IngressHosts returns every hostname the release's Ingresses currently serve, across the parent chart and any subchart (they share the release's `instance` label).
It answers one question: what would `helm upgrade` be taking away? An Ingress this release owns is deleted the moment the values stop asking for it, and the values are rewritten from scratch on every deploy — so a host nobody restated is a host that goes offline. Without reading the cluster there is no way to know a release HAS one, since the record only says what past runs were told.
Best-effort, like ServiceEndpoint: an empty result means "found none, or could not ask". The caller uses it to REFUSE, never to remove, so the failure mode of a cluster that cannot answer is the behaviour that already exists today.
func (ClusterTools) IsMicroK8s ¶ added in v1.7.0
func (t ClusterTools) IsMicroK8s() bool
IsMicroK8s reports whether the resolved tooling is microk8s. Callers use it for guidance that only makes sense there, such as naming the addons a feature needs (`microk8s enable ingress`).
func (ClusterTools) ReachCluster ¶ added in v1.7.0
func (t ClusterTools) ReachCluster(ctx context.Context, runner RemoteRunner) error
ReachCluster verifies the resolved tooling can actually talk to a cluster.
Separate from DetectClusterTools because "helm is installed" and "helm can reach a cluster" fail for different reasons and want different messages. This runs before anything is copied or built, so an unreachable cluster costs nothing.
func (ClusterTools) ReleaseExists ¶ added in v1.7.0
func (t ClusterTools) ReleaseExists(ctx context.Context, runner RemoteRunner, release, namespace string) bool
ReleaseExists reports whether the release is present in the namespace.
func (ClusterTools) ServiceEndpoint ¶ added in v1.7.0
func (t ClusterTools) ServiceEndpoint(ctx context.Context, runner RemoteRunner, release, namespace string) string
ServiceEndpoint returns a URL for the deployed app, preferring an Ingress host and falling back to the node IP plus a NodePort.
Best-effort by design: this is reported to the user at the end of a deploy, and a cluster whose address cannot be determined is not a failed deploy. It returns "" rather than an error when there is simply nothing to report.
func (ClusterTools) UninstallRelease ¶ added in v1.7.0
func (t ClusterTools) UninstallRelease(ctx context.Context, runner RemoteRunner, release, namespace string) error
UninstallRelease removes the release, leaving the namespace in place.
A missing release is not an error: destroy has to be re-runnable, and a record whose release was already removed by hand is a state destroy should finish cleaning up rather than refuse.
func (ClusterTools) UpdateDependencies ¶ added in v1.7.0
func (t ClusterTools) UpdateDependencies(ctx context.Context, runner RemoteRunner, chartDir string) error
UpdateDependencies vendors any subcharts the chart declares.
Required before install or template on a chart with a dependencies block: helm refuses outright with "found in Chart.yaml, but missing in charts/ directory". sfapi declares sfauthserver this way, so without this the release fails after the chart has already been copied to the host.
A no-op for a chart with no dependencies, so it is unconditional rather than gated on parsing Chart.yaml here.
func (ClusterTools) UpgradeRelease ¶ added in v1.7.0
func (t ClusterTools) UpgradeRelease(ctx context.Context, runner RemoteRunner, opts ReleaseOptions) error
UpgradeRelease installs or upgrades the release on the host.
`upgrade --install` rather than `install`, so the first deploy and every re-deploy are the same command — the property the whole pipeline leans on for re-runnability.
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
// Namespace, ReleaseName, ChartVersion and ImageRef describe a Kubernetes
// deployment (Provider == ProviderK8s); empty for every other provider.
// Destroy needs the first two to uninstall the release, and --release-only
// reads the last two back so it can re-release without regenerating or
// waiting on CI.
Namespace string `json:"namespace,omitempty"`
ReleaseName string `json:"release_name,omitempty"`
ChartVersion string `json:"chart_version,omitempty"`
ImageRef string `json:"image_ref,omitempty"`
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)
// AuthDomain and GRPCDomain are the OTHER hostnames this deployment was given
// — --auth-domain (the JWT auth server) and --grpc-domain (the gRPC front
// door) — recorded for the same reason Domain is, and read back by the same
// code: applyDeploymentSelector adopts all three when the next run does not
// state them.
//
// Recording them is not bookkeeping. On the k8s path a host that this run
// cannot see is a host the values file does not enable, and the chart's own
// default is `ingress.enabled: false` — so a re-deploy that simply forgot the
// flag made `helm upgrade` DELETE a live Ingress and took the site offline.
// Domain was half-protected by being in the record; these two were not stored
// anywhere, so they were lost on EVERY re-deploy. On the VM path they name the
// extra Caddy sites (see BootstrapParams), which are dropped the same way.
AuthDomain string `json:"auth_domain,omitempty"`
GRPCDomain string `json:"grpc_domain,omitempty"`
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 GitRepo ¶ added in v1.7.0
type GitRepo struct {
Root string // absolute path to the repository root
Branch string // currently checked-out branch
// RelPath is the workspace's path relative to Root, or "." when they are the
// same directory. It is what `git add` is scoped to.
RelPath string
}
GitRepo is the repository a workspace lives in.
Root and the workspace are often the same directory (the generated app IS the repo, as in a fresh `nuzur-<identifier>`), but need not be: the workspace can sit inside a larger repo. Both layouts are supported, and the difference matters — a commit must be scoped to the workspace path so a deploy never sweeps up unrelated work in progress elsewhere in the tree.
func DiscoverGitRepo ¶ added in v1.7.0
DiscoverGitRepo resolves the repository containing dir.
func (GitRepo) CommitAndPush ¶ added in v1.7.0
CommitAndPush stages the workspace path, commits it and pushes the branch.
Staging is scoped to RelPath: in a repo where the generated app is one directory among many, `git add -A` would sweep unrelated work into a deploy commit. Returns the SHA that is now on the remote.
func (GitRepo) HasChanges ¶ added in v1.7.0
HasChanges reports whether the workspace path has anything to commit, including untracked files.
func (GitRepo) HeadSHA ¶ added in v1.7.0
HeadSHA returns the full commit SHA at HEAD. Full rather than abbreviated because it is matched against GitHub Actions runs, and the image tag the generated workflow publishes is type=sha,format=long.
func (GitRepo) PushedSHAExistsOnRemote ¶ added in v1.7.0
PushedSHAExistsOnRemote reports whether HEAD is present on the tracking remote.
Checked before waiting on CI: with --no-commit the local HEAD may never have been pushed, and waiting for a workflow run on a commit GitHub has never seen would otherwise block until the timeout with nothing to show for it.
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 K8sProvisioner ¶ added in v1.7.0
type K8sProvisioner struct{}
K8sProvisioner implements Provisioner for an existing Kubernetes cluster reached over SSH. Like SSHProvisioner it does no provider-API work: the cluster already exists and the user owns it.
The Provisioner seam is a poor fit here — it models "get me an SSH-reachable box", and for this provider that box is only a place to run helm from. The actual work (chart, image, release) lives in the deploy pipeline's k8s steps. Implementing it anyway keeps one registry of providers and one code path for resolving a target.
func NewK8sProvisioner ¶ added in v1.7.0
func NewK8sProvisioner() *K8sProvisioner
func (*K8sProvisioner) ConfigureFirewall ¶ added in v1.7.0
func (p *K8sProvisioner) ConfigureFirewall(ctx context.Context, prov Provisioned, rules []FirewallRule) error
ConfigureFirewall is a no-op. Cluster networking is the cluster's business, and the ufw rules the VM path applies would break a Kubernetes node.
func (*K8sProvisioner) Destroy ¶ added in v1.7.0
func (p *K8sProvisioner) Destroy(ctx context.Context, prov Provisioned) error
Destroy is a no-op: the user owns the cluster. Removing the release is the destroy command's job (helm uninstall), not the provisioner's.
func (*K8sProvisioner) FindInstanceByName ¶ added in v1.7.0
func (p *K8sProvisioner) FindInstanceByName(ctx context.Context, name, region string) (string, error)
FindInstanceByName finds nothing: nuzur created no instance.
func (*K8sProvisioner) Provision ¶ added in v1.7.0
func (p *K8sProvisioner) Provision(ctx context.Context, spec Spec) (Provisioned, error)
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" // ProviderK8s deploys into an existing Kubernetes cluster as a Helm release, // rather than installing a runtime on the box. It still reaches the cluster // over SSH — helm and kubectl run ON the host (microk8s ships its own), so // nothing needs a kubeconfig locally and the API server is never exposed. // // It shares almost nothing with the VM path beyond that transport: no Docker, // no systemd unit, no Caddy, no on-box database, no agent, and emphatically // no ufw (see the bootstrap's firewall section — enabling a default-deny // policy on a cluster node cuts the API server, kubelet and every NodePort). ProviderK8s Provider = "k8s" // 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" )
func (Provider) CreatesInfrastructure ¶ added in v1.7.0
CreatesInfrastructure reports whether this provider creates provider-side infrastructure — a VM that nuzur must name and record before creating it, firewall afterwards, find again if a run is interrupted, and delete on destroy.
It is false for the providers where the user supplies the machine: BYO-SSH and k8s (and the empty value, which defaults to BYO-SSH). Those share a long list of consequences — no pending record, no provider firewall, no "Creating the server…", no VM to delete, no managed-box reuse — and every one of them used to be spelled out as `!= ProviderSSH` at the point of use. Adding a second such provider meant finding all nine and getting each one right; a single predicate means a third one only has to be named here.
func (Provider) UsesGivenHost ¶ added in v1.7.0
UsesGivenHost reports whether the target machine comes from --host rather than from provisioning. The inverse of CreatesInfrastructure, named for the question the targeting code actually asks.
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 ReleaseOptions ¶ added in v1.7.0
type ReleaseOptions struct {
Release string
Namespace string
ChartDir string // path ON THE HOST
// ValuesFiles are applied in order, so later files win. The generated one
// comes first and any user --chart-values file last, letting an operator
// override anything deploy decided.
ValuesFiles []string
// Wait blocks until the release's pods are Ready. Without it helm returns as
// soon as the objects are accepted, and a deploy would report success while
// the pods are still in ImagePullBackOff.
Wait bool
TimeoutSecs int
}
ReleaseOptions is one `helm upgrade --install`.
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)