From 4217a6bdc31f8dd3a78e2f6a01f349d742d9d6e8 Mon Sep 17 00:00:00 2001 From: Aric Camarata Date: Fri, 11 Sep 2026 17:45:16 -0400 Subject: [PATCH 1/2] feat(cli): add nself server for Hetzner server lifecycle (G-011) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Provisioning, resizing, and destroying a server had no CLI surface: nself access only manages SSH keys on an already-deployed host, and nself security only audits one. An operator building or tearing down a CI box tonight had to fall back to raw hcloud server create / hcloud server delete, with none of the safety a manual snapshot-and-verify procedure provides. Add nself server provision/list/resize/destroy, backed by a new internal/server package (Client interface over the Hetzner Cloud API, mockable in tests): - destroy refuses to run without --snapshot (taken and verified status=available before anything is deleted) or --force-no-backup. - destroy sets auto_delete=false on the server's primary IP(s) before deleting it, unless --release-ip is passed, and reports which IPs were retained/released. Hetzner primary IPs default to auto_delete=true, so deleting a server otherwise permanently destroys its IP too. - resize detects a disk-shrinking type change before calling the provider and explains the snapshot -> new server -> restore path, instead of surfacing Hetzner's raw invalid_input error. - the API token is resolved from --token, --token-env (defaults to the existing HETZNER_NSELF_TOKEN vault var), or HCLOUD_TOKEN — never hardcoded. +1 on the CLI-R11 command-surface budget (50 -> 51), same deliberate exception pattern used for `access` (#238): a new top-level command closing a real capability gap named in G-011, not surface creep. Regenerated the command inventory, wiki command index, and the surface-parity matrix; added .github/wiki/cmd-server.md. --- .github/command-inventory.json | 68 +++++++ .github/command-surface-budget.txt | 11 +- .github/surface-parity.json | 9 + .github/surface-parity.md | 3 +- .github/wiki/Commands.md | 3 +- .github/wiki/cmd-server.md | 149 ++++++++++++++ cmd/commands/error_harness_test.go | 5 + cmd/commands/groups.go | 1 + cmd/commands/server.go | 58 ++++++ cmd/commands/server_client.go | 39 ++++ cmd/commands/server_destroy.go | 108 ++++++++++ cmd/commands/server_list.go | 60 ++++++ cmd/commands/server_provision.go | 101 ++++++++++ cmd/commands/server_resize.go | 75 +++++++ cmd/commands/server_test.go | 293 ++++++++++++++++++++++++++++ internal/server/client.go | 115 +++++++++++ internal/server/client_ops.go | 177 +++++++++++++++++ internal/server/client_test.go | 115 +++++++++++ internal/server/destroy.go | 68 +++++++ internal/server/destroy_test.go | 119 +++++++++++ internal/server/fake_client_test.go | 145 ++++++++++++++ internal/server/list.go | 16 ++ internal/server/list_test.go | 31 +++ internal/server/primaryip.go | 45 +++++ internal/server/primaryip_test.go | 55 ++++++ internal/server/provision.go | 52 +++++ internal/server/provision_test.go | 62 ++++++ internal/server/resize.go | 72 +++++++ internal/server/resize_test.go | 67 +++++++ internal/server/snapshot.go | 86 ++++++++ internal/server/snapshot_test.go | 78 ++++++++ internal/server/token.go | 50 +++++ internal/server/token_test.go | 67 +++++++ internal/server/types.go | 113 +++++++++++ internal/server/wire.go | 106 ++++++++++ 35 files changed, 2619 insertions(+), 3 deletions(-) create mode 100644 .github/wiki/cmd-server.md create mode 100644 cmd/commands/server.go create mode 100644 cmd/commands/server_client.go create mode 100644 cmd/commands/server_destroy.go create mode 100644 cmd/commands/server_list.go create mode 100644 cmd/commands/server_provision.go create mode 100644 cmd/commands/server_resize.go create mode 100644 cmd/commands/server_test.go create mode 100644 internal/server/client.go create mode 100644 internal/server/client_ops.go create mode 100644 internal/server/client_test.go create mode 100644 internal/server/destroy.go create mode 100644 internal/server/destroy_test.go create mode 100644 internal/server/fake_client_test.go create mode 100644 internal/server/list.go create mode 100644 internal/server/list_test.go create mode 100644 internal/server/primaryip.go create mode 100644 internal/server/primaryip_test.go create mode 100644 internal/server/provision.go create mode 100644 internal/server/provision_test.go create mode 100644 internal/server/resize.go create mode 100644 internal/server/resize_test.go create mode 100644 internal/server/snapshot.go create mode 100644 internal/server/snapshot_test.go create mode 100644 internal/server/token.go create mode 100644 internal/server/token_test.go create mode 100644 internal/server/types.go create mode 100644 internal/server/wire.go diff --git a/.github/command-inventory.json b/.github/command-inventory.json index 949b2139..24b7a53d 100644 --- a/.github/command-inventory.json +++ b/.github/command-inventory.json @@ -2057,6 +2057,74 @@ "--to-file" ] }, + { + "name": "server", + "path": "nself server", + "short": "Provision, list, resize, and destroy Hetzner Cloud servers", + "hidden": false, + "group_id": "advanced", + "subcommands": [ + { + "name": "destroy", + "path": "nself server destroy", + "short": "Delete a Hetzner Cloud server", + "hidden": false, + "flags": [ + "--force-no-backup", + "--id", + "--json", + "--release-ip", + "--snapshot", + "--snapshot-timeout", + "--token", + "--token-env" + ] + }, + { + "name": "list", + "path": "nself server list", + "short": "List Hetzner Cloud servers", + "hidden": false, + "flags": [ + "--json", + "--label-selector", + "--token", + "--token-env" + ] + }, + { + "name": "provision", + "path": "nself server provision", + "short": "Create a new Hetzner Cloud server", + "hidden": false, + "flags": [ + "--image", + "--json", + "--label", + "--location", + "--name", + "--ssh-key", + "--token", + "--token-env", + "--type" + ] + }, + { + "name": "resize", + "path": "nself server resize", + "short": "Change a server's type (CPU/RAM/disk)", + "hidden": false, + "flags": [ + "--id", + "--json", + "--token", + "--token-env", + "--type", + "--upgrade-disk" + ] + } + ] + }, { "name": "service", "path": "nself service", diff --git a/.github/command-surface-budget.txt b/.github/command-surface-budget.txt index 182ed055..791e5fc2 100644 --- a/.github/command-surface-budget.txt +++ b/.github/command-surface-budget.txt @@ -10,4 +10,13 @@ # teammate's SSH key on an already-deployed host had no CLI path at all. # `nself access grant/revoke/list` is the command surface named directly # in #238's proposed shape. -50 +# +# 2026-09-11: +1 for `server` (gap G-011). Deliberate exception, not +# creep: `nself access` only manages SSH keys on an already-deployed host +# and `nself security` only audits one — neither can create, list, resize, +# or destroy the server itself, so an operator provisioning or tearing down +# a box had no CLI path and fell back to raw `hcloud server create` / +# `hcloud server delete`, with none of the backup/IP-protection safety this +# command enforces. `nself server provision/list/resize/destroy` is the +# surface G-011 names directly. +51 diff --git a/.github/surface-parity.json b/.github/surface-parity.json index 61c24a57..f90e55d4 100644 --- a/.github/surface-parity.json +++ b/.github/surface-parity.json @@ -352,6 +352,15 @@ "env_vars": "n/a", "openapi": "n/a (see below)" }, + { + "name": "server", + "path": "nself server", + "group_id": "advanced", + "wiki_page": true, + "mcp_tool": false, + "env_vars": "n/a", + "openapi": "n/a (see below)" + }, { "name": "service", "path": "nself service", diff --git a/.github/surface-parity.md b/.github/surface-parity.md index f0b2acc8..aeaeb563 100644 --- a/.github/surface-parity.md +++ b/.github/surface-parity.md @@ -51,6 +51,7 @@ One row per top-level command (CLI-R17), scored against the four surfaces a comm | `nself secrets` | config | yes | no | undocumented: EDITOR | n/a (see below) | | `nself security` | advanced | yes | no | n/a | n/a (see below) | | `nself self-heal` | observe | yes | no | n/a | n/a (see below) | +| `nself server` | advanced | yes | no | n/a | n/a (see below) | | `nself service` | config | yes | yes | n/a | n/a (see below) | | `nself start` | core | yes | yes | undocumented: NSELF_PROFILE, NSELF_SKIP_DB_INIT | n/a (see below) | | `nself status` | core | yes | yes | n/a | n/a (see below) | @@ -63,4 +64,4 @@ One row per top-level command (CLI-R17), scored against the four surfaces a comm | `nself verify-sbom` | advanced | yes | no | n/a | n/a (see below) | | `nself version` | account | yes | no | undocumented: BENCH_RESULTS_FILE | n/a (see below) | -Total: 50 commands. Missing wiki page: 0. No MCP tool: 33. Env vars found but undocumented: 16. +Total: 51 commands. Missing wiki page: 0. No MCP tool: 34. Env vars found but undocumented: 16. diff --git a/.github/wiki/Commands.md b/.github/wiki/Commands.md index df4e6d48..08d83dc5 100644 --- a/.github/wiki/Commands.md +++ b/.github/wiki/Commands.md @@ -94,7 +94,7 @@ tree in `cmd/commands/`. Run `make cmd-inventory` to refresh. ## Complete index Generated from the cobra registration tree in `cmd/commands/`. -Run `make cmd-inventory` to refresh. **Total top-level commands: 50** +Run `make cmd-inventory` to refresh. **Total top-level commands: 51** | Command | Short Description | Group | Subcommands | |---|---|---|---| @@ -137,6 +137,7 @@ Run `make cmd-inventory` to refresh. **Total top-level commands: 50** | `nself secrets` | Manage encrypted project secrets (age encryption) | config | audit, decrypt-on-deploy, edit, get, init, lint, list, list-schedules, rekey, retire, rotate, rotation-log, schedule, set, verify | | `nself security` | Server security: audit, setup, and status | advanced | audit, setup, status | | `nself self-heal` | Run targeted self-healing routines for nSelf components | observe | — | +| `nself server` | Provision, list, resize, and destroy Hetzner Cloud servers | advanced | destroy, list, provision, resize | | `nself service` | Manage optional services | config | add, configure, disable, enable, list, ps, restart, scale, start, stop, update, upgrade | | `nself start` | Boot your nSelf stack | core | — | | `nself status` | Show health status of all services | core | — | diff --git a/.github/wiki/cmd-server.md b/.github/wiki/cmd-server.md new file mode 100644 index 00000000..b6c85352 --- /dev/null +++ b/.github/wiki/cmd-server.md @@ -0,0 +1,149 @@ +# nself server + + +> Provision, list, resize, and destroy Hetzner Cloud servers. + + +## Synopsis + +``` +nself server [flags] +``` + +## Description + + +Manage the lifecycle of a Hetzner Cloud server: create one, list what exists, +resize one, or destroy one, all without a raw `hcloud` invocation. + +`nself access` manages SSH keys on an already-deployed server, and `nself +security` audits one — neither can create, resize, or destroy the server +itself. `nself server` fills that gap, and encodes the safety checks a manual +`hcloud server create` / `hcloud server delete` does not: `destroy` refuses +to run without a verified backup, protects the server's primary IP(s) from +being deleted along with it, and `resize` explains (rather than raw-errors +on) Hetzner's disk-shrink limitation. + +### nself server provision +Create a new server. Every server this command creates is labeled +`managed-by=nself-cli` (unless you pass your own `--label managed-by=...`, +which is respected as-is), so `nself server list` and any future cleanup pass +can tell nself-created servers apart from anything else in the same Hetzner +project. + +```bash +nself server provision --name ci-runner-3 --type cx22 --location fsn1 --image ubuntu-24.04 +``` + +Flags: `--name` (required), `--type` (required, e.g. `cx22`), `--location` +(required, e.g. `fsn1`), `--image` (required, e.g. `ubuntu-24.04`), +`--ssh-key` (repeatable, Hetzner SSH key name to authorize), `--label` +(repeatable `key=value`), `--json`. + +### nself server list +List servers in the Hetzner project, optionally filtered by label. + +```bash +nself server list --label-selector managed-by=nself-cli +``` + +Flags: `--label-selector`, `--json`. + +### nself server resize +Change a server's type (CPU/RAM/disk). Hetzner Cloud has no API to shrink a +server's disk: if `--type` names a type with a smaller disk than the server +currently has, this command refuses and explains the only supported path +(snapshot the current server, provision a new server of the smaller type, +restore from the snapshot, then `nself server destroy` the original) instead +of surfacing Hetzner's raw `invalid_input` error. + +```bash +nself server resize --id 12345 --type cx41 +``` + +Flags: `--id` (required), `--type` (required, target server type), +`--upgrade-disk` (also grow the disk to match the new type, irreversible), +`--json`. + +### nself server destroy +Delete a server. Safe by default: refuses to run at all unless you pass +`--snapshot` (takes one and waits for it to reach `status=available` before +deleting anything) or `--force-no-backup` (an explicit acknowledgment that no +backup is taken). Hetzner primary IPs default to `auto_delete=true`, so +deleting the server would permanently destroy its IP too; this command sets +`auto_delete=false` on the server's primary IP(s) first and prints which IPs +were retained, unless `--release-ip` says to let them go with the server. If +the snapshot fails or never reaches `status=available` within +`--snapshot-timeout`, the server is NOT deleted. + +```bash +nself server destroy --id 12345 --snapshot +``` + +Flags: `--id` (required), `--snapshot`, `--force-no-backup`, `--release-ip`, +`--snapshot-timeout` (default `10m`), `--json`. + +Every subcommand also takes `--token` (Hetzner Cloud API token, overrides +`--token-env`) and `--token-env` (env var to read the token from, default +`HETZNER_NSELF_TOKEN`; falls back to `HCLOUD_TOKEN` if unset). The token is +never logged. + + +## Flags + + +| Flag | Default | Description | +|------|---------|-------------| +| `--help`, `-h` | — | Show help | + + +## Subcommands + + +| Name | Description | +|------|-------------| +| `destroy` | Delete a Hetzner Cloud server | +| `list` | List Hetzner Cloud servers | +| `provision` | Create a new Hetzner Cloud server | +| `resize` | Change a server's type (CPU/RAM/disk) | + + +## Examples + + +```bash +# Provision a new CI box +nself server provision --name ci-runner-3 --type cx22 --location fsn1 --image ubuntu-24.04 --ssh-key deploy +``` + +```bash +# List every nself-managed server +nself server list --label-selector managed-by=nself-cli +``` + +```bash +# Grow a server, keeping the same disk-shrink-safe path +nself server resize --id 12345 --type cx41 +``` + +```bash +# Destroy a server after a verified snapshot, retaining its primary IP +nself server destroy --id 12345 --snapshot +``` + +```bash +# Destroy a throwaway CI box with no backup, releasing its IP too +nself server destroy --id 12345 --force-no-backup --release-ip +``` + + +## See Also + + +- [[cmd-access]], SSH key access on a server this command already provisioned +- [[cmd-security]], firewall, fail2ban, and sshd hardening for the same server +- [[cmd-deploy]], deploying the nself stack onto a server once it exists +- [[Commands]], full command index + + +← [[Commands]] | [[Home]] → diff --git a/cmd/commands/error_harness_test.go b/cmd/commands/error_harness_test.go index 5f9a7c3f..7596afb1 100644 --- a/cmd/commands/error_harness_test.go +++ b/cmd/commands/error_harness_test.go @@ -208,6 +208,11 @@ var errorHarnessCases = []errorHarnessCase{ {"security", []string{"security", "--no-such-flag-xyz"}, "(b) invalid flag"}, {"security", []string{"security", "unknownsub_xyz"}, "(c) unknown sub"}, + // ── server ───────────────────────────────────────────────────────────── + {"server", []string{"server"}, "(a) no project dir"}, + {"server", []string{"server", "--no-such-flag-xyz"}, "(b) invalid flag"}, + {"server", []string{"server", "unknownsub_xyz"}, "(c) unknown sub"}, + // ── service ──────────────────────────────────────────────────────────── {"service", []string{"service"}, "(a) no project dir"}, {"service", []string{"service", "--no-such-flag-xyz"}, "(b) invalid flag"}, diff --git a/cmd/commands/groups.go b/cmd/commands/groups.go index 3fdc9075..c9a08567 100644 --- a/cmd/commands/groups.go +++ b/cmd/commands/groups.go @@ -108,6 +108,7 @@ var commandGroupAssignments = map[string]string{ // Advanced & Enterprise. "access": groupAdvanced, "security": groupAdvanced, + "server": groupAdvanced, "verify-sbom": groupAdvanced, } diff --git a/cmd/commands/server.go b/cmd/commands/server.go new file mode 100644 index 00000000..13305054 --- /dev/null +++ b/cmd/commands/server.go @@ -0,0 +1,58 @@ +// Package commands — server.go +// +// `nself server` closes CLI gap G-011: provisioning, resizing, and +// destroying a server had no CLI surface, so an operator building a new CI +// box or tearing one down had to fall back to raw `hcloud server create` / +// `hcloud server delete` — neither expressible through nself, and neither +// carrying the safety checks below. This is distinct from `nself access` +// (SSH keys on an already-deployed server) and `nself security` (auditing +// one), both of which assume the server already exists. +package commands + +import ( + "github.com/nself-org/cli/internal/server" + + "github.com/spf13/cobra" +) + +// serverCmd is the parent command for `nself server ...`. +var serverCmd = &cobra.Command{ + Use: "server", + Short: "Provision, list, resize, and destroy Hetzner Cloud servers", + Long: `Manage the lifecycle of a Hetzner Cloud server: create one, list what +exists, resize one, or destroy one. + +Subcommands: + nself server provision Create a new server + nself server list List servers in the project + nself server resize Change a server's type (CPU/RAM/disk) + nself server destroy Delete a server + +destroy is safe by default: + - refuses to run without a verified backup (--snapshot or --force-no-backup) + - protects the server's primary IP(s) from auto-deletion unless --release-ip + is passed + +resize refuses (with an explanation) to shrink a server's disk — Hetzner +Cloud has no API for that; the only path is snapshot -> new server -> restore. + +A Hetzner Cloud API token is required: set HETZNER_NSELF_TOKEN (or a +project-scoped equivalent via --token-env, or HCLOUD_TOKEN) in the +environment, or pass --token explicitly. The token is never logged.`, + RunE: func(cmd *cobra.Command, args []string) error { + return cmd.Help() + }, +} + +func init() { + for _, c := range []*cobra.Command{serverProvisionCmd, serverListCmd, serverResizeCmd, serverDestroyCmd} { + c.Flags().String("token", "", "Hetzner Cloud API token (overrides --token-env)") + c.Flags().String("token-env", server.DefaultTokenEnvVar, "env var to read the API token from") + } + + serverCmd.AddCommand(serverProvisionCmd) + serverCmd.AddCommand(serverListCmd) + serverCmd.AddCommand(serverResizeCmd) + serverCmd.AddCommand(serverDestroyCmd) + RootCmd.AddCommand(serverCmd) +} diff --git a/cmd/commands/server_client.go b/cmd/commands/server_client.go new file mode 100644 index 00000000..c371e894 --- /dev/null +++ b/cmd/commands/server_client.go @@ -0,0 +1,39 @@ +package commands + +// Purpose: shared flag handling for the `nself server` subcommands — +// turning --token/--token-env into a server.Client. Split out so the +// provision/list/resize/destroy handlers stay pure cobra wiring, mirroring +// access_transport.go's newAccessTransport pattern. +// Inputs: a *cobra.Command carrying --token/--token-env flags. +// Outputs: a server.Client (always a real Hetzner client in the live CLI — +// newServerClient is swapped for a fake in server_test.go so command tests +// never reach the network) or a resolution error. +// Constraints: never logs or echoes the resolved token. + +import ( + "fmt" + + "github.com/nself-org/cli/internal/server" + + "github.com/spf13/cobra" +) + +// newServerClient is a package-level indirection so server_test.go can +// substitute a fake Client without any handler knowing the difference. The +// real CLI never reassigns it — every live invocation resolves through +// buildServerClient to a real Hetzner-backed client. +var newServerClient = buildServerClient + +// buildServerClient resolves --token/--token-env into a token and builds a +// live Hetzner Client from it. It never makes a network call itself; that +// only happens when a handler invokes an operation on the returned Client. +func buildServerClient(cmd *cobra.Command) (server.Client, error) { + explicit, _ := cmd.Flags().GetString("token") + envVar, _ := cmd.Flags().GetString("token-env") + + token, err := server.ResolveToken(explicit, envVar) + if err != nil { + return nil, fmt.Errorf("nself server: %w", err) + } + return server.NewHetznerClient(token), nil +} diff --git a/cmd/commands/server_destroy.go b/cmd/commands/server_destroy.go new file mode 100644 index 00000000..0439c4e1 --- /dev/null +++ b/cmd/commands/server_destroy.go @@ -0,0 +1,108 @@ +package commands + +// Purpose: `nself server destroy` handler — the safety-critical command +// G-011 exists for. Flag parsing and reporting only; every actual safety +// decision (backup gate, IP protection, delete ordering) lives in +// server.Destroy so it is unit-tested without a command layer in the way. +// Inputs: --id, --snapshot, --force-no-backup, --release-ip, +// --snapshot-timeout, --token, --token-env, --json. +// Outputs: printed confirmation naming the snapshot taken (if any) and +// which IPs were retained/released; a non-nil error (including +// server.ErrNoBackup) that leaves the server untouched. + +import ( + "errors" + "fmt" + + "github.com/nself-org/cli/internal/server" + "github.com/nself-org/cli/internal/ui" + + "github.com/spf13/cobra" +) + +var serverDestroyCmd = &cobra.Command{ + Use: "destroy", + Short: "Delete a Hetzner Cloud server", + Long: `Delete a Hetzner Cloud server. Safe by default: + + - Refuses to run at all unless you pass --snapshot (takes one and waits + for it to reach status=available before deleting anything) or + --force-no-backup (explicit acknowledgment that no backup is taken). + - Hetzner primary IPs default to auto_delete=true, so deleting the server + would permanently destroy its IP too. This command sets + auto_delete=false on the server's primary IP(s) first and prints which + IPs were retained, unless --release-ip says to let them go with the + server. + +If the snapshot fails or never reaches status=available within +--snapshot-timeout, the server is NOT deleted.`, + Example: ` nself server destroy --id 12345 --snapshot + nself server destroy --id 12345 --snapshot --snapshot-timeout 20m + nself server destroy --id 12345 --force-no-backup + nself server destroy --id 12345 --snapshot --release-ip`, + RunE: runServerDestroy, +} + +func init() { + f := serverDestroyCmd.Flags() + f.Int64("id", 0, "server ID (required)") + f.Bool("snapshot", false, "take a snapshot and verify it before deleting") + f.Bool("force-no-backup", false, "proceed without any backup (explicit acknowledgment)") + f.Bool("release-ip", false, "let the server's primary IP(s) be released with it, instead of retaining them") + f.Duration("snapshot-timeout", server.DefaultSnapshotWait, "how long to wait for the snapshot to become available") + f.Bool("json", false, "output as JSON") +} + +func runServerDestroy(cmd *cobra.Command, args []string) error { + id, _ := cmd.Flags().GetInt64("id") + takeSnapshot, _ := cmd.Flags().GetBool("snapshot") + forceNoBackup, _ := cmd.Flags().GetBool("force-no-backup") + releaseIP, _ := cmd.Flags().GetBool("release-ip") + snapshotTimeout, _ := cmd.Flags().GetDuration("snapshot-timeout") + jsonOut, _ := cmd.Flags().GetBool("json") + + client, err := newServerClient(cmd) + if err != nil { + return err + } + + if !jsonOut { + ui.CommandHeader("nself server destroy", fmt.Sprintf("server %d", id)) + if takeSnapshot { + ui.Info("Taking a snapshot and waiting for it to become available (up to " + snapshotTimeout.String() + ")...") + } + } + + result, err := server.Destroy(cmd.Context(), client, server.DestroyRequest{ + ServerID: id, TakeSnapshot: takeSnapshot, ForceNoBackup: forceNoBackup, + ReleaseIP: releaseIP, SnapshotWait: snapshotTimeout, + }) + if err != nil { + if errors.Is(err, server.ErrNoBackup) { + return fmt.Errorf("%w — see 'nself server destroy --help'", err) + } + return fmt.Errorf("destroy server %d: %w", id, err) + } + + if jsonOut { + return ui.PrintJSON(result) + } + printDestroyResult(result) + return nil +} + +// printDestroyResult reports exactly what Destroy did — never assumed from +// the request flags, since e.g. a server with no primary IPs retains none +// regardless of --release-ip. +func printDestroyResult(result *server.DestroyResult) { + if result.SnapshotID != 0 { + ui.Success(fmt.Sprintf("Snapshot %d verified available", result.SnapshotID)) + } + for _, ip := range result.RetainedIPs { + ui.Info(fmt.Sprintf("Retained primary IP %s (auto_delete=false)", ip.IP)) + } + for _, ip := range result.ReleasedIPs { + ui.Warn(fmt.Sprintf("Released primary IP %s (auto_delete=true, --release-ip was passed)", ip.IP)) + } + ui.Success("Server destroyed") +} diff --git a/cmd/commands/server_list.go b/cmd/commands/server_list.go new file mode 100644 index 00000000..fbebb563 --- /dev/null +++ b/cmd/commands/server_list.go @@ -0,0 +1,60 @@ +package commands + +// Purpose: `nself server list` handler — renders server.List's result as a +// table (default) or JSON (--json). +// Inputs: --label-selector, --token, --token-env, --json. +// Outputs: a table (or JSON array) of servers in the Hetzner project. + +import ( + "fmt" + + "github.com/nself-org/cli/internal/server" + "github.com/nself-org/cli/internal/ui" + + "github.com/spf13/cobra" +) + +var serverListCmd = &cobra.Command{ + Use: "list", + Short: "List Hetzner Cloud servers", + Example: ` nself server list + nself server list --label-selector managed-by=nself-cli + nself server list --json`, + RunE: runServerList, +} + +func init() { + serverListCmd.Flags().String("label-selector", "", "filter by Hetzner label selector, e.g. managed-by=nself-cli") + serverListCmd.Flags().Bool("json", false, "output as JSON") +} + +func runServerList(cmd *cobra.Command, args []string) error { + labelSelector, _ := cmd.Flags().GetString("label-selector") + jsonOut, _ := cmd.Flags().GetBool("json") + + client, err := newServerClient(cmd) + if err != nil { + return err + } + + servers, err := server.List(cmd.Context(), client, server.ListOptions{LabelSelector: labelSelector}) + if err != nil { + return fmt.Errorf("list servers: %w", err) + } + + if jsonOut { + return ui.PrintJSON(servers) + } + + if len(servers) == 0 { + fmt.Println("No servers found.") + return nil + } + + table := ui.NewTable("ID", "NAME", "STATUS", "TYPE", "LOCATION", "IPV4") + for _, s := range servers { + table.AddRow(fmt.Sprintf("%d", s.ID), s.Name, s.Status, s.ServerType, s.Location, s.IPv4) + } + table.Render() + return nil +} diff --git a/cmd/commands/server_provision.go b/cmd/commands/server_provision.go new file mode 100644 index 00000000..6ff842c1 --- /dev/null +++ b/cmd/commands/server_provision.go @@ -0,0 +1,101 @@ +package commands + +// Purpose: `nself server provision` handler — parses flags, builds a +// server.ProvisionRequest, and reports the created server back. +// Inputs: --name, --type, --location, --image, --ssh-key (repeatable), +// --label (repeatable key=value), --token, --token-env, --json. +// Outputs: printed confirmation (or JSON) with the new server's ID and IP; +// a non-nil error on any validation, resolution, or provider failure. + +import ( + "fmt" + "strings" + + "github.com/nself-org/cli/internal/server" + "github.com/nself-org/cli/internal/ui" + + "github.com/spf13/cobra" +) + +var serverProvisionCmd = &cobra.Command{ + Use: "provision", + Short: "Create a new Hetzner Cloud server", + Long: `Create a new Hetzner Cloud server. + +Every server nself provisions is labeled managed-by=nself-cli (unless you +pass your own --label managed-by=..., which is respected as-is), so +'nself server list' and any future cleanup pass can tell nself-created +servers apart from anything else in the same Hetzner project.`, + Example: ` nself server provision --name ci-runner-3 --type cx22 --location fsn1 --image ubuntu-24.04 --ssh-key deploy + nself server provision --name ci-runner-3 --type cx22 --location fsn1 --image ubuntu-24.04 --label purpose=ci --json`, + RunE: runServerProvision, +} + +func init() { + f := serverProvisionCmd.Flags() + f.String("name", "", "server name (required)") + f.String("type", "", "server type, e.g. cx22 (required)") + f.String("location", "", "datacenter location, e.g. fsn1 (required)") + f.String("image", "", "OS image, e.g. ubuntu-24.04 (required)") + f.StringArray("ssh-key", nil, "Hetzner SSH key name to authorize (repeatable)") + f.StringArray("label", nil, "label as key=value (repeatable)") + f.Bool("json", false, "output as JSON") +} + +func runServerProvision(cmd *cobra.Command, args []string) error { + name, _ := cmd.Flags().GetString("name") + serverType, _ := cmd.Flags().GetString("type") + location, _ := cmd.Flags().GetString("location") + image, _ := cmd.Flags().GetString("image") + sshKeys, _ := cmd.Flags().GetStringArray("ssh-key") + labelArgs, _ := cmd.Flags().GetStringArray("label") + jsonOut, _ := cmd.Flags().GetBool("json") + + labels, err := parseLabels(labelArgs) + if err != nil { + return err + } + + client, err := newServerClient(cmd) + if err != nil { + return err + } + + if !jsonOut { + ui.CommandHeader("nself server provision", fmt.Sprintf("%s (%s, %s)", name, serverType, location)) + } + + srv, err := server.Provision(cmd.Context(), client, server.ProvisionRequest{ + Name: name, ServerType: serverType, Location: location, Image: image, + SSHKeys: sshKeys, Labels: labels, + }) + if err != nil { + return fmt.Errorf("provision %s: %w", name, err) + } + + if jsonOut { + return ui.PrintJSON(srv) + } + + ui.Success(fmt.Sprintf("Created server %q (id %d)", srv.Name, srv.ID)) + ui.Info("IPv4: " + srv.IPv4) + ui.Info("Status: " + srv.Status) + return nil +} + +// parseLabels turns repeated "key=value" flag values into a map, rejecting +// anything that isn't exactly one "=". +func parseLabels(args []string) (map[string]string, error) { + if len(args) == 0 { + return nil, nil + } + labels := make(map[string]string, len(args)) + for _, a := range args { + k, v, ok := strings.Cut(a, "=") + if !ok || k == "" { + return nil, fmt.Errorf("--label must be key=value, got %q", a) + } + labels[k] = v + } + return labels, nil +} diff --git a/cmd/commands/server_resize.go b/cmd/commands/server_resize.go new file mode 100644 index 00000000..1e95e84c --- /dev/null +++ b/cmd/commands/server_resize.go @@ -0,0 +1,75 @@ +package commands + +// Purpose: `nself server resize` handler — changes a server's type +// (CPU/RAM/disk). Refuses a disk-shrinking resize with a plain-English +// explanation instead of Hetzner's raw "invalid_input" error (server.Resize +// does the actual disk-size comparison; this file only reports it). +// Inputs: --id, --type, --upgrade-disk, --token, --token-env, --json. +// Outputs: printed confirmation of the started action; a non-nil error +// (including server.ErrDiskShrink) on validation or provider failure. + +import ( + "errors" + "fmt" + + "github.com/nself-org/cli/internal/server" + "github.com/nself-org/cli/internal/ui" + + "github.com/spf13/cobra" +) + +var serverResizeCmd = &cobra.Command{ + Use: "resize", + Short: "Change a server's type (CPU/RAM/disk)", + Long: `Change a server's type (e.g. cx22 -> cx41). + +Hetzner Cloud has no API to shrink a server's disk. If --type names a type +with a smaller disk than the server currently has, this command refuses and +explains the only supported path: snapshot the current server, provision a +new server of the smaller type, restore from the snapshot, then destroy the +original with 'nself server destroy'.`, + Example: ` nself server resize --id 12345 --type cx41 + nself server resize --id 12345 --type cx41 --upgrade-disk`, + RunE: runServerResize, +} + +func init() { + f := serverResizeCmd.Flags() + f.Int64("id", 0, "server ID (required)") + f.String("type", "", "target server type, e.g. cx41 (required)") + f.Bool("upgrade-disk", false, "also grow the disk to match the new type (irreversible)") + f.Bool("json", false, "output as JSON") +} + +func runServerResize(cmd *cobra.Command, args []string) error { + id, _ := cmd.Flags().GetInt64("id") + targetType, _ := cmd.Flags().GetString("type") + upgradeDisk, _ := cmd.Flags().GetBool("upgrade-disk") + jsonOut, _ := cmd.Flags().GetBool("json") + + client, err := newServerClient(cmd) + if err != nil { + return err + } + + if !jsonOut { + ui.CommandHeader("nself server resize", fmt.Sprintf("server %d -> %s", id, targetType)) + } + + action, err := server.Resize(cmd.Context(), client, server.ResizeRequest{ + ServerID: id, TargetType: targetType, UpgradeDisk: upgradeDisk, + }) + if err != nil { + if errors.Is(err, server.ErrDiskShrink) { + return err // already carries the full explanation, don't wrap again + } + return fmt.Errorf("resize server %d: %w", id, err) + } + + if jsonOut { + return ui.PrintJSON(action) + } + + ui.Success(fmt.Sprintf("Started resize of server %d to %s (action %d, status %s)", id, targetType, action.ID, action.Status)) + return nil +} diff --git a/cmd/commands/server_test.go b/cmd/commands/server_test.go new file mode 100644 index 00000000..744341ca --- /dev/null +++ b/cmd/commands/server_test.go @@ -0,0 +1,293 @@ +package commands + +// Tests exercise the `nself server` cobra wiring (flag parsing, error +// propagation, output) against a fakeServerClient injected via the +// newServerClient indirection in server_client.go — never against the real +// Hetzner Cloud API. Deeper safety-logic tests (backup gate, IP protection, +// disk-shrink detection) live in internal/server/*_test.go. + +import ( + "context" + "testing" + + "github.com/nself-org/cli/internal/server" + + "github.com/spf13/cobra" + "github.com/spf13/pflag" +) + +// fakeServerClient is a minimal in-memory server.Client for command-layer +// tests. It never performs network I/O. +type fakeServerClient struct { + servers map[int64]*server.Server + nextID int64 + primaryIPs map[int64]*server.PrimaryIP +} + +func newFakeServerClient() *fakeServerClient { + return &fakeServerClient{ + servers: map[int64]*server.Server{}, + primaryIPs: map[int64]*server.PrimaryIP{}, + nextID: 1, + } +} + +func (f *fakeServerClient) CreateServer(_ context.Context, req server.ProvisionRequest) (*server.Server, error) { + id := f.nextID + f.nextID++ + s := &server.Server{ID: id, Name: req.Name, ServerType: req.ServerType, Location: req.Location, Status: "running", IPv4: "203.0.113.20", Labels: req.Labels} + f.servers[id] = s + return s, nil +} + +func (f *fakeServerClient) ListServers(_ context.Context, _ server.ListOptions) ([]server.Server, error) { + out := make([]server.Server, 0, len(f.servers)) + for _, s := range f.servers { + out = append(out, *s) + } + return out, nil +} + +func (f *fakeServerClient) GetServer(_ context.Context, id int64) (*server.Server, error) { + s, ok := f.servers[id] + if !ok { + return nil, errNotFound(id) + } + cp := *s + return &cp, nil +} + +func (f *fakeServerClient) DeleteServer(_ context.Context, id int64) error { + if _, ok := f.servers[id]; !ok { + return errNotFound(id) + } + delete(f.servers, id) + return nil +} + +func (f *fakeServerClient) ListServerTypes(_ context.Context) ([]server.ServerType, error) { + return []server.ServerType{{Name: "cx22", Disk: 40}, {Name: "cx41", Disk: 160}}, nil +} + +func (f *fakeServerClient) ChangeServerType(_ context.Context, id int64, targetType string, _ bool) (*server.Action, error) { + s, ok := f.servers[id] + if !ok { + return nil, errNotFound(id) + } + s.ServerType = targetType + return &server.Action{ID: 1, Status: "success"}, nil +} + +func (f *fakeServerClient) CreateSnapshot(_ context.Context, id int64, description string) (*server.Image, *server.Action, error) { + if _, ok := f.servers[id]; !ok { + return nil, nil, errNotFound(id) + } + return &server.Image{ID: 500, Status: "available", Description: description}, + &server.Action{ID: 1, Status: "success"}, nil +} + +func (f *fakeServerClient) GetImage(_ context.Context, id int64) (*server.Image, error) { + return &server.Image{ID: id, Status: "available"}, nil +} + +func (f *fakeServerClient) GetAction(_ context.Context, id int64) (*server.Action, error) { + return &server.Action{ID: id, Status: "success"}, nil +} + +func (f *fakeServerClient) ListPrimaryIPs(_ context.Context, id int64) ([]server.PrimaryIP, error) { + var out []server.PrimaryIP + for _, ip := range f.primaryIPs { + if ip.AssigneeID == id { + out = append(out, *ip) + } + } + return out, nil +} + +func (f *fakeServerClient) SetPrimaryIPAutoDelete(_ context.Context, ipID int64, autoDelete bool) error { + ip, ok := f.primaryIPs[ipID] + if !ok { + return errNotFound(ipID) + } + ip.AutoDelete = autoDelete + return nil +} + +func errNotFound(id int64) error { + return ¬FoundErr{id} +} + +type notFoundErr struct{ id int64 } + +func (e *notFoundErr) Error() string { return "not found" } + +// withFakeServerClient points newServerClient at fc for the duration of the +// test, restoring the real (Hetzner-backed) factory afterward. +func withFakeServerClient(t *testing.T, fc *fakeServerClient) { + t.Helper() + old := newServerClient + newServerClient = func(cmd *cobra.Command) (server.Client, error) { + return fc, nil + } + t.Cleanup(func() { newServerClient = old }) +} + +// sliceResetter is implemented by pflag's StringArray/StringSlice values. +// resetFlags (access_test.go) sets scalar flags back to their default via +// Value.Set(f.DefValue), but StringArrayValue.Set APPENDS rather than +// replaces, so Set("[]") on an already-empty flag leaves a bogus one-element +// slice ["[]"] instead of an empty one. Replace(nil) is the real reset. +type sliceResetter interface { + Replace([]string) error +} + +// resetServerFlags restores every `nself server` subcommand's flags to their +// registered defaults, including the repeatable --ssh-key/--label flags +// which resetFlags's generic Set(DefValue) approach cannot clear correctly. +func resetServerFlags() { + for _, c := range []*cobra.Command{serverProvisionCmd, serverListCmd, serverResizeCmd, serverDestroyCmd} { + c.Flags().VisitAll(func(f *pflag.Flag) { + if r, ok := f.Value.(sliceResetter); ok { + _ = r.Replace(nil) + } else { + _ = f.Value.Set(f.DefValue) + } + f.Changed = false + }) + } +} + +func TestServerCmd_Structure(t *testing.T) { + names := map[string]bool{} + for _, c := range serverCmd.Commands() { + names[c.Name()] = true + } + for _, want := range []string{"provision", "list", "resize", "destroy"} { + if !names[want] { + t.Errorf("nself server missing subcommand %q", want) + } + } +} + +func TestServerProvision_HappyPath(t *testing.T) { + resetServerFlags() + fc := newFakeServerClient() + withFakeServerClient(t, fc) + + cmd := serverProvisionCmd + _ = cmd.Flags().Set("name", "ci-runner-3") + _ = cmd.Flags().Set("type", "cx22") + _ = cmd.Flags().Set("location", "fsn1") + _ = cmd.Flags().Set("image", "ubuntu-24.04") + cmd.SetContext(context.Background()) + + if err := runServerProvision(cmd, nil); err != nil { + t.Fatalf("runServerProvision: %v", err) + } + if len(fc.servers) != 1 { + t.Fatalf("expected 1 server created, got %d", len(fc.servers)) + } +} + +func TestServerProvision_MissingName(t *testing.T) { + resetServerFlags() + fc := newFakeServerClient() + withFakeServerClient(t, fc) + + cmd := serverProvisionCmd + _ = cmd.Flags().Set("type", "cx22") + _ = cmd.Flags().Set("location", "fsn1") + _ = cmd.Flags().Set("image", "ubuntu-24.04") + cmd.SetContext(context.Background()) + + if err := runServerProvision(cmd, nil); err == nil { + t.Fatal("runServerProvision: want error when --name is missing") + } +} + +func TestServerProvision_BadLabel(t *testing.T) { + resetServerFlags() + fc := newFakeServerClient() + withFakeServerClient(t, fc) + + cmd := serverProvisionCmd + _ = cmd.Flags().Set("name", "x") + _ = cmd.Flags().Set("type", "cx22") + _ = cmd.Flags().Set("location", "fsn1") + _ = cmd.Flags().Set("image", "ubuntu-24.04") + _ = cmd.Flags().Set("label", "not-a-kv-pair") + cmd.SetContext(context.Background()) + + if err := runServerProvision(cmd, nil); err == nil { + t.Fatal("runServerProvision: want error for malformed --label") + } +} + +func TestServerList_Empty(t *testing.T) { + resetServerFlags() + fc := newFakeServerClient() + withFakeServerClient(t, fc) + + cmd := serverListCmd + cmd.SetContext(context.Background()) + if err := runServerList(cmd, nil); err != nil { + t.Fatalf("runServerList: %v", err) + } +} + +func TestServerResize_DiskShrink_Refused(t *testing.T) { + resetServerFlags() + fc := newFakeServerClient() + fc.servers[1] = &server.Server{ID: 1, Name: "web-1", ServerType: "cx41"} + withFakeServerClient(t, fc) + + cmd := serverResizeCmd + _ = cmd.Flags().Set("id", "1") + _ = cmd.Flags().Set("type", "cx22") + cmd.SetContext(context.Background()) + + if err := runServerResize(cmd, nil); err == nil { + t.Fatal("runServerResize: want error refusing the disk shrink") + } +} + +func TestServerDestroy_NoBackupFlag_Refused(t *testing.T) { + resetServerFlags() + fc := newFakeServerClient() + fc.servers[1] = &server.Server{ID: 1, Name: "web-1"} + withFakeServerClient(t, fc) + + cmd := serverDestroyCmd + _ = cmd.Flags().Set("id", "1") + cmd.SetContext(context.Background()) + + if err := runServerDestroy(cmd, nil); err == nil { + t.Fatal("runServerDestroy: want error when neither --snapshot nor --force-no-backup is passed") + } + if _, ok := fc.servers[1]; !ok { + t.Fatal("server was deleted despite missing the backup gate") + } +} + +func TestServerDestroy_ForceNoBackup_Succeeds(t *testing.T) { + resetServerFlags() + fc := newFakeServerClient() + fc.servers[1] = &server.Server{ID: 1, Name: "web-1"} + fc.primaryIPs[10] = &server.PrimaryIP{ID: 10, IP: "203.0.113.10", AssigneeID: 1, AutoDelete: true} + withFakeServerClient(t, fc) + + cmd := serverDestroyCmd + _ = cmd.Flags().Set("id", "1") + _ = cmd.Flags().Set("force-no-backup", "true") + cmd.SetContext(context.Background()) + + if err := runServerDestroy(cmd, nil); err != nil { + t.Fatalf("runServerDestroy: %v", err) + } + if _, ok := fc.servers[1]; ok { + t.Fatal("server still exists after a successful destroy") + } + if fc.primaryIPs[10].AutoDelete { + t.Error("primary IP auto_delete must be false by the time destroy completes without --release-ip") + } +} diff --git a/internal/server/client.go b/internal/server/client.go new file mode 100644 index 00000000..56c0c7b7 --- /dev/null +++ b/internal/server/client.go @@ -0,0 +1,115 @@ +package server + +// Purpose: the Client interface every `nself server` operation programs +// against, plus hetznerClient, its concrete implementation over the Hetzner +// Cloud API. The interface exists so cmd/commands/server_*.go can inject a +// fake in tests (mirroring internal/access.Transport) and so orchestration +// logic in provision.go/resize.go/destroy.go never depends on HTTP directly. +// Inputs: a Hetzner Cloud API token (see token.go for env-var resolution). +// Outputs: typed Server/ServerType/Image/Action/PrimaryIP values, or a +// wrapped error describing what the API call was and why it failed. +// Constraints: hetznerAPIBaseURL and hetznerHTTPClient are var indirections +// — same pattern as internal/access/hetzner_mismatch.go — so tests point at +// an httptest.Server instead of the real Hetzner Cloud API. No test in this +// package or its callers may reach the real API. + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "time" +) + +var ( + hetznerAPIBaseURL = "https://api.hetzner.cloud/v1" + hetznerHTTPClient = &http.Client{Timeout: 30 * time.Second} +) + +// Client is every Hetzner Cloud operation `nself server` needs. Introducing +// a second provider later means adding a second implementation of this +// interface, not touching provision.go/resize.go/destroy.go/list.go. +type Client interface { + CreateServer(ctx context.Context, req ProvisionRequest) (*Server, error) + ListServers(ctx context.Context, opts ListOptions) ([]Server, error) + GetServer(ctx context.Context, id int64) (*Server, error) + DeleteServer(ctx context.Context, id int64) error + ListServerTypes(ctx context.Context) ([]ServerType, error) + ChangeServerType(ctx context.Context, serverID int64, targetType string, upgradeDisk bool) (*Action, error) + CreateSnapshot(ctx context.Context, serverID int64, description string) (*Image, *Action, error) + GetImage(ctx context.Context, id int64) (*Image, error) + GetAction(ctx context.Context, id int64) (*Action, error) + ListPrimaryIPs(ctx context.Context, serverID int64) ([]PrimaryIP, error) + SetPrimaryIPAutoDelete(ctx context.Context, ipID int64, autoDelete bool) error +} + +// hetznerClient is the real Client, talking to api.hetzner.cloud/v1. +type hetznerClient struct { + token string +} + +// NewHetznerClient builds a Client backed by the live Hetzner Cloud API. +// token must be non-empty; resolve it with ResolveToken before calling this. +func NewHetznerClient(token string) Client { + return &hetznerClient{token: token} +} + +// apiError is Hetzner's standard error envelope, e.g. +// {"error":{"code":"invalid_input","message":"..."}}. +type apiError struct { + Error struct { + Code string `json:"code"` + Message string `json:"message"` + } `json:"error"` +} + +// do performs one Hetzner API call, decoding a JSON body into out (which may +// be nil for calls like DeleteServer that discard the response body). +func (c *hetznerClient) do(ctx context.Context, method, path string, body, out interface{}) error { + var reader io.Reader + if body != nil { + b, err := json.Marshal(body) + if err != nil { + return fmt.Errorf("encode request: %w", err) + } + reader = bytes.NewReader(b) + } + + req, err := http.NewRequestWithContext(ctx, method, hetznerAPIBaseURL+path, reader) + if err != nil { + return fmt.Errorf("build request: %w", err) + } + req.Header.Set("Authorization", "Bearer "+c.token) + if body != nil { + req.Header.Set("Content-Type", "application/json") + } + + resp, err := hetznerHTTPClient.Do(req) + if err != nil { + return fmt.Errorf("hetzner API %s %s: network error: %w", method, path, err) + } + defer func() { _ = resp.Body.Close() }() + + raw, err := io.ReadAll(resp.Body) + if err != nil { + return fmt.Errorf("hetzner API %s %s: read response: %w", method, path, err) + } + + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + var ae apiError + if json.Unmarshal(raw, &ae) == nil && ae.Error.Message != "" { + return fmt.Errorf("hetzner API %s %s: %d %s: %s", method, path, resp.StatusCode, ae.Error.Code, ae.Error.Message) + } + return fmt.Errorf("hetzner API %s %s: %d: %s", method, path, resp.StatusCode, string(raw)) + } + + if out == nil || len(raw) == 0 { + return nil + } + if err := json.Unmarshal(raw, out); err != nil { + return fmt.Errorf("hetzner API %s %s: decode response: %w", method, path, err) + } + return nil +} diff --git a/internal/server/client_ops.go b/internal/server/client_ops.go new file mode 100644 index 00000000..9fd57343 --- /dev/null +++ b/internal/server/client_ops.go @@ -0,0 +1,177 @@ +package server + +// Purpose: hetznerClient's Client interface methods — the actual HTTP calls +// against api.hetzner.cloud/v1. Each method is a thin encode-request / +// decode-response / map-to-our-types wrapper; the safety/orchestration logic +// (snapshot-before-destroy, disk-shrink detection, IP protection) lives in +// provision.go/resize.go/destroy.go/primaryip.go, never here. +// Inputs: a *hetznerClient (holds the API token) and typed request values. +// Outputs: this package's Server/ServerType/Image/Action/PrimaryIP types. +// Constraints: every method must go through do() (client.go) so tests can +// redirect hetznerAPIBaseURL to an httptest.Server — never call +// hetznerHTTPClient directly from here. + +import ( + "context" + "fmt" +) + +func (c *hetznerClient) CreateServer(ctx context.Context, req ProvisionRequest) (*Server, error) { + body := map[string]interface{}{ + "name": req.Name, + "server_type": req.ServerType, + "image": req.Image, + "location": req.Location, + } + if len(req.SSHKeys) > 0 { + body["ssh_keys"] = req.SSHKeys + } + if len(req.Labels) > 0 { + body["labels"] = req.Labels + } + if req.UserData != "" { + body["user_data"] = req.UserData + } + + var out struct { + Server wireServer `json:"server"` + } + if err := c.do(ctx, "POST", "/servers", body, &out); err != nil { + return nil, fmt.Errorf("create server %q: %w", req.Name, err) + } + s := out.Server.toServer() + return &s, nil +} + +func (c *hetznerClient) ListServers(ctx context.Context, opts ListOptions) ([]Server, error) { + path := "/servers" + if opts.LabelSelector != "" { + path += "?label_selector=" + opts.LabelSelector + } + var out struct { + Servers []wireServer `json:"servers"` + } + if err := c.do(ctx, "GET", path, nil, &out); err != nil { + return nil, fmt.Errorf("list servers: %w", err) + } + servers := make([]Server, 0, len(out.Servers)) + for _, w := range out.Servers { + servers = append(servers, w.toServer()) + } + return servers, nil +} + +func (c *hetznerClient) GetServer(ctx context.Context, id int64) (*Server, error) { + var out struct { + Server wireServer `json:"server"` + } + if err := c.do(ctx, "GET", fmt.Sprintf("/servers/%d", id), nil, &out); err != nil { + return nil, fmt.Errorf("get server %d: %w", id, err) + } + s := out.Server.toServer() + return &s, nil +} + +func (c *hetznerClient) DeleteServer(ctx context.Context, id int64) error { + if err := c.do(ctx, "DELETE", fmt.Sprintf("/servers/%d", id), nil, nil); err != nil { + return fmt.Errorf("delete server %d: %w", id, err) + } + return nil +} + +func (c *hetznerClient) ListServerTypes(ctx context.Context) ([]ServerType, error) { + var out struct { + ServerTypes []wireServerType `json:"server_types"` + } + if err := c.do(ctx, "GET", "/server_types", nil, &out); err != nil { + return nil, fmt.Errorf("list server types: %w", err) + } + types := make([]ServerType, 0, len(out.ServerTypes)) + for _, w := range out.ServerTypes { + types = append(types, w.toServerType()) + } + return types, nil +} + +func (c *hetznerClient) ChangeServerType(ctx context.Context, serverID int64, targetType string, upgradeDisk bool) (*Action, error) { + body := map[string]interface{}{ + "server_type": targetType, + "upgrade_disk": upgradeDisk, + } + var out struct { + Action wireAction `json:"action"` + } + if err := c.do(ctx, "POST", fmt.Sprintf("/servers/%d/actions/change_type", serverID), body, &out); err != nil { + return nil, fmt.Errorf("change server %d to type %q: %w", serverID, targetType, err) + } + a := out.Action.toAction() + return &a, nil +} + +func (c *hetznerClient) CreateSnapshot(ctx context.Context, serverID int64, description string) (*Image, *Action, error) { + body := map[string]interface{}{"type": "snapshot"} + if description != "" { + body["description"] = description + } + var out struct { + Image wireImage `json:"image"` + Action wireAction `json:"action"` + } + if err := c.do(ctx, "POST", fmt.Sprintf("/servers/%d/actions/create_image", serverID), body, &out); err != nil { + return nil, nil, fmt.Errorf("snapshot server %d: %w", serverID, err) + } + img := out.Image.toImage() + act := out.Action.toAction() + return &img, &act, nil +} + +func (c *hetznerClient) GetImage(ctx context.Context, id int64) (*Image, error) { + var out struct { + Image wireImage `json:"image"` + } + if err := c.do(ctx, "GET", fmt.Sprintf("/images/%d", id), nil, &out); err != nil { + return nil, fmt.Errorf("get image %d: %w", id, err) + } + img := out.Image.toImage() + return &img, nil +} + +func (c *hetznerClient) GetAction(ctx context.Context, id int64) (*Action, error) { + var out struct { + Action wireAction `json:"action"` + } + if err := c.do(ctx, "GET", fmt.Sprintf("/actions/%d", id), nil, &out); err != nil { + return nil, fmt.Errorf("get action %d: %w", id, err) + } + a := out.Action.toAction() + return &a, nil +} + +func (c *hetznerClient) ListPrimaryIPs(ctx context.Context, serverID int64) ([]PrimaryIP, error) { + srv, err := c.GetServer(ctx, serverID) + if err != nil { + return nil, fmt.Errorf("resolve primary IPs for server %d: %w", serverID, err) + } + var ips []PrimaryIP + for _, id := range []int64{srv.IPv4ID, srv.IPv6ID} { + if id == 0 { + continue + } + var out struct { + PrimaryIP wirePrimaryIP `json:"primary_ip"` + } + if err := c.do(ctx, "GET", fmt.Sprintf("/primary_ips/%d", id), nil, &out); err != nil { + return nil, fmt.Errorf("get primary IP %d: %w", id, err) + } + ips = append(ips, out.PrimaryIP.toPrimaryIP()) + } + return ips, nil +} + +func (c *hetznerClient) SetPrimaryIPAutoDelete(ctx context.Context, ipID int64, autoDelete bool) error { + body := map[string]interface{}{"auto_delete": autoDelete} + if err := c.do(ctx, "PUT", fmt.Sprintf("/primary_ips/%d", ipID), body, nil); err != nil { + return fmt.Errorf("set auto_delete=%v on primary IP %d: %w", autoDelete, ipID, err) + } + return nil +} diff --git a/internal/server/client_test.go b/internal/server/client_test.go new file mode 100644 index 00000000..21c67ded --- /dev/null +++ b/internal/server/client_test.go @@ -0,0 +1,115 @@ +package server + +// Purpose: proves hetznerClient's request/response wire format against a +// local httptest.Server standing in for the Hetzner Cloud API — mirroring +// internal/access/hetzner_mismatch_test.go's withHetznerServer pattern. No +// test here or anywhere else in this package ever reaches api.hetzner.cloud; +// hetznerAPIBaseURL is redirected for the duration of each test. + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +func withTestServer(t *testing.T, handler http.HandlerFunc) { + t.Helper() + srv := httptest.NewServer(handler) + t.Cleanup(srv.Close) + old := hetznerAPIBaseURL + hetznerAPIBaseURL = srv.URL + t.Cleanup(func() { hetznerAPIBaseURL = old }) +} + +func TestHetznerClient_CreateServer_SendsExpectedRequest(t *testing.T) { + var gotAuth, gotMethod, gotPath string + var gotBody map[string]interface{} + + withTestServer(t, func(w http.ResponseWriter, r *http.Request) { + gotAuth = r.Header.Get("Authorization") + gotMethod = r.Method + gotPath = r.URL.Path + _ = json.NewDecoder(r.Body).Decode(&gotBody) + + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"server":{"id":42,"name":"ci-box","status":"running", + "server_type":{"name":"cx22"},"datacenter":{"location":{"name":"fsn1"}}, + "public_net":{"ipv4":{"id":100,"ip":"203.0.113.10"},"ipv6":{"id":0,"ip":""}}, + "labels":{"managed-by":"nself-cli"}}}`)) + }) + + c := NewHetznerClient("test-token") + srv, err := c.CreateServer(context.Background(), ProvisionRequest{ + Name: "ci-box", ServerType: "cx22", Location: "fsn1", Image: "ubuntu-24.04", + }) + if err != nil { + t.Fatalf("CreateServer: %v", err) + } + + if gotAuth != "Bearer test-token" { + t.Errorf("Authorization = %q", gotAuth) + } + if gotMethod != http.MethodPost || gotPath != "/servers" { + t.Errorf("method/path = %s %s, want POST /servers", gotMethod, gotPath) + } + if gotBody["name"] != "ci-box" || gotBody["server_type"] != "cx22" { + t.Errorf("request body = %v", gotBody) + } + if srv.ID != 42 || srv.IPv4 != "203.0.113.10" || srv.Location != "fsn1" { + t.Errorf("parsed server = %+v", srv) + } +} + +func TestHetznerClient_ErrorEnvelope_IsWrapped(t *testing.T) { + withTestServer(t, func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusUnprocessableEntity) + _, _ = w.Write([]byte(`{"error":{"code":"invalid_input","message":"server_type is invalid"}}`)) + }) + + c := NewHetznerClient("test-token") + _, err := c.CreateServer(context.Background(), ProvisionRequest{ + Name: "x", ServerType: "bogus", Location: "fsn1", Image: "ubuntu-24.04", + }) + if err == nil { + t.Fatal("CreateServer: want error on non-2xx response") + } + if got := err.Error(); !strings.Contains(got, "invalid_input") || !strings.Contains(got, "server_type is invalid") { + t.Errorf("error = %q, want it to surface the Hetzner error code and message", got) + } +} + +func TestHetznerClient_DeleteServer(t *testing.T) { + var gotMethod, gotPath string + withTestServer(t, func(w http.ResponseWriter, r *http.Request) { + gotMethod, gotPath = r.Method, r.URL.Path + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"action":{"id":1,"status":"success"}}`)) + }) + + c := NewHetznerClient("test-token") + if err := c.DeleteServer(context.Background(), 42); err != nil { + t.Fatalf("DeleteServer: %v", err) + } + if gotMethod != http.MethodDelete || gotPath != "/servers/42" { + t.Errorf("method/path = %s %s, want DELETE /servers/42", gotMethod, gotPath) + } +} + +func TestHetznerClient_SetPrimaryIPAutoDelete(t *testing.T) { + var gotBody map[string]interface{} + withTestServer(t, func(w http.ResponseWriter, r *http.Request) { + _ = json.NewDecoder(r.Body).Decode(&gotBody) + w.WriteHeader(http.StatusOK) + }) + + c := NewHetznerClient("test-token") + if err := c.SetPrimaryIPAutoDelete(context.Background(), 10, false); err != nil { + t.Fatalf("SetPrimaryIPAutoDelete: %v", err) + } + if gotBody["auto_delete"] != false { + t.Errorf("request body auto_delete = %v, want false", gotBody["auto_delete"]) + } +} diff --git a/internal/server/destroy.go b/internal/server/destroy.go new file mode 100644 index 00000000..b5be6f04 --- /dev/null +++ b/internal/server/destroy.go @@ -0,0 +1,68 @@ +package server + +// Purpose: `nself server destroy` — the safety-critical orchestration this +// gap (G-011) exists to close. Tonight's manual procedure took a provider +// snapshot AND a local dump AND verified checksums before deleting a +// server; this encodes the provider-snapshot half of that as a hard gate +// (design requirement 1) and closes the primary-IP footgun found on all 8 +// production IPs (design requirement 2). +// Inputs: a Client and a DestroyRequest. +// Outputs: a DestroyResult describing what was actually done (snapshot ID, +// which IPs were retained/released), or an error if any safety +// precondition failed — in which case the server is NEVER deleted. +// Constraints: order matters and is not configurable — snapshot (if +// requested) completes and verifies BEFORE IP handling, and IP handling +// completes BEFORE the DELETE call. A failure at any step aborts before the +// next one runs. + +import ( + "context" + "errors" + "fmt" +) + +// ErrNoBackup is returned when destroy is invoked with neither --snapshot +// nor --force-no-backup. This is the design-requirement-1 gate: a destroy +// call may never reach the provider's DELETE endpoint without one of these +// being explicit. +var ErrNoBackup = errors.New("destroy refused: no verified backup — pass --snapshot to take one first, or --force-no-backup to proceed without one") + +// Destroy validates req, optionally takes and verifies a snapshot, +// protects (or releases) the server's primary IPs, and only then deletes +// the server. +func Destroy(ctx context.Context, client Client, req DestroyRequest) (*DestroyResult, error) { + if req.ServerID == 0 { + return nil, fmt.Errorf("destroy: server ID is required") + } + if !req.TakeSnapshot && !req.ForceNoBackup { + return nil, ErrNoBackup + } + + result := &DestroyResult{} + + if req.TakeSnapshot { + srv, err := client.GetServer(ctx, req.ServerID) + if err != nil { + return nil, fmt.Errorf("destroy: %w", err) + } + img, err := TakeVerifiedSnapshot(ctx, client, req.ServerID, + fmt.Sprintf("pre-destroy snapshot of %s", srv.Name), req.SnapshotWait) + if err != nil { + return nil, fmt.Errorf("destroy: pre-destroy snapshot failed, server NOT deleted: %w", err) + } + result.SnapshotID = img.ID + } + + retained, released, err := ProtectOrReleaseIPs(ctx, client, req.ServerID, req.ReleaseIP) + if err != nil { + return nil, fmt.Errorf("destroy: %w, server NOT deleted", err) + } + result.RetainedIPs = retained + result.ReleasedIPs = released + + if err := client.DeleteServer(ctx, req.ServerID); err != nil { + return nil, fmt.Errorf("destroy: %w", err) + } + + return result, nil +} diff --git a/internal/server/destroy_test.go b/internal/server/destroy_test.go new file mode 100644 index 00000000..eae71fbe --- /dev/null +++ b/internal/server/destroy_test.go @@ -0,0 +1,119 @@ +package server + +import ( + "context" + "errors" + "testing" + "time" +) + +func TestDestroy_NoBackupFlag_Refused(t *testing.T) { + fc := newFakeClient() + fc.servers[1] = &Server{ID: 1, Name: "web-1"} + + _, err := Destroy(context.Background(), fc, DestroyRequest{ServerID: 1}) + if !errors.Is(err, ErrNoBackup) { + t.Fatalf("Destroy error = %v, want ErrNoBackup", err) + } + if _, ok := fc.servers[1]; !ok { + t.Fatal("server was deleted despite no --snapshot or --force-no-backup — this is the design-1 gate") + } +} + +func TestDestroy_ForceNoBackup_Proceeds(t *testing.T) { + fc := newFakeClient() + fc.servers[1] = &Server{ID: 1, Name: "web-1"} + + result, err := Destroy(context.Background(), fc, DestroyRequest{ServerID: 1, ForceNoBackup: true}) + if err != nil { + t.Fatalf("Destroy: %v", err) + } + if result.SnapshotID != 0 { + t.Errorf("SnapshotID = %d, want 0 (no snapshot requested)", result.SnapshotID) + } + if _, ok := fc.servers[1]; ok { + t.Fatal("server still exists after Destroy with --force-no-backup") + } +} + +func TestDestroy_Snapshot_WaitsForAvailableBeforeDeleting(t *testing.T) { + oldInterval := snapshotPollInterval + snapshotPollInterval = time.Millisecond + t.Cleanup(func() { snapshotPollInterval = oldInterval }) + + fc := newFakeClient() + fc.servers[1] = &Server{ID: 1, Name: "web-1"} + pc := &pollCountingClient{fakeClient: fc, flipAfter: 1} + + result, err := Destroy(context.Background(), pc, DestroyRequest{ + ServerID: 1, TakeSnapshot: true, SnapshotWait: time.Second, + }) + if err != nil { + t.Fatalf("Destroy: %v", err) + } + if result.SnapshotID == 0 { + t.Error("SnapshotID = 0, want the created snapshot's image ID") + } + if _, ok := fc.servers[1]; ok { + t.Fatal("server still exists after a verified snapshot + destroy") + } +} + +func TestDestroy_SnapshotNeverAvailable_ServerNeverDeleted(t *testing.T) { + oldInterval := snapshotPollInterval + snapshotPollInterval = time.Millisecond + t.Cleanup(func() { snapshotPollInterval = oldInterval }) + + fc := newFakeClient() + fc.servers[1] = &Server{ID: 1, Name: "web-1"} + // flipAfter huge: the image never reaches "available" inside the wait budget. + pc := &pollCountingClient{fakeClient: fc, flipAfter: 1_000_000} + + _, err := Destroy(context.Background(), pc, DestroyRequest{ + ServerID: 1, TakeSnapshot: true, SnapshotWait: 20 * time.Millisecond, + }) + if err == nil { + t.Fatal("Destroy: want error when the snapshot never becomes available") + } + if _, ok := fc.servers[1]; !ok { + t.Fatal("server was deleted despite the snapshot never reaching status=available") + } +} + +func TestDestroy_DefaultRetainsPrimaryIP(t *testing.T) { + fc := newFakeClient() + fc.servers[1] = &Server{ID: 1, Name: "web-1"} + fc.primaryIPs[10] = &PrimaryIP{ID: 10, IP: "203.0.113.10", AssigneeID: 1, AutoDelete: true} + + result, err := Destroy(context.Background(), fc, DestroyRequest{ServerID: 1, ForceNoBackup: true}) + if err != nil { + t.Fatalf("Destroy: %v", err) + } + if len(result.RetainedIPs) != 1 { + t.Fatalf("RetainedIPs = %v, want the one primary IP", result.RetainedIPs) + } + if fc.primaryIPs[10].AutoDelete { + t.Error("primary IP auto_delete must be false by the time the server is destroyed") + } +} + +func TestDestroy_ReleaseIP_ReleasesInstead(t *testing.T) { + fc := newFakeClient() + fc.servers[1] = &Server{ID: 1, Name: "web-1"} + fc.primaryIPs[10] = &PrimaryIP{ID: 10, IP: "203.0.113.10", AssigneeID: 1, AutoDelete: false} + + result, err := Destroy(context.Background(), fc, DestroyRequest{ServerID: 1, ForceNoBackup: true, ReleaseIP: true}) + if err != nil { + t.Fatalf("Destroy: %v", err) + } + if len(result.ReleasedIPs) != 1 { + t.Fatalf("ReleasedIPs = %v, want the one primary IP", result.ReleasedIPs) + } +} + +func TestDestroy_MissingServerID(t *testing.T) { + fc := newFakeClient() + if _, err := Destroy(context.Background(), fc, DestroyRequest{ForceNoBackup: true}); err == nil { + t.Fatal("Destroy: want error when ServerID is 0") + } +} diff --git a/internal/server/fake_client_test.go b/internal/server/fake_client_test.go new file mode 100644 index 00000000..a0a45bcf --- /dev/null +++ b/internal/server/fake_client_test.go @@ -0,0 +1,145 @@ +package server + +// Purpose: an in-memory fake Client implementing the same interface as +// hetznerClient, shared by every *_test.go in this package. No test in this +// file (or any file using fakeClient) makes a network call — this is the +// hard constraint the task requires ("must NOT call a real provider API or +// create/destroy real infrastructure"). +// Inputs: constructed empty; tests populate .servers/.serverTypes/.images/ +// .actions/.primaryIPs directly before calling the function under test. +// Outputs: satisfies the Client interface exactly. + +import ( + "context" + "fmt" +) + +type fakeClient struct { + servers map[int64]*Server + serverTypes []ServerType + images map[int64]*Image + actions map[int64]*Action + primaryIPs map[int64]*PrimaryIP // keyed by IP ID + nextID int64 + + deletedServerIDs []int64 + createErr error +} + +func newFakeClient() *fakeClient { + return &fakeClient{ + servers: map[int64]*Server{}, + images: map[int64]*Image{}, + actions: map[int64]*Action{}, + primaryIPs: map[int64]*PrimaryIP{}, + nextID: 1, + } +} + +func (f *fakeClient) newID() int64 { + id := f.nextID + f.nextID++ + return id +} + +func (f *fakeClient) CreateServer(_ context.Context, req ProvisionRequest) (*Server, error) { + if f.createErr != nil { + return nil, f.createErr + } + id := f.newID() + s := &Server{ + ID: id, Name: req.Name, Status: "running", ServerType: req.ServerType, + Location: req.Location, Labels: req.Labels, + IPv4: "203.0.113.10", IPv4ID: f.newID(), + } + f.servers[id] = s + return s, nil +} + +func (f *fakeClient) ListServers(_ context.Context, _ ListOptions) ([]Server, error) { + out := make([]Server, 0, len(f.servers)) + for _, s := range f.servers { + out = append(out, *s) + } + return out, nil +} + +func (f *fakeClient) GetServer(_ context.Context, id int64) (*Server, error) { + s, ok := f.servers[id] + if !ok { + return nil, fmt.Errorf("server %d not found", id) + } + cp := *s + return &cp, nil +} + +func (f *fakeClient) DeleteServer(_ context.Context, id int64) error { + if _, ok := f.servers[id]; !ok { + return fmt.Errorf("server %d not found", id) + } + delete(f.servers, id) + f.deletedServerIDs = append(f.deletedServerIDs, id) + return nil +} + +func (f *fakeClient) ListServerTypes(_ context.Context) ([]ServerType, error) { + return f.serverTypes, nil +} + +func (f *fakeClient) ChangeServerType(_ context.Context, serverID int64, targetType string, _ bool) (*Action, error) { + s, ok := f.servers[serverID] + if !ok { + return nil, fmt.Errorf("server %d not found", serverID) + } + s.ServerType = targetType + return &Action{ID: f.newID(), Status: "success", Command: "change_server_type"}, nil +} + +func (f *fakeClient) CreateSnapshot(_ context.Context, serverID int64, description string) (*Image, *Action, error) { + if _, ok := f.servers[serverID]; !ok { + return nil, nil, fmt.Errorf("server %d not found", serverID) + } + imgID := f.newID() + img := &Image{ID: imgID, Type: "snapshot", Status: "creating", Description: description} + f.images[imgID] = img + act := &Action{ID: f.newID(), Status: "running", Command: "create_image"} + f.actions[act.ID] = act + return img, act, nil +} + +func (f *fakeClient) GetImage(_ context.Context, id int64) (*Image, error) { + img, ok := f.images[id] + if !ok { + return nil, fmt.Errorf("image %d not found", id) + } + cp := *img + return &cp, nil +} + +func (f *fakeClient) GetAction(_ context.Context, id int64) (*Action, error) { + act, ok := f.actions[id] + if !ok { + return nil, fmt.Errorf("action %d not found", id) + } + cp := *act + return &cp, nil +} + +func (f *fakeClient) ListPrimaryIPs(_ context.Context, serverID int64) ([]PrimaryIP, error) { + var out []PrimaryIP + for _, ip := range f.primaryIPs { + if ip.AssigneeID == serverID { + out = append(out, *ip) + } + } + return out, nil +} + +func (f *fakeClient) SetPrimaryIPAutoDelete(_ context.Context, ipID int64, autoDelete bool) error { + ip, ok := f.primaryIPs[ipID] + if !ok { + return fmt.Errorf("primary IP %d not found", ipID) + } + ip.AutoDelete = autoDelete + return nil +} diff --git a/internal/server/list.go b/internal/server/list.go new file mode 100644 index 00000000..d45c5ed5 --- /dev/null +++ b/internal/server/list.go @@ -0,0 +1,16 @@ +package server + +// Purpose: `nself server list` business logic — a thin pass-through to +// Client.ListServers. Exists as its own function (rather than the command +// calling the client directly) so tests and any future caller share one +// entry point, matching how List/Grant/Revoke work in internal/access. +// Inputs: a Client and ListOptions. +// Outputs: the servers Hetzner reports, unmodified. + +import "context" + +// List returns every server visible to client, optionally filtered by +// opts.LabelSelector. +func List(ctx context.Context, client Client, opts ListOptions) ([]Server, error) { + return client.ListServers(ctx, opts) +} diff --git a/internal/server/list_test.go b/internal/server/list_test.go new file mode 100644 index 00000000..38f180a7 --- /dev/null +++ b/internal/server/list_test.go @@ -0,0 +1,31 @@ +package server + +import ( + "context" + "testing" +) + +func TestList_ReturnsAllServers(t *testing.T) { + fc := newFakeClient() + fc.servers[1] = &Server{ID: 1, Name: "web-1"} + fc.servers[2] = &Server{ID: 2, Name: "web-2"} + + servers, err := List(context.Background(), fc, ListOptions{}) + if err != nil { + t.Fatalf("List: %v", err) + } + if len(servers) != 2 { + t.Fatalf("got %d servers, want 2", len(servers)) + } +} + +func TestList_Empty(t *testing.T) { + fc := newFakeClient() + servers, err := List(context.Background(), fc, ListOptions{}) + if err != nil { + t.Fatalf("List: %v", err) + } + if len(servers) != 0 { + t.Errorf("got %d servers, want 0", len(servers)) + } +} diff --git a/internal/server/primaryip.go b/internal/server/primaryip.go new file mode 100644 index 00000000..b5e15ed3 --- /dev/null +++ b/internal/server/primaryip.go @@ -0,0 +1,45 @@ +package server + +// Purpose: protect (or deliberately release) a server's primary IPs before +// destroy deletes it. Design requirement 2: Hetzner primary IPs default to +// auto_delete=true, so deleting the server permanently destroys the IP too +// — the exact state all 8 of this org's production primary IPs were found +// in. Destroy must flip auto_delete=false first unless the operator +// explicitly opts into releasing the IP with --release-ip. +// Inputs: a Client, a server ID, and whether the operator asked to release. +// Outputs: which IPs were retained (auto_delete now false) vs. released +// (auto_delete left/set true), so the command layer can print an accurate +// summary — never assumed, always read back from what was actually set. + +import ( + "context" + "fmt" +) + +// ProtectOrReleaseIPs reads serverID's primary IPs and, unless release is +// true, sets auto_delete=false on each so the upcoming server deletion does +// not take the IP with it. When release is true, it explicitly ensures +// auto_delete=true (Hetzner's default) so the IP is freed along with the +// server, and does so as a real, deliberate write rather than "leave it +// alone" — protecting a previous accidental protect-then-release cycle from +// ever landing on a false negative. +func ProtectOrReleaseIPs(ctx context.Context, client Client, serverID int64, release bool) (retained, released []PrimaryIP, err error) { + ips, err := client.ListPrimaryIPs(ctx, serverID) + if err != nil { + return nil, nil, fmt.Errorf("list primary IPs for server %d: %w", serverID, err) + } + + for _, ip := range ips { + wantAutoDelete := release + if err := client.SetPrimaryIPAutoDelete(ctx, ip.ID, wantAutoDelete); err != nil { + return nil, nil, fmt.Errorf("set auto_delete=%v on IP %s: %w", wantAutoDelete, ip.IP, err) + } + ip.AutoDelete = wantAutoDelete + if release { + released = append(released, ip) + } else { + retained = append(retained, ip) + } + } + return retained, released, nil +} diff --git a/internal/server/primaryip_test.go b/internal/server/primaryip_test.go new file mode 100644 index 00000000..a6a2c70f --- /dev/null +++ b/internal/server/primaryip_test.go @@ -0,0 +1,55 @@ +package server + +import ( + "context" + "testing" +) + +func TestProtectOrReleaseIPs_DefaultRetainsIP(t *testing.T) { + fc := newFakeClient() + fc.primaryIPs[10] = &PrimaryIP{ID: 10, IP: "203.0.113.10", Type: "ipv4", AssigneeID: 1, AutoDelete: true} + + retained, released, err := ProtectOrReleaseIPs(context.Background(), fc, 1, false) + if err != nil { + t.Fatalf("ProtectOrReleaseIPs: %v", err) + } + if len(released) != 0 { + t.Errorf("released = %v, want none", released) + } + if len(retained) != 1 || retained[0].IP != "203.0.113.10" { + t.Fatalf("retained = %v, want the one IP", retained) + } + if fc.primaryIPs[10].AutoDelete { + t.Error("auto_delete on the IP must be false after protecting it — this is the exact footgun G-011 closes") + } +} + +func TestProtectOrReleaseIPs_ReleaseFlag_LeavesAutoDeleteTrue(t *testing.T) { + fc := newFakeClient() + fc.primaryIPs[10] = &PrimaryIP{ID: 10, IP: "203.0.113.10", Type: "ipv4", AssigneeID: 1, AutoDelete: false} + + retained, released, err := ProtectOrReleaseIPs(context.Background(), fc, 1, true) + if err != nil { + t.Fatalf("ProtectOrReleaseIPs: %v", err) + } + if len(retained) != 0 { + t.Errorf("retained = %v, want none", retained) + } + if len(released) != 1 { + t.Fatalf("released = %v, want the one IP", released) + } + if !fc.primaryIPs[10].AutoDelete { + t.Error("--release-ip must set auto_delete=true so the IP is actually released") + } +} + +func TestProtectOrReleaseIPs_NoIPs_NoOp(t *testing.T) { + fc := newFakeClient() + retained, released, err := ProtectOrReleaseIPs(context.Background(), fc, 1, false) + if err != nil { + t.Fatalf("ProtectOrReleaseIPs: %v", err) + } + if len(retained) != 0 || len(released) != 0 { + t.Errorf("server with no primary IPs must report none retained/released, got retained=%v released=%v", retained, released) + } +} diff --git a/internal/server/provision.go b/internal/server/provision.go new file mode 100644 index 00000000..90366387 --- /dev/null +++ b/internal/server/provision.go @@ -0,0 +1,52 @@ +package server + +// Purpose: `nself server provision` business logic — validates the request +// and creates the server via Client. Deliberately thin: Hetzner does the +// real validation (bad server_type/location/image), we only add the checks +// that make a confusing provider error unnecessary. +// Inputs: a Client and a ProvisionRequest. +// Outputs: the created Server, or a wrapped error. +// Constraints: always stamps a managed-by=nself-cli label so `list` and a +// future cleanup pass can identify nself-created servers among others in +// the same Hetzner project. +import ( + "context" + "fmt" +) + +// ManagedByLabel marks every server this package creates, so `nself server +// list` (and any future audit) can distinguish nself-created boxes from +// anything else living in the same Hetzner project. +const ManagedByLabel = "managed-by" + +// ManagedByValue is ManagedByLabel's value on every server Provision creates. +const ManagedByValue = "nself-cli" + +// Provision validates req and creates the server through client. +func Provision(ctx context.Context, client Client, req ProvisionRequest) (*Server, error) { + if req.Name == "" { + return nil, fmt.Errorf("provision: --name is required") + } + if req.ServerType == "" { + return nil, fmt.Errorf("provision: --type is required (e.g. cx22)") + } + if req.Location == "" { + return nil, fmt.Errorf("provision: --location is required (e.g. fsn1)") + } + if req.Image == "" { + return nil, fmt.Errorf("provision: --image is required (e.g. ubuntu-24.04)") + } + + if req.Labels == nil { + req.Labels = map[string]string{} + } + if _, ok := req.Labels[ManagedByLabel]; !ok { + req.Labels[ManagedByLabel] = ManagedByValue + } + + srv, err := client.CreateServer(ctx, req) + if err != nil { + return nil, err + } + return srv, nil +} diff --git a/internal/server/provision_test.go b/internal/server/provision_test.go new file mode 100644 index 00000000..22613dc2 --- /dev/null +++ b/internal/server/provision_test.go @@ -0,0 +1,62 @@ +package server + +import ( + "context" + "testing" +) + +func TestProvision_HappyPath_StampsManagedByLabel(t *testing.T) { + fc := newFakeClient() + srv, err := Provision(context.Background(), fc, ProvisionRequest{ + Name: "ci-box", ServerType: "cx22", Location: "fsn1", Image: "ubuntu-24.04", + }) + if err != nil { + t.Fatalf("Provision: %v", err) + } + if srv.Name != "ci-box" { + t.Errorf("Name = %q, want ci-box", srv.Name) + } + if got := srv.Labels[ManagedByLabel]; got != ManagedByValue { + t.Errorf("label %s = %q, want %q", ManagedByLabel, got, ManagedByValue) + } + if len(fc.servers) != 1 { + t.Fatalf("expected exactly 1 server created, got %d", len(fc.servers)) + } +} + +func TestProvision_PreservesExplicitManagedByLabel(t *testing.T) { + fc := newFakeClient() + srv, err := Provision(context.Background(), fc, ProvisionRequest{ + Name: "ci-box", ServerType: "cx22", Location: "fsn1", Image: "ubuntu-24.04", + Labels: map[string]string{ManagedByLabel: "terraform"}, + }) + if err != nil { + t.Fatalf("Provision: %v", err) + } + if got := srv.Labels[ManagedByLabel]; got != "terraform" { + t.Errorf("label = %q, want caller's explicit value preserved", got) + } +} + +func TestProvision_MissingRequiredFields(t *testing.T) { + cases := []struct { + name string + req ProvisionRequest + }{ + {"missing name", ProvisionRequest{ServerType: "cx22", Location: "fsn1", Image: "ubuntu-24.04"}}, + {"missing type", ProvisionRequest{Name: "x", Location: "fsn1", Image: "ubuntu-24.04"}}, + {"missing location", ProvisionRequest{Name: "x", ServerType: "cx22", Image: "ubuntu-24.04"}}, + {"missing image", ProvisionRequest{Name: "x", ServerType: "cx22", Location: "fsn1"}}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + fc := newFakeClient() + if _, err := Provision(context.Background(), fc, c.req); err == nil { + t.Fatalf("Provision(%+v): want error, got nil", c.req) + } + if len(fc.servers) != 0 { + t.Fatalf("Provision must not create a server when validation fails") + } + }) + } +} diff --git a/internal/server/resize.go b/internal/server/resize.go new file mode 100644 index 00000000..2ddb4ab6 --- /dev/null +++ b/internal/server/resize.go @@ -0,0 +1,72 @@ +package server + +// Purpose: `nself server resize` business logic. Design requirement 3: +// Hetzner refuses to shrink a server's disk — the only path is +// snapshot-old -> create-new -> restore-from-snapshot -- so this must be +// detected and explained here, before ever calling the provider, rather +// than surfacing Hetzner's raw "invalid_input" error to the operator. +// Inputs: a Client, the target server ID, and a ResizeRequest. +// Outputs: the Action Hetzner started, or ErrDiskShrink with a plain-English +// explanation of the snapshot-based workaround. +// Constraints: the disk-shrink check requires one extra API call +// (ListServerTypes) to compare current vs. target disk size; this is +// deliberate — Hetzner's own error message for this case does not explain +// the workaround, so we do the comparison ourselves. + +import ( + "context" + "errors" + "fmt" +) + +// ErrDiskShrink is returned (wrapped, via errors.Is-compatible %w) when a +// resize would shrink the server's disk. Hetzner Cloud has no API to do +// this at all — it is a hard provider limitation, not a permission or quota +// issue — so this is never retried internally. +var ErrDiskShrink = errors.New("disk shrink is not supported by Hetzner Cloud") + +// Resize changes req.ServerID to req.TargetType. Before calling the +// provider, it fetches the server's current type and req.TargetType's specs +// and refuses (with ErrDiskShrink) if the target has a smaller disk than the +// server currently has. +func Resize(ctx context.Context, client Client, req ResizeRequest) (*Action, error) { + if req.ServerID == 0 { + return nil, fmt.Errorf("resize: server ID is required") + } + if req.TargetType == "" { + return nil, fmt.Errorf("resize: --type is required (target server type)") + } + + srv, err := client.GetServer(ctx, req.ServerID) + if err != nil { + return nil, fmt.Errorf("resize: %w", err) + } + + types, err := client.ListServerTypes(ctx) + if err != nil { + return nil, fmt.Errorf("resize: list server types: %w", err) + } + + var current, target *ServerType + for i := range types { + switch types[i].Name { + case srv.ServerType: + current = &types[i] + case req.TargetType: + target = &types[i] + } + } + if target == nil { + return nil, fmt.Errorf("resize: unknown target server type %q", req.TargetType) + } + if current != nil && target.Disk < current.Disk { + return nil, fmt.Errorf( + "resize %s (%s, %dGB disk) to %s (%dGB disk): %w — "+ + "the only supported path is: take a snapshot of the current server, "+ + "provision a new server of the smaller type from that snapshot, "+ + "verify it, then destroy the original", + srv.Name, srv.ServerType, current.Disk, req.TargetType, target.Disk, ErrDiskShrink) + } + + return client.ChangeServerType(ctx, req.ServerID, req.TargetType, req.UpgradeDisk) +} diff --git a/internal/server/resize_test.go b/internal/server/resize_test.go new file mode 100644 index 00000000..72fdd459 --- /dev/null +++ b/internal/server/resize_test.go @@ -0,0 +1,67 @@ +package server + +import ( + "context" + "errors" + "testing" +) + +func TestResize_DiskShrink_RefusedWithExplanation(t *testing.T) { + fc := newFakeClient() + fc.servers[1] = &Server{ID: 1, Name: "web-1", ServerType: "cx41"} + fc.serverTypes = []ServerType{ + {Name: "cx41", Cores: 4, Disk: 160}, + {Name: "cx22", Cores: 2, Disk: 40}, + } + + _, err := Resize(context.Background(), fc, ResizeRequest{ServerID: 1, TargetType: "cx22"}) + if err == nil { + t.Fatal("Resize: want error for disk shrink, got nil") + } + if !errors.Is(err, ErrDiskShrink) { + t.Errorf("Resize error = %v, want it to wrap ErrDiskShrink", err) + } + if fc.servers[1].ServerType != "cx41" { + t.Errorf("server type changed to %q despite refused resize", fc.servers[1].ServerType) + } +} + +func TestResize_Grow_Succeeds(t *testing.T) { + fc := newFakeClient() + fc.servers[1] = &Server{ID: 1, Name: "web-1", ServerType: "cx22"} + fc.serverTypes = []ServerType{ + {Name: "cx22", Cores: 2, Disk: 40}, + {Name: "cx41", Cores: 4, Disk: 160}, + } + + act, err := Resize(context.Background(), fc, ResizeRequest{ServerID: 1, TargetType: "cx41"}) + if err != nil { + t.Fatalf("Resize: %v", err) + } + if act.Status != "success" { + t.Errorf("action status = %q, want success", act.Status) + } + if fc.servers[1].ServerType != "cx41" { + t.Errorf("server type = %q, want cx41", fc.servers[1].ServerType) + } +} + +func TestResize_UnknownTargetType(t *testing.T) { + fc := newFakeClient() + fc.servers[1] = &Server{ID: 1, Name: "web-1", ServerType: "cx22"} + fc.serverTypes = []ServerType{{Name: "cx22", Disk: 40}} + + if _, err := Resize(context.Background(), fc, ResizeRequest{ServerID: 1, TargetType: "cx999"}); err == nil { + t.Fatal("Resize: want error for unknown target type, got nil") + } +} + +func TestResize_MissingFields(t *testing.T) { + fc := newFakeClient() + if _, err := Resize(context.Background(), fc, ResizeRequest{TargetType: "cx41"}); err == nil { + t.Error("Resize: want error when ServerID is 0") + } + if _, err := Resize(context.Background(), fc, ResizeRequest{ServerID: 1}); err == nil { + t.Error("Resize: want error when TargetType is empty") + } +} diff --git a/internal/server/snapshot.go b/internal/server/snapshot.go new file mode 100644 index 00000000..49e56f2a --- /dev/null +++ b/internal/server/snapshot.go @@ -0,0 +1,86 @@ +package server + +// Purpose: take a Hetzner snapshot and block until it is verifiably +// available, for `nself server destroy --snapshot`. Design requirement 1: +// tonight's manual procedure took a snapshot AND waited for it to reach +// status=available before deleting anything — a snapshot request that is +// still "creating" is not a backup yet, so destroy must never proceed past +// a snapshot that hasn't finished. +// Inputs: a Client, the server ID to snapshot, a description, and a wait +// budget (destroy.go passes DestroyRequest.SnapshotWait, defaulting to 10m +// in the command layer if unset). +// Outputs: the Image ID once status=available, or an error if the action +// failed or the wait budget was exceeded. +// Constraints: polls at a fixed interval rather than exponential backoff — +// snapshot creation is a single, already-slow operation (minutes), so +// backoff would only add latency with no benefit here. + +import ( + "context" + "fmt" + "time" +) + +// snapshotPollInterval is a var (not const) so tests can shrink it. +var snapshotPollInterval = 3 * time.Second + +// DefaultSnapshotWait is used by the command layer when --snapshot is passed +// without an explicit timeout. +const DefaultSnapshotWait = 10 * time.Minute + +// TakeVerifiedSnapshot creates a snapshot of serverID and blocks until +// Hetzner reports the resulting image as status=available. It returns as +// soon as either condition is met: success (image available), the +// underlying action reports status=error, or wait elapses. +func TakeVerifiedSnapshot(ctx context.Context, client Client, serverID int64, description string, wait time.Duration) (*Image, error) { + if wait <= 0 { + wait = DefaultSnapshotWait + } + + img, act, err := client.CreateSnapshot(ctx, serverID, description) + if err != nil { + return nil, fmt.Errorf("start snapshot: %w", err) + } + + return waitForSnapshotAvailable(ctx, client, img.ID, act, time.Now().Add(wait), wait) +} + +// waitForSnapshotAvailable polls act and the resulting image until the image +// reaches status=available, act reports status=error, deadline passes, or +// ctx is canceled — whichever comes first. +func waitForSnapshotAvailable(ctx context.Context, client Client, imageID int64, act *Action, deadline time.Time, wait time.Duration) (*Image, error) { + for { + if act.Status == "error" { + msg := "unknown error" + if act.Error != nil { + msg = fmt.Sprintf("%s: %s", act.Error.Code, act.Error.Message) + } + return nil, fmt.Errorf("snapshot action failed: %s", msg) + } + + img, err := client.GetImage(ctx, imageID) + if err != nil { + return nil, fmt.Errorf("poll snapshot image %d: %w", imageID, err) + } + if img.Status == "available" { + return img, nil + } + if time.Now().After(deadline) { + return nil, fmt.Errorf( + "snapshot image %d did not reach status=available within %s (last status: %q) — "+ + "destroy refused; re-run once the snapshot finishes, or check the Hetzner console", + imageID, wait, img.Status) + } + + select { + case <-ctx.Done(): + return nil, ctx.Err() + case <-time.After(snapshotPollInterval): + } + + act, err = client.GetAction(ctx, act.ID) + if err != nil { + return nil, fmt.Errorf("poll snapshot action %d: %w", act.ID, err) + } + } +} diff --git a/internal/server/snapshot_test.go b/internal/server/snapshot_test.go new file mode 100644 index 00000000..497b5ecb --- /dev/null +++ b/internal/server/snapshot_test.go @@ -0,0 +1,78 @@ +package server + +import ( + "context" + "testing" + "time" +) + +// pollCountingClient wraps fakeClient and flips its one image to +// status=available after flipAfter GetImage calls, so the wait loop is +// exercised across more than one poll tick without any goroutine/race. +type pollCountingClient struct { + *fakeClient + calls int + flipAfter int +} + +func (p *pollCountingClient) GetImage(ctx context.Context, id int64) (*Image, error) { + p.calls++ + if p.calls >= p.flipAfter { + p.images[id].Status = "available" + } + return p.fakeClient.GetImage(ctx, id) +} + +func TestTakeVerifiedSnapshot_BecomesAvailable(t *testing.T) { + oldInterval := snapshotPollInterval + snapshotPollInterval = time.Millisecond + t.Cleanup(func() { snapshotPollInterval = oldInterval }) + + fc := newFakeClient() + fc.servers[1] = &Server{ID: 1, Name: "web-1"} + pc := &pollCountingClient{fakeClient: fc, flipAfter: 3} + + img, act, err := pc.CreateSnapshot(context.Background(), 1, "test") + if err != nil { + t.Fatalf("seed CreateSnapshot: %v", err) + } + + got, err := waitForSnapshotAvailable(context.Background(), pc, img.ID, act, time.Now().Add(time.Second), time.Second) + if err != nil { + t.Fatalf("waitForSnapshotAvailable: %v", err) + } + if got.Status != "available" { + t.Errorf("status = %q, want available", got.Status) + } + if pc.calls < 3 { + t.Errorf("GetImage called %d times, want the wait loop to actually poll", pc.calls) + } +} + +func TestTakeVerifiedSnapshot_ActionError_Aborts(t *testing.T) { + fc := newFakeClient() + act := &Action{ID: 99, Status: "error", Error: &ActionError{Code: "resource_limit_exceeded", Message: "no capacity"}} + fc.actions[99] = act + fc.images[1] = &Image{ID: 1, Status: "creating"} + + _, err := waitForSnapshotAvailable(context.Background(), fc, 1, act, time.Now().Add(time.Second), time.Second) + if err == nil { + t.Fatal("want error when the action itself failed") + } +} + +func TestTakeVerifiedSnapshot_TimesOut(t *testing.T) { + oldInterval := snapshotPollInterval + snapshotPollInterval = time.Millisecond + t.Cleanup(func() { snapshotPollInterval = oldInterval }) + + fc := newFakeClient() + fc.images[1] = &Image{ID: 1, Status: "creating"} // never flips to available + act := &Action{ID: 1, Status: "running"} + fc.actions[1] = act + + _, err := waitForSnapshotAvailable(context.Background(), fc, 1, act, time.Now().Add(10*time.Millisecond), 10*time.Millisecond) + if err == nil { + t.Fatal("want timeout error when snapshot never becomes available") + } +} diff --git a/internal/server/token.go b/internal/server/token.go new file mode 100644 index 00000000..a7dce878 --- /dev/null +++ b/internal/server/token.go @@ -0,0 +1,50 @@ +package server + +// Purpose: resolve a Hetzner Cloud API token without ever hardcoding one. +// Mirrors the project-scoped vault pattern already in use for this repo +// (HETZNER_NSELF_TOKEN, documented in cli/.claude/CLAUDE.md's Vault +// Variables table) and generalizes it so any ~/Sites project's own token +// var (HETZNER_UNYECO_TOKEN, HETZNER_UMMECO_TOKEN, ...) works the same way +// without a code change — only the --token-env value differs. +// Inputs: an explicit --token flag value (highest priority, for one-off use +// or CI secrets), an env var name to check (--token-env, defaults to +// HETZNER_NSELF_TOKEN), and the process environment. +// Outputs: the resolved token, or an error naming exactly what was checked. +// Constraints: never logs or echoes the token value itself. + +import ( + "fmt" + "os" +) + +// DefaultTokenEnvVar is the vault-documented env var for this repo's own +// Hetzner project. Other ~/Sites projects pass their own var name via +// --token-env (e.g. HETZNER_UNYECO_TOKEN) rather than needing a code change. +const DefaultTokenEnvVar = "HETZNER_NSELF_TOKEN" + +// legacyTokenEnvVar is hcloud's own conventional var name, checked as a +// fallback so a shell already set up for the hcloud CLI works unmodified. +const legacyTokenEnvVar = "HCLOUD_TOKEN" + +// ResolveToken returns the Hetzner API token to use, in priority order: +// explicit (the --token flag) > envVar (--token-env, or DefaultTokenEnvVar +// if empty) > legacyTokenEnvVar. It never reads a token from a file or +// hardcodes one — vault.env is expected to be sourced into the process +// environment before nself runs, per this repo's credential doctrine. +func ResolveToken(explicit, envVar string) (string, error) { + if explicit != "" { + return explicit, nil + } + if envVar == "" { + envVar = DefaultTokenEnvVar + } + if v := os.Getenv(envVar); v != "" { + return v, nil + } + if v := os.Getenv(legacyTokenEnvVar); v != "" { + return v, nil + } + return "", fmt.Errorf( + "no Hetzner API token found: set %s (or %s) in the environment, or pass --token explicitly", + envVar, legacyTokenEnvVar) +} diff --git a/internal/server/token_test.go b/internal/server/token_test.go new file mode 100644 index 00000000..95557670 --- /dev/null +++ b/internal/server/token_test.go @@ -0,0 +1,67 @@ +package server + +import ( + "os" + "strings" + "testing" +) + +func TestResolveToken_ExplicitWins(t *testing.T) { + t.Setenv(DefaultTokenEnvVar, "from-env") + got, err := ResolveToken("from-flag", "") + if err != nil { + t.Fatalf("ResolveToken: %v", err) + } + if got != "from-flag" { + t.Errorf("got %q, want explicit flag value to win", got) + } +} + +func TestResolveToken_DefaultEnvVar(t *testing.T) { + os.Unsetenv(legacyTokenEnvVar) + t.Setenv(DefaultTokenEnvVar, "vault-token") + got, err := ResolveToken("", "") + if err != nil { + t.Fatalf("ResolveToken: %v", err) + } + if got != "vault-token" { + t.Errorf("got %q, want %s value", got, DefaultTokenEnvVar) + } +} + +func TestResolveToken_ProjectScopedEnvVar(t *testing.T) { + os.Unsetenv(legacyTokenEnvVar) + os.Unsetenv(DefaultTokenEnvVar) + t.Setenv("HETZNER_UNYECO_TOKEN", "unyeco-token") + got, err := ResolveToken("", "HETZNER_UNYECO_TOKEN") + if err != nil { + t.Fatalf("ResolveToken: %v", err) + } + if got != "unyeco-token" { + t.Errorf("got %q, want the project-scoped env var honored", got) + } +} + +func TestResolveToken_LegacyFallback(t *testing.T) { + os.Unsetenv(DefaultTokenEnvVar) + t.Setenv(legacyTokenEnvVar, "hcloud-cli-token") + got, err := ResolveToken("", "") + if err != nil { + t.Fatalf("ResolveToken: %v", err) + } + if got != "hcloud-cli-token" { + t.Errorf("got %q, want HCLOUD_TOKEN fallback", got) + } +} + +func TestResolveToken_NoneSet_ErrorNamesWhatWasChecked(t *testing.T) { + os.Unsetenv(DefaultTokenEnvVar) + os.Unsetenv(legacyTokenEnvVar) + _, err := ResolveToken("", "") + if err == nil { + t.Fatal("ResolveToken: want error when no token is available anywhere") + } + if !strings.Contains(err.Error(), DefaultTokenEnvVar) { + t.Errorf("error %q should name the env var it checked", err.Error()) + } +} diff --git a/internal/server/types.go b/internal/server/types.go new file mode 100644 index 00000000..2963b7fa --- /dev/null +++ b/internal/server/types.go @@ -0,0 +1,113 @@ +// Package server implements `nself server` (G-011): provisioning, listing, +// resizing, and destroying cloud servers, backed by the Hetzner Cloud API. +// +// Purpose: shared data types for the server lifecycle. Kept separate from +// client.go so the wire-format structs (json tags) are easy to audit +// independent of transport/HTTP concerns. +// Inputs: none (pure type definitions). +// Outputs: none. +// Constraints: field sets are intentionally narrow — only what `nself server` +// itself needs, not a full Hetzner API mirror. +package server + +import "time" + +// Server is the subset of a Hetzner Cloud server this package cares about. +type Server struct { + ID int64 `json:"id"` + Name string `json:"name"` + Status string `json:"status"` + ServerType string `json:"server_type"` + Location string `json:"location"` + Created time.Time `json:"created"` + IPv4 string `json:"ipv4"` + IPv6 string `json:"ipv6"` + IPv4ID int64 `json:"ipv4_id,omitempty"` + IPv6ID int64 `json:"ipv6_id,omitempty"` + Labels map[string]string `json:"labels,omitempty"` +} + +// ServerType describes a Hetzner server type's capacity, used by Resize to +// detect a disk-shrink request before ever calling the provider. +type ServerType struct { + Name string `json:"name"` + Cores int `json:"cores"` + Memory float64 `json:"memory"` + Disk int `json:"disk"` // GB +} + +// PrimaryIP is a Hetzner primary IP resource. AutoDelete controls whether +// the IP is destroyed along with its assigned server — the exact footgun +// design requirement 2 exists to close. +type PrimaryIP struct { + ID int64 `json:"id"` + IP string `json:"ip"` + Type string `json:"type"` // "ipv4" | "ipv6" + AssigneeID int64 `json:"assignee_id"` + AutoDelete bool `json:"auto_delete"` +} + +// Image is a Hetzner image/snapshot resource. +type Image struct { + ID int64 `json:"id"` + Type string `json:"type"` // "snapshot", "backup", "system", ... + Status string `json:"status"` // "creating" | "available" + Description string `json:"description"` +} + +// Action is a Hetzner async action (used to poll snapshot creation). +type Action struct { + ID int64 `json:"id"` + Status string `json:"status"` // "running" | "success" | "error" + Command string `json:"command"` + Error *ActionError `json:"error,omitempty"` + Progress int `json:"progress"` +} + +// ActionError is the error payload embedded in a failed Action. +type ActionError struct { + Code string `json:"code"` + Message string `json:"message"` +} + +// ProvisionRequest describes a server to create. +type ProvisionRequest struct { + Name string + ServerType string + Location string + Image string + SSHKeys []string + Labels map[string]string + UserData string +} + +// ListOptions filters `nself server list`. +type ListOptions struct { + LabelSelector string +} + +// ResizeRequest describes a resize (change_type) request. +type ResizeRequest struct { + ServerID int64 + TargetType string + UpgradeDisk bool +} + +// DestroyRequest describes a destroy request and the safety choices made by +// the operator invoking it. +type DestroyRequest struct { + ServerID int64 + TakeSnapshot bool + ForceNoBackup bool + ReleaseIP bool + SnapshotWait time.Duration +} + +// DestroyResult reports what a Destroy call actually did, so the command +// layer can print an accurate summary (never assume from the request alone — +// e.g. a server with no primary IPs retains none). +type DestroyResult struct { + SnapshotID int64 + RetainedIPs []PrimaryIP + ReleasedIPs []PrimaryIP +} diff --git a/internal/server/wire.go b/internal/server/wire.go new file mode 100644 index 00000000..01defa31 --- /dev/null +++ b/internal/server/wire.go @@ -0,0 +1,106 @@ +package server + +// Purpose: Hetzner Cloud API wire-format structs (exact JSON shapes the API +// sends/receives) and the mapping functions that convert them to this +// package's own Server/ServerType/PrimaryIP/Image types. Kept separate from +// types.go (our types) and client_ops.go (the calls) so a wire-format change +// touches exactly one file. +// Inputs: raw Hetzner API JSON, decoded by client.go's do(). +// Outputs: this package's own types, decoupled from Hetzner's field naming. +// Constraints: never export these wire structs — callers outside this +// package must only ever see types.go's stable shapes. + +import "time" + +type wireServer struct { + ID int64 `json:"id"` + Name string `json:"name"` + Status string `json:"status"` + Created time.Time `json:"created"` + ServerType wireServerType `json:"server_type"` + Datacenter struct { + Location struct { + Name string `json:"name"` + } `json:"location"` + } `json:"datacenter"` + PublicNet struct { + IPv4 struct { + ID int64 `json:"id"` + IP string `json:"ip"` + } `json:"ipv4"` + IPv6 struct { + ID int64 `json:"id"` + IP string `json:"ip"` + } `json:"ipv6"` + } `json:"public_net"` + Labels map[string]string `json:"labels"` +} + +func (w wireServer) toServer() Server { + return Server{ + ID: w.ID, + Name: w.Name, + Status: w.Status, + ServerType: w.ServerType.Name, + Location: w.Datacenter.Location.Name, + Created: w.Created, + IPv4: w.PublicNet.IPv4.IP, + IPv4ID: w.PublicNet.IPv4.ID, + IPv6: w.PublicNet.IPv6.IP, + IPv6ID: w.PublicNet.IPv6.ID, + Labels: w.Labels, + } +} + +type wireServerType struct { + Name string `json:"name"` + Cores int `json:"cores"` + Memory float64 `json:"memory"` + Disk int `json:"disk"` +} + +func (w wireServerType) toServerType() ServerType { + return ServerType(w) +} + +type wirePrimaryIP struct { + ID int64 `json:"id"` + IP string `json:"ip"` + Type string `json:"type"` + AssigneeID int64 `json:"assignee_id"` + AutoDelete bool `json:"auto_delete"` +} + +func (w wirePrimaryIP) toPrimaryIP() PrimaryIP { + return PrimaryIP(w) +} + +type wireImage struct { + ID int64 `json:"id"` + Type string `json:"type"` + Status string `json:"status"` + Description string `json:"description"` +} + +func (w wireImage) toImage() Image { + return Image(w) +} + +type wireAction struct { + ID int64 `json:"id"` + Status string `json:"status"` + Command string `json:"command"` + Progress int `json:"progress"` + Error *struct { + Code string `json:"code"` + Message string `json:"message"` + } `json:"error"` +} + +func (w wireAction) toAction() Action { + a := Action{ID: w.ID, Status: w.Status, Command: w.Command, Progress: w.Progress} + if w.Error != nil { + a.Error = &ActionError{Code: w.Error.Code, Message: w.Error.Message} + } + return a +} From fc7d56e158c5683017502b7abd3e21876f76e672 Mon Sep 17 00:00:00 2001 From: Aric Camarata Date: Fri, 11 Sep 2026 17:56:40 -0400 Subject: [PATCH 2/2] docs(wiki): regenerate sidebar and llms.txt index for nself server (make wiki-commands) --- .github/wiki/_Sidebar.md | 4 ++-- .github/wiki/llms.txt | 19 ++++++++++++++++++- 2 files changed, 20 insertions(+), 3 deletions(-) diff --git a/.github/wiki/_Sidebar.md b/.github/wiki/_Sidebar.md index 9d307491..11d54657 100644 --- a/.github/wiki/_Sidebar.md +++ b/.github/wiki/_Sidebar.md @@ -279,7 +279,7 @@ -**All commands (50)** +**All commands (51)** - _A:_ [[cmd-access]] · [[cmd-account]] · [[cmd-admin]] - _B:_ [[cmd-backup]] · [[cmd-build]] · [[cmd-bundle]] @@ -295,7 +295,7 @@ - _O:_ [[cmd-oauth]] · [[cmd-ops]] - _P:_ [[cmd-plugin]] · [[cmd-promote]] - _R:_ [[cmd-remove]] · [[cmd-reset]] · [[cmd-restart]] -- _S:_ [[cmd-secrets]] · [[cmd-security]] · [[cmd-self-heal]] · [[cmd-service]] · [[cmd-start]] · [[cmd-status]] · [[cmd-stop]] +- _S:_ [[cmd-secrets]] · [[cmd-security]] · [[cmd-self-heal]] · [[cmd-server]] · [[cmd-service]] · [[cmd-start]] · [[cmd-status]] · [[cmd-stop]] - _T:_ [[cmd-telemetry]] · [[cmd-template]] · [[cmd-trust]] - _U:_ [[cmd-update]] · [[cmd-urls]] - _V:_ [[cmd-verify-sbom]] · [[cmd-version]] diff --git a/.github/wiki/llms.txt b/.github/wiki/llms.txt index da988aa3..5d1a84d0 100644 --- a/.github/wiki/llms.txt +++ b/.github/wiki/llms.txt @@ -14,7 +14,7 @@ nself build # generate docker-compose + nginx nself start # boot the stack ``` -## Commands (50) +## Commands (51) ### nself access @@ -858,6 +858,23 @@ Flags: Full page: [[cmd-self-heal]] +### nself server + +Provision, list, resize, and destroy Hetzner Cloud servers + +``` +nself server [flags] +``` + +Subcommands: + +- `destroy` — Delete a Hetzner Cloud server +- `list` — List Hetzner Cloud servers +- `provision` — Create a new Hetzner Cloud server +- `resize` — Change a server's type (CPU/RAM/disk) + +Full page: [[cmd-server]] + ### nself service Manage optional services