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
- func BackfillDeployHookTokenDigests(ctx context.Context, cl client.Client) error
- type CommitResolver
- type DeployHookRateLimiter
- type DeployHookView
- type DeployStartedNotifier
- type DeployStore
- type DeployView
- type ListFilter
- type Service
- func (s *Service) Cancel(ctx context.Context, service, deployID string) (DeployView, error)
- func (s *Service) DeployHookHandler() http.Handler
- func (s *Service) Get(ctx context.Context, service, deployID string) (DeployView, error)
- func (s *Service) GetDeployHook(ctx context.Context, service string) (DeployHookView, error)
- func (s *Service) GraphQLMutation() graphql.Fields
- func (s *Service) GraphQLQuery() graphql.Fields
- func (s *Service) List(ctx context.Context, service string, filter ListFilter) ([]DeployView, error)
- func (s *Service) RegenerateDeployHook(ctx context.Context, service string) (DeployHookView, error)
- func (s *Service) RegisterMCP(srv *mcp.Server)
- func (s *Service) RegisterREST(mux *http.ServeMux)
- func (s *Service) Rollback(ctx context.Context, service, deployID string) (DeployView, error)
- func (s *Service) Trigger(ctx context.Context, service string, p TriggerParams) (DeployView, error)
- type TriggerParams
Constants ¶
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 ¶
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 ¶
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 ¶
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 ¶
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 ¶
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 ¶
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 ¶
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 ¶
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 ¶
RegisterMCP adds the deploy-history tools to the shared MCP server.
func (*Service) RegisterREST ¶
RegisterREST adds the Render-shaped deploy-history endpoints. Store unconfigured => the Service returns core.ErrDeploysUnavailable => 503 on these routes only.
func (*Service) Rollback ¶
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).