diff --git a/CHANGELOG.md b/CHANGELOG.md index 3f38878..e248d86 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/README.md b/README.md index c623f6c..27f36f2 100644 --- a/README.md +++ b/README.md @@ -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) @@ -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 @@ -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. @@ -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. diff --git a/instant/deploy_events.go b/instant/deploy_events.go new file mode 100644 index 0000000..c753c58 --- /dev/null +++ b/instant/deploy_events.go @@ -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 +} diff --git a/instant/deploy_events_test.go b/instant/deploy_events_test.go new file mode 100644 index 0000000..7eb7474 --- /dev/null +++ b/instant/deploy_events_test.go @@ -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()) + } +} diff --git a/instant/stack.go b/instant/stack.go new file mode 100644 index 0000000..33e19e2 --- /dev/null +++ b/instant/stack.go @@ -0,0 +1,365 @@ +package instant + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "mime/multipart" + "net/http" + "sort" + "strings" +) + +// StackServiceSpec declares one service in a multi-service stack create. +// +// Each service ships a gzipped build context (Dockerfile + source) the API +// hands to Kaniko. The service Name keys both the manifest entry and the +// multipart tarball field — the API looks the tarball up by service name. +type StackServiceSpec struct { + // Name is the service identifier. REQUIRED, must be unique within the + // stack, and is used as the multipart field name for this service's + // tarball. The API rejects an empty service name with a 400. + Name string + + // Tarball is the gzipped tar archive (Dockerfile + source) for this + // service. REQUIRED. The aggregate of every service tarball in one + // CreateStack call must stay under the API's 50 MiB multipart cap. + Tarball io.Reader + + // Port is the container port this service listens on (manifest `port`). + // 0 leaves it off the manifest and the API defaults it to 8080. + Port int + + // Expose, when true, fronts the service with an Ingress + TLS so it gets a + // public URL (manifest `expose`). Internal-only services leave it false. + Expose bool + + // Needs lists resource tokens (from ProvisionDatabase / Cache / …) whose + // connection URLs are injected into this service's environment as + // DATABASE_URL / REDIS_URL / … (manifest `needs`). Anonymous stacks may + // only reference anonymous resources. + Needs []string + + // Env are extra environment variables for this service (manifest `env`). + // Keys must be POSIX env-var names; values may use the "vault://KEY" form + // on authenticated stacks. The "service://other" form resolves to the + // in-cluster URL of a sibling service. + Env map[string]string +} + +// CreateStackOpts are the parameters for [Client.CreateStack]. +type CreateStackOpts struct { + // Name is the human-readable stack label. REQUIRED (1–64 chars, + // ^[A-Za-z0-9][A-Za-z0-9 _-]*$) — validated client-side before any + // network call, mirroring the provisioning endpoints. + Name string + + // Services are the services to deploy. At least one is REQUIRED. + Services []StackServiceSpec + + // Env is the environment scope (production / staging / development). + // Empty resolves to "development" server-side (migration 026). + Env string + + // IdempotencyKey is an optional replay guard forwarded as the + // Idempotency-Key header. First response is cached for 24h; replays return + // the cached body with X-Idempotent-Replay: true. + IdempotencyKey string +} + +// Stack is the response shape from [Client.CreateStack] and [Client.GetStack]. +// +// The two endpoints return slightly different field sets: +// - POST /stacks/new returns Slug, Env, Status, Tier, ExpiresIn, Note. +// - GET /stacks/:slug returns Slug, Status, Tier, Name, Services, ExpiresAt. +// +// All fields are decoded onto this one struct; a field absent from a given +// response stays at its zero value. +type Stack struct { + // OK is always true on success. + OK bool `json:"ok"` + + // Slug is the public stack identifier (the API's `stack_id`). It is the + // secret used to fetch / poll an anonymous stack, and the path segment for + // GetStack. + Slug string `json:"stack_id"` + + // Status is the lifecycle state: "building" (create accepted, build pods + // launching), "healthy" (all services up), "failed", "deleting", or + // "stopped". Poll GetStack until it leaves "building". + Status string `json:"status"` + + // Tier is the plan tier the stack was created under ("anonymous" for an + // unauthenticated create). + Tier string `json:"tier"` + + // Name is the human-readable label (populated by GetStack). + Name string `json:"name,omitempty"` + + // Env is the resolved environment scope (populated by CreateStack). + Env string `json:"env,omitempty"` + + // Services lists the per-service status + URL (populated by GetStack). + Services []StackService `json:"services,omitempty"` + + // ExpiresIn is the human TTL string for an anonymous stack ("6h"), + // populated by CreateStack. Empty for authenticated stacks. + ExpiresIn string `json:"expires_in,omitempty"` + + // ExpiresAt is the RFC3339 expiry timestamp, populated by GetStack for + // stacks that have one. Empty for permanent (paid-tier) stacks. + ExpiresAt string `json:"expires_at,omitempty"` + + // Note is an advisory / upgrade message from the server (populated by + // CreateStack). + Note string `json:"note,omitempty"` +} + +// StackService is one service within a [Stack] as returned by GetStack. +type StackService struct { + // Name is the service identifier from the manifest. + Name string `json:"name"` + + // Status is the per-service lifecycle state. + Status string `json:"status"` + + // Expose reports whether the service has a public Ingress. + Expose bool `json:"expose"` + + // Port is the container port the service listens on. + Port int `json:"port"` + + // URL is the public HTTPS endpoint for an exposed service once healthy. + // Empty for internal services or before the service is serving. + URL string `json:"url"` +} + +// CreateStack deploys a multi-service stack via POST /stacks/new. +// +// This is the documented ANONYMOUS deploy path: /deploy/new requires +// authentication, but /stacks/new accepts anonymous callers (a single-service +// stack is the way an unauthenticated agent ships an app). The SDK synthesises +// an instant.yaml manifest from the supplied services and uploads it alongside +// each service's tarball as multipart/form-data. +// +// The call is accepted asynchronously: the returned Stack reflects the +// build-accepted state (Status="building"). Poll [Client.GetStack] with the +// returned Slug until Status leaves "building". +// +// CreateStack returns an *APIError on 4xx/5xx — e.g. 402 deployment_limit_reached +// (tier gate) or 429 rate_limit_exceeded (anonymous deploy cap) — so callers can +// branch on the status code without parsing strings. +// +// Example: +// +// 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, +// }}, +// }) +// if err != nil { log.Fatal(err) } +// fmt.Println("stack:", st.Slug, "status:", st.Status) +func (c *Client) CreateStack(ctx context.Context, opts CreateStackOpts) (*Stack, error) { + if err := validateResourceName(opts.Name); err != nil { + return nil, fmt.Errorf("CreateStack: %w", err) + } + if len(opts.Services) == 0 { + return nil, fmt.Errorf("CreateStack: at least one service is required") + } + seen := make(map[string]struct{}, len(opts.Services)) + for i, svc := range opts.Services { + if svc.Name == "" { + return nil, fmt.Errorf("CreateStack: services[%d] requires a non-empty Name", i) + } + if svc.Tarball == nil { + return nil, fmt.Errorf("CreateStack: service %q requires a non-nil Tarball reader", svc.Name) + } + if _, dup := seen[svc.Name]; dup { + return nil, fmt.Errorf("CreateStack: duplicate service name %q", svc.Name) + } + seen[svc.Name] = struct{}{} + } + + var buf bytes.Buffer + contentType, err := writeStackMultipart(&buf, opts) + if err != nil { + return nil, err + } + + // Stacks build pods — a synchronous accept under hot-pool load can be slow, + // so route through the provisioning client (no 30 s read-path cap) plus the + // longer provisioning deadline, exactly like Deploy and the /*/new helpers. + pctx, cancel := c.provisionContext(ctx) + defer cancel() + + url := c.baseURL + "/stacks/new" + req, err := http.NewRequestWithContext(pctx, http.MethodPost, url, &buf) + if err != nil { + return nil, fmt.Errorf("CreateStack: building request: %w", err) + } + req.Header.Set("Content-Type", contentType) + if opts.IdempotencyKey != "" { + req.Header.Set("Idempotency-Key", opts.IdempotencyKey) + } + + resp, err := c.provisionClient.Do(req) + if err != nil { + return nil, fmt.Errorf("CreateStack: request failed: %w", err) + } + defer func() { _ = resp.Body.Close() }() + + c.logHeaders(resp) + + if resp.StatusCode >= 400 { + raw, _ := io.ReadAll(resp.Body) + apiErr := &APIError{StatusCode: resp.StatusCode, raw: string(raw)} + _ = json.Unmarshal(raw, apiErr) + return nil, apiErr + } + + var out Stack + if err := json.NewDecoder(resp.Body).Decode(&out); err != nil { + return nil, fmt.Errorf("CreateStack: decoding response: %w", err) + } + if out.Slug == "" { + return nil, fmt.Errorf("CreateStack: server returned empty stack_id") + } + if out.Note != "" { + c.logger.Info("instant.dev stack created", + "slug", out.Slug, + "tier", out.Tier, + "status", out.Status, + "note", out.Note, + ) + } + return &out, nil +} + +// GetStack fetches a stack's status + per-service detail via +// GET /stacks/:slug. Use it to poll a stack returned by [Client.CreateStack] +// until its Status leaves "building". +// +// An anonymous caller may fetch an anonymous stack by its slug (the slug is the +// secret); an authenticated caller may only fetch stacks owned by its team. +// A missing or other-team stack returns an *APIError with StatusCode 404 — +// branch on [IsNotFound]. +// +// Example: +// +// st, err := client.GetStack(ctx, slug) +// if instant.IsNotFound(err) { fmt.Println("stack gone") } +// for _, svc := range st.Services { +// fmt.Printf("%s %s %s\n", svc.Name, svc.Status, svc.URL) +// } +func (c *Client) GetStack(ctx context.Context, slug string) (*Stack, error) { + if slug == "" { + return nil, fmt.Errorf("GetStack: slug is required") + } + var out Stack + if err := c.get(ctx, "/stacks/"+slug, &out); err != nil { + return nil, fmt.Errorf("GetStack: %w", err) + } + return &out, nil +} + +// writeStackMultipart writes the /stacks/new multipart body (manifest + name + +// optional env + one tarball part per service) to w and returns the +// Content-Type header value carrying the boundary. Taking an io.Writer (rather +// than building into a concrete *bytes.Buffer inline) keeps the multipart +// error arms reachable: a writer that fails mid-stream exercises the +// WriteField / CreateFormFile / Close failure paths a *bytes.Buffer can never +// reach. opts is assumed already validated by the CreateStack pre-flight. +func writeStackMultipart(w io.Writer, opts CreateStackOpts) (string, error) { + mw := multipart.NewWriter(w) + + if err := mw.WriteField("manifest", buildStackManifest(opts.Services)); err != nil { + return "", fmt.Errorf("CreateStack: writing manifest field: %w", err) + } + if err := mw.WriteField("name", opts.Name); err != nil { + return "", fmt.Errorf("CreateStack: writing name field: %w", err) + } + if opts.Env != "" { + if err := mw.WriteField("env", opts.Env); err != nil { + return "", fmt.Errorf("CreateStack: writing env field: %w", err) + } + } + + // One tarball part per service, keyed by service name — the API looks the + // tarball up by the service name (form.File[name]). + for _, svc := range opts.Services { + part, err := mw.CreateFormFile(svc.Name, svc.Name+".tar.gz") + if err != nil { + return "", fmt.Errorf("CreateStack: building tarball field for %q: %w", svc.Name, err) + } + if _, err := io.Copy(part, svc.Tarball); err != nil { + return "", fmt.Errorf("CreateStack: reading tarball for %q: %w", svc.Name, err) + } + } + + if err := mw.Close(); err != nil { + return "", fmt.Errorf("CreateStack: closing multipart writer: %w", err) + } + return mw.FormDataContentType(), nil +} + +// buildStackManifest renders the instant.yaml the API expects from the supplied +// service specs. Services are emitted in name order so the manifest is +// deterministic (stable test output, reproducible idempotency). YAML scalars +// are quoted so a value containing a colon or other YAML metacharacter can't +// corrupt the document — the manifest is machine-generated from user input. +func buildStackManifest(services []StackServiceSpec) string { + ordered := make([]StackServiceSpec, len(services)) + copy(ordered, services) + sort.Slice(ordered, func(i, j int) bool { return ordered[i].Name < ordered[j].Name }) + + var b strings.Builder + b.WriteString("services:\n") + for _, svc := range ordered { + fmt.Fprintf(&b, " %s:\n", yamlScalar(svc.Name)) + // build points at the in-tarball context root; the API keys the + // tarball by service name, so "." (the tarball root) is correct. + b.WriteString(" build: \".\"\n") + if svc.Port > 0 { + fmt.Fprintf(&b, " port: %d\n", svc.Port) + } + if svc.Expose { + b.WriteString(" expose: true\n") + } + if len(svc.Needs) > 0 { + b.WriteString(" needs:\n") + for _, n := range svc.Needs { + fmt.Fprintf(&b, " - %s\n", yamlScalar(n)) + } + } + if len(svc.Env) > 0 { + b.WriteString(" env:\n") + keys := make([]string, 0, len(svc.Env)) + for k := range svc.Env { + keys = append(keys, k) + } + sort.Strings(keys) + for _, k := range keys { + fmt.Fprintf(&b, " %s: %s\n", yamlScalar(k), yamlScalar(svc.Env[k])) + } + } + } + return b.String() +} + +// yamlScalar double-quotes a string for safe embedding as a YAML scalar, +// escaping backslashes and double-quotes. Keeps a generated manifest valid even +// when a service name, env key, or value contains YAML-significant characters. +func yamlScalar(s string) string { + s = strings.ReplaceAll(s, `\`, `\\`) + s = strings.ReplaceAll(s, `"`, `\"`) + return `"` + s + `"` +} diff --git a/instant/stack_internal_test.go b/instant/stack_internal_test.go new file mode 100644 index 0000000..d7bc621 --- /dev/null +++ b/instant/stack_internal_test.go @@ -0,0 +1,103 @@ +package instant + +import ( + "bytes" + "strings" + "testing" +) + +// errAfterWriter is an io.Writer that succeeds for the first n Write calls and +// then returns an error on every subsequent call. It lets us drive the +// multipart writer's WriteField / CreateFormFile / Close failure arms in +// writeStackMultipart, which a *bytes.Buffer (whose Write never fails) cannot +// reach. +type errAfterWriter struct { + remaining int +} + +func (w *errAfterWriter) Write(p []byte) (int, error) { + if w.remaining <= 0 { + return 0, errWriteFull + } + w.remaining-- + return len(p), nil +} + +var errWriteFull = &writeErr{} + +type writeErr struct{} + +func (*writeErr) Error() string { return "writer full" } + +// TestWriteStackMultipart_WriterFailures sweeps an increasing write budget and +// asserts that EVERY multipart error arm (manifest field, name field, env +// field, CreateFormFile, io.Copy-into-part, and Close) is reached for some +// budget, and that a generous budget succeeds. Sweeping rather than hard-coding +// per-arm budgets keeps the test robust to the multipart writer's internal +// flush timing (which can shift between Go versions) — we only require that +// each documented failure message is reachable, not that it lands at an exact +// write count. +func TestWriteStackMultipart_WriterFailures(t *testing.T) { + opts := CreateStackOpts{ + Name: "app", + Env: "production", + Services: []StackServiceSpec{ + {Name: "api", Tarball: bytes.NewBufferString("tar")}, + }, + } + + wantFrags := []string{ + "writing manifest field", + "writing name field", + "writing env field", + "building tarball field", + "reading tarball", + "closing multipart writer", + } + seen := make(map[string]bool, len(wantFrags)) + sawSuccess := false + + // 64 is comfortably above the ~8 writes a one-service body needs; the upper + // budgets exercise the success path. + for budget := 0; budget <= 64; budget++ { + w := &errAfterWriter{remaining: budget} + _, err := writeStackMultipart(w, opts) + if err == nil { + sawSuccess = true + continue + } + for _, frag := range wantFrags { + if strings.Contains(err.Error(), frag) { + seen[frag] = true + } + } + } + + for _, frag := range wantFrags { + if !seen[frag] { + t.Errorf("no write budget reached the %q error arm", frag) + } + } + if !sawSuccess { + t.Error("no write budget produced a successful body") + } +} + +// TestWriteStackMultipart_Success confirms the helper returns a multipart +// Content-Type and writes a non-empty body on the happy path. +func TestWriteStackMultipart_Success(t *testing.T) { + var buf bytes.Buffer + ct, err := writeStackMultipart(&buf, CreateStackOpts{ + Name: "app", + Services: []StackServiceSpec{{Name: "api", Tarball: bytes.NewBufferString("tar")}}, + }) + if err != nil { + t.Fatalf("writeStackMultipart: %v", err) + } + if !strings.HasPrefix(ct, "multipart/form-data;") { + t.Errorf("content-type = %q", ct) + } + if buf.Len() == 0 { + t.Error("expected non-empty multipart body") + } +} diff --git a/instant/stack_test.go b/instant/stack_test.go new file mode 100644 index 0000000..ba99eba --- /dev/null +++ b/instant/stack_test.go @@ -0,0 +1,507 @@ +package instant_test + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/InstaNode-dev/sdk-go/instant" +) + +// TestCreateStack_HappyPath verifies the SDK uploads a valid multipart form +// (manifest + name + env + per-service tarballs) to POST /stacks/new and parses +// the 202 response (stack_id/status/tier/env/expires_in) onto Stack. It also +// asserts the synthesised manifest round-trips through a real YAML parser and +// carries the service's port/expose/needs/env. +func TestCreateStack_HappyPath(t *testing.T) { + var ( + gotManifest string + gotName string + gotEnv string + gotIdemKey string + gotTarball []byte + contentType string + ) + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/stacks/new" { + t.Errorf("path = %q; want /stacks/new", r.URL.Path) + http.Error(w, "wrong path", http.StatusNotFound) + return + } + if r.Method != http.MethodPost { + t.Errorf("method = %q; want POST", r.Method) + } + gotIdemKey = r.Header.Get("Idempotency-Key") + contentType = r.Header.Get("Content-Type") + + if err := r.ParseMultipartForm(50 << 20); err != nil { + t.Errorf("ParseMultipartForm: %v", err) + http.Error(w, "bad form", http.StatusBadRequest) + return + } + gotManifest = r.FormValue("manifest") + gotName = r.FormValue("name") + gotEnv = r.FormValue("env") + + // Tarball is keyed by service name ("api"). + f, _, err := r.FormFile("api") + if err != nil { + t.Errorf("FormFile api: %v", err) + } else { + defer f.Close() + gotTarball, _ = io.ReadAll(f) + } + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusAccepted) + _ = json.NewEncoder(w).Encode(map[string]any{ + "ok": true, + "stack_id": "ab12cd34", + "env": "production", + "status": "building", + "tier": "anonymous", + "expires_in": "6h", + "note": "Stack is building. Poll GET /stacks/ab12cd34 for status.", + }) + })) + defer srv.Close() + + client := instant.New(instant.WithBaseURL(srv.URL)) + st, err := client.CreateStack(context.Background(), instant.CreateStackOpts{ + Name: "my-app", + Env: "production", + Services: []instant.StackServiceSpec{{ + Name: "api", + Tarball: bytes.NewBufferString("fake-api-tarball"), + Port: 8080, + Expose: true, + Needs: []string{"aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"}, + Env: map[string]string{"LOG_LEVEL": "debug"}, + }}, + IdempotencyKey: "stack-key-1", + }) + if err != nil { + t.Fatalf("CreateStack: %v", err) + } + + if st.Slug != "ab12cd34" { + t.Errorf("Slug = %q; want ab12cd34", st.Slug) + } + if st.Status != "building" { + t.Errorf("Status = %q; want building", st.Status) + } + if st.Tier != "anonymous" { + t.Errorf("Tier = %q", st.Tier) + } + if st.Env != "production" { + t.Errorf("Env = %q", st.Env) + } + if st.ExpiresIn != "6h" { + t.Errorf("ExpiresIn = %q", st.ExpiresIn) + } + + // Wire-shape assertions. + if !strings.HasPrefix(contentType, "multipart/form-data;") { + t.Errorf("Content-Type = %q; want multipart/form-data;...", contentType) + } + if gotIdemKey != "stack-key-1" { + t.Errorf("Idempotency-Key = %q", gotIdemKey) + } + if gotName != "my-app" { + t.Errorf("name field = %q", gotName) + } + if gotEnv != "production" { + t.Errorf("env field = %q", gotEnv) + } + if string(gotTarball) != "fake-api-tarball" { + t.Errorf("tarball bytes = %q", gotTarball) + } + + // The synthesised manifest must carry the service config. Assert on the + // generated text directly (the SDK is zero-dependency, so the test can't + // pull in a YAML parser); the parser's own round-trip is covered in the + // api repo's manifest tests. + for _, want := range []string{ + "services:", + ` "api":`, + ` build: "."`, + " port: 8080", + " expose: true", + " needs:", + ` - "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"`, + " env:", + ` "LOG_LEVEL": "debug"`, + } { + if !strings.Contains(gotManifest, want) { + t.Errorf("manifest missing %q\nfull manifest:\n%s", want, gotManifest) + } + } +} + +// TestCreateStack_OmitsEnvWhenEmpty verifies the env multipart field is not +// sent when CreateStackOpts.Env is empty (server defaults to "development"). +func TestCreateStack_OmitsEnvWhenEmpty(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _ = r.ParseMultipartForm(10 << 20) + if _, ok := r.MultipartForm.Value["env"]; ok { + t.Error("env should be omitted when CreateStackOpts.Env is empty") + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusAccepted) + _ = json.NewEncoder(w).Encode(map[string]any{"ok": true, "stack_id": "deadbeef", "status": "building"}) + })) + defer srv.Close() + + client := instant.New(instant.WithBaseURL(srv.URL)) + _, err := client.CreateStack(context.Background(), instant.CreateStackOpts{ + Name: "x", + Services: []instant.StackServiceSpec{{Name: "web", Tarball: bytes.NewBufferString("t")}}, + }) + if err != nil { + t.Fatalf("CreateStack: %v", err) + } +} + +// TestCreateStack_SurfacesAPIError pins the tier-gate error path: a 402 +// deployment_limit_reached must surface as *APIError so callers can branch on +// the status code. +func TestCreateStack_SurfacesAPIError(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.StatusPaymentRequired) + _ = json.NewEncoder(w).Encode(map[string]any{ + "ok": false, + "error": "deployment_limit_reached", + "message": "Hobby tier allows 1 deployment(s)", + "agent_action": "Tell the user to upgrade at https://instanode.dev/pricing.", + "upgrade_url": "https://instanode.dev/pricing", + }) + })) + defer srv.Close() + + client := instant.New(instant.WithBaseURL(srv.URL)) + _, err := client.CreateStack(context.Background(), instant.CreateStackOpts{ + Name: "x", + Services: []instant.StackServiceSpec{{Name: "api", Tarball: bytes.NewBufferString("t")}}, + }) + if err == nil { + t.Fatal("expected an error on 402, got nil") + } + var apiErr *instant.APIError + if !errors.As(err, &apiErr) { + t.Fatalf("expected *APIError; got %T (%v)", err, err) + } + if apiErr.StatusCode != http.StatusPaymentRequired { + t.Errorf("StatusCode = %d; want 402", apiErr.StatusCode) + } + if apiErr.Code != "deployment_limit_reached" { + t.Errorf("Code = %q; want deployment_limit_reached", apiErr.Code) + } +} + +// TestCreateStack_PreflightValidation covers the client-side guard arms (bad +// name, no services, empty service name, nil tarball, duplicate name) plus the +// empty-stack_id response branch. +func TestCreateStack_PreflightValidation(t *testing.T) { + client := instant.New(instant.WithBaseURL("http://unused.invalid")) + + if _, err := client.CreateStack(context.Background(), instant.CreateStackOpts{}); err == nil { + t.Error("empty name: expected error") + } + + if _, err := client.CreateStack(context.Background(), instant.CreateStackOpts{Name: "ok"}); err == nil { + t.Error("no services: expected error") + } else if !strings.Contains(err.Error(), "at least one service") { + t.Errorf("no services error = %v", err) + } + + if _, err := client.CreateStack(context.Background(), instant.CreateStackOpts{ + Name: "ok", + Services: []instant.StackServiceSpec{{Tarball: bytes.NewBufferString("t")}}, + }); err == nil || !strings.Contains(err.Error(), "non-empty Name") { + t.Errorf("empty service name: got err = %v", err) + } + + if _, err := client.CreateStack(context.Background(), instant.CreateStackOpts{ + Name: "ok", + Services: []instant.StackServiceSpec{{Name: "api"}}, + }); err == nil || !strings.Contains(err.Error(), "non-nil Tarball") { + t.Errorf("nil tarball: got err = %v", err) + } + + if _, err := client.CreateStack(context.Background(), instant.CreateStackOpts{ + Name: "ok", + Services: []instant.StackServiceSpec{ + {Name: "api", Tarball: bytes.NewBufferString("a")}, + {Name: "api", Tarball: bytes.NewBufferString("b")}, + }, + }); err == nil || !strings.Contains(err.Error(), "duplicate service name") { + t.Errorf("duplicate service: got err = %v", err) + } + + // Empty-stack_id response branch. + emptySlug := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusAccepted) + _ = json.NewEncoder(w).Encode(map[string]any{"ok": true, "status": "building"}) + })) + defer emptySlug.Close() + ec := instant.New(instant.WithBaseURL(emptySlug.URL)) + if _, err := ec.CreateStack(context.Background(), instant.CreateStackOpts{ + Name: "x", + Services: []instant.StackServiceSpec{{Name: "api", Tarball: bytes.NewBufferString("t")}}, + }); err == nil || !strings.Contains(err.Error(), "empty stack_id") { + t.Errorf("empty stack_id: got err = %v", err) + } +} + +// failingReader returns an error on Read so we can exercise CreateStack's +// io.Copy failure branch. +type failingReader struct{} + +func (failingReader) Read(_ []byte) (int, error) { return 0, errors.New("boom") } + +// TestCreateStack_TarballReadError covers the io.Copy failure arm when a +// service Tarball reader errors mid-read. +func TestCreateStack_TarballReadError(t *testing.T) { + client := instant.New(instant.WithBaseURL("http://unused.invalid")) + _, err := client.CreateStack(context.Background(), instant.CreateStackOpts{ + Name: "x", + Services: []instant.StackServiceSpec{{Name: "api", Tarball: failingReader{}}}, + }) + if err == nil || !strings.Contains(err.Error(), "reading tarball") { + t.Errorf("expected tarball read error; got %v", err) + } +} + +// TestCreateStack_TwoServices exercises the manifest builder's sort comparator +// (>= 2 services) so the manifest order is deterministic, and confirms both +// service tarballs are uploaded under their respective field names. +func TestCreateStack_TwoServices(t *testing.T) { + var gotManifest string + gotTarballs := map[string]string{} + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _ = r.ParseMultipartForm(10 << 20) + gotManifest = r.FormValue("manifest") + for _, name := range []string{"api", "worker"} { + if f, _, err := r.FormFile(name); err == nil { + b, _ := io.ReadAll(f) + _ = f.Close() + gotTarballs[name] = string(b) + } + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusAccepted) + _ = json.NewEncoder(w).Encode(map[string]any{"ok": true, "stack_id": "cafef00d", "status": "building"}) + })) + defer srv.Close() + + client := instant.New(instant.WithBaseURL(srv.URL)) + // Pass services out of order to prove the builder sorts them. + _, err := client.CreateStack(context.Background(), instant.CreateStackOpts{ + Name: "x", + Services: []instant.StackServiceSpec{ + {Name: "worker", Tarball: bytes.NewBufferString("worker-tar"), Port: 9000}, + {Name: "api", Tarball: bytes.NewBufferString("api-tar"), Port: 8080, Expose: true}, + }, + }) + if err != nil { + t.Fatalf("CreateStack: %v", err) + } + + // Deterministic order: "api" block precedes "worker" block. + apiIdx := strings.Index(gotManifest, ` "api":`) + workerIdx := strings.Index(gotManifest, ` "worker":`) + if apiIdx < 0 || workerIdx < 0 || apiIdx > workerIdx { + t.Errorf("manifest not sorted (api before worker)\n%s", gotManifest) + } + if gotTarballs["api"] != "api-tar" || gotTarballs["worker"] != "worker-tar" { + t.Errorf("tarballs = %v", gotTarballs) + } +} + +// TestCreateStack_TransportError covers the provisionClient.Do failure arm: +// pointing the client at a closed server yields a transport error (after the +// SDK's one retry), which must surface as a request-failed error, not a panic. +func TestCreateStack_TransportError(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {})) + closedURL := srv.URL + srv.Close() // close immediately so Do() fails to connect + + client := instant.New(instant.WithBaseURL(closedURL)) + _, err := client.CreateStack(context.Background(), instant.CreateStackOpts{ + Name: "x", + Services: []instant.StackServiceSpec{{Name: "api", Tarball: bytes.NewBufferString("t")}}, + }) + if err == nil || !strings.Contains(err.Error(), "request failed") { + t.Errorf("expected request-failed transport error; got %v", err) + } +} + +// TestCreateStack_BadBaseURLBuildsRequestError covers the +// http.NewRequestWithContext failure arm via a control character in the URL. +func TestCreateStack_BadBaseURLBuildsRequestError(t *testing.T) { + client := instant.New(instant.WithBaseURL("http://example.com/\x7f")) + _, err := client.CreateStack(context.Background(), instant.CreateStackOpts{ + Name: "x", + Services: []instant.StackServiceSpec{{Name: "api", Tarball: bytes.NewBufferString("t")}}, + }) + if err == nil || !strings.Contains(err.Error(), "building request") { + t.Errorf("expected building-request error; got %v", err) + } +} + +// TestCreateStack_DecodeError covers the json.Decode failure arm: a 2xx +// response whose body is not valid JSON. +func TestCreateStack_DecodeError(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.StatusAccepted) + _, _ = io.WriteString(w, "this is not json") + })) + defer srv.Close() + + client := instant.New(instant.WithBaseURL(srv.URL)) + _, err := client.CreateStack(context.Background(), instant.CreateStackOpts{ + Name: "x", + Services: []instant.StackServiceSpec{{Name: "api", Tarball: bytes.NewBufferString("t")}}, + }) + if err == nil || !strings.Contains(err.Error(), "decoding response") { + t.Errorf("expected decode error; got %v", err) + } +} + +// TestGetStack_HappyPath verifies GET /stacks/:slug parsing, including the +// per-service detail (name/status/expose/port/url) and expires_at. +func TestGetStack_HappyPath(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/stacks/ab12cd34" { + t.Errorf("path = %q; want /stacks/ab12cd34", r.URL.Path) + } + if r.Method != http.MethodGet { + t.Errorf("method = %q; want GET", r.Method) + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "ok": true, + "stack_id": "ab12cd34", + "status": "healthy", + "tier": "hobby", + "name": "my-app", + "expires_at": "2026-06-11T00:00:00Z", + "services": []map[string]any{ + {"name": "api", "status": "healthy", "expose": true, "port": 8080, "url": "https://ab12cd34.deployment.instanode.dev"}, + {"name": "worker", "status": "healthy", "expose": false, "port": 9000, "url": ""}, + }, + }) + })) + defer srv.Close() + + client := instant.New(instant.WithBaseURL(srv.URL)) + st, err := client.GetStack(context.Background(), "ab12cd34") + if err != nil { + t.Fatalf("GetStack: %v", err) + } + if st.Slug != "ab12cd34" { + t.Errorf("Slug = %q", st.Slug) + } + if st.Status != "healthy" { + t.Errorf("Status = %q; want healthy", st.Status) + } + if st.Name != "my-app" { + t.Errorf("Name = %q", st.Name) + } + if st.ExpiresAt != "2026-06-11T00:00:00Z" { + t.Errorf("ExpiresAt = %q", st.ExpiresAt) + } + if len(st.Services) != 2 { + t.Fatalf("len(Services) = %d; want 2", len(st.Services)) + } + if st.Services[0].Name != "api" || !st.Services[0].Expose || st.Services[0].Port != 8080 { + t.Errorf("Services[0] = %+v", st.Services[0]) + } + if st.Services[0].URL != "https://ab12cd34.deployment.instanode.dev" { + t.Errorf("Services[0].URL = %q", st.Services[0].URL) + } + if st.Services[1].Expose { + t.Errorf("Services[1].Expose = true; want false") + } +} + +// TestGetStack_NotFound pins the 404 path → *APIError, and the empty-slug +// preflight guard. +func TestGetStack_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": "Stack not found"}) + })) + defer srv.Close() + + client := instant.New(instant.WithBaseURL(srv.URL)) + _, err := client.GetStack(context.Background(), "missing0") + if !instant.IsNotFound(err) { + t.Fatalf("expected IsNotFound; got %v", err) + } + + // Empty-slug preflight. + if _, err := client.GetStack(context.Background(), ""); err == nil || !strings.Contains(err.Error(), "slug is required") { + t.Errorf("empty slug: got err = %v", err) + } +} + +// TestCreateStack_ManifestNeedsAndEnvOnly exercises the manifest builder arms +// for a service with no port/expose but with needs+env, and the YAML-quoting of +// values containing metacharacters. +func TestCreateStack_ManifestNeedsAndEnvOnly(t *testing.T) { + var gotManifest string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _ = r.ParseMultipartForm(10 << 20) + gotManifest = r.FormValue("manifest") + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusAccepted) + _ = json.NewEncoder(w).Encode(map[string]any{"ok": true, "stack_id": "feedface", "status": "building"}) + })) + defer srv.Close() + + client := instant.New(instant.WithBaseURL(srv.URL)) + _, err := client.CreateStack(context.Background(), instant.CreateStackOpts{ + Name: "x", + Services: []instant.StackServiceSpec{{ + Name: "worker", + Tarball: bytes.NewBufferString("t"), + // no Port (→ omitted, server defaults to 8080), no Expose + Needs: []string{"tok-1"}, + Env: map[string]string{"DSN": "postgres://u:p@h:5432/db?x=1"}, + }}, + }) + if err != nil { + t.Fatalf("CreateStack: %v", err) + } + + // Port omitted (no `port:` line for this service → server defaults to 8080). + if strings.Contains(gotManifest, "port:") { + t.Errorf("manifest should omit port when 0\n%s", gotManifest) + } + if strings.Contains(gotManifest, "expose:") { + t.Errorf("manifest should omit expose when false\n%s", gotManifest) + } + // needs + env present; the colon-laden DSN value is double-quoted so it + // stays a single YAML scalar. + for _, want := range []string{ + ` - "tok-1"`, + ` "DSN": "postgres://u:p@h:5432/db?x=1"`, + } { + if !strings.Contains(gotManifest, want) { + t.Errorf("manifest missing %q\n%s", want, gotManifest) + } + } +} diff --git a/instant/types.go b/instant/types.go index b560a6a..0c4ff61 100644 --- a/instant/types.go +++ b/instant/types.go @@ -53,6 +53,21 @@ type ProvisionResult struct { ExpiresAt string `json:"expires_at,omitempty"` } +// VectorResult is returned by [Client.ProvisionVector]. It is a ProvisionResult +// plus the two pgvector-specific fields the /vector/new response carries. +type VectorResult struct { + ProvisionResult + + // Extension is the Postgres extension installed on the database — always + // "pgvector" for a /vector/new resource. + Extension string `json:"extension,omitempty"` + + // Dimensions is the embedding width the server recorded (the value you + // supplied, or the server default of 1536 when you left it 0). It is a + // hint only — pgvector columns set their own width at table-create time. + Dimensions int `json:"dimensions,omitempty"` +} + // ResourceLimits describes the storage, memory, or connection limits for a provisioned resource. type ResourceLimits struct { // StorageMB is the storage limit in megabytes (Postgres, MongoDB, NATS). @@ -68,6 +83,56 @@ type ResourceLimits struct { ExpiresIn string `json:"expires_in,omitempty"` } +// DeploymentEvent is one row of a deployment's failure-autopsy timeline, as +// returned by [Client.DeploymentEvents] (GET /api/v1/deployments/:id/events). +// +// When a build or rollout fails, the worker captures the build-pod's exit +// reason, last log lines, and a remediation hint and writes them here — so an +// agent can read the timeline and self-correct the deploy (rule 27: the +// silent-deploy-failure autopsy surface). +type DeploymentEvent struct { + // Kind is the event category (e.g. "build", "rollout", "autopsy"). + Kind string `json:"kind"` + + // Reason is the short machine reason (e.g. "BackoffLimitExceeded", + // "DeadlineExceeded", "StartFailed"). + Reason string `json:"reason"` + + // Event is the raw k8s/build event string this row was distilled from. + Event string `json:"event"` + + // ExitCode is the build/container exit code when one was captured. It is a + // pointer so a row with no exit code (null on the wire) is distinguishable + // from a genuine exit code of 0. + ExitCode *int32 `json:"exit_code"` + + // LastLines is the tail of the build/runtime log captured at failure — + // usually the most actionable field for diagnosing a broken build. + LastLines string `json:"last_lines"` + + // Hint is a human/LLM-ready remediation suggestion the autopsy attached. + Hint string `json:"hint"` + + // CreatedAt is the RFC3339 timestamp the event was recorded. + CreatedAt string `json:"created_at"` +} + +// DeploymentEventList is returned by [Client.DeploymentEvents]. +type DeploymentEventList struct { + // OK is always true on success. + OK bool `json:"ok"` + + // DeploymentID is the internal deployment row UUID the events belong to. + DeploymentID string `json:"deployment_id"` + + // Events is the autopsy timeline, newest-first, capped by the server + // (default 50; override with the limit query the SDK sets from opts). + Events []DeploymentEvent `json:"events"` + + // Count is the number of events returned in this response. + Count int `json:"count"` +} + // Resource represents a provisioned resource as returned by the resource management API. type Resource struct { // ID is the internal resource UUID. diff --git a/instant/vector.go b/instant/vector.go new file mode 100644 index 0000000..829400d --- /dev/null +++ b/instant/vector.go @@ -0,0 +1,87 @@ +package instant + +import ( + "context" + "fmt" +) + +// VectorOpts are the parameters for [Client.ProvisionVector]. +// +// It embeds [ProvisionOpts] (Name is REQUIRED; IdempotencyKey is optional) and +// adds the pgvector-specific Dimensions hint. +type VectorOpts struct { + ProvisionOpts + + // Dimensions is the embedding width hint echoed back on the response. + // + // It is metadata only — pgvector lets you pick the column width at + // table-create time, so this does not constrain the database. 0 leaves the + // field off the request and the API defaults it to 1536 (OpenAI + // text-embedding-ada-002). The API rejects values outside 1..16000 + // (pgvector's hard upper bound) with a 400 invalid_dimensions. + Dimensions int `json:"dimensions,omitempty"` +} + +// ProvisionVector provisions a pgvector-enabled Postgres database via +// POST /vector/new. The provisioning pipeline, connection-string format, +// AES-at-rest storage, and tier limits are identical to [Client.ProvisionDatabase] +// — the only deltas are the resource_type tag and the extra Extension / +// Dimensions fields on the response. +// +// No account is required. Anonymous resources expire after 24h unless claimed. +// +// Tier limits mirror Postgres exactly (the underlying storage IS Postgres): +// +// Anonymous: 10 MB, 2 connections, 24h TTL +// Hobby: 1 GB, 8 connections +// Pro: 10 GB, 20 connections +// Team: unlimited +// +// opts is REQUIRED and opts.Name must be a valid resource name (1–64 chars, +// matching ^[A-Za-z0-9][A-Za-z0-9 _-]*$). An invalid or missing name returns +// an error before any network request is made. +// +// Example: +// +// vdb, err := client.ProvisionVector(ctx, &instant.VectorOpts{ +// ProvisionOpts: instant.ProvisionOpts{Name: "embeddings"}, +// Dimensions: 1536, +// }) +// if err != nil { log.Fatal(err) } +// fmt.Println("pgvector URL:", vdb.ConnectionURL, "dims:", vdb.Dimensions) +func (c *Client) ProvisionVector(ctx context.Context, opts *VectorOpts) (*VectorResult, error) { + if opts == nil { + return nil, fmt.Errorf("ProvisionVector: opts is required: a non-nil *VectorOpts with a valid Name must be supplied") + } + if err := validateResourceName(opts.Name); err != nil { + return nil, fmt.Errorf("ProvisionVector: %w", err) + } + if opts.Dimensions < 0 { + return nil, fmt.Errorf("ProvisionVector: Dimensions must be >= 0 (0 = server default), got %d", opts.Dimensions) + } + + body := map[string]any{"name": opts.Name} + if opts.Dimensions > 0 { + body["dimensions"] = opts.Dimensions + } + + var result VectorResult + if err := c.provisionJSONWithHeaders(ctx, "/vector/new", body, provisionHeaders(&opts.ProvisionOpts), &result); err != nil { + return nil, fmt.Errorf("ProvisionVector: %w", err) + } + if result.Token == "" { + return nil, fmt.Errorf("ProvisionVector: server returned empty token") + } + if result.ConnectionURL == "" { + return nil, fmt.Errorf("ProvisionVector: server returned empty connection_url") + } + if result.Note != "" { + c.logger.Info("instant.dev vector provisioned", + "token", result.Token, + "tier", result.Tier, + "dimensions", result.Dimensions, + "note", result.Note, + ) + } + return &result, nil +} diff --git a/instant/vector_test.go b/instant/vector_test.go new file mode 100644 index 0000000..e7d1ffd --- /dev/null +++ b/instant/vector_test.go @@ -0,0 +1,211 @@ +package instant_test + +import ( + "context" + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/InstaNode-dev/sdk-go/instant" +) + +// TestProvisionVector_HappyPath spins up a httptest server mimicking +// POST /vector/new, asserts the SDK sent name + dimensions in the JSON body, +// and verifies the parsed VectorResult (including the pgvector-specific +// Extension + Dimensions fields) matches the API contract. +func TestProvisionVector_HappyPath(t *testing.T) { + var gotBody map[string]any + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/vector/new" { + t.Errorf("path = %q; want /vector/new", r.URL.Path) + http.Error(w, "wrong path", http.StatusNotFound) + return + } + if r.Method != http.MethodPost { + t.Errorf("method = %q; want POST", r.Method) + } + if got := r.Header.Get("Idempotency-Key"); got != "vec-key-1" { + t.Errorf("Idempotency-Key = %q; want vec-key-1", got) + } + _ = json.NewDecoder(r.Body).Decode(&gotBody) + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusCreated) + _ = json.NewEncoder(w).Encode(map[string]any{ + "ok": true, + "id": "11111111-2222-3333-4444-555555555555", + "token": "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee", + "name": "embeddings", + "connection_url": "postgres://usr_x:pw@host:5432/db_x", + "tier": "anonymous", + "env": "development", + "extension": "pgvector", + "dimensions": 768, + "limits": map[string]any{"storage_mb": 10, "connections": 2, "expires_in": "24h"}, + "note": "Claim at https://instanode.dev/start?t=...", + }) + })) + defer srv.Close() + + client := instant.New(instant.WithBaseURL(srv.URL)) + res, err := client.ProvisionVector(context.Background(), &instant.VectorOpts{ + ProvisionOpts: instant.ProvisionOpts{Name: "embeddings", IdempotencyKey: "vec-key-1"}, + Dimensions: 768, + }) + if err != nil { + t.Fatalf("ProvisionVector: %v", err) + } + + if res.Token != "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee" { + t.Errorf("Token = %q", res.Token) + } + if res.ConnectionURL != "postgres://usr_x:pw@host:5432/db_x" { + t.Errorf("ConnectionURL = %q", res.ConnectionURL) + } + if res.Extension != "pgvector" { + t.Errorf("Extension = %q; want pgvector", res.Extension) + } + if res.Dimensions != 768 { + t.Errorf("Dimensions = %d; want 768", res.Dimensions) + } + if res.Tier != "anonymous" { + t.Errorf("Tier = %q", res.Tier) + } + + // Wire-body assertions. + if gotBody["name"] != "embeddings" { + t.Errorf("body name = %v; want embeddings", gotBody["name"]) + } + // JSON numbers decode as float64. + if gotBody["dimensions"] != float64(768) { + t.Errorf("body dimensions = %v; want 768", gotBody["dimensions"]) + } +} + +// TestProvisionVector_OmitsDimensionsWhenZero verifies the SDK leaves the +// dimensions field off the wire when the caller passes 0, so the server +// applies its own default (1536) instead of receiving an explicit 0 (which it +// would reject as out-of-range). +func TestProvisionVector_OmitsDimensionsWhenZero(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var body map[string]any + _ = json.NewDecoder(r.Body).Decode(&body) + if _, present := body["dimensions"]; present { + t.Errorf("dimensions should be omitted when Dimensions == 0, body = %v", body) + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusCreated) + _ = json.NewEncoder(w).Encode(map[string]any{ + "ok": true, + "token": "tok", + "connection_url": "postgres://x", + "extension": "pgvector", + "dimensions": 1536, + }) + })) + defer srv.Close() + + client := instant.New(instant.WithBaseURL(srv.URL)) + res, err := client.ProvisionVector(context.Background(), &instant.VectorOpts{ + ProvisionOpts: instant.ProvisionOpts{Name: "v"}, + }) + if err != nil { + t.Fatalf("ProvisionVector: %v", err) + } + if res.Dimensions != 1536 { + t.Errorf("Dimensions = %d; want server default 1536", res.Dimensions) + } +} + +// TestProvisionVector_SurfacesAPIError pins the error-envelope path: a 400 +// invalid_dimensions must surface as *APIError with the canonical code intact. +func TestProvisionVector_SurfacesAPIError(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.StatusBadRequest) + _ = json.NewEncoder(w).Encode(map[string]any{ + "ok": false, + "error": "invalid_dimensions", + "message": "dimensions must be between 1 and 16000", + "agent_action": "Pick a dimension in 1..16000 and retry.", + }) + })) + defer srv.Close() + + client := instant.New(instant.WithBaseURL(srv.URL)) + _, err := client.ProvisionVector(context.Background(), &instant.VectorOpts{ + ProvisionOpts: instant.ProvisionOpts{Name: "v"}, + Dimensions: 99999, // server-side range error + }) + if err == nil { + t.Fatal("expected an error on 400, got nil") + } + var apiErr *instant.APIError + if !errors.As(err, &apiErr) { + t.Fatalf("expected *APIError; got %T (%v)", err, err) + } + if apiErr.StatusCode != http.StatusBadRequest { + t.Errorf("StatusCode = %d; want 400", apiErr.StatusCode) + } + if apiErr.Code != "invalid_dimensions" { + t.Errorf("Code = %q; want invalid_dimensions", apiErr.Code) + } +} + +// TestProvisionVector_PreflightValidation covers the client-side guard arms +// (nil opts, bad name, negative dimensions, empty token, empty connection_url) +// that fail before / after the network call. +func TestProvisionVector_PreflightValidation(t *testing.T) { + client := instant.New(instant.WithBaseURL("http://unused.invalid")) + + if _, err := client.ProvisionVector(context.Background(), nil); err == nil { + t.Error("nil opts: expected error") + } else if !strings.Contains(err.Error(), "opts is required") { + t.Errorf("nil opts error = %v", err) + } + + if _, err := client.ProvisionVector(context.Background(), &instant.VectorOpts{}); err == nil { + t.Error("empty name: expected error") + } + + if _, err := client.ProvisionVector(context.Background(), &instant.VectorOpts{ + ProvisionOpts: instant.ProvisionOpts{Name: "ok"}, + Dimensions: -5, + }); err == nil { + t.Error("negative dimensions: expected error") + } else if !strings.Contains(err.Error(), "Dimensions must be >= 0") { + t.Errorf("negative dimensions error = %v", err) + } + + // Empty-token response branch. + emptyTok := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusCreated) + _ = json.NewEncoder(w).Encode(map[string]any{"ok": true, "connection_url": "postgres://x"}) + })) + defer emptyTok.Close() + tc := instant.New(instant.WithBaseURL(emptyTok.URL)) + if _, err := tc.ProvisionVector(context.Background(), &instant.VectorOpts{ + ProvisionOpts: instant.ProvisionOpts{Name: "v"}, + }); err == nil || !strings.Contains(err.Error(), "empty token") { + t.Errorf("empty token: got err = %v", err) + } + + // Empty-connection-url response branch. + emptyURL := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusCreated) + _ = json.NewEncoder(w).Encode(map[string]any{"ok": true, "token": "tok"}) + })) + defer emptyURL.Close() + uc := instant.New(instant.WithBaseURL(emptyURL.URL)) + if _, err := uc.ProvisionVector(context.Background(), &instant.VectorOpts{ + ProvisionOpts: instant.ProvisionOpts{Name: "v"}, + }); err == nil || !strings.Contains(err.Error(), "empty connection_url") { + t.Errorf("empty connection_url: got err = %v", err) + } +}