Skip to content

feat: generated API clients and facade with retries, RMW PUT and pagination - #10

Open
RogerCecilpot wants to merge 3 commits into
feat/260-scoop-deb-rpmfrom
feat/236-api-clients
Open

RogerCecilpot wants to merge 3 commits into
feat/260-scoop-deb-rpmfrom
feat/236-api-clients

Conversation

@RogerCecilpot

Copy link
Copy Markdown

Closes devlyft/devlyft#236. Stacked on #5 (which is on #2); retarget to main once those merge.

Important

Review the vendored specs before merging — this repo is public. Everything under internal/api/specs/ comes from the private platform repo. What is published is decided by internal/api/specs/allowlist.yaml; please review that file and the four specs, not just the code.

Source: devlyft/devlyft@bef84e3b430ac77c34dab97cd1c94d976cbdb49d: cmd/apps/{deploys,org,logs,metrics}-api/api/openapi/api.yaml. pods-api is not used (its endpoints are SPIFFE-only); pods are read through org-api.

What

  • hack/specprune + allowlist.yaml: keeps only allowlisted operations and the components they reference. It strips servers, security and every x- extension, drops listed fields, and rewrites listed descriptions. It refuses to write anything while a description matches a deny pattern (SPIFFE, internal service names, Agones/fleet/game server, cluster.local/k8s/vault, …) or a property name looks internal (cluster|node|host|ip|tenant|…) without being dropped or reviewed. That makes it safe for #237 to run unattended. make vendor-specs [REF=<sha>] fetches the allowlisted files with gh and re-runs it.
  • Generated clients: oapi-codegen v2.5.1 (the platform's version), client + models, in internal/api/gen/<service>. Generated with make generate; CI's lint job fails if generated code or mocks are stale. kin-openapi and oasdiff/yaml are pinned to the platform's versions (oapi-codegen v2.5.1 doesn't build with newer ones) and excluded from Dependabot.
  • Facade (internal/api):
    • api.Client covers every call the tier 1+2 commands need; gomock mock in internal/api/mock. New(Options) takes the base URL, a TokenSource (asked on every attempt, so #245/#263 can refresh), an *http.Client (for #244's proxy/CA), a logger and a user agent devlyft/<version> (<os>/<arch>). Tokens are only sent over HTTPS or to loopback.
    • Retries: GET/PUT/DELETE retry on 429, 5xx (not 501) and connection errors, using exponential backoff with full jitter (0.5s base, 10s cap). Retry-After is honoured as seconds or as an HTTP date; a longer one stops the retries. Limits are 4 attempts and 30s total waiting, and Ctrl-C stops the wait. POST is never retried. The final error says (after N attempts) and carries details.attempts.
    • Errors: non-2xx responses map to clierr.FromHTTP with the server's message (detail/message/error/title, or short plain text) and request ID (X-Request-Id header, else a request_id body field, ready for #233). An invalid ID is a usage error (exit 2).
    • Read-modify-write: UpdateDeploy(ctx, id, mutate) GETs the deploy and builds the full UpdateDeployRequest (build_id, name and location_id always set). It applies the mutation and PUTs the result, sending If-Match when the GET returned an ETag (#239). UpdateRequestFromDeploy is exported for deploy --dry-run. Under HPA, replicas is sent as 0 because the API rejects both together.
    • Pagination: Paged fetches only as many limit/offset pages as --limit needs and survives servers that ignore paging; Trim covers endpoints without paging.
    • Debug log: each exchange (method, URL, status, duration, headers, bodies up to 4 KB) goes through internal/redact. Bodies are read only at debug level.

Vendored endpoints

Method Path Service Used by
GET /v1/organizations org-api orgs list, name resolution
GET /v1/organizations/{organization_id} org-api orgs describe
GET /v1/organizations/{organization_id}/projects org-api projects list, name resolution
GET /v1/projects/{project_id} org-api projects describe
GET /v1/projects/{project_id}/environments org-api envs list, name resolution
GET /v1/environments/{environment_id} org-api envs describe
GET /v1/locations org-api --location name resolution (public, unauthenticated)
GET /v1/projects/{project_id}/environments/{environment_id}/secrets org-api secrets list
POST /v1/projects/{project_id}/environments/{environment_id}/secrets/{secret_name} org-api secrets set
DELETE /v1/projects/{project_id}/environments/{environment_id}/secrets/{secret_name} org-api secrets delete
GET /v1/projects/{project_id}/pods org-api pods list --project
GET /v1/environments/{environment_id}/pods org-api pods list (context env)
GET /v1/deploys/{deploy_id}/pods org-api pods list --deploy, deploy --wait
GET /v1/pods/{pod_id} org-api pods describe
GET /v1/projects/{project_id}/builds deploys-api builds list, deploy build lookup
GET /v1/builds/{build_id} deploys-api builds describe
POST /v1/projects/{project_id}/builds deploys-api builds create, deploy
GET /v1/projects/{project_id}/environments/{environment_id}/deploys deploys-api deploys list, name resolution, deploy upsert
GET /v1/deploys/{deploy_id} deploys-api deploys describe, RMW update
POST /v1/projects/{project_id}/environments/{environment_id}/deploys deploys-api deploys create, deploy
PUT /v1/deploys/{deploy_id} deploys-api deploys update, deploy
DELETE /v1/deploys/{deploy_id} deploys-api deploys delete
GET /v1/deploys/{deploy_id}/routes deploys-api routes list
POST /v1/deploys/{deploy_id}/routes deploys-api routes create
DELETE /v1/deploys/{deploy_id}/routes/{route_id} deploys-api routes delete
GET /v1/organizations/{organization_id}/domains deploys-api domains list
POST /v1/organizations/{organization_id}/domains deploys-api domains create
POST /v1/organizations/{organization_id}/domains/{domain}/verify deploys-api domains verify
DELETE /v1/organizations/{organization_id}/domains/{domain} deploys-api domains delete
GET /v1/deploys/{deployId}/logs logs-api logs (history; --follow is the WebSocket, #254)
GET /v1/metric-types metrics-api metrics --type validation and help
GET /v1/deploys/{deployId}/metrics metrics-api metrics <deploy>
GET /v1/pods/{podId}/metrics metrics-api metrics <pod> (takes the pod name)

Deliberately not vendored (in the source specs, but no CLI v1 command needs them):

  • Cross-scope lists: GET /v1/builds, GET /v1/deploys, GET /v1/pods.
  • Build writes: PUT/DELETE /v1/builds/{id}.
  • Registry credentials: all of their endpoints.
  • Org/project/env writes: create, update and delete (tier 3).
  • Nodes, instance types, org/node metrics: all of their endpoints.

Fields to review

Field Decision Why / recommendation
Build.ports, CreateBuildRequest.ports (and BuildPort) Dropped Game-server ports; their descriptions describe Agones, fleets and sidecar ports. Not a CLI feature. Re-add with public wording if game servers become public.
Pod.host_ports (and PodHostPort.host) Dropped Game-server host IPs and ports; not used by pods describe. Same as above.
Deploy.placement.cluster_name Kept (reviewed) The API already returns it to customers, and it helps support (PENDING vs placed). Recommend keep.
Pod.cluster_name Kept (reviewed) Same. Note the spec marks it required.
Route.hostname, CreateRouteRequest.hostname Kept (reviewed) Customer-facing custom-domain routing; flagged only by the host pattern.
CustomDomain.verification_token Kept The TXT value the customer must publish; public by design.
Deploy.instance_type description example devlyft-amd-balanced-2 Kept Instance type names are customer-visible. Confirm they're not internal SKUs.
Organization.slug description ("usable wherever an organization id is accepted by the CLI") Kept See open question 1 below.

Descriptions rewritten (originals named internal services or SPIFFE hops):

  • The secrets list and set operations.
  • The four pods operations.
  • DeployEnvVar, DeploySecretRef and Deploy.env (K8s wording).

The overrides are in allowlist.yaml.

Found while doing this (needs platform follow-ups)

  1. Org slugs versus UUID path params. Organization.slug says it is usable wherever an organization ID is accepted, but every organization_id path parameter is format: uuid. The generated client therefore only takes UUIDs, so #244 must resolve slugs to IDs with a list call. Either the spec format is wrong or the description is.
  2. Broken discriminator. The DeployEnvItem discriminator has no mapping, so oapi-codegen's FromDeployEnvVar/ValueByDiscriminator use "DeployEnvVar" as the kind instead of "env", and the API would reject it. The contract tests caught this. The CLI uses api.EnvVarItem/SecretRefItem/EnvItemValue instead, and a test fails once the spec gains a mapping, so they can be removed. Fix upstream with mapping: {env: …, secret: …}.
  3. Undocumented error bodies. No error response bodies are documented in any spec, so error parsing is best-effort (problem details, {error}, {message}). #233 would make this concrete.
  4. Pod metrics key. /v1/pods/{podId}/metrics takes the pod name, while /v1/pods/{pod_id} takes the pod ID.

Tests

  • Retry policy. Covered cases:
    • 503 then 200
    • 429 with Retry-After in seconds and as a date
    • an over-long Retry-After
    • max attempts with the attempt count
    • the total-wait cap
    • PUT and DELETE retried; POST never retried; 4xx and 501 not retried
    • connection errors (GET retried, POST not)
    • Ctrl-C stopping the wait
    • the body replayed on retry
    • a fresh token on each attempt
  • Read-modify-write PUT. With and without an ETag (If-Match); required fields carried over; HPA leaves replicas unset; a mutation error stops the write.
  • Pagination. With server paging, without it, and with a server that ignores it.
  • Error mapping. Body and header request IDs, problem details, {error}, plain text, and HTML ignored.
  • Debug log. Redacts the token and the secret value.
  • Contract tests. An httptest server routes every facade call through kin-openapi openapi3filter. It validates each request against the vendored specs, and the canned responses too. It also fails if any vendored operation goes unused, so the allowlist can't carry dead surface.
  • hack/specprune. Pruning, extension stripping, drops, overrides, and both refusal paths.

Coverage: internal/api 81.7%, hack/specprune 74.1%. make lint, make test, make vuln, actionlint and shellcheck all pass. make generate and go mod tidy produce no diff.

🤖 Generated with Claude Code

@RogerCecilpot
RogerCecilpot changed the base branch from feat/246-output-errors-exit-codes to feat/260-scoop-deb-rpm October 25, 2026 12:57
RogerCecilpot and others added 2 commits October 25, 2026 13:00
hack/specprune turns the platform's private OpenAPI specs into public
copies under internal/api/specs, driven by a reviewed allowlist: only the
operations the tier 1 and 2 commands use and the components they reference
are kept; servers, security requirements and every x- extension are
removed; game-server fields are dropped; descriptions that name internal
services are rewritten. It refuses to write anything while a description
matches a deny pattern or a property name looks internal and has not been
reviewed, so #237's automation can run it unattended.

hack/vendor-specs.sh (make vendor-specs) fetches the allowlisted files at
a pinned ref; the vendored specs record their source commit.

Clients are generated with oapi-codegen v2.5.1, the platform's version,
into internal/api/gen/<service> (make generate). CI fails if generated
code is out of date.

Refs devlyft/devlyft#236

Co-Authored-By: Claude Opus 5.5 <[email protected]>
internal/api.Client is every call the tier 1 and 2 commands make, over the
generated clients, with a gomock mock in internal/api/mock.

- GET, PUT and DELETE retry on 429, 5xx (not 501) and connection errors
  with exponential backoff and full jitter, honouring Retry-After in
  seconds or as a date; POST is never retried. Attempts and total wait are
  capped, cancellation stops retries, and the final error states the
  attempt count.
- The bearer token is asked for on every attempt, so refreshing token
  sources plug in; tokens are only sent over HTTPS or to loopback.
- Non-2xx responses map to clierr errors with the server's message and
  request ID (X-Request-Id header or body field).
- UpdateDeploy reads the deploy, applies a mutation to a full update
  request and writes it back, sending If-Match when the GET returns an
  ETag.
- Paged collects from limit/offset endpoints; Trim limits the rest.
- Each exchange is debug-logged through internal/redact.
- EnvVarItem and SecretRefItem build env entries with the right kind: the
  spec's discriminator has no mapping, so the generated helpers use schema
  names as the kind, which the API rejects.

Contract tests validate every request and response against the vendored
specs with kin-openapi and check every vendored operation is used.

Refs devlyft/devlyft#236

Co-Authored-By: Claude Opus 5.5 <[email protected]>
oapi-codegen v2.5.1 does not build against newer kin-openapi or
oasdiff/yaml, so Dependabot must not bump them on their own.

Refs devlyft/devlyft#236

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant