Skip to content

Commit db0c58a

Browse files
feat(sdk): add CreateStack/GetStack, ProvisionVector, DeploymentEvents (#22)
Close the SDK endpoint-coverage gap surfaced by the live durability probe — the SDK could provision db/cache/nosql/queue/storage/webhook + Deploy but was missing live, documented prod endpoints, most importantly the ANONYMOUS deploy path (Deploy/POST /deploy/new is RequireAuth; anon agents deploy via stacks). New methods (all mirror the existing provision-method pattern, the 120 s provisioning-timeout client for slow build pods, and the full APIError envelope handling): - CreateStack -> POST /stacks/new (multipart manifest + per-service tarballs; the documented anonymous deploy path). Returns *Stack (slug/status/tier/env/ expires_in). Synthesises instant.yaml from []StackServiceSpec. - GetStack -> GET /stacks/:slug (poll status + per-service URLs). - ProvisionVector -> POST /vector/new (pgvector; optional Dimensions hint; returns *VectorResult embedding ProvisionResult + Extension/Dimensions). - DeploymentEvents -> GET /api/v1/deployments/:id/events (rule-27 failure autopsy: kind/reason/exit_code/last_lines/hint; ExitCode is *int32 for null). Verified each request/response shape against api router.go + the stack/vector/ deploy handlers before wiring. Brief said /stacks/new takes a JSON {services/name/env} body — the api actually takes multipart/form-data (manifest YAML + tarball-per-service); implemented to the real contract and synthesise the manifest client-side. Create response key is stack_id (mapped to Stack.Slug); there is no top-level url on create (per-service URLs come from GetStack). Tests: httptest contract tests for each method (success + error-envelope + preflight + reachable transport/build/decode arms). A writeStackMultipart seam takes an io.Writer so the multipart error arms are reachable via a failing writer. 100% patch coverage (185 diff lines, 0 missing); project floor 98.4%. go build/vet, go test -short -race, gofmt, golangci-lint (0 issues) all green. README method tables + anonymous-deploy/autopsy examples + CHANGELOG updated. Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
1 parent a71a6e0 commit db0c58a

10 files changed

Lines changed: 1651 additions & 6 deletions

CHANGELOG.md

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,29 @@ existing callers.
77

88
## Unreleased
99

10+
### Added
11+
12+
- **`CreateStack` + `GetStack` — the anonymous deploy path.** `Deploy`
13+
(`POST /deploy/new`) requires an API key, so an unauthenticated agent could
14+
not deploy through the SDK at all. `CreateStack` wraps `POST /stacks/new`,
15+
which accepts anonymous callers (a single-service stack is a complete app),
16+
closing the biggest gap surfaced by the live SDK durability probe. The SDK
17+
synthesises the `instant.yaml` manifest from `[]StackServiceSpec`
18+
(name/tarball/port/expose/needs/env) and uploads it with each service's
19+
tarball as `multipart/form-data`. The call is asynchronous (returns
20+
`Status:"building"`); `GetStack(ctx, slug)` polls status + per-service URLs.
21+
Both run on the 120 s provisioning client (stacks build pods). Errors surface
22+
as `*APIError` so callers can branch on 402 (tier gate) / 429 (anon cap) / 404.
23+
- **`ProvisionVector` — pgvector-enabled Postgres (`POST /vector/new`).** Mirrors
24+
`ProvisionDatabase` and adds an optional `Dimensions` hint (0 → server default
25+
1536). Returns a `VectorResult` (embeds `ProvisionResult`) with the extra
26+
`Extension` (`"pgvector"`) and `Dimensions` fields the endpoint echoes.
27+
- **`DeploymentEvents` — the deploy failure-autopsy timeline
28+
(`GET /api/v1/deployments/:id/events`).** Returns the captured build exit
29+
reason, last log lines, and remediation hint per event so an agent can read
30+
why a deploy failed and self-correct instead of re-uploading a broken build.
31+
`ExitCode` is a `*int32` so a null exit code is distinguishable from a real 0.
32+
1033
### Fixed
1134

1235
- **Provisioning + deploy calls now default to a 120 s per-request timeout

README.md

Lines changed: 60 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -57,12 +57,16 @@ func main() {
5757
| `ProvisionCache` | `(ctx, *ProvisionOpts) (*ProvisionResult, error)` | Redis cache namespace |
5858
| `ProvisionMongoDB` | `(ctx, *ProvisionOpts) (*ProvisionResult, error)` | MongoDB database + scoped user |
5959
| `ProvisionQueue` | `(ctx, *ProvisionOpts) (*ProvisionResult, error)` | NATS JetStream stream |
60+
| `ProvisionVector` | `(ctx, *VectorOpts) (*VectorResult, error)` | pgvector-enabled Postgres (POST /vector/new) |
6061

6162
### Deployment
6263

6364
| Method | Signature | Description |
6465
|---|---|---|
65-
| `Deploy` | `(ctx, DeployOpts) (*Deployment, error)` | Build + deploy an app from a gzipped tarball (POST /deploy/new) |
66+
| `Deploy` | `(ctx, DeployOpts) (*Deployment, error)` | Build + deploy a single app from a gzipped tarball (POST /deploy/new — requires an API key) |
67+
| `CreateStack` | `(ctx, CreateStackOpts) (*Stack, error)` | Deploy a multi-service stack (POST /stacks/new — the **anonymous** deploy path; works without an API key) |
68+
| `GetStack` | `(ctx, slug string) (*Stack, error)` | Poll a stack's status + per-service URLs (GET /stacks/:slug) |
69+
| `DeploymentEvents` | `(ctx, id string, limit int) (*DeploymentEventList, error)` | Failure-autopsy timeline for a deploy (GET /api/v1/deployments/:id/events) |
6670

6771
### Resource Management (requires API key)
6872

@@ -107,6 +111,13 @@ mdb, err := client.ProvisionMongoDB(ctx, &instant.ProvisionOpts{Name: "app-mongo
107111
// NATS JetStream
108112
q, err := client.ProvisionQueue(ctx, &instant.ProvisionOpts{Name: "app-queue"})
109113
// q.ConnectionURL → nats://usr:pass@host:4222
114+
115+
// pgvector (embeddings) — same as Postgres, plus a dimensions hint
116+
vdb, err := client.ProvisionVector(ctx, &instant.VectorOpts{
117+
ProvisionOpts: instant.ProvisionOpts{Name: "embeddings"},
118+
Dimensions: 1536, // 0 → server default (1536)
119+
})
120+
// vdb.ConnectionURL → postgres://... vdb.Extension → "pgvector" vdb.Dimensions
110121
```
111122

112123
Anonymous resources expire after **24 hours**. Claim them permanently with a free account
@@ -115,8 +126,9 @@ Anonymous resources expire after **24 hours**. Claim them permanently with a fre
115126
### Timeouts: provisioning runs longer than reads
116127

117128
Provisioning is **synchronous**`ProvisionDatabase` / `Cache` / `MongoDB` /
118-
`Queue` / `Storage` / `Webhook` and `Deploy` block while the API creates the
119-
real backend. Under production hot-pool contention a *fresh* Postgres provision
129+
`Queue` / `Vector` / `Storage` / `Webhook`, `Deploy`, and `CreateStack` block
130+
while the API creates the real backend (or accepts the build). Under production
131+
hot-pool contention a *fresh* Postgres provision
120132
can take **more than 30 seconds**. If the client gave up at 30 s, the server
121133
kept working and held a 60 s in-flight idempotency marker, so the next retry hit
122134
`409 idempotency_key_in_progress` instead of succeeding.
@@ -165,12 +177,54 @@ fmt.Println("deploy id:", d.ID, "status:", d.Status, "url:", d.URL)
165177
`Deployment.Status` is one of `building`, `deploying`, `healthy`, `failed`, `stopped`.
166178
Poll the deploy by id via the live API to watch it reach a terminal state.
167179

180+
`Deploy` (POST /deploy/new) requires an API key. To deploy **anonymously** — no
181+
account, exactly like provisioning a database — use `CreateStack` (a single-service
182+
stack is a complete app):
183+
184+
```go
185+
f, _ := os.Open("api.tar.gz")
186+
defer f.Close()
187+
188+
st, err := client.CreateStack(ctx, instant.CreateStackOpts{
189+
Name: "my-app",
190+
Env: "production",
191+
Services: []instant.StackServiceSpec{{
192+
Name: "api",
193+
Tarball: f,
194+
Port: 8080,
195+
Expose: true, // public Ingress + TLS
196+
}},
197+
})
198+
if err != nil { log.Fatal(err) }
199+
200+
// Poll until the build finishes.
201+
for {
202+
st, _ = client.GetStack(ctx, st.Slug)
203+
if st.Status != "building" { break }
204+
time.Sleep(2 * time.Second)
205+
}
206+
for _, svc := range st.Services {
207+
fmt.Printf("%s %s %s\n", svc.Name, svc.Status, svc.URL)
208+
}
209+
```
210+
211+
When a deploy fails, `DeploymentEvents` returns the failure-autopsy timeline (build
212+
exit reason, last log lines, a remediation hint) so an agent can self-correct:
213+
214+
```go
215+
evs, _ := client.DeploymentEvents(ctx, d.AppID, 0) // 0 = server default limit
216+
for _, e := range evs.Events {
217+
fmt.Printf("%s/%s: %s\n%s\n", e.Kind, e.Reason, e.Hint, e.LastLines)
218+
}
219+
```
220+
168221
### What's NOT covered yet
169222

170-
This SDK currently exposes a focused slice of the platform surface. The full agent API
171-
documents ~90+ additional endpoints across deployments management (`GET /deploy/:id`,
223+
This SDK exposes a focused slice of the platform surface. The full agent API documents
224+
~90+ additional endpoints across deployments management (`GET /deploy/:id`,
172225
`PATCH /deploy/:id/env`, `POST /deploy/:id/redeploy`, `DELETE /deploy/:id`, logs SSE),
173-
multi-service stacks (`POST /stacks/new` and friends), billing (`POST /api/v1/billing/checkout`,
226+
stack mutation (`PATCH /stacks/:slug/env`, `POST /stacks/:slug/redeploy`,
227+
`DELETE /stacks/:slug`), billing (`POST /api/v1/billing/checkout`,
174228
`/api/v1/billing/usage`), team management, env-twin / promotion, vault, audit, webhook
175229
receivers, custom domains, GitHub App connections, and more.
176230

instant/deploy_events.go

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
package instant
2+
3+
import (
4+
"context"
5+
"fmt"
6+
"net/url"
7+
"strconv"
8+
)
9+
10+
// DeploymentEvents fetches a deployment's failure-autopsy timeline via
11+
// GET /api/v1/deployments/:id/events. It is the read surface behind rule 27:
12+
// when a deploy fails silently (build pod GC'd, runtime never came up), the
13+
// worker captures the exit reason, last log lines, and a remediation hint as
14+
// deployment events — and this method exposes them so an agent can read the
15+
// timeline and self-correct rather than re-uploading the same broken build.
16+
//
17+
// id is the deployment's public app id (the 8-char slug, [Deployment.AppID]).
18+
// Requires a valid API key (Bearer token); a missing or other-team deployment
19+
// returns an *APIError with StatusCode 404 — branch on [IsNotFound].
20+
//
21+
// limit caps the number of events returned. Pass 0 for the server default
22+
// (currently 50); any value <= 0 is sent as the default.
23+
//
24+
// Example — inspect why a deploy failed:
25+
//
26+
// evs, err := client.DeploymentEvents(ctx, d.AppID, 0)
27+
// if err != nil { log.Fatal(err) }
28+
// for _, e := range evs.Events {
29+
// fmt.Printf("%s/%s: %s\n%s\n", e.Kind, e.Reason, e.Hint, e.LastLines)
30+
// }
31+
func (c *Client) DeploymentEvents(ctx context.Context, id string, limit int) (*DeploymentEventList, error) {
32+
if id == "" {
33+
return nil, fmt.Errorf("DeploymentEvents: id is required")
34+
}
35+
path := "/api/v1/deployments/" + id + "/events"
36+
if limit > 0 {
37+
q := url.Values{}
38+
q.Set("limit", strconv.Itoa(limit))
39+
path += "?" + q.Encode()
40+
}
41+
42+
var out DeploymentEventList
43+
if err := c.get(ctx, path, &out); err != nil {
44+
return nil, fmt.Errorf("DeploymentEvents: %w", err)
45+
}
46+
return &out, nil
47+
}

instant/deploy_events_test.go

Lines changed: 183 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,183 @@
1+
package instant_test
2+
3+
import (
4+
"context"
5+
"encoding/json"
6+
"errors"
7+
"net/http"
8+
"net/http/httptest"
9+
"strings"
10+
"testing"
11+
12+
"github.com/InstaNode-dev/sdk-go/instant"
13+
)
14+
15+
// TestDeploymentEvents_HappyPath verifies GET /api/v1/deployments/:id/events
16+
// parsing: the autopsy timeline (kind/reason/exit_code/last_lines/hint), the
17+
// nullable exit_code (present on one row, null on another), and the
18+
// deployment_id + count envelope. It also asserts the limit query is sent.
19+
func TestDeploymentEvents_HappyPath(t *testing.T) {
20+
var gotLimit string
21+
22+
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
23+
if r.URL.Path != "/api/v1/deployments/6fffcc21/events" {
24+
t.Errorf("path = %q; want /api/v1/deployments/6fffcc21/events", r.URL.Path)
25+
http.Error(w, "wrong path", http.StatusNotFound)
26+
return
27+
}
28+
if r.Method != http.MethodGet {
29+
t.Errorf("method = %q; want GET", r.Method)
30+
}
31+
gotLimit = r.URL.Query().Get("limit")
32+
33+
w.Header().Set("Content-Type", "application/json")
34+
_ = json.NewEncoder(w).Encode(map[string]any{
35+
"ok": true,
36+
"deployment_id": "11111111-2222-3333-4444-555555555555",
37+
"count": 2,
38+
"events": []map[string]any{
39+
{
40+
"kind": "build",
41+
"reason": "BackoffLimitExceeded",
42+
"event": "Job has reached the specified backoff limit",
43+
"exit_code": 1,
44+
"last_lines": "npm ERR! missing script: build",
45+
"hint": "Add a build script to package.json",
46+
"created_at": "2026-06-10T12:00:00Z",
47+
},
48+
{
49+
"kind": "rollout",
50+
"reason": "ProgressDeadlineExceeded",
51+
"event": "Deployment exceeded its progress deadline",
52+
"exit_code": nil,
53+
"last_lines": "",
54+
"hint": "Check the container's readiness probe",
55+
"created_at": "2026-06-10T12:05:00Z",
56+
},
57+
},
58+
})
59+
}))
60+
defer srv.Close()
61+
62+
client := instant.New(instant.WithBaseURL(srv.URL))
63+
list, err := client.DeploymentEvents(context.Background(), "6fffcc21", 25)
64+
if err != nil {
65+
t.Fatalf("DeploymentEvents: %v", err)
66+
}
67+
68+
if gotLimit != "25" {
69+
t.Errorf("limit query = %q; want 25", gotLimit)
70+
}
71+
if list.DeploymentID != "11111111-2222-3333-4444-555555555555" {
72+
t.Errorf("DeploymentID = %q", list.DeploymentID)
73+
}
74+
if list.Count != 2 {
75+
t.Errorf("Count = %d; want 2", list.Count)
76+
}
77+
if len(list.Events) != 2 {
78+
t.Fatalf("len(Events) = %d; want 2", len(list.Events))
79+
}
80+
81+
first := list.Events[0]
82+
if first.Kind != "build" || first.Reason != "BackoffLimitExceeded" {
83+
t.Errorf("Events[0] kind/reason = %q/%q", first.Kind, first.Reason)
84+
}
85+
if first.ExitCode == nil || *first.ExitCode != 1 {
86+
t.Errorf("Events[0].ExitCode = %v; want 1", first.ExitCode)
87+
}
88+
if first.LastLines != "npm ERR! missing script: build" {
89+
t.Errorf("Events[0].LastLines = %q", first.LastLines)
90+
}
91+
if first.Hint != "Add a build script to package.json" {
92+
t.Errorf("Events[0].Hint = %q", first.Hint)
93+
}
94+
95+
second := list.Events[1]
96+
if second.ExitCode != nil {
97+
t.Errorf("Events[1].ExitCode = %v; want nil (null exit_code)", *second.ExitCode)
98+
}
99+
if second.Reason != "ProgressDeadlineExceeded" {
100+
t.Errorf("Events[1].Reason = %q", second.Reason)
101+
}
102+
}
103+
104+
// TestDeploymentEvents_DefaultLimit verifies that passing limit <= 0 omits the
105+
// limit query so the server applies its own default (50).
106+
func TestDeploymentEvents_DefaultLimit(t *testing.T) {
107+
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
108+
if r.URL.RawQuery != "" {
109+
t.Errorf("query = %q; want empty (no limit) when limit <= 0", r.URL.RawQuery)
110+
}
111+
w.Header().Set("Content-Type", "application/json")
112+
_ = json.NewEncoder(w).Encode(map[string]any{
113+
"ok": true, "deployment_id": "d", "count": 0, "events": []any{},
114+
})
115+
}))
116+
defer srv.Close()
117+
118+
client := instant.New(instant.WithBaseURL(srv.URL))
119+
list, err := client.DeploymentEvents(context.Background(), "6fffcc21", 0)
120+
if err != nil {
121+
t.Fatalf("DeploymentEvents: %v", err)
122+
}
123+
if list.Count != 0 || len(list.Events) != 0 {
124+
t.Errorf("expected empty timeline, got count=%d len=%d", list.Count, len(list.Events))
125+
}
126+
}
127+
128+
// TestDeploymentEvents_NotFound pins the 404 path → *APIError (IsNotFound), and
129+
// the empty-id preflight guard.
130+
func TestDeploymentEvents_NotFound(t *testing.T) {
131+
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
132+
w.Header().Set("Content-Type", "application/json")
133+
w.WriteHeader(http.StatusNotFound)
134+
_ = json.NewEncoder(w).Encode(map[string]any{"ok": false, "error": "not_found", "message": "Deployment not found"})
135+
}))
136+
defer srv.Close()
137+
138+
client := instant.New(instant.WithBaseURL(srv.URL))
139+
_, err := client.DeploymentEvents(context.Background(), "missing0", 0)
140+
if !instant.IsNotFound(err) {
141+
t.Fatalf("expected IsNotFound; got %v", err)
142+
}
143+
var apiErr *instant.APIError
144+
if !errors.As(err, &apiErr) {
145+
t.Fatalf("expected *APIError; got %T", err)
146+
}
147+
148+
// Empty-id preflight.
149+
if _, err := client.DeploymentEvents(context.Background(), "", 0); err == nil || !strings.Contains(err.Error(), "id is required") {
150+
t.Errorf("empty id: got err = %v", err)
151+
}
152+
}
153+
154+
// TestDeploymentEvents_SurfacesUnauthorized confirms a 401 (no/invalid API key)
155+
// surfaces as *APIError with the right helper predicate — the events endpoint
156+
// requires auth.
157+
func TestDeploymentEvents_SurfacesUnauthorized(t *testing.T) {
158+
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
159+
w.Header().Set("Content-Type", "application/json")
160+
w.WriteHeader(http.StatusUnauthorized)
161+
_ = json.NewEncoder(w).Encode(map[string]any{
162+
"ok": false,
163+
"error": "unauthorized",
164+
"error_code": "missing_credentials",
165+
"message": "A valid API key is required",
166+
"agent_action": "Have the user log in and pass the API key.",
167+
})
168+
}))
169+
defer srv.Close()
170+
171+
client := instant.New(instant.WithBaseURL(srv.URL))
172+
_, err := client.DeploymentEvents(context.Background(), "6fffcc21", 0)
173+
if !instant.IsUnauthorized(err) {
174+
t.Fatalf("expected IsUnauthorized; got %v", err)
175+
}
176+
var apiErr *instant.APIError
177+
if !errors.As(err, &apiErr) {
178+
t.Fatalf("expected *APIError; got %T", err)
179+
}
180+
if apiErr.CanonicalCode() != "missing_credentials" {
181+
t.Errorf("CanonicalCode = %q; want missing_credentials", apiErr.CanonicalCode())
182+
}
183+
}

0 commit comments

Comments
 (0)