Skip to content

Add opt-in control-plane lifecycle hooks on CreateActor and DeleteActor - #3853

Draft
shankgan wants to merge 11 commits into
agent-substrate:mainfrom
shankgan:feat/lifecycle-hooks
Draft

shankgan wants to merge 11 commits into
agent-substrate:mainfrom
shankgan:feat/lifecycle-hooks

Conversation

@shankgan

@shankgan shankgan commented Nov 14, 2026 •

Copy link
Copy Markdown

Proof of Concept (Stacked on #3655): The first 5 commits (f2b82e1b..0280c40f) belong to #3655. Only the last 6 commits (32d1e086..f993a435) belong to this pull request:
View diff of the 6 lifecycle-hook commits only (0280c40f...feat/lifecycle-hooks)

I will rebase onto main once #3655 merges.

External control-plane services that integrate with Substrate (such as workload identity authorities, service registries, and admission controllers) need a way to run policy checks, stamp metadata.annotations, and allocate or clean up external per-actor state when an Actor is created or deleted, without polling ListActors.

Add opt-in control-plane lifecycle hooks on CreateActor and DeleteActor behind the LifecycleHooks preview gate and the --lifecycle-hooks-config flag on ate-api-server:

  1. atepg pre-assigned metadata (32d1e086): Preserve caller-assigned metadata.uid and metadata.create_time in setCreateMetadata when set by the control plane before insert so hooks observe the same uid and create_time that get persisted.
  2. Wire contract (0775ae5e, api/hook/v1/hook.proto): Define substrate.hook.v1.LifecycleHook with two unary RPCs (OnCreateActor and OnDeleteActor). OnCreateActor receives the Actor, the resolved parent ActorTemplate as read-only context, and caller_principal, and returns allowed, status_message, and mutated_annotations. OnDeleteActor receives (atespace, name, uid, caller_principal) as a notification with an empty response.
  3. Configuration and TLS 1.3 (2b0bc6c0, cmd/ateapi/internal/lifecyclehook): Parse --lifecycle-hooks-config (maxWaitDuration default 100ms, max 250ms; up to 8 hook targets with disjoint non-reserved allowedAnnotationPrefixes) and dial each target over TLS 1.3 (caBundle or caCertFile + serverName, with MTLS client credentials reloaded via credbundle.ClientLoader or TLSOnly).
  4. Parallel HookDispatcher and compensating rollback (d607f7c2): Fan OnCreateActor out to all configured hooks in a single parallel wave under maxWaitDuration. A hook blocks creation only by returning allowed: false; a hook that errors or times out is logged and skipped. Check returned annotation keys against the hook's allowedAnnotationPrefixes and Kubernetes qualified-name syntax. Return a CreateOutcome whose Rollback sends compensating OnDeleteActor calls in parallel to every hook that allowed the create or timed out.
  5. controlapi integration (0563453a): When LifecycleHooks is enabled and --lifecycle-hooks-config is set, CreateActor acquires the non-blocking per-actor lease (lease:actor:<atespace>:<name>), verifies the name is free, resolves the template, tag source, and initial external volumes, calls OnCreateActor, re-validates the mutated actor against the 16-key / 4 KiB annotation bounds, and rolls back via a single defer if any hook denies, validation fails, or store.CreateActor fails. DeleteActor calls OnDeleteActor before removing the actor row. Golden actors created and deleted by ActorTemplateReconciler bypass hooks. When --lifecycle-hooks-config is unset, CreateActor and DeleteActor are unchanged.
  6. End-to-end mTLS functional tests and benchmark (f993a435): Add TestLifecycleHooksEndToEnd and BenchmarkCreateActorLifecycleHooks in cmd/ateapi/internal/controlapi/functionaltest/lifecycle_hooks_test.go against three TLS 1.3 / mTLS gRPC hook servers and PostgreSQL.

Benchmark Summary (BenchmarkCreateActorLifecycleHooks)

Configuration Latency (ns/op) Throughput (actors/s) Bytes (B/op) Allocs (allocs/op)
0_NoHooks 2,165,702 (2.17 ms) 455.9 119,630 472
1_SingleMTLSHook 6,086,269 (6.09 ms) 163.4 363,711 1,011
3_ParallelMTLSHooks 6,171,949 (6.17 ms) 161.0 660,840 1,722

Fixes #3819

AI use: AI-assisted, I reviewed and tested it.

  • Tests pass
  • Docs updated

Add map<string, string> annotations to ResourceMetadata for decorative, non-queryable metadata on resources such as Actor and ActorTemplate.

Signed-off-by: Shankar Ganesan <shankgan@google.com>
Validate annotation keys as k8s-label-key while rejecting reserved ate.dev domains, cap entries at 16, and bound total key and value size at 4 KiB.

Signed-off-by: Shankar Ganesan <shankgan@google.com>
Copy incoming metadata.annotations onto the stored metadata during UpdateActor while keeping server-owned metadata fields intact, and add apivalidation, controlapi, and functionaltest coverage.

Signed-off-by: Shankar Ganesan <shankgan@google.com>
Add --annotations flag to kubectl-ate create actor and update actor for passing comma-separated key=value pairs (with --annotations="" clearing annotations on update), add unit tests for CLI flag parsing and ActorTemplate manifest round-trip, and document annotations in the API reference guide.

Signed-off-by: Shankar Ganesan <shankgan@google.com>
Benchmark protobuf serialization, update validation, PostgreSQL CRUD (CreateActor, GetActor, UpdateActor, ListActors at 100 and 1000 per page), and the cold ResumeActor/SuspendActor lifecycle with no annotations and with 4 KiB of annotations in four shapes: one or sixteen entries of random base64, which PostgreSQL stores out of line uncompressed, and one or sixteen entries of text with random identifiers, which it stores out of line compressed. The PostgreSQL sub-benchmarks store a ~1.4 KiB actor status so annotations push the row across the 2 KiB TOAST threshold.

Each run reports pg_column_size, octet_length, TOAST chunk count and relation size, EXPLAIN (BUFFERS, SERIALIZE) block counts for GetActor, WAL bytes and table growth per UpdateActor and per resume/suspend cycle with the two halves timed separately, and the ListActors response size.

Add storetest.Pool so tests can run SQL the store API does not expose, and widen the storetest helpers from *testing.T to testing.TB so benchmarks can provision isolated PostgreSQL test databases.

Signed-off-by: Shankar Ganesan <shankgan@google.com>
setCreateMetadata always overwrote metadata.uid and metadata.create_time
with fresh values. Keep a uid and create_time the control plane assigned
before the insert so it can pass the final metadata to other parties
ahead of persisting the row, and only generate them when the caller left
them empty.

Signed-off-by: Shankar Ganesan <shankgan@google.com>
Define the substrate.hook.v1.LifecycleHook gRPC service that
ate-api-server calls on Actor create and delete. OnCreateActor carries
the actor with its server-assigned uid and create_time, the resolved
ActorTemplate as read-only context, and the authenticated caller
principal, and returns an allow/deny decision with annotation mutations.
OnDeleteActor carries the actor reference, uid, and caller principal as
a notification.

The service ships behind a preview gate.

Signed-off-by: Shankar Ganesan <shankgan@google.com>
Add the YAML configuration for lifecycle hook targets: a shared
maxWaitDuration (default 100ms, at most 250ms) and up to 8 hooks, each
with an address, a list of annotation prefixes the hook may write, and
TLS settings. A hook with no prefixes can allow or deny a create without
mutating annotations. Validation requires TLS 1.3 on every target,
exactly one CA trust source (caBundle or caCertFile), a serverName for
DNS SAN verification, non-reserved annotation prefixes (rejecting
ate.dev and its subdomains), and pairwise disjoint prefixes across hooks
so their mutations merge without ordering conflicts.

Build gRPC transport credentials from that config using standard Go
crypto/tls DNS SAN verification and credbundle.ClientLoader so the
client credential reloads per handshake.

Signed-off-by: Shankar Ganesan <shankgan@google.com>
Dispatcher dials every configured hook at startup over the TLS
credentials from the config and fans OnCreateActor out to all hooks in a
single parallel wave under the shared wait budget. Each hook receives
its own clone of the request. Responses are merged only after every key
a hook returned is checked against that hook's allowed annotation
prefixes and Kubernetes qualified-name syntax. An out-of-scope or
malformed key fails closed with Internal.

A hook blocks a create only by returning allowed=false. A hook that
errors or misses the deadline is logged and skipped, and the create
proceeds with the other hooks' mutations.

OnCreateActor always returns a CreateOutcome populated with every hook
that allowed the create and every hook that did not answer, even when
OnCreateActor returns an error. Calling Rollback sends a one-shot
compensating OnDeleteActor to those hooks so a single caller defer
covers both intra-wave failures and later validation or store failures.

OnDeleteActor notifies every hook in parallel and returns nothing. An
error or timeout is logged and cannot block deletion.

Errors map to PermissionDenied for a hook denial and Internal for a
prefix or key syntax violation.

Signed-off-by: Shankar Ganesan <shankgan@google.com>
…review gate

Add the LifecycleHooks preview gate and the --lifecycle-hooks-config
flag. When both are set, ate-api-server builds a HookDispatcher and
calls it from the control plane:

CreateActor acquires the same non-blocking actor lease that Update,
Delete, Resume, and Suspend use, confirms the name is free, resolves the
template, source tag, and initial external volumes, assigns metadata.uid
and create_time, and calls OnCreateActor with the resolved template and
the caller principal before the store insert. A single defer calls
outcome.Rollback unless the store insert commits, so hooks that allowed
the create or timed out are rolled back on any intra-wave or later
validation or store error.

DeleteActor calls OnDeleteActor inside a CallLifecycleDeleteHooks trace
span after the actor's runtime, volumes, and snapshots are released and
before the row is removed. The call cannot fail the deletion.

Golden actors created and deleted by the template reconciler carry a
context marker and skip hooks. With no config file the server behaves as
before.

Signed-off-by: Shankar Ganesan <shankgan@google.com>
…nd benchmark

Add TestLifecycleHooksEndToEnd to exercise the full lifecycle hooks stack
over TLS 1.3 and mTLS against real gRPC hook servers,
lifecyclehook.LoadConfig, NewDispatcher, RPCService, and PostgreSQL. The
test covers parallel annotation merging and pre-assigned metadata
fidelity, hook denial and compensating OnDeleteActor rollbacks,
post-hook annotation overflow and unpermitted prefix rollbacks, hook
timeouts skipped on create and rolled back when a sibling hook denies,
golden actor creation and deletion via ActorTemplateReconciler bypassing
hooks, and DeleteActor notification resilience when hooks fail or time
out.

Add BenchmarkCreateActorLifecycleHooks to measure CreateActor throughput
and allocations with zero, one, and three parallel mTLS hooks.

Signed-off-by: Shankar Ganesan <shankgan@google.com>
@shankgan
shankgan force-pushed the feat/lifecycle-hooks branch from 41c765f to f993a43 Compare November 14, 2026 06:37

This branch has not been deployed

No deployments
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.

Support opt-in control-plane hooks on CreateActor and DeleteActor

1 participant