Skip to content

feat(deployment): let an ungated resource link a gated one - #252

Open
ItamarZand88 wants to merge 14 commits into
mainfrom
itamar/alien-385-let-an-ungated-resource-link-a-gated-one
Open

feat(deployment): let an ungated resource link a gated one#252
ItamarZand88 wants to merge 14 commits into
mainfrom
itamar/alien-385-let-an-ungated-resource-link-a-gated-one

Conversation

@ItamarZand88

Copy link
Copy Markdown
Contributor

Summary

Say you have a worker that always runs, and an optional resource the deployer can
turn off. Until now you could not write that: anything linking a gated resource
had to be gated on the same input, so turning the resource off turned the worker
off too. Now the worker stays and just loses that binding.

When a deployer says no to a gated resource:

  1. The resource leaves the stack, same as before.
  2. Every link pointing at it leaves too, along with its permission grant.
    This is the fix. Removal used to be half done, which is why the preflight had
    to reject this shape instead of allowing it.
  3. The worker keeps running and starts without ALIEN_<ID>_BINDING.

So a plain link to a gated resource is now a warning instead of a build failure.
Everything else still fails.

What was broken

remove_declined deleted the resource but left the links pointing at it. That
graph is rejected later by the executor and by binding resolution, so a preflight
rule existed to stop you writing it in the first place. The rule was covering for
an incomplete cleanup.

What I did

Finished the cleanup, then relaxed the rule that was compensating for it.

"Which resources own links" is now its own small trait, ResourceLinks, instead
of extra methods on ResourceDefinition. Four types implement it: Worker,
Container, Daemon and Build. Build matters here. It owns links but is not a
compute type, so anything built around "is this a workload" would have quietly
skipped it.

Only plain links get cleaned up. Three cases still fail on purpose:

  • Triggers, because the wiring lives on the source resource. Removing the
    trigger from the worker would leave the bucket notification behind.
  • Ordering dependencies, because they are not bindings.
  • Setup-created resources, because setup bakes their links in and the runtime
    cannot rewrite them afterwards.

Two update bugs this found

Flipping a gate off and on again is meant to remove the resource and
bring it back. On real AWS neither direction finished: the worker never
picked its link back up, and re-declining left the resource running after
the deployer said no. Both times the release reported success.

The cause turned out to be one mistake with two faces: the engine judged
an update "done" by resource health, while the work it had deliberately
held back was invisible to that judgement.

It holds a resource's change back while a dependency it needs is still
provisioning, and holds a deletion back while something still points at
the resource. Both are correct on their own. But once every resource
looked healthy, the update reported success with the held-back work still
pending, and nothing steps a finished update. So accepting a gate left
the worker without its binding, and declining one again left the resource
running after the deployer said no.

An update is now finished only when the engine has nothing left to do:
every resource it reconciles matches the config the release asks for, and
every resource it owes a deletion is gone. Both answers come from the
engine's own planning rules, so completion can never wait on work the
planner would refuse to schedule.

Three edges came out of holding that bar, all covered by tests: a
resource the engine does not reconcile cannot hold an update open, nor
can a deletion it would never perform, and externally bound resources
adopt config changes directly instead of having their updates dropped.

Files touched

  • alien-core/src/resource_links.rs - the new trait and its lookups, plus a test
    that reads the list of resource types out of the deserializer itself, so a new
    type has to say whether it owns links
  • alien-deployment/src/pending.rs - the cleanup: links, ordering edges, grants
  • alien-preflights/.../resource_enabled_valid.rs - warn or fail, per edge type
  • alien-deployment/src/updating.rs, alien-infra/src/core/executor.rs - update
    completion and the external-binding adoption
  • alien-deployment/tests/test_platform.rs - the gate flip through the real
    state machine, no cloud needed
  • alien-test/, tests/e2e/test-apps/enabled-demo/ - a worker linking both an
    on and an off resource

How I tested

812 unit tests across alien-core, alien-preflights and alien-deployment, plus
the alien-infra executor suite and 240 renderer tests, including the gating
matrix.

