Skip to content

πŸŽ™οΈ task - fix(ec2Instance): privateIp/publicIp permadrift β€” dot-path readonly keys are never omittedΒ #91

Description

@ehm-a-seaturtle

πŸ¦«πŸŽ™οΈ dispatch to foreman

πŸ’§ task enqueued
   β”œβ”€ priority = ?
   β”œβ”€ yieldage = ?
   └─ leverage = ?

title
fix(ec2Instance): privateIp/publicIp permadrift β€” dot-path readonly keys are never omitted
description

.what

DeclaredAwsEc2Instance reads UPDATE on every plan, forever, on any instance that AWS
assigned an ip to. an apply lands, and the next plan reads the identical change again β€” a
permadrift, not ordinary drift.

the two fields at fault are already declared readonly. the declaration is correct; it is
simply never honored, because it uses a dot-path form that omitReadonly cannot see.

  "interface": DeclaredAwsEc2InstanceNetworkInterface {
-       "privateIp": "10.20.1.84",
-       "publicIp": "54.208.209.118",
        "publicIpEnabled": true,
        "sourceDestChecked": false,

.why it never converges

the chain, traced end to end and read off the shipped source:

step where what happens
1 DeclaredAwsEc2Instance.readonly declares ['network.interface.privateIp', 'network.interface.publicIp'] β€” dot-paths, with a .note that says "nested via dot-path"
2 getReadonlyKeys (domain-objects) returns those strings verbatim, with no dot-path expansion
3 omitReadonly (domain-objects) hands them to type-fns's omit, which matches top-level keys only β€” so 'network.interface.privateIp' matches no key, and the omit is a silent no-op
4 omitReadonly's recursion does descend into network, but getReadonlyKeys returns explicit readonly keys only for DomainEntity. DeclaredAwsEc2InstanceNetwork and ...NetworkInterface are DomainLiteral and declare no readonly of their own, so the recursion removes neither field
5 computeChange (declastruct) both fields survive into serialize(), so checkAreResourcesEquivalent is false β†’ UPDATE

so the field is readonly in intent and settable in effect. no write can close the gap,
because AWS owns both values and the desired side can never carry them.

the root cause is upstream, in domain-objects β€” omitReadonly does not support the
dot-path readonly form that declastruct-aws already uses and documents. this issue is filed
here because this is where the symptom surfaces; see .the two fix sites for the routing.

.the blast radius

it is loud, not dangerous β€” but it is not cosmetic either:

  • every plan on every ec2 instance with an assigned ip reads UPDATE
  • it trains the operator to skim the plan, which is the one habit a declarative tool exists to prevent
  • worse on an immutable resource: an operator who sees a perpetual UPDATE on an instance
    cannot tell it apart from a real drift that needs a terminate-and-recreate

.verified benign at apply

worth stated plainly, so nobody treats this as urgent: the apply does converge, and it does
not attempt a recreate.

getEc2InstanceImmutableDrift compares exactly four things:

  • template
  • network.subnet
  • network.security.groups
  • network.interface.publicIpEnabled

privateIp and publicIp are absent from all four, so no immutable drift is raised and
setEc2Instance does not dead-end. confirmed live on 2026-08-14 against a camp NAT: the plan
read UPDATE, the apply converged, and the next plan read UPDATE again.

.the two fix sites

(a) cause-level, in domain-objects β€” recommended

teach omitReadonly to honor a dot-path readonly key: split on ., walk to the parent, and omit
the leaf. getReadonlyKeys already returns the paths, so the change is bounded to the omit step.

this is the honest fix, per rule.require.solve-at-cause:

  • it repairs a contract declastruct-aws already relies on and documents
  • it fixes every other resource that declares a nested readonly, present and future
  • without it, the dot-path form stays a silent trap: it looks declared, it reads as honored, and
    it omits neither field

it also wants a clamp that bites β€” a test where a dot-path readonly field differs between two
otherwise-identical objects, asserted to compare equal.

(b) local, in declastruct-aws β€” the workaround

move the readonly declaration down onto the nested object, or strip the two fields from the
remote cast before the compare.

this quiets the plan with no domain-objects release. but it leaves the dot-path form broken
for every other caller, and it papers over the contract rather than repairs it β€” so it is worth
taken only as a stopgap, and worth a note that points at (a).

.repro

any ec2 instance with an AWS-assigned ip. observed on a NAT instance in a private-subnet vpc
(publicIpEnabled: true, sourceDestChecked: false).

  1. declare a DeclaredAwsEc2Instance, apply it
  2. plan again β†’ UPDATE, with privateIp / publicIp as the only difference
  3. apply again β†’ converges
  4. plan again β†’ the identical UPDATE

.the test that settles it

apply, then re-plan.

  • the change is gone β†’ ordinary drift
  • the change is identical β†’ a permadrift, and the compare is the defect, never the resource

⚠️ that test yields a false positive on an immutable resource, where an apply throws rather
than converges. an ec2 instance is immutable to a template/subnet/security-group change, so on
this resource the sharper test is: does the apply throw an immutability error (ordinary drift,
blocked by shape) or succeed and re-drift (a true permadrift)? this one succeeds and
re-drifts.

.kin

three permadrifts of three distinct causes suggests the compare seam is worth a pass of its own,
rather than three point fixes.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions