deploys

package
v0.0.0-...-dbd5cab Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Aug 25, 2026 License: Apache-2.0 Imports: 28 Imported by: 0

Documentation

Overview

Package deploys is the deploy-history feature (w2/m5): every rollout of a store-managed App is a row in lego/backend/internal/store, listable and triggerable over REST/GraphQL/MCP under Render's names (list_deploys / get_deploy / POST .../deploys) — the poll-loop a Render-trained agent already knows how to run. It requires the control-plane store (BEX_CP_DB_URI): deploy history has no CR-only equivalent to fall back to, so with the store unwired every verb reports core.ErrDeploysUnavailable (503) — the env-vars precedent, omitted rather than faked.

Index

Constants

View Source
const (
	// DeployHookTokenAnnotation persists the rotatable credential on the App CR.
	// An annotation keeps the feature available in both store-backed and CR-only
	// modes without changing the App CRD or involving the operator. Tenants never
	// receive Kubernetes credentials; bex-api is the only public reveal surface.
	DeployHookTokenAnnotation = "bex.co/deploy-hook-token"
	// DeployHookTokenDigestLabel is the non-secret lookup index for the token.
	// A URL credential is never a label value; its SHA-256 digest lets the API
	// server perform an exact selector instead of returning every tenant App.
	DeployHookTokenDigestLabel = "bex.co/deploy-hook-token-digest"

	// The v1 trigger budget is fixed and per token. A two-request burst tolerates
	// one immediate CI retry; sustained calls refill at six per minute. This is
	// independent of BEX_RATE_LIMIT because hook requests have no caller identity.
	DefaultDeployHookRPM   = 6.0
	DefaultDeployHookBurst = 2
)

Variables

This section is empty.

Functions

func BackfillDeployHookTokenDigests

func BackfillDeployHookTokenDigests(ctx context.Context, cl client.Client) error

BackfillDeployHookTokenDigests migrates pre-index Apps before the public hook route starts serving. It performs the one intentional cluster-wide list at startup, then patches only Apps that already carry a valid hook credential. Conflicts are retried and recompute the digest from the latest token, making concurrent replicas and rotations safe.

Types

type CommitResolver

type CommitResolver interface {
	ResolveCommit(ctx context.Context, workspaceID, repoURL, ref string) (store.CommitInfo, bool, error)
}

CommitResolver resolves a repo ref (branch, tag, or SHA) to the exact commit it points at, via workspaceID's GitHub App connection — github.Service's DeployCommitSource satisfies it (w9/001). nil on the Service ⇒ deploy rows carry no commit metadata (omitted, not faked). ok=false (nil err) means "nothing to resolve" (no connection, repo not in the grant, unknown ref); commit metadata is provenance, so callers treat a non-nil err the same way rather than failing the deploy.

type DeployHookRateLimiter

type DeployHookRateLimiter struct {
	*core.KeyedRateLimiter[[sha256.Size]byte]
}

DeployHookRateLimiter is a token-keyed in-memory bucket. Like the main API limiter (BEX_RATE_LIMIT) and the device-flow limiter, it is REPLICA-LOCAL by design: with bex-api's two replicas (w1/m52) the effective per-token ceiling is up to 2× DefaultDeployHookRPM. That is an accepted, bounded over-provision (w1/m58 audit): the endpoint is credential-gated (a leaked dhk- token, never an anonymous flood) and its action is latest-pending idempotent — extra triggers are superseded, not multiplied into extra builds — so a coarse per-replica ceiling is an abuse damper, not a security boundary. A shared control-plane counter was considered and rejected as disproportionate (a DB round trip per hook request to tighten a non-threat, and inconsistent with the other two replica-local limiters). Keys are SHA-256 digests so the raw credential is not retained in the limiter map.

func NewDeployHookRateLimiter

func NewDeployHookRateLimiter(rpm float64, burst int) *DeployHookRateLimiter

NewDeployHookRateLimiter constructs a per-token limiter. Non-positive rpm disables it (nil), matching the main API limiter's constructor contract.

type DeployHookView

type DeployHookView struct {
	URL string `json:"url"`
}

DeployHookView is the identical REST/GraphQL/MCP management shape. URL is the credential: callers must handle it like an API key and avoid logging it.

type DeployStartedNotifier

type DeployStartedNotifier interface {
	NotifyDeployStarted(ctx context.Context, tenantID, appName, notificationsToSend string)
}

DeployStartedNotifier is the request-time notification seam. The notifications service satisfies it structurally; keeping the interface here avoids a feature-package dependency while letting Trigger fire only after the deploy row was opened successfully.

type DeployStore

type DeployStore interface {
	// CreateDeploy opens a deploy row; generation is the App CR's
	// metadata.generation this deploy runs under, captured once at open time
	// (w2/m10) — Cancel derives its build-Job identity from the stored value,
	// never a fresh re-fetch (see buildJobName). commit is the resolved commit
	// this deploy runs (w9/001), zero when unresolvable.
	CreateDeploy(ctx context.Context, appID, trigger, image string, generation int64, commit store.CommitInfo) (store.Deploy, error)
	// CreateRollbackDeploy opens a "rollback"-triggered deploy row (w2/m10)
	// restoring image, provenance-tagged with the source deploy id and the
	// target's own commit metadata (w9/001).
	CreateRollbackDeploy(ctx context.Context, appID, image, rollbackOf string, generation int64, commit store.CommitInfo) (store.Deploy, error)
	ListDeploys(ctx context.Context, appID string, filter store.DeployFilter) ([]store.Deploy, error)
	GetDeploy(ctx context.Context, appID, deployID string) (store.Deploy, error)
	// CloseDeploy transitions a still-open deploy row terminal, CAS-guarded
	// (see store.Store.CloseDeploy) — Cancel's write path, and the same method
	// the reconciler's write-back uses.
	CloseDeploy(ctx context.Context, id, status, resolvedImage string) (bool, error)
	// SetAppImage writes the row-owned image field — Rollback's row-first
	// write, same discipline as apps.Service.writeThroughStore.
	SetAppImage(ctx context.Context, id string, image string) error
}

DeployStore is the Service's seam to the control-plane store — the narrow slice of Store it needs, the same way apps.IntentStore narrows Store to the lifecycle verbs' writes. *store.PGStore satisfies it.

type DeployView

type DeployView struct {
	ID             string
	ServiceID      string
	Status         string
	Image          string
	Trigger        string
	RollbackOf     string
	CommitID       string
	CommitMessage  string
	CommitAuthorAt *time.Time
	CreatedAt      time.Time
	UpdatedAt      time.Time
	StartedAt      *time.Time
	FinishedAt     *time.Time
	// PreDeployStatus is the pre-deploy command's outcome for this deploy (w1/m33):
	// "" (no step) | "running" | "succeeded" | "failed". A deploy that fails its
	// migration is update_failed with PreDeployStatus "failed"; one that fails its
	// health check is update_failed with PreDeployStatus "" — the field is how a
	// client tells the two apart. Its logs are retrievable via the logs surface
	// (`type=predeploy`).
	PreDeployStatus string
	// FailureReason is the actionable cause of a failed deploy (w9/011) — the
	// operator's diagnosis (crash loop with the $PORT hint, image-pull failure,
	// build error) or a health-gate-timeout line. Empty unless Status is a
	// failure. A bex extra beyond Render's deploy shape, like RollbackOf.
	FailureReason string
}

DeployView is the neutral projection of a store.Deploy the adapters render in Render's deploy shape. CommitID/CommitMessage (w9/001) are the resolved commit a build-from-git deploy ran, "" when unresolved — the adapters omit rather than fake them. CommitAuthorAt (w2/m42) is the git author timestamp captured from the same GitHub commit object — nil when unavailable. RollbackOf is a bex extra (w2/m10): empty for every deploy except one Rollback created, naming the source deploy it restores.

type ListFilter

type ListFilter struct {
	Statuses       []string
	CreatedBefore  time.Time
	CreatedAfter   time.Time
	UpdatedBefore  time.Time
	UpdatedAfter   time.Time
	FinishedBefore time.Time
	FinishedAfter  time.Time
	Cursor         string
	Limit          int
}

ListFilter is the neutral shape the REST/GraphQL/MCP adapters translate Render's status, exclusive created/updated/finished time bounds, keyset cursor, and limit params into. A zero limit (absent) is bounded by the store at core.MaxPageLimit, as are values above it (codex-security round-6 #7).

func FilterOf

func FilterOf(statuses []string, createdBefore, createdAfter, updatedBefore, updatedAfter, finishedBefore, finishedAfter, cursor string, limit int) (ListFilter, error)

FilterOf builds a ListFilter from the params in the string form every adapter has them in (a query value, a GraphQL argument, an MCP tool field) — one translator for all three surfaces, the events.FilterOf precedent, so a REST call and a tool call with the same params cannot page differently. Unlike events' permissive reading, a malformed value is core.ErrBadRequest (400): events falls back to its default window, but deploys has none — silently dropping a bound (or turning a negative limit into "absent", which the store reads <=0 as) would return the default page as if it were the filtered one. Both limit bounds are the store's invariant (store.DeployFilter), not re-clamped here.

type Service

type Service struct {
	*core.Base
	Store DeployStore
	// StartedNotifier is invoked asynchronously after a trigger opens its deploy
	// row. nil keeps notifications disabled without changing trigger behavior.
	StartedNotifier DeployStartedNotifier
	// Commits resolves the triggering ref to the exact commit a
	// build-from-git deploy runs (w9/001) — github.Service's
	// DeployCommitSource. nil ⇒ deploy rows open with no commit metadata.
	Commits CommitResolver
	// DeployHookBaseURL is BEX_API_PUBLIC_URL (for example
	// "https://api.bex.co"). It prefixes the secret trigger path returned by the
	// authenticated REST/GraphQL/MCP management surfaces. Empty keeps the path
	// relative, which is useful for local tests but not a copy-ready CI URL.
	DeployHookBaseURL string
	// DeployHookLimiter is the token-keyed limiter for the unauthenticated deploy
	// hook endpoint. It is deliberately separate from api.RateLimiter, whose
	// buckets are keyed by authenticated caller. Nil uses the fixed v1 default.
	DeployHookLimiter *DeployHookRateLimiter
	// BuildNamespace is BEX_BUILD_NAMESPACE — the namespace Cancel looks for a
	// repo-backed App's in-flight build Job in (lego/operator's own build
	// namespace, must match so the Job identity resolves); empty falls back to
	// the App's own namespace, the operator's own default (w2/m10).
	BuildNamespace string
	// CloneSecrets refreshes a repo-backed App's private-clone credential at
	// trigger time (apps.Service's reconciler bridge, wired in the composition
	// root). GitHub App installation tokens live ONE HOUR — a manual trigger or
	// deploy hook changes the release identity and makes the operator rebuild
	// with the PREVIOUS deploy's token, which git surfaces as the misleading
	// "could not read Username" (its 401-then-prompt fallback). Found live on
	// prod 2026-07-17 (agentmarketcap-1: every trigger=api build failed at
	// clone while webhook-triggered siblings built fine). nil ⇒ no refresh
	// (GitHub integration off), prior behavior.
	CloneSecrets store.CloneSecreter
}

Service lists and triggers deploys for store-managed Apps. Embeds *core.Base for the auth gate and GetApp (App-name lookup + tenant gate) — the same fetch every other feature service shares.

func (*Service) Cancel

func (s *Service) Cancel(ctx context.Context, service, deployID string) (DeployView, error)