I broke each new check on purpose to confirm it actually catches things:

  • turn off the grant cleanup, and the grant test fails with ["cache", "store"]
  • drop a type from the ownership list, and the drift test fails naming that type
  • disable either half of the completion check, and its test reports success
    with work still pending
  • disable the binding adoption, and the executor test never reaches synced

The cloud e2e installs enabled-demo on real AWS with an ungated worker
linking both halves of two gated pairs, and checks the worker comes up holding
the accepted bindings and none of the declined ones, with each declined
resource and its grant absent from the account. Flipping a gate on a running
deployment is an upgrade, which this harness does not cover for any app, so
that half runs against the test platform instead.

On permissions:

  • A declined resource's grant no longer reaches the prepared stack, so nothing
    applies it. See scrub_declined_grants.
  • The rule about "*" grants is still a hard failure. Gating a resource cannot
    take a wildcard grant off a role, so only the per-link cases were relaxed.
  • Management profiles get cleaned too. */provision sits under "*" and is
    untouched. What a declined resource loses is */heartbeat and
    worker/dispatch-command, which is a tightening.
  • Follow-ups for existing behaviour this touches are tracked separately.

A gated resource that a deployer declines left the stack while anything
linking it kept the reference, so `remove_declined` produced a graph the
executor and binding resolution both reject. The preflight forbade that
shape outright, which made "optional resource, mandatory consumer"
inexpressible: gating an optional resource forced gating the worker that
consumes it, even when the consumer must always exist.

Removal is now complete — a declined resource takes its inbound links
with it — so the consumer keeps its own lifecycle and simply starts
without that binding. `executor.rs` and `add_linked_resources` are
untouched and stay strict, which is what still catches a resource that
should exist but is missing or not Running.

Links only. A trigger's wiring lives on the source resource, so dropping
it from the consumer would leave the bucket notification or event-source
mapping behind; triggers, ordering edges, and resource types that do not
report their links all still refuse. The `"*"`-grant rule in the same
preflight stays a hard error, since gating cannot revoke a wildcard.
The scrub and the gate preflight both need "the links this resource owns",
distinct from the triggers and ordering edges `get_dependencies` folds in
alongside them. That was expressed as two hooks on `ResourceDefinition`
with identical overrides in Worker, Container and Daemon.

`ResourceDefinition` is not the place for it. Its only hooks are the
`as_any` escape hatch, and adding one per capability accumulates. The
capability now lives in `resource_links`, with the trait implemented by
the types that own links and two free functions resolving it, so the
definition trait keeps its current shape.

Resolution is by concrete type rather than by resource-type string: a
misspelled tag cannot silently classify a resource as link-free. Callers
that walk every resource skip a `None`; a caller that has already
established it holds a link owner should fail loudly on one.

Behaviour is unchanged for all three types.
Build owns links the same way the compute kinds do: an author-facing
`.link()`, and `add_linked_resources` turning them into
`ALIEN_<ID>_BINDING` before the build runs. `resource_link_permissions`
already derives grants from `build.links` alongside the others. Leaving
Build out would have kept the capability knowingly incomplete.

Declining a resource a Build links now drops the link instead of refusing
the stack. That cannot break build-time access, because a declined target
does not exist and the binding could never have been produced -- today the
path errors with DependencyNotReady. It cannot break ordering either,
since ordering derives from the same reference.

Build carries no retained-grant caveat: it is frozen-only, so its declines
are stripped before the mutations run and no grant is ever derived for a
dropped link. That differs from live compute, where the strip runs after
and the grant deliberately stays so a later acceptance needs no permission
change.

The registry drift test reads the registered type list back out of the
deserializer's own refusal, so a new resource type must declare whether it
owns links or the test fails naming it.
The unit tests cover the strip in isolation; nothing exercised an ungated
resource linking a gated one through an actual install, and nothing
exercised accepting a gate that had been declined.

`enabled-demo` now has its ungated `agent` worker link both halves of two
gated pairs, so one install proves both answers: the `-on` bindings are
present and the `-off` ones are not. The kv pair is frozen so its answers
are fixed at install; the worker pair is live and derives no permission
set from a link, which is what makes it safe to flip afterwards without a
runtime policy write on the setup-owned role.

