diff --git a/CHANGELOG.md b/CHANGELOG.md index e98934a..bdcf289 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,26 @@ While the CLI is pre-1.0, minor versions may carry breaking changes to the publi ### Added +- **`dhq servers create`**: `--key-pair-identifier` provisions a Managed VPS with + an existing account SSH key, selected by the public identifier that + `dhq ssh-keys list` prints. Sent as a top-level provisioning param alongside + `--region`/`--size`/`--os-image`. Omit it to keep the current behaviour, where + DeployHQ creates and reuses one shared managed key. Rejected locally, before + any request, for a non-`managed_vps` protocol or alongside + `--global-key-pair-id` (the ssh/rsync equivalent). Requires the matching API + change (DHQ-692). +- **SDK**: `ServerCreateRequest.KeyPairIdentifier` (hoisted, `json:"-"`), and + `ManagedVPSInfo.SSHKey` (`*ManagedVPSSSHKey` — identifier, title, fingerprint) + for the read-back. Purely additive. + +### Fixed + +- **Agent skill**: `references/global-resources.md` documented + `dhq ssh-keys create --name --public-key`; neither flag exists. Keys are + generated server-side and the flags are `--title` (required) and `--type` + (`ED25519` default, or `RSA`). Also documents `ssh-keys download -o` and + `ssh-keys delete`. + - **`dhq servers create` / `dhq servers update`**: five deployment-configuration flags — `--branch` (the server's preferred branch), `--auto-deploy` (DeployHQ's native repository auto-deployment), `--atomic` (zero-downtime diff --git a/internal/commands/servers.go b/internal/commands/servers.go index b72280a..53a6168 100644 --- a/internal/commands/servers.go +++ b/internal/commands/servers.go @@ -386,7 +386,7 @@ func newServersCreateCmd() *cobra.Command { var spaMode bool var subdirectory string // Managed VPS (beta) - var region, size, osImage string + var region, size, osImage, keyPairIdentifier string // Billing guardrail (mirrors the gate in `dhq launch`) var acceptCost bool // Deployment configuration shared with `dhq servers update` @@ -440,6 +440,23 @@ func newServersCreateCmd() *cobra.Command { if err := deployFlags.validate(); err != nil { return err } + if keyPairIdentifier != "" { + // The identifier is hoisted to the Managed VPS provisioning + // boundary; no other protocol has one, and the backend would + // silently drop it rather than error. + if protocolType != "managed_vps" { + return &output.UserError{ + Message: fmt.Sprintf("--key-pair-identifier is only valid with --protocol-type managed_vps (got %q)", protocolType), + Hint: "For ssh and rsync servers use --global-key-pair-id instead.", + } + } + if globalKeyPairID != "" { + return &output.UserError{ + Message: "--key-pair-identifier and --global-key-pair-id are mutually exclusive", + Hint: "Use --key-pair-identifier for managed_vps; --global-key-pair-id applies to ssh and rsync servers.", + } + } + } projectID, err := cliCtx.RequireProject() if err != nil { @@ -485,6 +502,8 @@ func newServersCreateCmd() *cobra.Command { Region: region, Size: size, OSImage: osImage, + // Hoisted to a top-level sibling of `server` by CreateServer. + KeyPairIdentifier: keyPairIdentifier, } deployFlags.applyToCreate(&req) // Static Hosting (beta) — nested attributes @@ -665,6 +684,10 @@ func newServersCreateCmd() *cobra.Command { cmd.Flags().StringVar(®ion, "region", "", "DigitalOcean region slug, e.g. lon1, nyc3 (managed_vps)") cmd.Flags().StringVar(&size, "size", "", "DigitalOcean droplet size slug, e.g. s-1vcpu-1gb (managed_vps)") cmd.Flags().StringVar(&osImage, "os-image", "", "OS image slug (managed_vps, default: ubuntu-24-04-x64)") + cmd.Flags().StringVar(&keyPairIdentifier, "key-pair-identifier", "", + "Public identifier of an existing account SSH key to provision the droplet with (managed_vps). "+ + "Use the Identifier column from `dhq ssh-keys list`. "+ + "Omit to let DeployHQ create and reuse its shared managed key") // Cost-acknowledgement guardrail — required for managed_vps in non-interactive mode cmd.Flags().BoolVar(&acceptCost, "accept-cost", false, "Acknowledge Managed VPS provisioning — "+managedVPSAcknowledgePhrase()+" (required for non-interactive managed_vps creation)") diff --git a/internal/commands/servers_test.go b/internal/commands/servers_test.go index 3ce1267..0f1be67 100644 --- a/internal/commands/servers_test.go +++ b/internal/commands/servers_test.go @@ -593,3 +593,65 @@ func TestServersCreate_ManagedVPSWithAcceptCost_Proceeds(t *testing.T) { assert.Equal(t, "lon1", cap.body["region"]) assert.Equal(t, "s-1vcpu-1gb", cap.body["size"]) } + +// ── Managed VPS SSH key selection (DHQ-692) ────────────────────────────────── + +func TestServersCreate_RegistersKeyPairIdentifierFlag(t *testing.T) { + root := NewRootCmd("test") + createCmd, _, err := root.Find([]string{"servers", "create"}) + require.NoError(t, err) + require.NotNil(t, createCmd.Flags().Lookup("key-pair-identifier")) + + // It is Managed-VPS-only; update has no provisioning boundary to hoist to. + updateCmd, _, err := root.Find([]string{"servers", "update"}) + require.NoError(t, err) + assert.Nil(t, updateCmd.Flags().Lookup("key-pair-identifier"), + "--key-pair-identifier applies to Managed VPS provisioning, not update") +} + +func TestServersCreate_KeyPairIdentifierRejectedForNonVPS_NoHTTP(t *testing.T) { + withResolvableContext(t) + called := blockNetwork(t) + + err := runServersCmd(t, "servers", "create", + "--name", "web", "--protocol-type", "ssh", "--hostname", "h", "--username", "u", + "--key-pair-identifier", "key-uuid-123", + ) + require.Error(t, err) + assert.Contains(t, err.Error(), "managed_vps") + assert.False(t, *called, "must fail locally, before any request") +} + +func TestServersCreate_KeyPairIdentifierConflictsWithGlobalKeyPairID_NoHTTP(t *testing.T) { + withResolvableContext(t) + called := blockNetwork(t) + + err := runServersCmd(t, "servers", "create", + "--name", "vps", "--protocol-type", "managed_vps", + "--region", "lon1", "--size", "s-1vcpu-1gb", "--accept-cost", + "--key-pair-identifier", "key-uuid-123", + "--global-key-pair-id", "other-key", + ) + require.Error(t, err) + assert.Contains(t, err.Error(), "mutually exclusive") + assert.False(t, *called, "must fail locally, before any request") +} + +func TestServersCreate_ManagedVPSSendsKeyPairIdentifierTopLevel(t *testing.T) { + withResolvableContext(t) + cap := captureRequest(t) + + err := runServersCmd(t, "servers", "create", + "--name", "vps", "--protocol-type", "managed_vps", + "--region", "lon1", "--size", "s-1vcpu-1gb", "--accept-cost", + "--key-pair-identifier", "key-uuid-123", + ) + require.NoError(t, err) + + srv := cap.server(t) + assert.NotContains(t, srv, "key_pair_identifier", "must not nest inside server") + + cap.mu.Lock() + defer cap.mu.Unlock() + assert.Equal(t, "key-uuid-123", cap.body["key_pair_identifier"]) +} diff --git a/pkg/sdk/managed_vps_key_test.go b/pkg/sdk/managed_vps_key_test.go new file mode 100644 index 0000000..65fd1d1 --- /dev/null +++ b/pkg/sdk/managed_vps_key_test.go @@ -0,0 +1,88 @@ +package sdk + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// The key identifier is a TOP-LEVEL provisioning param, a sibling of `server` — +// the backend reads params[:key_pair_identifier], not params[:server][...]. +// Sending it nested is a silent no-op: Rails' permit list for Servers::ManagedVps +// drops unknown nested keys and still returns 2xx. +func TestCreateServer_ManagedVPS_KeyPairIdentifierIsTopLevel(t *testing.T) { + var body map[string]any + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + require.NoError(t, json.NewDecoder(r.Body).Decode(&body)) + w.WriteHeader(http.StatusCreated) + _ = json.NewEncoder(w).Encode(Server{Identifier: "srv-1", Name: "vps"}) + })) + defer srv.Close() + + c := newTestClient(t, srv) + _, err := c.CreateServer(context.Background(), "my-app", ServerCreateRequest{ + Name: "vps", + ProtocolType: "managed_vps", + Region: "lon1", + Size: "s-1vcpu-1gb", + KeyPairIdentifier: "key-uuid-123", + }) + require.NoError(t, err) + + assert.Equal(t, "key-uuid-123", body["key_pair_identifier"], "must be a top-level sibling of server") + + server := body["server"].(map[string]any) + assert.NotContains(t, server, "key_pair_identifier", "must NOT be nested inside server") + assert.NotContains(t, server, "key_pair_id", "the internal id is never sent") +} + +func TestCreateServer_OmittedKeyPairIdentifierIsAbsent(t *testing.T) { + var body map[string]any + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + require.NoError(t, json.NewDecoder(r.Body).Decode(&body)) + w.WriteHeader(http.StatusCreated) + _ = json.NewEncoder(w).Encode(Server{Identifier: "srv-1"}) + })) + defer srv.Close() + + c := newTestClient(t, srv) + _, err := c.CreateServer(context.Background(), "my-app", ServerCreateRequest{ + Name: "vps", ProtocolType: "managed_vps", Region: "lon1", Size: "s-1vcpu-1gb", + }) + require.NoError(t, err) + assert.NotContains(t, body, "key_pair_identifier") +} + +// Read-back: the CLI must surface which key was applied, and never key material. +func TestServer_ManagedVPS_SSHKeyReadBack(t *testing.T) { + raw := `{ + "identifier": "srv-1", + "name": "vps", + "managed_vps": { + "hosted_resource_identifier": "hr-1", + "status": "active", + "region": "lon1", + "size": "s-1vcpu-1gb", + "ssh_key": {"identifier": "key-uuid-123", "title": "ops key", "fingerprint": "SHA256:abc"} + } + }` + var s Server + require.NoError(t, json.Unmarshal([]byte(raw), &s)) + require.NotNil(t, s.ManagedVPS) + require.NotNil(t, s.ManagedVPS.SSHKey) + assert.Equal(t, "key-uuid-123", s.ManagedVPS.SSHKey.Identifier) + assert.Equal(t, "ops key", s.ManagedVPS.SSHKey.Title) + assert.Equal(t, "SHA256:abc", s.ManagedVPS.SSHKey.Fingerprint) +} + +func TestServer_ManagedVPS_SSHKeyAbsent(t *testing.T) { + var s Server + require.NoError(t, json.Unmarshal([]byte(`{"identifier":"srv-1","managed_vps":{"status":"provisioning"}}`), &s)) + require.NotNil(t, s.ManagedVPS) + assert.Nil(t, s.ManagedVPS.SSHKey, "absent ssh_key must stay nil, not a zero-valued struct") +} diff --git a/pkg/sdk/servers.go b/pkg/sdk/servers.go index d9e68b8..5ab12d3 100644 --- a/pkg/sdk/servers.go +++ b/pkg/sdk/servers.go @@ -44,6 +44,9 @@ func (c *Client) CreateServer(ctx context.Context, projectID string, req ServerC if req.OSImage != "" { body["os_image"] = req.OSImage } + if req.KeyPairIdentifier != "" { + body["key_pair_identifier"] = req.KeyPairIdentifier + } var server Server if err := c.post(ctx, fmt.Sprintf("/projects/%s/servers", projectID), body, &server); err != nil { return nil, err diff --git a/pkg/sdk/types.go b/pkg/sdk/types.go index f4a6a57..63fb451 100644 --- a/pkg/sdk/types.go +++ b/pkg/sdk/types.go @@ -213,6 +213,13 @@ type ServerCreateRequest struct { Size string `json:"-"` // OSImage is the DigitalOcean image slug (defaults to "ubuntu-24-04-x64" when empty). OSImage string `json:"-"` + // KeyPairIdentifier selects an existing account SSH key for a Managed VPS by + // its PUBLIC identifier — the one `dhq ssh-keys list` prints. The backend + // resolves it against the account's own keys and rejects an unknown or + // foreign identifier with 422, creating neither a server nor a hosted + // resource. Leave empty to let DeployHQ create and reuse its shared managed + // key. Not the internal database id, which the API never accepts from a client. + KeyPairIdentifier string `json:"-"` } // ServerUpdateRequest is the payload for updating a server. @@ -531,6 +538,21 @@ type ManagedVPSInfo struct { Size string `json:"size,omitempty"` // MonthlyCost is the droplet's monthly cost (string or number on the wire). MonthlyCost FlexString `json:"monthly_cost,omitempty"` + // SSHKey identifies the account SSH key the droplet was provisioned with. + // Nil until a key has been assigned. Deliberately narrow — the API exposes + // no key material and no internal id on this endpoint. + SSHKey *ManagedVPSSSHKey `json:"ssh_key,omitempty"` +} + +// ManagedVPSSSHKey is the account SSH key a Managed VPS was provisioned with, +// as reported by the server read-back. +type ManagedVPSSSHKey struct { + // Identifier is the key's public identifier, matching `dhq ssh-keys list`. + Identifier string `json:"identifier"` + // Title is the key's human-readable name. + Title string `json:"title,omitempty"` + // Fingerprint is the key's fingerprint. + Fingerprint string `json:"fingerprint,omitempty"` } // StaticHostingInfo is the nested `static_hosting` object within a server response for static_hosting servers. diff --git a/skill-evals/deployhq/evals.json b/skill-evals/deployhq/evals.json index ca9a3c9..e63fea9 100644 --- a/skill-evals/deployhq/evals.json +++ b/skill-evals/deployhq/evals.json @@ -607,6 +607,49 @@ }, "must_not_contain": ["servers update srv-001 --atomic"], "notes": "Atomic cannot be changed once a server has any deployment. The agent must check for existing deployments or warn the user rather than blindly issuing 'dhq servers update --atomic', which the backend rejects." + }, + { + "id": "list-ssh-keys", + "category": "global", + "prompt": "Show me the SSH keys on my account", + "expected": { + "command": "dhq ssh-keys list", + "flags": ["--json"] + }, + "notes": "The Identifier column is the public identifier used everywhere else." + }, + { + "id": "create-ssh-key", + "category": "global", + "prompt": "Create a new SSH key called 'Ops Recovery Key'", + "expected": { + "command": "dhq ssh-keys create", + "flags": ["--title", "Ops Recovery Key"] + }, + "must_not_contain": ["--name", "--public-key"], + "notes": "Keys are generated server-side; --title is the flag, not --name, and there is no --public-key." + }, + { + "id": "create-managed-vps-with-existing-key", + "category": "servers", + "prompt": "Create a Managed VPS called ops in my-app using my existing SSH key key-abc123, in lon1 at s-1vcpu-1gb", + "expected": { + "command": "dhq servers create", + "flags": ["-p", "my-app", "--protocol-type", "managed_vps", "--key-pair-identifier", "key-abc123", "--accept-cost"] + }, + "must_not_contain": ["--global-key-pair-id", "api POST"], + "notes": "managed_vps uses --key-pair-identifier with the public identifier; --global-key-pair-id is the ssh/rsync flag and is rejected here. --accept-cost is required non-interactively." + }, + { + "id": "verify-managed-vps-key", + "category": "servers", + "prompt": "Which SSH key was my Managed VPS srv-001 in my-app provisioned with?", + "expected": { + "command": "dhq servers show", + "args": ["srv-001"], + "flags": ["-p", "my-app", "managed_vps"] + }, + "notes": "The key is under managed_vps.ssh_key in the read-back." } ] } diff --git a/skills/deployhq/SKILL.md b/skills/deployhq/SKILL.md index 326d010..00d0455 100644 --- a/skills/deployhq/SKILL.md +++ b/skills/deployhq/SKILL.md @@ -41,7 +41,7 @@ Verify with: `dhq auth status` - **TTY mode**: Table output with headers - **Piped/non-TTY**: Auto-switches to JSON -- **`--json`**: Force JSON output. Optionally select fields: `--json name,status,identifier` +- **`--json`**: Force JSON output. Optionally select fields: `--json=name,status,identifier` - **Breadcrumbs**: JSON responses include `breadcrumbs` array with suggested next commands - **Exit codes**: 0 = success, non-zero = failure @@ -136,7 +136,7 @@ dhq api POST /projects//deployments --body '{"deployment":{...}}' - `dhq env-vars create` prompts for value if `--value` is omitted (not agent-friendly — always pass `--value`) - `dhq servers create` / `dhq servers update` configure deployment behaviour with `--branch` (preferred branch), `--auto-deploy` (DeployHQ's native auto-deployment), `--atomic`, `--atomic-strategy` and `--atomic-retention`. On `update`, only flags you explicitly pass are sent — omitted flags never disturb existing settings - **`--atomic` must be set before the server's first deployment.** The backend refuses to change it once any deployment exists ("cannot be changed after a deployment has been made to this server") and there is no override — the only remedy is a new server. Set it at `create` time; check `dhq deployments list -p --json` before ever putting `--atomic` on an `update` -- `--atomic` fails **silently** on accounts without atomic deployments enabled: the fields are stripped server-side and the request still returns 2xx with atomic off. A zero exit code does not prove it applied — read it back with `dhq servers show -p --json atomic,atomic_strategy,atomic_retention` +- `--atomic` fails **silently** on accounts without atomic deployments enabled: the fields are stripped server-side and the request still returns 2xx with atomic off. A zero exit code does not prove it applied — read it back with `dhq servers show -p --json=atomic,atomic_strategy,atomic_retention` ## Triggers diff --git a/skills/deployhq/references/global-resources.md b/skills/deployhq/references/global-resources.md index d076d17..62e9838 100644 --- a/skills/deployhq/references/global-resources.md +++ b/skills/deployhq/references/global-resources.md @@ -107,16 +107,51 @@ dhq global-config-files delete 12345 ## SSH Keys +Account-level SSH keys. These are the same keys a Managed VPS can be provisioned +with — see `--key-pair-identifier` in [servers.md](servers.md). + ### `dhq ssh-keys list` +Columns: `Title | Identifier | Type | Fingerprint`. **`Identifier` is the public +identifier** — the value every other command takes. There is no way to obtain or +pass an internal database id, by design. + ```bash dhq ssh-keys list --json +dhq ssh-keys list --json=title,identifier,fingerprint ``` ### `dhq ssh-keys create` +Keys are **generated server-side** — you do not supply your own public key. + +| Flag | Required | Description | +|------|----------|-------------| +| `--title` | yes | Key title | +| `--type` | no | `ED25519` (default) or `RSA` | + ```bash -dhq ssh-keys create --name "Deploy Key" --public-key "ssh-rsa AAAA..." --json +dhq ssh-keys create --title "Ops Recovery Key" --json +dhq ssh-keys create --title "Legacy Host Key" --type RSA --json ``` +### `dhq ssh-keys download ` +Writes the **private** key. Without `-o/--output` it goes to stdout, which for a +secret is almost never what you want in an agent or CI context — prefer a file. + +```bash +dhq ssh-keys download key-abc123 -o ~/.ssh/deployhq_ops +chmod 600 ~/.ssh/deployhq_ops +``` + +Never paste the output into a log, a PR, an issue, or a chat transcript. + +### `dhq ssh-keys delete ` +```bash +dhq ssh-keys delete key-abc123 +``` + +A key still attached to a Managed VPS cannot be deleted — the backend rejects it +and names the resources using it. Delete or re-key the resource first. + ## Templates ### `dhq templates list` diff --git a/skills/deployhq/references/servers.md b/skills/deployhq/references/servers.md index 1cf2134..3fa70ae 100644 --- a/skills/deployhq/references/servers.md +++ b/skills/deployhq/references/servers.md @@ -7,7 +7,7 @@ List servers in project. ```bash dhq servers list -p my-app --json -dhq servers list -p my-app --json name,identifier,protocol_type +dhq servers list -p my-app --json=name,identifier,protocol_type ``` ### `dhq servers show ` @@ -64,7 +64,7 @@ Two further backend constraints on `--atomic` — note that they fail *different > the setting back: > > ```bash -> dhq servers show srv-001 -p my-app --json atomic,atomic_strategy,atomic_retention +> dhq servers show srv-001 -p my-app --json=atomic,atomic_strategy,atomic_retention > ``` > > If `atomic` is `false` after a command that exited 0, the account does not @@ -99,7 +99,7 @@ treat the two as equivalent. ```bash # Unpin a server from its branch; it reverts to the repository default dhq servers update srv-001 -p my-app --branch "" --json -dhq servers show srv-001 -p my-app --json branch,preferred_branch +dhq servers show srv-001 -p my-app --json=branch,preferred_branch ``` **Static Hosting flags (beta, requires managed-resources beta on account):** @@ -117,6 +117,42 @@ dhq servers show srv-001 -p my-app --json branch,preferred_branch | `--region` | DigitalOcean region slug (e.g. lon1, nyc3). Use `dhq api GET /managed_hosting/regions` to list. | | `--size` | DigitalOcean droplet size slug (e.g. s-1vcpu-1gb). Use `dhq api GET /managed_hosting/sizes` to list. | | `--os-image` | OS image slug (default: ubuntu-24-04-x64) | +| `--key-pair-identifier` | Public identifier of an existing account SSH key to provision the droplet with. Use the `Identifier` column from `dhq ssh-keys list`. Omit to let DeployHQ create and reuse its shared managed key. | + +**Choosing the SSH key for a Managed VPS** + +By default DeployHQ creates one shared account key (titled `Managed VPS Key`) on +first use and reuses it for every subsequent droplet. Pass +`--key-pair-identifier` to provision with a specific existing account key +instead — which is what you want for deterministic operator recovery access. + +Use the **public identifier** from `dhq ssh-keys list`. The internal database id +is never accepted from a client. + +```bash +# 1. find the key +dhq ssh-keys list --json=title,identifier,fingerprint + +# 2. provision with it +dhq servers create -p my-app --name ops --protocol-type managed_vps \ + --region lon1 --size s-1vcpu-1gb --accept-cost \ + --key-pair-identifier key-abc123 --json + +# 3. verify which key was actually applied +dhq servers show -p my-app --json=managed_vps +``` + +The read-back returns the key under `managed_vps.ssh_key` as +`{identifier, title, fingerprint}` — enough to confirm the selection, and +deliberately no key material and no internal id. + +An identifier that does not exist, or belongs to a different account, is +rejected with **422** and creates neither a server nor a hosted resource. Unlike +the atomic account-permission case, this one fails loudly. + +`--key-pair-identifier` is Managed-VPS-only and is rejected locally, before any +request, if paired with another protocol or with `--global-key-pair-id` (which +is the ssh/rsync equivalent). **SSH/FTP/FTPS/Rsync flags:** @@ -213,9 +249,9 @@ dhq servers create -p my-app --name Production --protocol-type managed_vps \ # (accounts without atomic deployments enabled have the fields stripped silently). # Use the identifiers returned by the two create calls above. dhq servers show -p my-app \ - --json atomic,atomic_strategy,atomic_retention + --json=atomic,atomic_strategy,atomic_retention dhq servers show -p my-app \ - --json atomic,atomic_strategy,atomic_retention + --json=atomic,atomic_strategy,atomic_retention # Staging has no native auto-deployment, so ship it explicitly when you want to. # No -b needed: the server's preferred branch (`staging`) is used. @@ -259,7 +295,7 @@ dhq servers update srv-001 -p my-app --atomic-retention 10 --json # Enabling atomic — only valid while the server has NO deployments yet, # and it can succeed with a 2xx without applying, so read the value back dhq servers update srv-002 -p my-app --atomic --json -dhq servers show srv-002 -p my-app --json atomic,atomic_strategy,atomic_retention +dhq servers show srv-002 -p my-app --json=atomic,atomic_strategy,atomic_retention ``` ### `dhq servers delete `