Cancel kills a still-open deploy (Render's POST .../deploys/{id}/cancel, w2/m10): best-effort terminates the in-flight build Job for a repo-backed service (an image-backed service has no build Job — the delete is a harmless not-found no-op for it), computing the Job's identity from the deploy row's OWN stored Generation rather than the App's current one — a later, unrelated spec write (a scale, an env change, another trigger) bumps metadata.generation independently of this deploy, and would otherwise make Cancel compute the wrong Job name and silently no-op past the real build. It then closes the row canceled with the same CAS-guarded CloseDeploy the reconciler's write-back uses — whichever of Cancel and a genuinely- converging rollout gets there first wins, so a race can never leave the row half-canceled. Canceling the k8s rollout itself is out of scope (matches Render: an image-backed deploy has no build to interrupt in the first place). A deploy that already reached any terminal status is past the cancelable window: Render's 409, never a silent no-op.

func (*Service) DeployHookHandler

func (s *Service) DeployHookHandler() http.Handler

DeployHookHandler serves the open credential-gated endpoint. It accepts GET and POST like Render, supports Render's `ref` commit query parameter and `imgURL` image override, and returns Render's {deploy:{id}} success envelope.

func (*Service) Get

func (s *Service) Get(ctx context.Context, service, deployID string) (DeployView, error)

Get fetches one deploy by dep-… id, scoped to service (Render's get_deploy / GET .../deploys/{deployId}). A deployId belonging to a different service, or a hand-applied service with no history at all, is core.ErrNotFound — the same "not yours" shape GetApp's tenant gate uses, never a cross-app leak through the id alone.

func (*Service) GetDeployHook

func (s *Service) GetDeployHook(ctx context.Context, service string) (DeployHookView, error)

GetDeployHook returns (and lazily mints) a service's stable deploy-hook URL. Reading this credential requires the same sensitive-read relation as database connection strings. Lazy minting avoids touching every existing App at once.

func (*Service) GraphQLMutation

func (s *Service) GraphQLMutation() graphql.Fields

GraphQLMutation returns triggerDeploy (w2/006), restartServer (consolidated from apps — w2/m30), plus cancelDeploy/rollbackService (w2/m10). triggerDeploy mirrors Render's dashboard Manual Deploy action — delegates to the same Trigger verb as REST POST .../deploys so the surfaces cannot drift. restartServer replaces apps.GraphQL's restartServer: routing through deploys.Restart ensures every restart opens a deploy-history row. cancelDeploy/rollbackService follow the suspendService/resumeService convention.

func (*Service) GraphQLQuery

func (s *Service) GraphQLQuery() graphql.Fields

GraphQLQuery returns the deploys(serviceId, …) field for the composition root to merge into the root Query. The filter arguments mirror the REST query params 1:1 — status, created/updated/finished time bounds, cursor, limit — through the same FilterOf translator, so the two surfaces cannot page differently; all absent means the full history, the pre-m31 contract.

func (*Service) List

func (s *Service) List(ctx context.Context, service string, filter ListFilter) ([]DeployView, error)

List returns a service's deploy history, newest first (Render's list_deploys / GET .../deploys), narrowed by filter (w2/m31) — a zero ListFilter returns the newest core.MaxPageLimit page (cursor for the rest). A hand-applied App has no history: an empty list, not an error.

func (*Service) RegenerateDeployHook

func (s *Service) RegenerateDeployHook(ctx context.Context, service string) (DeployHookView, error)

RegenerateDeployHook atomically replaces the service's credential. Requests that resolve the old URL after this patch completes see no match and 404.

SECURITY (codex #1): rotation RETURNS a fresh bearer secret, so it must be restricted to the same developer-and-up tier that gates reading the hook (GetDeployHook's can_view_sensitive) — not the weaker can_operate a contributor holds. It gates on can_create rather than can_view_sensitive because rotation is a state-changing WRITE (it must stay in the service events feed as deploy_hook_regenerated); can_view_sensitive would reclassify it as a read and drop the allowed rotation from the feed. Both relations deny contributors, so the authorization outcome is identical while the audit semantics stay correct.

func (*Service) RegisterMCP

func (s *Service) RegisterMCP(srv *mcp.Server)

RegisterMCP adds the deploy-history tools to the shared MCP server.

func (*Service) RegisterREST

func (s *Service) RegisterREST(mux *http.ServeMux)

RegisterREST adds the Render-shaped deploy-history endpoints. Store unconfigured => the Service returns core.ErrDeploysUnavailable => 503 on these routes only.

func (*Service) Rollback

func (s *Service) Rollback(ctx context.Context, service, deployID string) (DeployView, error)

Rollback creates a fresh deploy restoring a previously-live deploy's exact image (Render's POST .../rollback {deployId}, w2/m10) — never a history rewrite: the new row's own lifecycle (open -> live/failed) converges through the same reconciler write-back every other deploy uses. Only a deploy that itself reached live is a valid target — ResolvedImage is the only field trustworthy enough to restore blind (an in-progress, failed, or canceled deploy never has one). Restores what ran (the image), not workspace config — replicas/tier/idleTTL stay put, keeping this minimal.

SECURITY (codex round-16 #2/#5): deployID SELECTs the executable image that becomes App.spec.image, so this is create-like (can_create), not lifecycle — the same executable-selection class as Trigger(imageUrl). It also produces a deploy write, so it shares Trigger's RequireBillingMutation gate.

func (*Service) Trigger

func (s *Service) Trigger(ctx context.Context, service string, p TriggerParams) (DeployView, error)

Trigger starts a fresh deploy (Render's POST .../deploys): bumps spec.RestartedAt to create a new release identity/generation — triggering the operator to rebuild/restart — then opens a dep-… row (trigger "api") stamped with that generation so Cancel can later find the right build Job.

p.CommitID, if non-empty, sets spec.BuildCommit so the operator checks out that ref instead of Branch HEAD; the field is explicitly reset to "" on every trigger without a commitId so Branch HEAD is always the default.

p.DeployMode "deploy_only" is an explicit request NOT to rebuild:

  • repo-backed service: rejected with ErrBadRequest (this public trigger does not expose cached-artifact deployment; use build_and_deploy).
  • image-backed service: accepted (nothing to build regardless of mode).

Suspended services refuse the trigger: there is nothing to roll.

type TriggerParams

type TriggerParams struct {
	// CommitID pins the build to a specific Git ref instead of Branch HEAD.
	// Rejected for cron_job services (they run on a schedule, not per-commit).
	// Only meaningful for repo-backed services; silently ignored for image-backed.
	CommitID string
	// DeployMode selects the deploy strategy. "deploy_only" skips the build
	// step — valid for image-backed services (nothing to build anyway), but
	// returns ErrBadRequest for repo-backed ones (the public trigger does not
	// expose cached-artifact deployment; use build_and_deploy). Empty
	// or "build_and_deploy" is the normal full-rebuild path.
	DeployMode string
	// ImageURL overrides the image for this deploy (Render's imageUrl). Only
	// accepted for image-backed services; rejected with ErrBadRequest for
	// repo-backed ones (the origin-safety rule: a git-sourced service must be
	// rebuilt from source — swapping its image at trigger time would silently
	// divorce the running container from the committed source and is always a
	// mistake, not an oversight). Any valid image reference is accepted for an
	// image-backed service (the authenticated caller chooses the image).
	ImageURL string
	// ClearCache is Render's "clear" | "do_not_clear" string enum (its
	// dashboard's "Clear build cache and deploy"). bex builds are always
	// cache-free (ephemeral BuildKit Jobs, no --cache-to/--cache-from), so both
	// values are already-true no-ops: a trigger always rebuilds from a clean
	// slate. The field is accepted (and enum-validated) for Render/CLI
	// compatibility across all three surfaces, not because it changes behavior;
	// only a value outside the enum is rejected. Empty = omitted = the default.
	ClearCache string
}

TriggerParams carries the optional body fields of Render's CreateDeploy request (commitId, clearCache, deployMode, imageUrl) that bex can honestly honor. Zero value = default behavior (Branch HEAD, full build-and-deploy).

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL