Documentation
¶
Overview ¶
Package engine holds hlab's presentation-free orchestration: it persists VM declarations and drives Terraform (lifecycle) and Ansible (provisioning) over the homelab-state store. Both the CLI (cmd) and the dashboard TUI (internal/ tui) call into it, so the create/provision/destroy logic — including rollback of a partial VM — lives in exactly one place.
The engine never prints or spins; callers wrap its (blocking) operations with whatever progress UI they use (a spinner in the CLI, an async log panel in the TUI). Output verbosity is controlled through the injected Terraform runner's Verbose field and the engine's AnsibleVerbose field.
Index ¶
- func DeclaredIP(vm *state.VMSpec) string
- func FirstIPv4(addrs []string) string
- type AdoptOptions
- type DriftStatus
- type Engine
- func (e *Engine) AddSSHKey(vm *state.VMSpec, pubKey string) error
- func (e *Engine) Adopt(vm *state.VMSpec) (drift string, err error)
- func (e *Engine) BuildAdoptSpec(g proxmox.Guest, opts AdoptOptions) (*state.VMSpec, []string, error)
- func (e *Engine) Create(res *wizard.Result) (string, error)
- func (e *Engine) DeleteSnapshot(name, snapName string) error
- func (e *Engine) Destroy(name string) error
- func (e *Engine) DetectDrift(targets ...*state.VMSpec) ([]DriftStatus, error)
- func (e *Engine) DryRun(res *wizard.Result) error
- func (e *Engine) EnsureStaticApplied(vm *state.VMSpec)
- func (e *Engine) FindAdoptable(arg, wantKind string) (*proxmox.Guest, error)
- func (e *Engine) Guests() ([]proxmox.Guest, error)
- func (e *Engine) InjectSSHKeyViaAgent(vm *state.VMSpec, pubKey string) error
- func (e *Engine) InjectSSHKeyViaConsole(vm *state.VMSpec, pubKey string) error
- func (e *Engine) InjectSSHKeyViaConsoleWithPassword(vm *state.VMSpec, pubKey, password string) error
- func (e *Engine) Migrate(name, toNode string) error
- func (e *Engine) Provision(vm *state.VMSpec) error
- func (e *Engine) Reboot(name string) error
- func (e *Engine) RebootGuest(node, kind string, vmid int) error
- func (e *Engine) Reconfigure(vm *state.VMSpec) error
- func (e *Engine) ResolveIP(vm *state.VMSpec) string
- func (e *Engine) RollbackSnapshot(name, snapName string) error
- func (e *Engine) Snapshot(name, snapName, description string, withRAM bool) error
- func (e *Engine) Snapshots(name string) ([]proxmox.Snapshot, error)
- func (e *Engine) Start(name string) error
- func (e *Engine) StartGuest(node, kind string, vmid int) error
- func (e *Engine) Stop(name string, force bool) error
- func (e *Engine) StopGuest(node, kind string, vmid int, force bool) error
- func (e *Engine) StoredCTPassword(name string) (string, error)
- func (e *Engine) Update(vm *state.VMSpec, upgrade bool) error
- type Proxmox
- type Runner
Constants ¶
This section is empty.
Variables ¶
This section is empty.
Functions ¶
func DeclaredIP ¶
DeclaredIP returns the static IP (without prefix) from the declaration, or "" for DHCP VMs. For static VMs this is authoritative and avoids reporting the transient DHCP lease a guest may hold briefly during first boot.
Types ¶
type AdoptOptions ¶
AdoptOptions carries the operator-editable knobs for BuildAdoptSpec: an override for the (kebabified) declaration name, and — VMs only — the SSH connection username to use when the live guest has no ciuser set (containers always log in as root, regardless of this field).
type DriftStatus ¶
DriftStatus is a managed guest's drift classification, as returned by DetectDrift. State is one of "in-sync" (no meaningful drift), "in-place" (an apply would change it in place), "replace" (an apply would destroy and recreate it), "missing" (it isn't in Terraform state at all, so an apply would create it) or "orphaned" (it's in Terraform state but has NO declaration — e.g. an adopt whose rollback couldn't `state rm` — so an untargeted apply would plan to destroy it; the live guest still exists).
type Engine ¶
type Engine struct {
Cfg *config.Config
Store *state.Store
Runner Runner // Terraform (see deps.go); caller sets Out/Ctx before long ops
PM Proxmox // Proxmox discovery + power/snapshots (see deps.go)
// AnsibleVerbose is applied to the Ansible runner created per Provision call.
AnsibleVerbose int
// AnsibleOut, when set, streams Ansible output there (the TUI log panel).
AnsibleOut io.Writer
// Ctx, when set, binds long operations to a context so they can be cancelled.
Ctx context.Context
}
Engine bundles the dependencies the orchestration needs.
func New ¶
New builds an Engine from its dependencies. runner/pm are interfaces (see deps.go) so tests can inject fakes; production passes the concrete *terraform.Runner and *proxmox.Client.
func (*Engine) AddSSHKey ¶
AddSSHKey records an SSH public key on a managed guest's declaration and reconciles Terraform so no drift lingers. The key must already have been installed on the live guest's authorized_keys by the caller (cmd, over SSH) — this is the persistence + IaC-reconciliation half of `hlab {vm,ct} add-ssh-key`, kept here so the CLI stays thin. Idempotent: a key already in the declaration is a no-op (no duplicate, no apply, no commit). Blocking for VMs (runs a targeted plan + apply).
Reconciliation is deliberately type-specific, to keep `hlab plan` clean and never risk a replace:
- VM: main.tf renders initialization.user_account.keys from the declaration and does NOT lifecycle-ignore it, so a raw plan would report the new key as in-place drift indefinitely. A targeted Apply pushes the key into Terraform state (bpg updates cloud-init in place — never a replace), leaving state == declaration. It is guarded by a PlanDetailed replace-veto so an unexpected replace can never be auto-applied.
- LXC: container.tf's lifecycle ignore_changes covers initialization[0].user_account, so Terraform never plans container SSH keys. Sync keeps tfvars consistent with the declaration and there is nothing to apply — `hlab plan` stays clean on its own.
func (*Engine) Adopt ¶
Adopt brings a discovered (unmanaged) guest under hlab's control: it saves the synthesized declaration, imports the live guest into Terraform state at the address hlab would normally create it under, patches back the config-only attributes the import leaves null (terraform.AdoptedStateAttrs), and verifies with a targeted plan that nothing would force a replace before declaring success. The live guest is never modified by any of this — on any failure the adoption is rolled back (state rm best-effort + declaration deleted + workspace resynced) and the guest is left exactly as it was found. Blocking, long-running, like Create/Migrate/Reconfigure.
Returns a non-empty drift summary when the guest matches the declaration except for in-place changes (e.g. agent/cloud-init/machine-type attributes the next apply will set) — callers should surface it as a warning, since it means the next real apply has work to do.
func (*Engine) BuildAdoptSpec ¶
func (e *Engine) BuildAdoptSpec(g proxmox.Guest, opts AdoptOptions) (*state.VMSpec, []string, error)
BuildAdoptSpec synthesizes a VMSpec (ready for Adopt) from a live guest's Proxmox config, plus a list of human-readable warnings about what the first managed apply will change. It rejects — without writing or importing anything — configurations hlab's Terraform can't safely represent: a live guest whose shape doesn't match what main.tf/container.tf render would either lose resources silently or force a replace once adopted.
func (*Engine) Create ¶
Create persists the declaration, rebuilds the Terraform workspace and creates the VM, rolling back a partially-created VM on failure. It returns the resolved IP. This is a blocking, long-running operation.
func (*Engine) DeleteSnapshot ¶
DeleteSnapshot removes a snapshot of a managed guest and waits for it to complete.
func (*Engine) Destroy ¶
Destroy destroys the guest (VM or container) in Proxmox and removes its declaration, re-syncing the workspace without it. Blocking, long-running.
func (*Engine) DetectDrift ¶
func (e *Engine) DetectDrift(targets ...*state.VMSpec) ([]DriftStatus, error)
DetectDrift runs a read-only `terraform plan` and classifies managed-guest drift, filtering out the provider/hlab bookkeeping noise a raw plan reports on every guest today (see terraform.driftIgnore/driftPaths). With no targets it covers the whole fleet; with one or more targets it scopes the plan (and the classified statuses) to just those guests — a faster `hlab plan <name>`. It mirrors the Reconfigure/Migrate preamble — Sync + Init — but never applies: Sync only rewrites tfvars from the current declarations, it doesn't touch state, and DriftReport itself only plans. The single code path used by both the CLI (`hlab plan`) and the TUI (`P`).
func (*Engine) DryRun ¶
DryRun shows the Terraform plan for a would-be VM without applying it or persisting the declaration. The plan output streams (Runner.Plan always streams).
func (*Engine) EnsureStaticApplied ¶
EnsureStaticApplied makes sure a static-IP VM actually came up on its address. Ubuntu cloud images rename the NIC to eth0 only after a reboot, so the first boot can linger on a DHCP lease. If the static IP isn't present shortly, reboot once and wait for it.
func (*Engine) FindAdoptable ¶
FindAdoptable resolves arg — a numeric vmid or an exact guest name — to a live, unmanaged guest of the requested kind ("qemu" or "lxc"; "" accepts either). Read-only: it never mutates anything. Rejects templates, guests hlab already manages, and (when wantKind is set) a kind mismatch, each with an actionable error message.
func (*Engine) Guests ¶
Guests lists every VM and LXC container in the cluster, used to refresh managed statuses and to surface guests not managed by hlab. Read-only discovery.
func (*Engine) InjectSSHKeyViaAgent ¶
InjectSSHKeyViaAgent seeds an SSH public key into a keyless VM through the QEMU guest agent and then persists it via AddSSHKey. This is the one way in for a VM created with no SSH key: Ubuntu cloud images ship `PasswordAuthentication no`, so sshd refuses the cloud-init password, and hlab connects with key auth only — it can't reach the VM over SSH to install the first key. The guest agent runs as root inside the VM and needs no login, so hlab execs a small script there that appends the key to the connection user's ~/.ssh/authorized_keys (the key is fed on stdin via input-data, so it never has to be shell-quoted into the command), after which ordinary SSH — and AddSSHKey's Terraform reconciliation — works.
It requires qemu-guest-agent running in the VM (hlab's golden images set agent=1) and the VM.GuestAgent.Unrestricted privilege on the API token; a 403 is surfaced naming that exact privilege. This is the VM analogue of InjectSSHKeyViaConsole — an LXC container has no guest agent, so it uses the console path instead and is rejected here.
func (*Engine) InjectSSHKeyViaConsole ¶
InjectSSHKeyViaConsole seeds an SSH public key into a keyless LXC container over the Proxmox console (termproxy) and then persists it via AddSSHKey. This is the one way in for a container created with no SSH key: sshd refuses root password auth (PermitRootLogin prohibit-password), so hlab cannot reach it over SSH to install the first key — but the root password DOES work on the console. hlab logs in there, appends the key to /root/.ssh/authorized_keys and verifies it, after which ordinary SSH (and AddSSHKey's reconciliation) works.
It requires the container's root password and the VM.Console privilege on the API token. The password normally comes from the gitignored secrets file (stored when the container was created), but that file is machine-local and never versioned, so it is absent for a container created on another machine (or by an older hlab): callers can supply it out of band via InjectSSHKeyViaConsoleWithPassword. When no password is available at all, KeylessAddKeyError explains the fix. Only valid for LXC; VM (serial/VNC) consoles use a different protocol and are out of scope.
func (*Engine) InjectSSHKeyViaConsoleWithPassword ¶
func (e *Engine) InjectSSHKeyViaConsoleWithPassword(vm *state.VMSpec, pubKey, password string) error
InjectSSHKeyViaConsoleWithPassword is InjectSSHKeyViaConsole with the console login password supplied by the caller (rather than looked up in the secrets file), so an operator can recover a container whose root password never made it into this machine's local secrets file by entering it interactively. An empty password (none stored and none entered) still fails with KeylessAddKeyError.
func (*Engine) Migrate ¶
Migrate moves a managed guest to another cluster node. The node is part of the declaration, so unlike power actions this is a lifecycle change. For a VM the move goes through Terraform (the vm resource sets migrate=true, so the bpg provider migrates it — online if running — instead of recreating it). For an LXC container, which the bpg resource can't migrate in place, hlab performs the move via the Proxmox API and then re-anchors the Terraform resource to the new node. Blocking, long-running. On failure the declaration is restored so it keeps matching reality.
func (*Engine) Provision ¶
Provision installs the VM's currently-selected software and dotfiles via Ansible (the selection must already be set on vm). It persists the selection and commits the declaration. Blocking, long-running.
func (*Engine) Reboot ¶
Reboot requests a graceful guest reboot. Like Start/Stop it is a runtime action and persists nothing.
func (*Engine) Reconfigure ¶
Reconfigure applies an edited hardware spec (cores / memory / disk) to an existing managed guest: it persists the updated declaration, re-syncs the workspace and applies. The bpg provider updates the guest in place (cores/memory change the config; the disk can only grow — a smaller size is rejected up front). Works for both VMs and LXC containers. Blocking, long-running. On failure the previous declaration is restored so state/tfvars/reality stay in agreement.
func (*Engine) ResolveIP ¶
ResolveIP returns the best-known IP for a guest: the declared static IP, else a discovered address. VMs use the QEMU guest-agent (via terraform output, refreshing once if needed). LXC containers have no agent, but the host can read the container's namespace directly, so a DHCP container's address is still discoverable via ContainerIPv4s.
func (*Engine) RollbackSnapshot ¶
RollbackSnapshot rolls a managed guest back to a snapshot and waits for it to complete. Changes made since the snapshot are discarded. If the guest was running, it is started again afterwards — a snapshot without live memory state (all LXC snapshots) otherwise leaves it stopped.
func (*Engine) Snapshot ¶
Snapshot creates a snapshot of a managed guest and waits for it to complete. withRAM captures the live memory (only valid for a running VM; ignored for containers). Snapshots are runtime state, not part of the declaration, so nothing is persisted.
func (*Engine) Start ¶
Start powers on the guest. Power state is runtime, not part of the declaration, so nothing is persisted or committed. Routes to the qemu/lxc path by type.
func (*Engine) StartGuest ¶
StartGuest / StopGuest / RebootGuest are the power actions for discovered (unmanaged) guests, addressed by node + kind ("qemu"/"lxc") + vmid since they have no declaration in the store. Runtime only; nothing is persisted.
func (*Engine) Stop ¶
Stop powers off the guest: a graceful shutdown by default, or a hard stop (cut power) when force is set. Like Start, it changes only runtime state.
func (*Engine) StoredCTPassword ¶
StoredCTPassword returns the container's root password from the gitignored secrets file, or "" when none is stored. Callers use it to decide whether they must obtain the password another way (e.g. prompt the operator) before a console key injection.
func (*Engine) Update ¶
Update re-runs Ansible against an already-provisioned guest using its saved software/dotfiles selection — no prompting, and unlike Provision it is not gated on the selection being non-empty (a "just the OS" guest is still reconcilable/updatable). When upgrade is true the playbook additionally applies safe package/runtime upgrades (apt upgrade, mise self-update + runtime upgrades) and re-runs the self-updating CLI installers. Blocking, long-running.
type Proxmox ¶
type Proxmox interface {
Version() (string, error)
Nodes() ([]proxmox.Node, error)
ClusterGuests() ([]proxmox.Guest, error)
AgentIPv4s(node string, vmid int) ([]string, error)
ContainerIPv4s(node string, vmid int) ([]string, error)
VMConfig(node string, vmid int) (*proxmox.GuestConfig, error)
ContainerConfig(node string, vmid int) (*proxmox.GuestConfig, error)
GuestStatus(node, kind string, vmid int) (string, error)
Snapshots(node, kind string, vmid int) ([]proxmox.Snapshot, error)
CreateSnapshot(node, kind string, vmid int, name, description string, withRAM bool) (string, error)
RollbackSnapshot(node, kind string, vmid int, name string, start bool) (string, error)
DeleteSnapshot(node, kind string, vmid int, name string) (string, error)
MigrateContainer(node string, vmid int, target string, restart bool) (string, error)
ConsoleExec(node string, vmid int, script []string, login proxmox.ConsoleLogin) error
AgentPing(node string, vmid int) error
AgentExec(node string, vmid int, argv []string, inputData []byte) (proxmox.AgentExecResult, error)
WaitTask(node, upid string, timeout time.Duration) error
RebootVM(node string, vmid int) error
StartGuest(node, kind string, vmid int) error
ShutdownGuest(node, kind string, vmid int) error
StopGuest(node, kind string, vmid int) error
RebootGuest(node, kind string, vmid int) error
}
Proxmox is the read-only discovery plus power/snapshot behavior the engine depends on. *proxmox.Client is the production implementation; tests inject a fake. Discovery stays read-only; every mutation the engine performs still goes through the Runner (Terraform) or these explicit power/snapshot calls.
type Runner ¶
type Runner interface {
Sync(vms []*state.VMSpec, passwords map[string]string) error
Init() error
Apply(target *state.VMSpec) error
Destroy(target *state.VMSpec) error
Plan() error
Refresh() error
IPAddresses() map[string][]string
ExistingPasswords() (map[string]string, error)
Import(target *state.VMSpec) error
PatchResourceAttrs(target *state.VMSpec, attrs map[string]any) error
SetResourceNode(target *state.VMSpec, node string) error
StateRm(target *state.VMSpec) error
PlanDetailed(target *state.VMSpec) (changes, replace bool, summary string, err error)
DriftReport(targets ...*state.VMSpec) ([]terraform.DriftChange, error)
SetOut(io.Writer)
SetCtx(context.Context)
// Detach unbinds any cancellation context so cleanup/rollback still runs after
// the original operation was cancelled.
Detach()
}
Runner is the Terraform-facing behavior the engine orchestrates. The interface is owned by the consumer (engine), not the implementer: *terraform.Runner is the production implementation, and tests inject a fake to exercise the rollback branches without shelling out to real terraform. SetOut/SetCtx let a caller stream output and bind cancellation without reaching into the concrete Runner's fields.