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 @@ -7,6 +7,29 @@ existing callers.

## Unreleased

### Added

- **`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`,
which accepts anonymous callers (a single-service stack is a complete app),
closing the biggest gap surfaced by the live SDK durability probe. The SDK
synthesises the `instant.yaml` manifest from `[]StackServiceSpec`
(name/tarball/port/expose/needs/env) and uploads it with each service's
tarball as `multipart/form-data`. The call is asynchronous (returns
`Status:"building"`); `GetStack(ctx, slug)` polls status + per-service URLs.
Both run on the 120 s provisioning client (stacks build pods). Errors surface
as `*APIError` so callers can branch on 402 (tier gate) / 429 (anon cap) / 404.
- **`ProvisionVector` — pgvector-enabled Postgres (`POST /vector/new`).** Mirrors
`ProvisionDatabase` and adds an optional `Dimensions` hint (0 → server default
1536). Returns a `VectorResult` (embeds `ProvisionResult`) with the extra
`Extension` (`"pgvector"`) and `Dimensions` fields the endpoint echoes.
- **`DeploymentEvents` — the deploy failure-autopsy timeline
(`GET /api/v1/deployments/:id/events`).** Returns the captured build exit
reason, last log lines, and remediation hint per event so an agent can read
why a deploy failed and self-correct instead of re-uploading a broken build.
`ExitCode` is a `*int32` so a null exit code is distinguishable from a real 0.

### Fixed

- **Provisioning + deploy calls now default to a 120 s per-request timeout
Expand Down
66 changes: 60 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -57,12 +57,16 @@ func main() {
| `ProvisionCache` | `(ctx, *ProvisionOpts) (*ProvisionResult, error)` | Redis cache namespace |
| `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) |

### Deployment

| Method | Signature | Description |
|---|---|---|
| `Deploy` | `(ctx, DeployOpts) (*Deployment, error)` | Build + deploy an app from a gzipped tarball (POST /deploy/new) |
| `Deploy` | `(ctx, DeployOpts) (*Deployment, error)` | Build + deploy a single app from a gzipped tarball (POST /deploy/new — requires an API key) |
| `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) |

### Resource Management (requires API key)

