Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
25 changes: 24 additions & 1 deletion internal/commands/servers.go
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down Expand Up @@ -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.",
}
}
}
Comment thread
thdurante marked this conversation as resolved.

projectID, err := cliCtx.RequireProject()
if err != nil {
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -665,6 +684,10 @@ func newServersCreateCmd() *cobra.Command {
cmd.Flags().StringVar(&region, "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)")
Expand Down
62 changes: 62 additions & 0 deletions internal/commands/servers_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"])
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
88 changes: 88 additions & 0 deletions pkg/sdk/managed_vps_key_test.go
Original file line number Diff line number Diff line change
@@ -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")
Comment thread
thdurante marked this conversation as resolved.
}
3 changes: 3 additions & 0 deletions pkg/sdk/servers.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
22 changes: 22 additions & 0 deletions pkg/sdk/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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.
Expand Down
43 changes: 43 additions & 0 deletions skill-evals/deployhq/evals.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
},
Comment thread
coderabbitai[bot] marked this conversation as resolved.
"notes": "The key is under managed_vps.ssh_key in the read-back."
}
]
}
4 changes: 2 additions & 2 deletions skills/deployhq/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -136,7 +136,7 @@ dhq api POST /projects/<permalink>/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 <project> --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 <id> -p <project> --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 <id> -p <project> --json=atomic,atomic_strategy,atomic_retention`

## Triggers

Expand Down
Loading
Loading