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
23 changes: 23 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,29 @@ existing callers.

### Added

- **Operate-verb parity with the MCP server (MCP PR #41).** Eight new methods
drive the full bundle lifecycle after provisioning, all on the 30 s read
client (none are synchronous provisions) and all surfacing `*APIError` with
the full agent-native envelope:
- `SetVaultKey` / `RotateVaultKey` (`PUT /api/v1/vault/:env/:key`,
`POST /api/v1/vault/:env/:key/rotate`) — write/rotate an encrypted secret;
every write mints a new version and the plaintext is never echoed back.
Reference stored secrets from deploys as `vault://<env>/<KEY>`.
- `UpdateDeployEnv` (`PATCH /deploy/:id/env`) — merge env vars into an
existing deployment; returns the merged (redacted) map plus the
redeploy-required note.
- `UpdateStackEnv` (`PATCH /stacks/:slug/env`) — same merge semantics for
stacks; an empty-string value deletes that key.
- `PresignStorage` (`POST /storage/:token/presign`) — mint a short-lived
(≤1 h) presigned S3 URL scoped to the resource's tenant prefix. Broker
mode: the token in the URL is the credential, no API key needed.
- `PauseResource` / `ResumeResource`
(`POST /api/v1/resources/:id/{pause,resume}`) — suspend a resource without
deleting it (Pro+ to pause; resume is never tier-gated) and bring it back
with the connection URL unchanged.
- `WakeDeployment` (`POST /deploy/:id/wake`) — explicitly wake a
scaled-to-zero deployment; surfaces the server's 501
`scale_to_zero_disabled` when the platform flag is off.
- **`CreateStack` + `GetStack` — the anonymous deploy path.** `Deploy`
(`POST /deploy/new`) requires an API key, so an unauthenticated agent could
not deploy through the SDK at all. `CreateStack` wraps `POST /stacks/new`,
Expand Down
29 changes: 25 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,8 @@ func main() {
| `ProvisionMongoDB` | `(ctx, *ProvisionOpts) (*ProvisionResult, error)` | MongoDB database + scoped user |
| `ProvisionQueue` | `(ctx, *ProvisionOpts) (*ProvisionResult, error)` | NATS JetStream stream |
| `ProvisionVector` | `(ctx, *VectorOpts) (*VectorResult, error)` | pgvector-enabled Postgres (POST /vector/new) |
| `ProvisionStorage` | `(ctx, *ProvisionOpts) (*StorageResult, error)` | S3-compatible object-storage prefix (POST /storage/new) |
| `ProvisionWebhook` | `(ctx, *ProvisionOpts) (*WebhookResult, error)` | Webhook receiver URL (POST /webhook/new) |

### Deployment

Expand All @@ -67,6 +69,22 @@ func main() {
| `CreateStack` | `(ctx, CreateStackOpts) (*Stack, error)` | Deploy a multi-service stack (POST /stacks/new — the **anonymous** deploy path; works without an API key) |
| `GetStack` | `(ctx, slug string) (*Stack, error)` | Poll a stack's status + per-service URLs (GET /stacks/:slug) |
| `DeploymentEvents` | `(ctx, id string, limit int) (*DeploymentEventList, error)` | Failure-autopsy timeline for a deploy (GET /api/v1/deployments/:id/events) |
| `UpdateDeployEnv` | `(ctx, id string, env map[string]string) (*DeployEnvUpdate, error)` | Merge env vars into a deployment; redeploy to apply (PATCH /deploy/:id/env) |
| `UpdateStackEnv` | `(ctx, slug string, env map[string]string) (*StackEnvUpdate, error)` | Merge env vars into a stack; empty value deletes the key (PATCH /stacks/:slug/env) |
| `WakeDeployment` | `(ctx, id string) (*WakeResult, error)` | Wake a scaled-to-zero deployment (POST /deploy/:id/wake; 501 when the platform flag is off) |

### Vault (requires API key, paid tier)

| Method | Signature | Description |
|---|---|---|
| `SetVaultKey` | `(ctx, env, key, value string) (*VaultWriteResult, error)` | Store an encrypted secret as a new version (PUT /api/v1/vault/:env/:key) |
| `RotateVaultKey` | `(ctx, env, key, value string) (*VaultWriteResult, error)` | Rotate a secret — new value, version+1, distinct audit action (POST /api/v1/vault/:env/:key/rotate) |

### Storage access

| Method | Signature | Description |
|---|---|---|
| `PresignStorage` | `(ctx, token string, PresignOpts) (*PresignResult, error)` | Mint a ≤1h presigned S3 URL (POST /storage/:token/presign — the token is the credential, no API key needed) |

### Resource Management (requires API key)

Expand All @@ -76,6 +94,8 @@ func main() {
| `GetResource` | `(ctx, token string) (*Resource, error)` | Get a resource by token |
| `DeleteResource` | `(ctx, token string) error` | Soft-delete a resource |
| `RotateCredentials` | `(ctx, token string) (*RotateResult, error)` | New password → return updated connection URL |
| `PauseResource` | `(ctx, token string) (*PauseResumeResult, error)` | Suspend without deleting — storage kept, connections refused (POST /api/v1/resources/:id/pause, Pro+) |
| `ResumeResource` | `(ctx, token string) (*PauseResumeResult, error)` | Un-pause — connection URL unchanged (POST /api/v1/resources/:id/resume) |

### Account & Claiming

Expand Down Expand Up @@ -222,10 +242,11 @@ for _, e := range evs.Events {

This SDK exposes a focused slice of the platform surface. The full agent API documents
~90+ additional endpoints across deployments management (`GET /deploy/:id`,
`PATCH /deploy/:id/env`, `POST /deploy/:id/redeploy`, `DELETE /deploy/:id`, logs SSE),
stack mutation (`PATCH /stacks/:slug/env`, `POST /stacks/:slug/redeploy`,
`DELETE /stacks/:slug`), billing (`POST /api/v1/billing/checkout`,
`/api/v1/billing/usage`), team management, env-twin / promotion, vault, audit, webhook
`POST /deploy/:id/redeploy`, `DELETE /deploy/:id`),
stack mutation (`POST /stacks/:slug/redeploy`,
`DELETE /stacks/:slug`), vault reads (`GET /api/v1/vault/:env[/:key]`,
`POST /api/v1/vault/copy`), billing (`POST /api/v1/billing/checkout`,
`/api/v1/billing/usage`), team management, env-twin / promotion, audit, webhook
receivers, custom domains, GitHub App connections, and more.

See the [full OpenAPI](https://api.instanode.dev/openapi.json) for the canonical list.
Expand Down
21 changes: 21 additions & 0 deletions instant/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -300,6 +300,27 @@ func (c *Client) postJSONWithHeaders(ctx context.Context, path string, body any,
return c.doWithHeaders(ctx, http.MethodPost, path, r, headers, out)
}

// putJSON executes a PUT request with a JSON-encoded body and decodes the
// response. It runs on the read-path client (defaultTimeout) — every PUT on
// the API surface is a quick mutation, not a synchronous provision.
func (c *Client) putJSON(ctx context.Context, path string, body any, out any) error {
r, err := jsonBodyReader(body)
if err != nil {
return err
}
return c.doWithHeaders(ctx, http.MethodPut, path, r, nil, out)
}

// patchJSON executes a PATCH request with a JSON-encoded body and decodes the
// response. Read-path timeout class, same rationale as putJSON.
func (c *Client) patchJSON(ctx context.Context, path string, body any, out any) error {
r, err := jsonBodyReader(body)
if err != nil {
return err
}
return c.doWithHeaders(ctx, http.MethodPatch, path, r, nil, out)
}

// provisionJSONWithHeaders POSTs a JSON body for a synchronous provisioning
// call. It routes through provisionClient (no client-wide Timeout cap) and
// applies the longer provisioning deadline via the request context, so a slow
Expand Down
30 changes: 30 additions & 0 deletions instant/client_helpers_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
package instant

// client_helpers_test.go — pins the marshal-error branches of the putJSON /
// patchJSON helpers introduced for the operate verbs. No public method can
// reach them (every operate body is a plain struct), so they are exercised
// directly with an unmarshalable body.

import (
"context"
"strings"
"testing"
)

func TestPutJSON_MarshalError(t *testing.T) {
c := New(WithBaseURL("http://127.0.0.1:0"))
// A func value is not JSON-serializable — json.Marshal must fail before
// any network I/O happens.
err := c.putJSON(context.Background(), "/x", func() {}, nil)
if err == nil || !strings.Contains(err.Error(), "marshalling request body") {
t.Errorf("err = %v, want marshalling error", err)
}
}

func TestPatchJSON_MarshalError(t *testing.T) {
c := New(WithBaseURL("http://127.0.0.1:0"))
err := c.patchJSON(context.Background(), "/x", func() {}, nil)
if err == nil || !strings.Contains(err.Error(), "marshalling request body") {
t.Errorf("err = %v, want marshalling error", err)
}
}
127 changes: 127 additions & 0 deletions instant/deploy_ops.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
package instant

// deploy_ops.go — operate verbs on an existing deployment: env-var mutation
// (PATCH /deploy/:id/env) and the scale-to-zero explicit wake
// (POST /deploy/:id/wake). Both live on the /deploy group alongside
// /deploy/new — NOT under /api/v1/deployments — mirroring the API's routing
// (api/internal/handlers/deploy.go).

import (
"context"
"fmt"
"net/url"
)

const (
// deployPathPrefix is the deployment operate-verb endpoint family. The
// deployment's public app_id is appended as a path-escaped segment.
deployPathPrefix = "/deploy/"

// deployEnvSuffix is the PATCH env-merge sub-resource.
deployEnvSuffix = "/env"

// deployWakeSuffix is the scale-to-zero explicit-wake sub-resource.
deployWakeSuffix = "/wake"
)

// DeployEnvUpdate is returned by [Client.UpdateDeployEnv].
type DeployEnvUpdate struct {
// OK is always true on success.
OK bool `json:"ok"`

// Env is the FULL merged env map after the update, with secret values
// redacted (consistent with GET /deploy/:id). The stored server-side map
// is unredacted; only the response JSON is masked.
Env map[string]string `json:"env"`

// Note reminds the caller that a redeploy is required to apply the
// change (e.g. "Run POST /deploy/<id>/redeploy to apply changes.").
Note string `json:"note,omitempty"`
}

// WakeResult is returned by [Client.WakeDeployment].
type WakeResult struct {
// OK is always true on success.
OK bool `json:"ok"`

// Message is the server's human-readable confirmation.
Message string `json:"message,omitempty"`

// Deployment is the refreshed deployment record (sleeping state cleared).
// Nil if the server omitted it.
Deployment *Deployment `json:"deployment,omitempty"`
}

// envUpdateBody is the JSON body for PATCH /deploy/:id/env and
// PATCH /stacks/:slug/env — both take {"env": {...}}.
type envUpdateBody struct {
Env map[string]string `json:"env"`
}

// UpdateDeployEnv merges env vars into an existing deployment via
// PATCH /deploy/:id/env.
//
// id is the deployment's public app id ([Deployment.AppID]). The API MERGES
// the supplied keys into the deployment's existing env vars (incoming wins on
// collision) and returns the full merged map with secret values redacted.
// Values prefixed with "vault://" are stored verbatim and resolved at the
// next redeploy. Requires a valid API key; a missing or other-team deployment
// returns a 404 *APIError.
//
// The change is persisted but NOT applied until the deployment is redeployed
// — the returned [DeployEnvUpdate.Note] says so.
//
// Example:
//
// res, err := client.UpdateDeployEnv(ctx, d.AppID, map[string]string{
// "FEATURE_X": "on",
// "API_KEY": "vault://production/API_KEY",
// })
// if err != nil { log.Fatal(err) }
// fmt.Println(res.Note)
func (c *Client) UpdateDeployEnv(ctx context.Context, id string, env map[string]string) (*DeployEnvUpdate, error) {
if id == "" {
return nil, fmt.Errorf("UpdateDeployEnv: id is required")
}
if len(env) == 0 {
return nil, fmt.Errorf("UpdateDeployEnv: env must be a non-empty map")
}
var out DeployEnvUpdate
path := deployPathPrefix + url.PathEscape(id) + deployEnvSuffix
if err := c.patchJSON(ctx, path, envUpdateBody{Env: env}, &out); err != nil {
return nil, fmt.Errorf("UpdateDeployEnv: %w", err)
}
return &out, nil
}

// WakeDeployment explicitly wakes a scaled-to-zero (sleeping) deployment via
// POST /deploy/:id/wake.
//
// id is the deployment's public app id ([Deployment.AppID]). On success the
// API scales the app back to one replica and refreshes its last-activity
// marker; the app becomes reachable once its pod is Ready (a one-time cold
// start — a request racing the wake gets the ingress's upstream-down response
// until then). Idempotent: waking an already-awake app just refreshes the
// activity marker.
//
// The wake surface is FLAG-GATED server-side: when scale-to-zero is not
// enabled on the platform (the default) the API returns a 501 *APIError with
// code "scale_to_zero_disabled". A transient scaling failure returns 503
// ("wake_failed") — safe to retry. Requires a valid API key; cross-tenant ids
// return 404.
//
// Example:
//
// res, err := client.WakeDeployment(ctx, d.AppID)
// if err != nil { log.Fatal(err) }
// fmt.Println(res.Message)
func (c *Client) WakeDeployment(ctx context.Context, id string) (*WakeResult, error) {
if id == "" {
return nil, fmt.Errorf("WakeDeployment: id is required")
}
var out WakeResult
if err := c.post(ctx, deployPathPrefix+url.PathEscape(id)+deployWakeSuffix, &out); err != nil {
return nil, fmt.Errorf("WakeDeployment: %w", err)
}
return &out, nil
}
124 changes: 124 additions & 0 deletions instant/deploy_ops_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
package instant

// deploy_ops_test.go — exercises UpdateDeployEnv (PATCH /deploy/:id/env) and
// WakeDeployment (POST /deploy/:id/wake) against httptest servers mirroring
// the api deploy handler envelopes.

import (
"context"
"encoding/json"
"errors"
"net/http"
"strings"
"testing"
)

func TestUpdateDeployEnv_HappyPath(t *testing.T) {
srv, call := newOperateServer(t, http.StatusOK,
`{"ok":true,"env":{"FEATURE_X":"on","SECRET":"se****et"},"note":"Run POST /deploy/abc12345/redeploy to apply changes."}`)

c := New(WithBaseURL(srv.URL), WithAPIKey("k"))
res, err := c.UpdateDeployEnv(context.Background(), "abc12345", map[string]string{"FEATURE_X": "on"})
if err != nil {
t.Fatalf("UpdateDeployEnv: %v", err)
}

if call.Method != http.MethodPatch {
t.Errorf("method = %q, want PATCH", call.Method)
}
if call.Path != "/deploy/abc12345/env" {
t.Errorf("path = %q", call.Path)
}
var body struct {
Env map[string]string `json:"env"`
}
if err := json.Unmarshal([]byte(call.Body), &body); err != nil {
t.Fatalf("body decode: %v (raw %q)", err, call.Body)
}
if body.Env["FEATURE_X"] != "on" {
t.Errorf("body env = %v", body.Env)
}
if !res.OK || res.Env["FEATURE_X"] != "on" || res.Env["SECRET"] != "se****et" {
t.Errorf("result = %+v", res)
}
if !strings.Contains(res.Note, "redeploy") {
t.Errorf("Note = %q, want redeploy reminder", res.Note)
}
}

func TestUpdateDeployEnv_ValidationErrors(t *testing.T) {
c := New(WithBaseURL("http://127.0.0.1:0"))

if _, err := c.UpdateDeployEnv(context.Background(), "", map[string]string{"K": "v"}); err == nil ||
!strings.Contains(err.Error(), "id is required") {
t.Errorf("empty id: err = %v", err)
}
if _, err := c.UpdateDeployEnv(context.Background(), "abc12345", nil); err == nil ||
!strings.Contains(err.Error(), "non-empty map") {
t.Errorf("empty env: err = %v", err)
}
}

func TestUpdateDeployEnv_APIError(t *testing.T) {
srv, _ := newOperateServer(t, http.StatusNotFound,
`{"ok":false,"error":"not_found","message":"Deployment not found"}`)

_, err := New(WithBaseURL(srv.URL)).UpdateDeployEnv(
context.Background(), "abc12345", map[string]string{"K": "v"})
if err == nil || !strings.Contains(err.Error(), "UpdateDeployEnv") {
t.Fatalf("expected UpdateDeployEnv-prefixed error, got %v", err)
}
if !IsNotFound(err) {
t.Errorf("expected 404 APIError, got %v", err)
}
}

func TestWakeDeployment_HappyPath(t *testing.T) {
srv, call := newOperateServer(t, http.StatusOK,
`{"ok":true,"message":"Deployment woken — the app will be reachable once its pod is Ready (cold start).","deployment":{"id":"3f4a","app_id":"abc12345","status":"deploying","url":"https://abc12345.deployment.instanode.dev"}}`)

c := New(WithBaseURL(srv.URL), WithAPIKey("k"))
res, err := c.WakeDeployment(context.Background(), "abc12345")
if err != nil {
t.Fatalf("WakeDeployment: %v", err)
}

if call.Method != http.MethodPost {
t.Errorf("method = %q, want POST", call.Method)
}
if call.Path != "/deploy/abc12345/wake" {
t.Errorf("path = %q", call.Path)
}
if call.Body != "" {
t.Errorf("body = %q, want empty", call.Body)
}
if !res.OK || !strings.Contains(res.Message, "woken") {
t.Errorf("result = %+v", res)
}
if res.Deployment == nil || res.Deployment.AppID != "abc12345" || res.Deployment.Status != "deploying" {
t.Errorf("Deployment = %+v", res.Deployment)
}
}

func TestWakeDeployment_ValidationError(t *testing.T) {
c := New(WithBaseURL("http://127.0.0.1:0"))
if _, err := c.WakeDeployment(context.Background(), ""); err == nil ||
!strings.Contains(err.Error(), "id is required") {
t.Errorf("empty id: err = %v", err)
}
}

func TestWakeDeployment_FlagGated501(t *testing.T) {
srv, _ := newOperateServer(t, http.StatusNotImplemented,
`{"ok":false,"error":"scale_to_zero_disabled","message":"Scale-to-zero is not enabled on this platform."}`)

_, err := New(WithBaseURL(srv.URL)).WakeDeployment(context.Background(), "abc12345")
if err == nil || !strings.Contains(err.Error(), "WakeDeployment") {
t.Fatalf("expected WakeDeployment-prefixed error, got %v", err)
}
var apiErr *APIError
if !errors.As(err, &apiErr) || apiErr.StatusCode != http.StatusNotImplemented ||
apiErr.CanonicalCode() != "scale_to_zero_disabled" {
t.Errorf("expected 501 scale_to_zero_disabled APIError, got %v", err)
}
}
Loading
Loading