Expand Down Expand Up @@ -107,6 +111,13 @@ mdb, err := client.ProvisionMongoDB(ctx, &instant.ProvisionOpts{Name: "app-mongo
// NATS JetStream
q, err := client.ProvisionQueue(ctx, &instant.ProvisionOpts{Name: "app-queue"})
// q.ConnectionURL → nats://usr:pass@host:4222

// pgvector (embeddings) — same as Postgres, plus a dimensions hint
vdb, err := client.ProvisionVector(ctx, &instant.VectorOpts{
ProvisionOpts: instant.ProvisionOpts{Name: "embeddings"},
Dimensions: 1536, // 0 → server default (1536)
})
// vdb.ConnectionURL → postgres://... vdb.Extension → "pgvector" vdb.Dimensions
```

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

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

`Deploy` (POST /deploy/new) requires an API key. To deploy **anonymously** — no
account, exactly like provisioning a database — use `CreateStack` (a single-service
stack is a complete app):

```go
f, _ := os.Open("api.tar.gz")
defer f.Close()

st, err := client.CreateStack(ctx, instant.CreateStackOpts{
Name: "my-app",
Env: "production",
Services: []instant.StackServiceSpec{{
Name: "api",
Tarball: f,
Port: 8080,
Expose: true, // public Ingress + TLS
}},
})
if err != nil { log.Fatal(err) }

// Poll until the build finishes.
for {
st, _ = client.GetStack(ctx, st.Slug)
if st.Status != "building" { break }
time.Sleep(2 * time.Second)
}
for _, svc := range st.Services {
fmt.Printf("%s %s %s\n", svc.Name, svc.Status, svc.URL)
}
```

When a deploy fails, `DeploymentEvents` returns the failure-autopsy timeline (build
exit reason, last log lines, a remediation hint) so an agent can self-correct:

```go
evs, _ := client.DeploymentEvents(ctx, d.AppID, 0) // 0 = server default limit
for _, e := range evs.Events {
fmt.Printf("%s/%s: %s\n%s\n", e.Kind, e.Reason, e.Hint, e.LastLines)
}
```

### What's NOT covered yet

This SDK currently exposes a focused slice of the platform surface. The full agent API
documents ~90+ additional endpoints across deployments management (`GET /deploy/:id`,
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),
multi-service stacks (`POST /stacks/new` and friends), billing (`POST /api/v1/billing/checkout`,
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
receivers, custom domains, GitHub App connections, and more.

Expand Down
47 changes: 47 additions & 0 deletions instant/deploy_events.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
package instant

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

// DeploymentEvents fetches a deployment's failure-autopsy timeline via
// GET /api/v1/deployments/:id/events. It is the read surface behind rule 27:
// when a deploy fails silently (build pod GC'd, runtime never came up), the
// worker captures the exit reason, last log lines, and a remediation hint as
// deployment events — and this method exposes them so an agent can read the
// timeline and self-correct rather than re-uploading the same broken build.
//
// id is the deployment's public app id (the 8-char slug, [Deployment.AppID]).
// Requires a valid API key (Bearer token); a missing or other-team deployment
// returns an *APIError with StatusCode 404 — branch on [IsNotFound].
//
// limit caps the number of events returned. Pass 0 for the server default
// (currently 50); any value <= 0 is sent as the default.
//
// Example — inspect why a deploy failed:
//
// evs, err := client.DeploymentEvents(ctx, d.AppID, 0)
// if err != nil { log.Fatal(err) }
// for _, e := range evs.Events {
// fmt.Printf("%s/%s: %s\n%s\n", e.Kind, e.Reason, e.Hint, e.LastLines)
// }
func (c *Client) DeploymentEvents(ctx context.Context, id string, limit int) (*DeploymentEventList, error) {
if id == "" {
return nil, fmt.Errorf("DeploymentEvents: id is required")
}
path := "/api/v1/deployments/" + id + "/events"
if limit > 0 {
q := url.Values{}
q.Set("limit", strconv.Itoa(limit))
path += "?" + q.Encode()
}

var out DeploymentEventList
if err := c.get(ctx, path, &out); err != nil {
return nil, fmt.Errorf("DeploymentEvents: %w", err)
}
return &out, nil
}
183 changes: 183 additions & 0 deletions instant/deploy_events_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,183 @@
package instant_test

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

"github.com/InstaNode-dev/sdk-go/instant"
)

// TestDeploymentEvents_HappyPath verifies GET /api/v1/deployments/:id/events
// parsing: the autopsy timeline (kind/reason/exit_code/last_lines/hint), the
// nullable exit_code (present on one row, null on another), and the
// deployment_id + count envelope. It also asserts the limit query is sent.
func TestDeploymentEvents_HappyPath(t *testing.T) {
var gotLimit string

srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/api/v1/deployments/6fffcc21/events" {
t.Errorf("path = %q; want /api/v1/deployments/6fffcc21/events", r.URL.Path)
http.Error(w, "wrong path", http.StatusNotFound)
return
}
if r.Method != http.MethodGet {
t.Errorf("method = %q; want GET", r.Method)
}
gotLimit = r.URL.Query().Get("limit")

w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{
"ok": true,
"deployment_id": "11111111-2222-3333-4444-555555555555",
"count": 2,
"events": []map[string]any{
{
"kind": "build",
"reason": "BackoffLimitExceeded",
"event": "Job has reached the specified backoff limit",
"exit_code": 1,
"last_lines": "npm ERR! missing script: build",
"hint": "Add a build script to package.json",
"created_at": "2026-06-10T12:00:00Z",
},
{
"kind": "rollout",
"reason": "ProgressDeadlineExceeded",
"event": "Deployment exceeded its progress deadline",
"exit_code": nil,
"last_lines": "",
"hint": "Check the container's readiness probe",
"created_at": "2026-06-10T12:05:00Z",
},
},
})
}))
defer srv.Close()

client := instant.New(instant.WithBaseURL(srv.URL))
list, err := client.DeploymentEvents(context.Background(), "6fffcc21", 25)
if err != nil {
t.Fatalf("DeploymentEvents: %v", err)
}

if gotLimit != "25" {
t.Errorf("limit query = %q; want 25", gotLimit)
}
if list.DeploymentID != "11111111-2222-3333-4444-555555555555" {
t.Errorf("DeploymentID = %q", list.DeploymentID)
}
if list.Count != 2 {
t.Errorf("Count = %d; want 2", list.Count)
}
if len(list.Events) != 2 {
t.Fatalf("len(Events) = %d; want 2", len(list.Events))
}

first := list.Events[0]
if first.Kind != "build" || first.Reason != "BackoffLimitExceeded" {
t.Errorf("Events[0] kind/reason = %q/%q", first.Kind, first.Reason)
}
if first.ExitCode == nil || *first.ExitCode != 1 {
t.Errorf("Events[0].ExitCode = %v; want 1", first.ExitCode)
}
if first.LastLines != "npm ERR! missing script: build" {
t.Errorf("Events[0].LastLines = %q", first.LastLines)
}
if first.Hint != "Add a build script to package.json" {
t.Errorf("Events[0].Hint = %q", first.Hint)
}

second := list.Events[1]
if second.ExitCode != nil {
t.Errorf("Events[1].ExitCode = %v; want nil (null exit_code)", *second.ExitCode)
}
if second.Reason != "ProgressDeadlineExceeded" {
t.Errorf("Events[1].Reason = %q", second.Reason)
}
}

// TestDeploymentEvents_DefaultLimit verifies that passing limit <= 0 omits the
// limit query so the server applies its own default (50).
func TestDeploymentEvents_DefaultLimit(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.RawQuery != "" {
t.Errorf("query = %q; want empty (no limit) when limit <= 0", r.URL.RawQuery)
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{
"ok": true, "deployment_id": "d", "count": 0, "events": []any{},
})
}))
defer srv.Close()

client := instant.New(instant.WithBaseURL(srv.URL))
list, err := client.DeploymentEvents(context.Background(), "6fffcc21", 0)
if err != nil {
t.Fatalf("DeploymentEvents: %v", err)
}
if list.Count != 0 || len(list.Events) != 0 {
t.Errorf("expected empty timeline, got count=%d len=%d", list.Count, len(list.Events))
}
}

// TestDeploymentEvents_NotFound pins the 404 path → *APIError (IsNotFound), and
// the empty-id preflight guard.
func TestDeploymentEvents_NotFound(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusNotFound)
_ = json.NewEncoder(w).Encode(map[string]any{"ok": false, "error": "not_found", "message": "Deployment not found"})
}))
defer srv.Close()

client := instant.New(instant.WithBaseURL(srv.URL))
_, err := client.DeploymentEvents(context.Background(), "missing0", 0)
if !instant.IsNotFound(err) {
t.Fatalf("expected IsNotFound; got %v", err)
}
var apiErr *instant.APIError
if !errors.As(err, &apiErr) {
t.Fatalf("expected *APIError; got %T", err)
}

// Empty-id preflight.
if _, err := client.DeploymentEvents(context.Background(), "", 0); err == nil || !strings.Contains(err.Error(), "id is required") {
t.Errorf("empty id: got err = %v", err)
}
}

// TestDeploymentEvents_SurfacesUnauthorized confirms a 401 (no/invalid API key)
// surfaces as *APIError with the right helper predicate — the events endpoint
// requires auth.
func TestDeploymentEvents_SurfacesUnauthorized(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusUnauthorized)
_ = json.NewEncoder(w).Encode(map[string]any{
"ok": false,
"error": "unauthorized",
"error_code": "missing_credentials",
"message": "A valid API key is required",
"agent_action": "Have the user log in and pass the API key.",
})
}))
defer srv.Close()

client := instant.New(instant.WithBaseURL(srv.URL))
_, err := client.DeploymentEvents(context.Background(), "6fffcc21", 0)
if !instant.IsUnauthorized(err) {
t.Fatalf("expected IsUnauthorized; got %v", err)
}
var apiErr *instant.APIError
if !errors.As(err, &apiErr) {
t.Fatalf("expected *APIError; got %T", err)
}
if apiErr.CanonicalCode() != "missing_credentials" {
t.Errorf("CanonicalCode = %q; want missing_credentials", apiErr.CanonicalCode())
}
}
Loading
Loading