`flip_terraform_gate` then drives the transition on the SAME deployment.
`apply_terraform_and_import` allocates a fresh workdir and imports as a new
install, so calling it twice would stand up a second unrelated deployment
rather than flipping the first. The flip reuses the retained workdir and
state, rewrites one `input_*` variable, re-applies, and re-imports under
the original deployment-group token so the manager takes its update path.
Accepting a declined gate must both recreate the resource and restore the
link that was scrubbed with it.
Review found the retention argument backwards. The claim was that a grant
removed on decline could not return on re-acceptance without re-running
setup. Both deployment paths disprove it: handle_pending and
handle_update_pending each run the preflight mutations on the declared
stack and apply the live strip afterwards, so resource_link_permissions
re-derives every link grant on every deploy and every update.

Retention is not merely unnecessary. The GCP service-account controller
applies every non-"*" profile entry without consulting the desired
resources, and kv binds its role at project scope because IAM cannot scope
to a collection. A declined store therefore left the consumer's identity
holding project-wide data access -- reachable only because this branch is
what first permits an ungated resource to link a gated one.

Also refuses a setup-created link owner against a runtime-resolved gate.
Setup bakes that link and the runtime cannot rewrite a setup-created
resource, so the answer could never reach it; the scrub would drop it from
the desired stack while the deployed resource kept it.

The gate-flip harness could not have worked. It reached
/v1/stack/import through terraform_import_request_from_outputs, which
mints a fresh random deployment name per call, so the manager created a
second deployment instead of updating the installed one and the extra
deployment escaped cleanup. It now carries the installed name and, because
the import response holds only setup-delivered resources and merely
schedules reconciliation, waits for the deployment to settle and reads the
state the executor produced.

Smaller fixes from the same pass: resolve strictly in the untouched-links
test, which behind an `if let` could not fail from a broken resolver; hold
LINK_OWNERSHIP's declared owners against the wired count, so a type
declared as owning links but never wired cannot pass as covered; reuse
alien_core::binding_env_var_name rather than reformatting it; log dropped
ordering edges; and drop change-narration from comments.
Every added block is now within the three-line cap, with the elaboration
that restated the code removed rather than reworded.
The refusal tests read only errors, so a spurious warning alongside a
correct refusal would pass. The drift guard held its declared owners
against a hand-maintained count, which a contributor could bump in step
with a mistaken edit; it now derives from the same fixture list the
resolve and scrub tests use, and asserts each declared owner actually
resolves.

Also makes the strip's reliance on the release-time preflight loud in
debug rather than an ambient comment, and corrects the grant-scrub note:
controllers read resource-scoped grants from the prepared stack on this
deploy, so the scrub prevents the leak now, not only next time.
Two comments still justified the strip ordering by claiming a declined
workload's profile grants stay stable, which the grant scrub makes false.
What actually absorbs the difference is the gated-presence exemption in
permission_profiles_unchanged, so they now say that.

Also derives the frozen-owner side of the runtime-gate refusal from
should_emit_in_setup rather than testing the lifecycle directly, so the
owner and dependency sides cannot drift apart if a frozen type ever stops
being setup-emitted.
Completion was judged from resource health, but the executor deliberately
holds work back: a resource's update waits while a dependency it needs
provisions, and a deletion waits while a consumer still records the
dependency. Once everything looked healthy the update reported success
with that held-back work outstanding, and nothing steps a finished
update. Accepting a gate left the consumer without its binding, and
declining one left the resource running after the deployer said no.

An update now also requires every resource the executor reconciles to
match the config the release asks for, and every deletion it owes to be
done. Both answers come from the executor's own planning rules, so
completion cannot wait on work the planner would refuse to schedule.

An external binding carries no controller, so its planned update was
being dropped; it now adopts the new config the way creation does.
Setup renders a link's binding into the install template as a reference
to the target, so declining the gate leaves the template pointing at a
resource setup never created. Only owners the runtime manages can have
their links scrubbed, so only those warn.

An ordering edge that predates the release-time rule is repaired rather
than asserted on, and a test pins that a live decline leaves the frozen
digest untouched.
The existing live-gate flip has no consumer, so nothing defers the
store's deletion and the update never had the chance to finish early.
Runs the real state machine on the test platform, so the shape needs no
cloud to prove.
Flipping a gate on a running deployment is an upgrade, and no app in this
harness covers upgrades against real cloud. What stays is the install
direction: a declined resource and its grant are absent from the account,
and an ungated worker holds exactly the accepted bindings. The vault pair
is asserted through its IAM policy, since a vault on AWS is an SSM name
prefix with nothing to list. The agent's endpoint and command channel had
no assertion behind them and were the slowest part of its bring-up.
Comment thread crates/alien-infra/src/core/executor.rs
@greptile-apps

greptile-apps Bot commented Jul 28, 2026

Copy link
Copy Markdown

Greptile Summary

This PR allows ungated resources to link gated resources while preserving deployment convergence.

  • Introduces a shared abstraction for resources that own links.
  • Removes links, ordering edges, and scoped permission grants when gated resources are declined.
  • Relaxes preflight validation for removable plain links while retaining failures for structural edges.
  • Makes update completion account for unreconciled configurations and pending deletions.
  • Refreshes external-binding state when controllerless resources undergo scheduled configuration updates.
  • Adds unit, executor, deployment-state-machine, distribution, and end-to-end coverage.

Confidence Score: 3/5

The PR is not yet safe to merge because binding-only changes to an external resource can still leave its persisted outputs pointing at the previous target.

The new adoption branch correctly recomputes outputs when a resource update is scheduled, but planning still compares only declared resource configuration and cannot schedule that branch for a controllerless external resource whose binding alone changed.

Files Needing Attention: crates/alien-infra/src/core/executor.rs

Important Files Changed

Filename Overview
crates/alien-core/src/resource_links.rs Adds link-owner discovery and mutation support for Worker, Container, Daemon, and Build resources.
crates/alien-deployment/src/pending.rs Extends declined-resource cleanup to remove inbound links, ordering dependencies, and scoped grants.
crates/alien-deployment/src/updating.rs Bases update completion on executor-scoped configuration convergence and planned deletion completion.
crates/alien-infra/src/core/executor.rs Exposes executor reconciliation scope, centralizes deletion planning, and adopts updated state for controllerless external resources.
crates/alien-preflights/src/compile_time/resource_enabled_valid.rs Permits removable plain links to gated resources while preserving validation for unsupported dependency forms.
crates/alien-deployment/tests/test_platform.rs Exercises linked live-gate removal through the resumable deployment state machine.
crates/alien-infra/src/core/executor_tests/edge_cases_tests.rs Adds executor coverage for external-resource adoption and deferred deletion behavior.

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart TD
  Gate{Gate accepted?}
  Gate -->|Yes| Target[Keep gated resource]
  Target --> Link[Keep consumer link and grant]
  Gate -->|No| Remove[Remove gated resource]
  Remove --> Scrub[Scrub inbound links, ordering edges, and scoped grants]
  Scrub --> Reconcile[Executor reconciles surviving resources]
  Reconcile --> Pending{Config drift or pending deletion?}
  Pending -->|Yes| Continue[Keep update in progress]
  Continue --> Reconcile
  Pending -->|No| Running[Mark deployment running]
Loading

Reviews (3): Last reviewed commit: "fix: name the update failure and drop th..." | Re-trigger Greptile

Outputs are derived from the external binding, and creation sets them
from it. Adoption carried the old ones over, so a repointed binding would
leave dependents resolving the resource it used to name.
Comment thread crates/alien-infra/src/core/executor.rs
The no-controller update failure was reported as a serialization error,
which is not what went wrong; it now says what is missing, matching the
sibling arm. The import helper the removed gate flip used has no caller
left, and the compiler said so.

Review also found the deferred-delete test could not fail: its worker had
no controller state, so the update that releases the delete never ran.
It now deploys the pair for real and asserts the deferral lifts.
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