Compare commits

..

27 Commits

Author SHA1 Message Date
Ben Duncan 5335ea9ee0 fix(vm): reboot by relaunching QEMU after a clean guest shutdown (#1383)
RebootInstances powered the guest down under -action shutdown=pause and
then ran system_reset + cont in the same QEMU process. On some hosts the
reset left the guest silent in firmware with QMP still answering, while a
fresh QEMU on the same volumes always booted.

A reboot now shuts the guest down over ACPI (forcing it off after 60s, as
before), ends that QEMU, and starts a fresh one on the same mounted volumes,
taps, IMDS datapath and identity, the way nova's soft reboot and Proxmox's
qm reboot do. The instance stays running in Describe, no hooks fire, the
console log is appended rather than truncated, and the existing QMP client
is reconnected in place so the heartbeat carries on.

A relaunched guest that writes nothing to its console within 60s has its
run state, vCPUs, vCPU thread states and registers (sampled twice) logged,
and if it has printed before, its QEMU is replaced once more. A relaunch
that cannot start goes to crash recovery. Stop, terminate and hot-plug
wait for a relaunch in flight so volumes are never sealed under a process
being started. Guest-initiated reboots stay an in-process reset.

The daemon now logs the host kernel, QEMU version and the OVMF package
and checksum at start, and every reboot relaunch carries them.
2026-11-01 02:48:00 -06:00
Ben Duncan 70744c34dc fix(e2e): harden nightly e2e against seven false-red signatures; release auto-assigned IP before stopped (#1381)
* fix(vm): release an auto-assigned public IP before a stop publishes stopped

* fix(e2e): skip a TryReady caller once the suite's SSH datapath is marked broken

* fix(e2e): skip a catalog entry whose third-party mirror is unreachable, still fail on a non-2xx

* fix(e2e): retry the diskperf fio install with backoff before calling it an infra fault

* fix(e2e): make the storagefault root-load check observe the load it started

* fix(ci): give the single suite a 40m budget and the nightly cells that run it 60m

* fix(ci): fail a nightly cell fast when a hypervisor it provisions on is out of disk
2026-11-01 01:49:27 -05:00
Ben Duncan 367475d646 fix(fence): stop the writer first, deliver the fence notice, kill the fenced guest (#1382)
Three defects in the fence path made partition recovery fail:

- onVolumeLeaseLost read the lease holder before killing nbdkit, with no
  deadline, spending the 5s margin between local validity and the server
  TTL. The kill now comes first and the read is bounded at 2s.
- The fence notice was one NATS publish, lost when the node's NATS
  restarts mid-fence. It is now a request repeated until the daemon
  acknowledges, dropped once the volume is exported here again.
- A fenced guest got a 20s ACPI powerdown, a 20s pidfile wait and a
  SIGTERM, keeping QEMU alive while a survivor relaunched it. It is now
  SIGKILLed, and resumeAfterIOError never resumes a fenced guest.
2026-11-01 01:49:22 -05:00
Ben Duncan eb593679cc fix(vpcd): converge every node's VPC ingress routes from intent (#1380)
The routed-NAT host route to a VPC CIDR was installed only by the vpcd
that received the IGW attach event, which goes through a queue group,
and by whichever node won a leased drift pass. Every other node waited
for the five-minute resync, and with two VPCs on one CIDR each node kept
whichever route it met first.

Each node now runs a host ingress loop outside the drift lease. It reads
intent and the gateway LRPs on every intent write, on a nudge from the
local IGW hooks and every 30s, installs or repoints the routes, and
prunes tagged routes no attached VPC holds. A shared CIDR goes to the
bootstrap VPC, then to the lowest VPC ID.
2026-11-01 01:49:17 -05:00
Ben Duncan 93df191739 fix(firewall): keep conntrack running while the host policy is disarmed (#1379)
The kernel tracks connections only while some rule uses conntrack. With
inet spinifex_filter unloaded nothing tracked, so OVN RAFT links formed
while disarmed (or before the daemon's first arm) were first seen
mid-stream at arm time. With tcp_loose a packet from the server end became
a new flow from peer:6643/6644 to an ephemeral port and hit the drop rule,
and RAFT lost its leader.

Add table inet spinifex_conntrack (accept-only) and keep it loaded on
every node: setup.sh loads it at install, the boot unit loads it
unconditionally, firstboot loads it before OVN, and disable swaps the
policy for it in one transaction instead of turning conntrack off.
2026-11-01 01:49:12 -05:00
Josh Siviour 990f32dad4 refactor: move the gateway's public routes, admin wiring and unread config out (11d-1) (#1378)
* test: pin the order of the unauthenticated mounts against SigV4 and the throttle

* refactor: mount the OIDC and registry routes through a public-routes hook

* refactor: take the admin registration name and request-id context as gateway config

* refactor: move node discovery out of the gateway into the awsgw role

* refactor: drop the gateway config fields production never reads (Config, RootCAs, InternalSuffix)

* refactor: drop the gateway Version and Commit config fields

* refactor: compose the production gateway config in one tested function

* refactor: drop the gateway IAMService, STSService, NATSConn and ExpectedNodes config fields

Production never reads them: composeGateway builds Policy and Readiness from
its own inputs. Tests that used the config as a carrier now keep those
collaborators in gwfix.Deps, recorded per config with gwfix.WithDeps and read
back with gwfix.DepsOf. Gateway non-test code now imports nothing under
domains/, runtime/ or operator/.

Test edits are setup only: 153 GatewayConfig literals in 45 files had the four
keys moved into gwfix.WithDeps(..., gwfix.Deps{...}), and 113 field reads
became gwfix.DepsOf(x).{IAM,STS,NATS,Nodes}. No assertion changed.

Script 1, literal rewrite (balanced-brace scanner, keys moved out of the
literal):

    import re,sys,subprocess,os
    KEYS={'IAMService':'IAM','STSService':'STS','NATSConn':'NATS','ExpectedNodes':'Nodes'}
    def scan_close(s,i):
        # s[i]=='{' ; return index of matching '}' ; skips strings, runes, comments
        d=0;n=len(s);k=i
        while k<n:
            c=s[k]
            if c=='"':
                k+=1
                while s[k]!='"':
                    k+= 2 if s[k]=='\\' else 1
            elif c=='`':
                k=s.index('`',k+1)
            elif c=="'":
                k+=1
                while s[k]!="'":
                    k+= 2 if s[k]=='\\' else 1
            elif s.startswith('//',k):
                k=s.index('\n',k)
                continue
            elif s.startswith('/*',k):
                k=s.index('*/',k)+1
            elif c in '{([': d+=1
            elif c in '})]':
                d-=1
                if d==0: return k
            k+=1
        raise Exception('unbalanced')
    def split_top(body):
        parts=[];d=0;start=0;k=0;n=len(body)
        while k<n:
            c=body[k]
            if c=='"':
                k+=1
                while body[k]!='"': k+= 2 if body[k]=='\\' else 1
            elif c=='`': k=body.index('`',k+1)
            elif c=="'":
                k+=1
                while body[k]!="'": k+= 2 if body[k]=='\\' else 1
            elif body.startswith('//',k):
                k=body.index('\n',k); continue
            elif body.startswith('/*',k): k=body.index('*/',k)+1
            elif c in '{([': d+=1
            elif c in '})]': d-=1
            elif c==',' and d==0:
                parts.append(body[start:k]); start=k+1
            k+=1
        parts.append(body[start:])
        return parts
    changed={}
    files=[l for l in subprocess.check_output(['git','ls-files','*.go']).decode().split() if os.path.exists(l)]
    SKIP={'spinifex/gateway/gateway.go','spinifex/runtime/roles/awsgw/compose.go','spinifex/runtime/roles/awsgw/awsgw.go'}
    lit=re.compile(r'(&?)((?:gateway\.)?GatewayConfig)\{')
    total=0
    for f in files:
        if f in SKIP: continue
        s=open(f).read()
        if 'GatewayConfig{' not in s: continue
        out=[];pos=0;cnt=0
        for m in lit.finditer(s):
            if m.start()<pos: continue
            o=m.end()-1
            c=scan_close(s,o)
            body=s[o+1:c]
            parts=split_top(body)
            keep=[];moved=[]
            for p in parts:
                km=re.match(r'\s*(IAMService|STSService|NATSConn|ExpectedNodes)\s*:\s*(.*)$',p,re.S)
                if km: moved.append((KEYS[km.group(1)],km.group(2).strip()))
                else: keep.append(p)
            if not moved: continue
            # rebuild
            kb=','.join(keep)
            if kb.strip()=='' : kb=''
            elif not kb.rstrip().endswith('\n') and '\n' in body: pass
            d=', '.join(f'{k}: {v}' for k,v in moved)
            new=f'gwfix.WithDeps({m.group(1)}{m.group(2)}{{{kb}}}, gwfix.Deps{{{d}}})'
            if not m.group(1): new=f'gwfix.WithDeps(&{m.group(2)}{{{kb}}}, gwfix.Deps{{{d}}})'  # value literals flagged below
            out.append((m.start(),c+1,new)); pos=c+1; cnt+=1
            if not m.group(1): print('VALUE LITERAL',f,s.count('\n',0,m.start())+1)
        if not out: continue
        r=[];last=0
        for a,b,n in out:
            r.append(s[last:a]);r.append(n);last=b
        r.append(s[last:])
        s2=''.join(r)
        if 'testkit/gwfix"' not in s2:
            s2=re.sub(r'import \(\n','import (\n\t"github.com/mulgadc/spinifex/internal/testkit/gwfix"\n',s2,1)
        open(f,'w').write(s2); total+=cnt; changed[f]=cnt
    print('literals rewritten',total,'files',len(changed))

Script 2, read rewrite (driven by the compiler's "has no field or method"
errors for the type, so only GatewayConfig receivers are touched):

    import re,subprocess,sys,collections
    M={'IAMService':'IAM','STSService':'STS','NATSConn':'NATS','ExpectedNodes':'Nodes'}
    def run():
        env=dict(__import__('os').environ,GOWORK='off')
        out=''
        for tags,pk in [('',['./spinifex/gateway','./spinifex/runtime/roles/awsgw/...']),('integration',['./tests/integration/...'])]:
            cmd=['go','test','-count=1','-run','NONE_X','-gcflags=-e']+(['-tags',tags] if tags else [])+pk
            r=subprocess.run(cmd,capture_output=True,text=True,env=env)
            out+=r.stdout+r.stderr
        return out
    fixed=0
    for it in range(8):
        out=run()
        errs=re.findall(r'^(\S+\.go):(\d+):(\d+): (.*?) undefined \(type (\*?(?:gateway\.)?GatewayConfig) has no field or method (\w+)\)',out,re.M)
        if not errs:
            print(out[-3000:]); break
        byfile=collections.defaultdict(list)
        for f,l,c,expr,t,fld in errs: byfile[f].append((int(l),int(c),expr,fld))
        for f,es in byfile.items():
            lines=open(f).read().split('\n')
            for l,c,expr,fld in sorted(set(es),reverse=True):
                line=lines[l-1]
                # expr like 'gw.IAMService' or 'x.y.IAMService'
                new=f'gwfix.DepsOf({expr[:-len(fld)-1]}).{M[fld]}'
                if expr not in line: print('MISS',f,l,expr); continue
                lines[l-1]=line.replace(expr,new)
                fixed+=1
            s='\n'.join(lines)
            if 'testkit/gwfix"' not in s:
                s=re.sub(r'import \(\n','import (\n\t"github.com/mulgadc/spinifex/internal/testkit/gwfix"\n',s,count=1)
            open(f,'w').write(s)
    print('read sites fixed',fixed)

* docs: record the gateway mount hooks and config-field removal in the package boundary migration

* docs: fill in the 11d-1 PR number in the package boundary migration
2026-11-01 13:04:31 +10:00
Josh Siviour 06d51b7e45 refactor: move service-named pipeline behaviour into registration metadata (ADR-0002:S9) (#1374)
* test: pin service-keyed gateway behaviour before it moves to registration metadata

* refactor: declare the XML throttle code on the registration

The pipeline picked Throttling for iam, sts, eks and route53 by name. EKS is a JSON registration and never reached that arm, so it is not carried over.

* refactor: declare the XML error shape on the registration

EC2 and the operator surface declare the EC2 shape. S3 is not served here, so the composition declares its scope traits and a rejected S3 request keeps S3's body.

* refactor: declare the global signing region on the registration

* refactor: supply the unsigned web-identity exchange as a pre-auth handler

STS composition now hands the pipeline a dispatch.PreAuth. The gateway no longer recognises STS or the action by name, and the layering allowlist row for gateway -> sts/awsapi is paid off.

* refactor: declare payload-body scopes as traits

The action recovery for a rejected request skipped S3 by name. The composition now declares the S3 scope as carrying object data, and the gateway reads the trait.

* test: wire the integration harness through PreAuth
2026-11-01 11:34:05 +10:00
Josh Siviour 593dd58831 fix: EKS control-plane VMs are not Kubernetes Nodes (#1373)
* test: pin control-plane no-agent config, etcd member removal and CP isolation e2e

* test: tighten CP isolation e2e and assert a logged prune failure

* fix: run EKS control planes without a k3s agent and prune etcd members directly

Control-plane VMs no longer register as Nodes, so pods cannot schedule onto a
network that cannot reach the workers or the advertised endpoint. With no Node
to delete, a replaced peer's etcd member is removed by a new eks-etcd-member
helper that matches exactly one member by peer-URL host.

* fix: refuse to remove the local etcd member by ID, not only by IP

Self-protection no longer depends on first boot deriving its own address: the
helper asks the connected endpoint for its member ID and refuses a match on it.

* test(e2e): align EKS suite with control planes that are not Nodes

Assert zero Nodes and delivered addon objects before any nodegroup. Check Ready worker, metrics APIService, addon deletion and pod logs once a worker exists, scoping logs to Ready nodes and waiting 10m for metrics-server to reschedule.

* docs: EKS control-plane hosts are not Kubernetes Nodes

Document that kubectl get nodes lists workers only, add upgrade notes for existing clusters, and list the eks-etcd-member helper.

* docs: explain why addon manifests keep the CriticalAddonsOnly toleration

Comment-only. The toleration stays for clusters created before control-plane hosts stopped registering as Nodes.

* test: cover the etcd member client and CLI against a TLS fake etcd

The real etcd client and the command had no tests, leaving the PR below the
diff coverage threshold. A fake etcd serving the Cluster and Maintenance APIs
over mutual TLS drives the client with the K3s certificate layout, and main
becomes a testable run function.
2026-11-01 10:55:57 +10:00
Josh Siviour 0fd85d117b test: move remaining service and verdict tests out of gateway (#1372)
* refactor: move gateway auth fixtures to runtime-free gwfix package

* refactor: move ACM gateway tests to awsgw

* refactor: keep gateway-internal ACM tests in gateway and drop test-only exports

* refactor: move RDS gateway tests to awsgw

* refactor: move ELBv2 gateway tests to awsgw

* refactor: move IAM gateway tests to awsgw

* refactor: move STS gateway tests to awsgw

* refactor: move tagging gateway tests to awsgw

* refactor: move operator and bedrock gateway tests to awsgw

* refactor: move ECR gateway tests to awsgw

* refactor: move ECS and EKS gateway tests to awsgw

* refactor: move EC2 gateway tests to awsgw

* refactor: move credential verdict tests to awsgw

* refactor: move policy-gate dispatch tests to their service packages

* refactor: move checkPolicy verdict tests to policyauth

* refactor: run gateway tests on a fake verifier, real auth only for listed verdict tests
2026-11-01 08:42:07 +10:00
Ben Duncan 51d7ac91f5 fix: bound the console's instance-type payload and stop polling it (#1371)
* fix: bound the console's instance-type payload and stop polling it

DescribeInstanceTypes with capacity=true returned one entry per free slot per
node: 3,528,765 bytes of XML for 50 distinct types on prod, refetched every
five seconds. The response was sized by free capacity, so the emptier the
cluster the larger it got, and at 44,346 slots it exceeded the NATS maximum
payload and the console reported no compute at all.

The listing now names each admissible type once. The free-slot figure crosses
as a number through a new GetInstanceTypeCapacity action, open to any account
because the launch form a tenant uses is what needs it. The catalogue query no
longer polls, and the EKS and ECS dialogs read it only when open.

The gateway negotiates gzip inside the SigV4 group, which excludes the OCI
Distribution registry structurally. A response is compressed only if the client
offered gzip, the handler declared neither a length nor an encoding, the
content type is neither streaming nor already compressed, the status carries a
body, and 1 KiB was written before any flush.

An unknown capacity figure no longer caps the count at 1. The launch decides,
and RunInstances answers InsufficientInstanceCapacity if it cannot.

* test: read the free slot count from its own action in the console e2e

Two e2e tests encoded capacity as repeated instance-type entries, so one
expected a caption the deduplicated listing can no longer produce. The fixture
answers GetInstanceTypeCapacity instead, which lets a test give the catalogue
and the figure different answers.

A third test covers the case the change introduces: a session whose policy does
not grant the action reads no figure, so the form offers the type with no
capacity caption and leaves the launch enabled.

The committed bundle is rebuilt, which rehashes every chunk reaching the two
query modules.
2026-10-31 14:03:19 -05:00
Ben Duncan 707dbefa13 fix: bound the capacity describe reply by instance-type count, not free slots (#1368)
* fix: bound the capacity describe reply by instance-type count, not free slots

A node answering `DescribeInstanceTypes` with `capacity=true` sent one full copy of an instance type's description per free slot, so the reply grew with how much capacity the host had free.
At 1344 bytes per slot the four-node production cluster, holding no guests, produced 874 slots and a reply over the 1 MB NATS maximum payload.
Every node's reply was dropped, the gateway collected zero frames, and it returned an empty list as a success.
The console reads an empty capacity list as no capacity and refuses every launch, which is how a cluster with hundreds of free vCPU reported "No compute available".

The emptier the cluster, the more likely the failure, which is the opposite of what the shape should be.

A node now returns each admissible type once with the number of instances of it the node can take, so its reply is bounded by the size of the instance-type catalogue and not by free capacity.
On the production catalogue that is 84 KB for 62 types whatever the slot count, against 59 MB for the same reply on a large idle host under the old shape.
The gateway sums the per-type counts across nodes and expands them on the last hop, because the client SDK drops response fields it does not model and the console displays the figure to the customer.

A capacity fan-out that collected no frames now returns a retryable ServiceUnavailable.
Reporting an empty list as a success is what turned a transport failure into a false statement about the cluster.

Two tests pin the shape: the node reply for an idle 4096-vCPU host stays under the NATS payload limit, and a capacity fan-out with no responders does not return an empty success.

* test: cover the console against a real cluster, and pin the capacity banner

The console has never been exercised against a real control plane.
`playwright.config.ts` pins `localhost:3101` and `:3102` and intercepts every API call, so the suite cannot be pointed at a cluster and cannot see a defect whose whole content is what the control plane actually sends.
That is why an idle production cluster came to tell customers it had no compute: the capacity listing was dropped in transport, the empty result read as no capacity, and nothing in CI could reach the page that says so.

`playwright.live.config.ts` runs the same console against a cluster named by `SPX_CONSOLE_URL`, signing in with `SPX_ACCESS_KEY_ID` and `SPX_SECRET_ACCESS_KEY`.
It starts no server, mocks nothing, runs one worker because it launches real guests, and is never part of `pnpm e2e`.
The suite signs in, asserts a cluster with free capacity offers instance types rather than reporting none, reads the per-type capacity figure, launches an instance and waits for `running`, reads its live metric graphs, terminates it in a `finally` block, and loads the nine listing pages a customer reaches from the sidebar.

Three tests in the mocked suite pin the banner's own logic, which is cheap and catches the UI side of the same failure: the form offers what the capacity listing reports, it shows the free slot count for the selected type, and an account with genuinely no capacity is told so and cannot submit.
The third passes on the current code and is the one that documents why the banner exists at all.

* test: correct three live-console selectors found by running it against prod

The first run of the live suite was 11 passed and 2 failed, both in the suite rather than in the console.

The dashboard check looked for node status on `/`, where it is not shown.
It now loads `/nodes` and asserts the `Resource usage by node` and `Instance type capacity` tables, which is where the daemon reports a node's own capacity.

The OS tile's radio is `sr-only` behind the distribution logo, so `check()` failed its actionability test on the `<img>` covering it.
The click goes to the enclosing `<label>` instead and the browser forwards it.

The terminate dialog holds its confirm disabled until the phrase is typed, so the teardown waited out its timeout on a disabled button and left the instance running.
The helper types `terminate` first.

With those three corrected the launch, running, live-metrics and terminate path passes against the production console in 34 s.
2026-10-31 13:42:13 -05:00
Ben Duncan 4050b63ada feat(ui): enforce ADR-0016 with guard tests and move dialog forms into FormPanel (#1369) 2026-10-31 13:41:53 -05:00
Ben Duncan 955714f557 fix: let a snapshot go once every volume cloned from it is already deleting (#1370)
* fix: let a snapshot go once every volume cloned from it is already deleting

DeleteVolume now records State=deleting and leaves the teardown to the
reclaim reaper, so a clone stays listed after the tenant has deleted it.
DeleteSnapshot counted that clone as still reading the snapshot and refused
with InvalidSnapshot.InUse, which broke DeleteDBSnapshot after deleting the
DB instance restored from it and left the retained source volume behind.

A deleting volume's teardown removes only its own objects and nothing moves
it out of deleting, so it no longer holds the snapshot's chunks.

* test(e2e/rds): delete a snapshot straight after the instance restored from it

The subtest deleted the source instance between the two, which gave the
volume reclaim reaper time to finish the restored volume on a fast node and
hid the refusal the nightly hit. Deleting the snapshot immediately makes the
regression deterministic.

The release wait now covers a full reclaim sweep: since deletion became
asynchronous a released volume is gone only after the reaper's next pass.
2026-10-31 13:27:01 -05:00
Josh Siviour 2e770e8859 test: move per-service gateway tests into their domains (#1366)
* refactor: one authorization gate in ingress/aws/pipeline

The key merge and the caller guards move out of the gateway so tests that
drive a registration directly use the same gate as the pipeline.

* test: run ACM handler and policy tests in domains/acm/awsapi

Adds internal/testkit/gwtest, which invokes a registration through the
pipeline's own authorization gate with real policy evaluation.

* test: share policy assertion helpers through gwtest

* test: run ELBv2 handler and policy tests in domains/elbv2/awsapi

* refactor: one actor projection in ingress/aws/pipeline

* test: run RDS handler and policy tests in domains/rds/awsapi

* test: run ECS handler and policy tests in domains/ecs/awsapi

* test: assert the exact policy gate verdict in gwtest

* test: run EKS authz and wire tests in domains/eks/awsapi

* test: run IAM authorization tests in domains/iam/awsapi

The policy-driven DeleteRole and AddRoleToInstanceProfile tests run in the IAM package through gwtest, with PassRoleTo shared. The HTTP-handler, examples and signed-pipeline tests stay in gateway.

* test: run ECR authorization tests in domains/ecr/awsapi

* test: run ECR repository, image and lifecycle-preview tests in domains/ecr/awsapi

* test: run ECR dispatch error tests in domains/ecr/awsapi

* test: run Bedrock authorization tests in domains/ochre/awsapi

* refactor: move EC2 authz tests into domains/ec2/awsapi

* test: restore the ECS scope-table completeness gate in domains/ecs/awsapi

* Revert "test: restore the ECS scope-table completeness gate in domains/ecs/awsapi"

This reverts commit 0211a0a1bf.
2026-10-31 23:45:35 +10:00
Ben Duncan f3ee105ed4 docs: add a diagram of a Vultr deployment to the guide (#1367)
Shows deploy-vultr.sh building a private Vultr VPC with one node or three,
the nodes clustering over the private network for the control plane, EBS
and S3, Spinifex calling the Vultr API for reserved IPs, and the AWS
services running across the fleet.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 08:36:10 -05:00
Ben Duncan 00070b1ecd docs: tidy the Vultr guide for publication (#1365)
Drops the horizontal rules the docs site rendered as stray text, links
the AWS CLI install page, captions each screenshot, shows the expected
spx get nodes output for three nodes, explains what a three-node
deployment protects against, and simplifies the limits to the sizing
and quorum a reader needs.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 08:20:53 -05:00
Josh Siviour 9405d2c233 feat(sqs): queue and Lambda-facing message API foundation (#1364)
* feat(sqs): queue and Lambda-facing message API foundation

Adds the SQS JSON 1.0 adapter (unregistered in production) with queue
create, lookup, attributes, list, tags and the message operations a
Lambda poller uses: SendMessage(Batch), ReceiveMessage with long
polling and attribute selection, DeleteMessage(Batch) and
ChangeMessageVisibility(Batch), backed by the queue record owner and
the JetStream message store. String and Binary message attributes are
validated, stored and checksummed; Number attributes are refused until
AWS behaviour is confirmed.

* test(sqs): poller lifecycle through the message owners

Covers receive, release, redelivery, delete, visibility extension, queue
binding of receipt handles and long polling against the real queue and
JetStream message stores, from the message package itself.

* test(sqs): move SDK coverage from the gateway to the SQS domain

Drives the registration through dispatch.Invocation over the real queue
and message owners, so the gateway package carries no SQS changes.
2026-10-31 22:27:11 +10:00
Josh Siviour a431fd3385 refactor: gateway request record and capabilities (ADR-0002:S7, S8) (#1363)
* test: pin gateway pipeline order, request ids and region

* refactor: carry request facts in one ingress request record

* fix: seal the request principal once

* refactor: build the caller ARN once in credverify

* refactor: gateway takes policy and admission as capabilities

* refactor: gateway takes cluster readiness as a capability

* chore: gofmt GatewayConfig field alignment

* docs: record slice 11a request record and capabilities

* docs: add PR number to slice 11a migration row

* refactor: move pipeline capability interfaces to ingress/aws/pipeline
2026-10-31 17:57:15 +10:00
Josh Siviour 3c285b2f37 ci: ask PR authors whether Mulga design needs to change (#1362) 2026-10-31 15:56:46 +10:00
Ben Duncan da3e4e5b73 feat: run Spinifex on Vultr, with failover, workbooks and the fixes they needed (#1361)
* feat: Vultr reserved-IP provider and floating external-address pool

Adds a source="vultr" external pool backed by Vultr reserved IPs: an API
client pinned by captured responses, an in-memory fake with the measured
semantics, and a floating allocator that creates, claims, reconciles and
releases labelled addresses. OCI and Vultr claims now share one
AddressClaimer pass.

Also fixes the host firewall dropping guest IMDS when imds_host_meta_ip is
set, by matching the pre-DNAT destination, and arms the Spinifex firewall in
place of an active ufw.

* fix: detach a Vultr reserved IP before deleting it

Vultr was measured refusing delete of an attached address with 400 "Unable to detach subnet", so release, rollback and reconcile now detach first. A first claim after allocation logs at info rather than warn.

* fix: release the auto-assigned public IP an Elastic IP displaces

AssociateAddress restamped the ENI with the EIP and never handed back the
address launch had assigned. Nothing referred to it afterwards, so it stayed
allocated for good, and on a cloud pool every associate left a billed address.
AWS releases it on associate; withdraw its NAT rule and release it, scoped to
the ENI, leaving any address another associated EIP holds alone.

* fix: retry Vultr's refused detach on reserved-IP detach and delete

Vultr has answered a detach with 400 "Unable to detach subnet" and accepted
the same detach moments later. Surfacing it left the address for reconcile
to collect, up to twenty billed minutes later; retry it like a 5xx instead.

* fix: stop reporting the released launch address after an EIP is disassociated

Associating an EIP now releases the auto-assigned public IP, but the stored
instance kept its launch stamp, and DescribeInstances fell back to it once the
EIP was disassociated. The public-IP change handler now moves the stamp with
the runtime address.

* feat: add a Vultr deploy script for a single-node Spinifex on a VX VM

deploy.sh up/destroy/status drives Terraform for the VPC and VM, installs and
forms Spinifex over SSH, copies the API key to the node rather than through
user-data, and sweeps boot volumes and reserved IPs Terraform does not own.
Cloud-init keeps needrestart off the Spinifex, OVS and OVN units and disables
unattended upgrades on these disposable hosts.

* feat: form a three-node Vultr deployment with install-node.sh over the VPC

Each node comes up as an armed single node, the state install-node.sh forms
from, with the cluster and Geneve planes on the VPC NIC. The pool and every
node's provider_host_id go on each node after formation.

* fix: read the pinned host key with ssh-keygen -F when forming a Vultr cluster

ssh_config hashes known_hosts entries by default on Debian, so a grep for the
address found nothing and set -e ended the run without a message.

* docs: add the Vultr guide, one command to deploy one or three nodes and one to remove them

* docs: drop the failover write note from the Vultr guide

* fix: keep needrestart from restarting the Spinifex, OVS and OVN units

Guest QEMU runs in spinifex-daemon's cgroup, so needrestart charged QEMU's
replaced libraries to the daemon and restarted it after every dpkg run. The
restart never cleared them, so an unattended upgrade ran the unit into its
start limit and left the control plane down.

setup.sh now writes /etc/needrestart/conf.d/90-spinifex.conf, deselecting
spinifex-, ovs-, ovsdb-, ovn- and openvswitch- units. needrestart still lists
them, so restarting after an upgrade stays the operator's choice.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* chore: drop the Vultr cloud-init needrestart override now setup.sh writes it

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* docs: replace the Vultr guide's apt upgrade warning now the installer keeps needrestart off the stack

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* docs: install the Vultr guide's build from the dev channel until Vultr support is released

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix: answer ARP for Vultr reserved IPs on bare metal

Vultr routes a reserved IP to a VM by MAC but ARPs for it on a bare-metal
server's public segment, and a Vultr pool gave vpcd neither a gateway nor a
bind bridge to proxy-ARP on, so nothing answered and no traffic arrived. A
Vultr pool now proxy-ARPs on the interface the node routes the internet
through. setup.sh installs arping, which the gratuitous ARP already called.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* feat: deploy Spinifex on Vultr bare metal, renamed deploy-vultr.sh

A vbm- plan creates bare-metal servers, one or three. Without --name the
deployment writes the AWS profile spinifex, up prints every node's address
and the API key allowlist, credentials and ssh subcommands are added, no AMI
is imported unless --image is given, and OpenTofu or Terraform is used. The
host key is pinned only once cloud-init is done, since bare metal first
answers SSH from Vultr's installer with a different key.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* docs: rework the Vultr guide into the OCI guide's flow, with bare metal

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix: restamp LaunchTime when a stopped or recovered instance relaunches

The pending watchdog times a launch from LaunchTime, which still held the
original launch. An instance recovered from a failed node, or started more
than five minutes after it was first launched, was terminated as stuck in
pending seconds after QEMU started. AWS restamps LaunchTime on every start.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* feat: spread the nginx-alb workbook across nodes, and power Vultr nodes off and on from deploy-vultr.sh

The workbook takes server_count and placement_strategy, so three web servers
can run one per node. Its README now matches the template (public subnets,
no NAT gateway) and the diagram shows the spread. deploy-vultr.sh gains
stop and start, which halt and start a node through the Vultr API.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix: let a terminate finish tearing down before the reaper calls it wedged

OrphanQEMUReaper finalized any shutting-down instance whose QEMU had exited,
on the premise that a live terminate still has a live QEMU. A terminate is
still deleting volumes, releasing its IP and leaving its placement group for
tens of seconds after QEMU exits, so the instance was reported terminated
first. Terraform then deleted the placement group and got
InvalidPlacementGroup.InUse. The reconcile now waits two minutes from
shutting-down, well inside the ten-minute stuck-terminate backstop.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix: release an ovs-vswitchd left in flow-restore-wait after a node boots

ovn-controller clears flow-restore-wait once its flows are installed and
sets it again straight after. When both writes reach ovs-vswitchd as one
update, it only sees true, starts no upcall threads and forwards nothing.
Seen on Vultr bare metal after a hard power-off: instances recovered onto
the rebooted node ran but were unreachable. vpcd now clears the flag on any
node held that way for a minute, longer than ovs-ctl's own restore takes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* docs: add a resiliency test to the Vultr guide and the nginx-alb workbook

The Vultr guide gains step 8, which spreads three web servers across a
three-node deployment, powers a node off and back on, and shows what was
measured on VMs and bare metal. The workbook gains the same test as step 6.
The guide's table of contents is repaired, and its limits now cover losing
two nodes and recovery inside a spread placement group.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix: never terminate an instance that has run because a relaunch failed

The pending watchdog terminated any instance pending past five minutes, and
terminating deletes DeleteOnTermination volumes. A start of a stopped
instance, a recovery from a failed node and a daemon relaunch all pass
through pending, so a slow one destroyed an existing instance and its root
disk. On Vultr a recovered instance was terminated seconds after QEMU
started and its root volume deleted.

An instance is now marked FirstLaunch from RunInstances until it first
reaches running, cleared before the running state is written. The watchdog
acts only on first launches, as AWS terminates only a launch that never
ran. MarkFailed refuses to terminate any other instance and marks it
recovery-failed instead, which keeps its volumes, ENI and addresses and
shows it as stopped. A claimed relaunch is never a first launch.

Invariant tests pin ADR-0003:S7. The instancerecovery e2e suite gains a
test that ages its guest past the watchdog before recovering it, then
stops and starts it, and requires the instance, its root volume and the
data on it to survive both.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* feat: run the Terraform workbooks against a Vultr deployment on demand, and show the Vultr portal in the guide

e2e-cloudvendor-vultr.yml is dispatch-only. It builds the ref, deploys it
with deploy-vultr.sh on one or three VMs or bare-metal servers, runs the
published workbooks from node1 with the same driver as the hypervisor
nightly and OCI, and destroys the deployment, failing if anything is left
billing. It needs one secret, VULTR_API_KEY, and generates its SSH key per
run. The guide gains screenshots of the API key's access control list and
of the nodes in the Vultr portal, and its portal navigation now matches.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix: install setup.sh on Vultr nodes when the tarball does not carry it

build-install-artifacts.sh writes setup.sh beside the tarball, so a node
installed from its tarball had no /usr/local/share/spinifex/setup.sh and
arming the firewall failed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix: pass the remote command after the destination in the Vultr e2e driver

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix: run each workbook in the cluster's own region

The workbook driver passed no region, so every workbook used its default,
ap-southeast-2, and on a cluster in any other region every request failed
SignatureDoesNotMatch. It now passes the region of the node's spinifex
profile.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* feat: count a node's vCPUs as online CPUs and reserve by host size (ADR-0015)

A node counted physical cores, collapsing SMT threads, and held back a
fixed 2 vCPU, so a 16-thread host offered 6 vCPU to instances. A vCPU is
now one online CPU, as AWS counts it: a thread with SMT on, a core with
SMT off or on ARM. The OS and Spinifex keep 1 CPU up to 8, 2 up to 16, 4
up to 63 and 8 from 64, and the rest is allocated one to one. The
existing overrides stay operator-only. Invariant tests pin every tier
boundary.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* docs: state the Vultr guide's sizing under the online-CPU count

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* docs: drop an unmeasured EKS sizing claim from the Vultr README

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix: let a guest reach an Elastic IP hosted on its own node

A guest's packet to an EIP on its own node left its VPC from the transit
range, was masqueraded to the transit host address, and went back into
OVN, which exempts that range from EIP DNAT, so it was never answered.
On Vultr an EKS worker could not reach its control plane's endpoint, so
it never joined. AWS lets an instance reach a public address in its own
VPC.

Hairpinned transit traffic now jumps to SPX-EIP-HAIRPIN, above the egress
MASQUERADE, where each EIP's rule sends it from the uplink address, as the
host's own traffic to the EIP already is. The rule follows the EIP's
ingress plumbing in and out, and anything else returns to the MASQUERADE.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix: resolve Spinifex's LB and EKS names on Vultr nodes

Vultr's Ubuntu image writes a static resolv.conf naming Vultr's resolvers,
so lookups bypassed the split DNS Spinifex configures in systemd-resolved
and an EKS endpoint name did not resolve on the node. deploy-vultr.sh
points resolv.conf at the resolved stub, as stock Ubuntu does.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test: prove a guest reaches an EIP on its own node in the natuplink e2e

The nat-single cells run it with a public pool, so the hairpin path that
left EKS workers unable to reach their control plane on Vultr is now
covered nightly.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* docs: record the natuplink hairpin check in the e2e coverage index

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix: keep each Vultr node's logs when an e2e run fails

The driver destroyed a node whose install failed together with the log
that said why. It now copies /root/*.log from every node into the run's
artifacts before the teardown, and the workflow uploads them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 23:12:58 -05:00
Josh Siviour 23be7c201a fix: launch EKS workers with IMDS hop limit 2 and honour launch template metadata options (#1360)
* test: pin EKS worker IMDS metadata options and launch template resolution

* fix: launch EKS workers with IMDS hop limit 2 and honour launch template metadata options

* fix: default unset launch template metadata fields for EKS workers

* test: e2e coverage for EKS worker IMDS hop limit and launch template metadata options

* test: drop the unneeded GetCallerIdentity policy from the worker IMDS e2e

* docs: describe the EKS worker IMDS defaults and launch template overrides

* test: drop the stale GetCallerIdentity policy from the IRSA pod e2e
2026-10-31 14:07:05 +10:00
Ben Duncan e5b9fef0ae feat: count a node's vCPUs as online CPUs and reserve by host size (ADR-0015) (#1359)
A node counted physical cores, collapsing SMT threads, and held back a
fixed 2 vCPU, so a 16-thread host offered 6 vCPU to instances. A vCPU is
now one online CPU, as AWS counts it: a thread with SMT on, a core with
SMT off or on ARM. The OS and Spinifex keep 1 CPU up to 8, 2 up to 16, 4
up to 63 and 8 from 64, and the rest is allocated one to one. The
existing overrides stay operator-only. Invariant tests pin every tier
boundary.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 22:53:45 -05:00
Ben Duncan 5271b16d2a feat(ui): console design system, side-panel resource views and live EC2 metrics (#1358)
* feat: lay the Spinifex UI design-system foundation

Brand tokens in OKLCH with semantic status and a colour-blind-checked
chart palette, Archivo headings and a 13px type scale, and a pre-paint
theme script so dark mode never flashes white under the console CSP.

Adds the missing Base UI primitives, a /design catalogue route, a route
ratchet against hand-written tables, a ban on chart-library imports in
routes, and lets the content column shrink so wide tables scroll in
place. The dev server now forwards /api and /proxy to the Go console.

Sonner is removed: its inline style element is blocked by the CSP.

* feat(ui): side panels, TanStack table, uPlot charts and e2e gate

- DataTable renders @tanstack/react-table, with column hiding as container
  queries so columns drop when a side panel narrows the list.
- UniFi-style DetailPanel and ResourceView: Instances, VPCs and Internet
  gateways open in a panel beside the list from 1024px and as a full page
  below; selected, tab and create live in the URL. Create flows for
  instances, VPCs and gateways open in the same panel.
- Instance monitoring charts on uPlot (CPU, network, packets), on simulated
  data and badged as such.
- Dates in the AWS console form, e.g. 2026/06/04 02:00 GMT-5.
- Tabs style Base UI's data-active, so the selected tab now shows.
- Playwright suite gates CI as the UI End-to-End job in ui.yml.

* feat(ui): one details component for panel and page, UniFi-style overlay panel

The side panel now lies over the list at full height with a shaded edge, and a
click on the list's background closes it. Instances, VPCs and internet
gateways each render one details component as either the panel or the
resource's own page, so the page gains the monitoring graphs and the panel
gains IAM role, GPU, console output, change type and create image. SSH moves
into Instance information as rows. Internet gateways attach and detach from
the panel.

* feat(ui): roll the panel pattern out to every service and gate screenshots in CI

Every EC2, IAM, Route 53, Bedrock, EKS, ECS and RDS resource is now a
list plus a side panel, with one details component serving as both the
panel and the one-column page. DetailCard, DetailRow, ListCard,
ResourceList and DeleteResourceButton are deleted.

- Panel: slide-out close, row selection closes it, expand keeps the tab
  and fetched console output, PanelSpinner while loading.
- Instances: info tips, theme-aware coloured console output, power
  button group plus Actions menu, Stop and Reboot confirms, security
  group, Elastic IP and network interface sections, and a live
  one-second monitoring window on simulated data.
- Launch instance: OS tiles picking the newest version, architecture
  choice, location tile, expanded root volume, honest default subnet.
- On/off settings are switches app-wide; disabled controls show
  cursor: not-allowed.
- The e2e job runs in the pinned Playwright image with screenshots
  gating; make ui-baselines and make ui-e2e-container use the same image.

* feat: bulk select and delete on every resource list, OS marks and instance type sizes

Every list with a delete action gets row checkboxes and a selection bar.
Delete confirms in a dialog naming each resource, makes one call per
resource, keeps each refusal against its row and retries only failures.

The Launch form shows distribution marks for Ubuntu, Debian, Rocky,
Alpine and CentOS, served as files under the CSP, and states the
selected instance type's vCPUs and memory.

* feat: sign-in overlay, larger type scale, typed confirms on irreversible deletes and dialog shortcuts

The sign-in card sits over an inert shell. Any sidebar group folds, and entering one unfolds it. Instance DNS names copy. Escape cancels and Cmd/Ctrl+Enter confirms every dialog. Volume, snapshot, RDS, EKS, ECS and hosted zone deletes need "delete" typed, and terminate needs "terminate".

* feat: fade sign-in and sign-out in place, and show the confirm shortcut

Signing in no longer reloads the page: the card and a lighter veil fade out and the console routes to the dashboard. Signing out cross-fades into the sign-in card with a Signed out notice. Confirm buttons show Cmd or Ctrl Enter for the platform.

* feat(ui): generic sidebar group names and a paged S3 object browser

The sidebar names its groups Compute, Databases, Authentication and Object
Storage; the command palette still finds each by its AWS service name.

Buckets get row selection and delete. Inside a bucket, folders and objects
share one table paged by ListObjectsV2 continuation tokens, with a find box
that queries by prefix, multi-select delete through DeleteObjects (folders
with everything under them), and an object panel showing HeadObject
properties, user metadata and a download.

* feat(ui): show upload progress and stop the S3 proxy cutting off slow transfers

The Upload button shows a running batch's percentage, and a large or slow
batch opens a dialog with bytes sent, bit-rate, time left and per-file
cancel and retry. Uploads run in a store outside React, so they continue
while the user browses.

The console's S3 proxy now allows an hour per request. The server's 15 s
ReadTimeout cancelled every upload part that took longer, so large uploads
failed on links below roughly 18 Mbit/s.

* feat(ui): stream live EC2 metrics to the Monitoring tab

The Monitoring tab drew simulated data. It now streams CPU, network
bytes and packets from the owning node's qmp-collector every 500 ms
while someone is watching, with nothing stored.

- qmp-collector: a watched sampler on schedstat and tap counters, on a
  30 s lease, keeping five minutes in memory for reconnects. It answers
  only watches for the account in the VM's discovery file.
- awsgw: spinifex:GetInstanceMetrics, checked against the instance ARN,
  answered as text/event-stream in 60 s segments. Another account's
  instance is InvalidInstanceID.NotFound.
- console: a signed stream reader that reconnects with Since and backs
  off on failure or an empty segment, stops on InvalidAction, and a
  chart axis that scrolls between samples. The simulator and its badge
  are removed.

* feat: catch refused creates in every form, answer the third review, and guard tables

Every create form now catches a refused mutation, so the failure shows beside
the submit button and no longer escapes as an unhandled rejection. Three EKS
tabs that swallowed a refused create now show it.

Third-review answers: the attach dialog offers running or stopped instances in
the volume's zone; the default security group's delete is disabled with its
reason; the load balancer panel splits into Listeners and Attributes tabs; a
main route table lists its implicit subnets; IAM single delete dialogs name the
same dependants as the bulk ones; a snapshot can be deleted in any state.

Every list in a route renders through DataTable, enforced by a unit test with a
named allowance, and the /design baselines are redrawn in the pinned container.

* feat: cap live metrics streams, prune hidden selections, and move panel focus

Each gateway holds at most 10 live metrics streams per account and 200 in
total, refusing past either with RequestLimitExceeded, and counts opened and
refused streams. A row that leaves a list leaves its selection, the instances
page uses the shared selection hook, the detail panel takes focus on open and
returns it on close, and Escape yields only to the popup it was pressed in or
a modal. The UI workflow also runs on live metrics backend changes.
2026-10-30 22:40:47 -05:00
Josh Siviour e06baa3aef fix: keep an ECR token valid after its role session expires (#1357)
* test: authorise an ECR token from the live role once its session is gone

* feat: carry the role ID in an ECR token minted for an assumed role

* feat: rehydrate an assumed-role caller from the live role

* fix: authorise an ECR token from its live role after the session expires

* docs: note that an ECR token outlives its role session
2026-10-31 12:20:24 +10:00
Josh Siviour ded44feedb refactor: register EC2 through the ingress seam and delete the legacy service switch (#1355)
* test: characterise EC2 request labels and the cluster-unavailable gate order

Pins the span, audit, throttle and policy-gate labels an EC2 request carries through the signed pipeline, and that the NATS gate answers before policy, action lookup and gateway-local actions.

* refactor(gateway): register EC2 through the ADR-0002 seam

EC2 is composed in awsgw from domains/ec2/awsapi and served by the
registration seam. The legacy service switch and its tables are removed,
along with the two paid-off gateway allowlist rows.

* docs: record the EC2 seam registration in the package boundary migration

* docs: record the EC2 seam registration PR number
2026-10-31 12:09:59 +10:00
Ben Duncan cf4a6abafb fix: make volume deletion asynchronous so a large volume cannot fail its own delete (#1356)
DeleteVolume returned only once every object behind the volume was gone, which
made the call's latency a function of the volume's size. Object removal costs
one round trip per object, so no timeout is both short enough for a control
plane request and long enough for a large volume: on a 100 GiB volume the
provider request overran its 30 s budget at 52 s, the tenant was told the
delete failed, and the teardown that had already started was left with no
record accountable for it.

AWS does not work this way. DeleteVolume's output in the EC2 model is
smithy.api#Unit, VolumeState includes deleting, and the documentation says a
volume can remain in that state for several minutes.

DeleteVolume now validates, decides every AWS-returnable error, records
State=deleting and returns. VolumeReclaimReaper converges the teardown and
removes the record only once it is complete, so an unreachable or saturated
blob node delays reclamation instead of failing a delete the tenant has already
been told succeeded. The reaper is cluster-wide and leader-gated, which is also
what serialises the teardown: three concurrent destructive handlers ran one
volume's objects on prod because a retry had nothing to collapse against.

A volume already recorded as deleting answers a second delete from the record,
which collapses both the tenant's retry and DeleteOnTermination's own delete.

Two ordering fixes behind it. The provider handler now tears down local state
before deleting objects, because the local half is fixed-cost and sat behind the
half that scales, so a timeout left this node's WAL and dirty marker behind for
good. And volume.delete and snapshot.delete are classified as data movement,
since both reference real data and neither answers from state already held.
2026-10-30 15:49:33 -05:00
Ben Duncan 06f93802b8 fix: keep needrestart from restarting the Spinifex, OVS and OVN units (#1341)
Guest QEMU runs in spinifex-daemon's cgroup, so needrestart charged QEMU's
replaced libraries to the daemon and restarted it after every dpkg run. The
restart never cleared them, so an unattended upgrade ran the unit into its
start limit and left the control plane down.

setup.sh now writes /etc/needrestart/conf.d/90-spinifex.conf, deselecting
spinifex-, ovs-, ovsdb-, ovn- and openvswitch- units. needrestart still lists
them, so restarting after an upgrade stays the operator's choice.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 15:17:15 -05:00
697 changed files with 11619 additions and 18945 deletions
+1 -1
View File
@@ -104,7 +104,7 @@ jobs:
- name: Build microVM artifacts (lb-agent + kernel + initramfs + qboot + SQS store and worker images)
if: steps.version.outputs.exists != 'true'
run: cd spinifex && make build-microvm-image build-sqs-worker-microvm-image
run: cd spinifex && make build-microvm-image build-sqs-store-microvm-image build-sqs-worker-microvm-image
- name: Build amd64 Tarball
if: steps.version.outputs.exists != 'true'
-20
View File
@@ -1203,16 +1203,6 @@ jobs:
sleep 2
rm -f "$TMPDIR"/tmp.*.qcow2 "$TMPDIR"/tmp.*.log "$TMPDIR"/tmp.*-node.log
# The ISO build runs as root inside the workspace, and the next job on
# this runner, from any workflow, cannot remove what it leaves there.
- name: Release the workspace
if: always()
run: |
sudo umount -R -l "$GITHUB_WORKSPACE"/mulga/scripts/iso-builder/.cache/rootfs 2>/dev/null || true
sudo rm -rf --one-file-system "$GITHUB_WORKSPACE"/mulga/scripts/iso-builder/.cache \
"$GITHUB_WORKSPACE"/mulga/scripts/iso-builder/dist
sudo chown -R "$(id -u):$(id -g)" "$GITHUB_WORKSPACE"
iso-multi:
name: cell-22 (iso/dhcp/three-node)
# After cell-21 rather than beside it: same concurrency group, and running
@@ -1364,16 +1354,6 @@ jobs:
rm -rf /tmp/cell22-artifacts-$GITHUB_RUN_ID /tmp/cell22-$GITHUB_RUN_ID.log \
"$WORK_DIR"
# The ISO build runs as root inside the workspace, and the next job on
# this runner, from any workflow, cannot remove what it leaves there.
- name: Release the workspace
if: always()
run: |
sudo umount -R -l "$GITHUB_WORKSPACE"/mulga/scripts/iso-builder/.cache/rootfs 2>/dev/null || true
sudo rm -rf --one-file-system "$GITHUB_WORKSPACE"/mulga/scripts/iso-builder/.cache \
"$GITHUB_WORKSPACE"/mulga/scripts/iso-builder/dist
sudo chown -R "$(id -u):$(id -g)" "$GITHUB_WORKSPACE"
# Predastore throughput ("cell 23"). Predastore has no runners of its own and
# spinifex depends on it, so the benchmark lives here and checks predastore
# out as a sibling.
+1 -1
View File
@@ -58,7 +58,7 @@ jobs:
cache: false
- name: Build microVM artifacts (lb-agent + kernel + initramfs + qboot + SQS store and worker images)
run: cd spinifex && make build-microvm-image build-sqs-worker-microvm-image
run: cd spinifex && make build-microvm-image build-sqs-store-microvm-image build-sqs-worker-microvm-image
- name: Build amd64 Tarball
env:
+3
View File
@@ -21,6 +21,9 @@ build/microvm/vmlinuz
build/microvm/initramfs.cpio.gz
build/microvm/qboot.rom
build/microvm/qboot.LICENSE
build/microvm/sqs-store/vmlinuz
build/microvm/sqs-store/initramfs.cpio.gz
build/microvm/sqs-store/.stage.*/
build/microvm/sqs-worker/vmlinuz
build/microvm/sqs-worker/initramfs.cpio.gz
build/microvm/sqs-worker/.stage.*/
+27 -15
View File
@@ -153,32 +153,37 @@ $(MICROVM_ARTIFACTS) &: $(MICROVM_INPUTS)
bin/lb-agent:
$(MAKE) build-lb-agent
# The dedicated SQS server, built from the go.mod-pinned module so the image
# ships exactly the reviewed version. Static for the musl guest.
# The SQS store microVM image: a separate artifact from the LB image above, not
# installed or released by any target yet.
SQS_STORE_MICROVM_OUT_DIR := $(MICROVM_OUT_DIR)/sqs-store
SQS_STORE_MICROVM_ARTIFACTS := $(SQS_STORE_MICROVM_OUT_DIR)/vmlinuz $(SQS_STORE_MICROVM_OUT_DIR)/initramfs.cpio.gz
SQS_STORE_MICROVM_INPUTS := scripts/build-sqs-store-microvm-image.sh scripts/lib/sqs-store-microvm-publish.sh $(SQS_STORE_MICROVM_OUT_DIR)/init.sh $(SQS_STORE_MICROVM_OUT_DIR)/inittab $(addprefix $(SQS_STORE_MICROVM_OUT_DIR)/,sqs-store-lib.sh sqs-store-prepare sqs-store-run sqs-store-stop) bin/nats-server
$(SQS_STORE_MICROVM_ARTIFACTS) &: $(SQS_STORE_MICROVM_INPUTS)
./scripts/build-sqs-store-microvm-image.sh
# The dedicated SQS store server, built from the go.mod-pinned module so the
# image ships exactly the reviewed version. Static for the musl guest.
bin/nats-server: go.mod go.sum
@mkdir -p bin
GOWORK=off CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -trimpath -ldflags '-s -w' -o $@ github.com/nats-io/nats-server/v2
# go_local_srcs lists the main module package dirs and files guest package $(1)
# links, scanned with the guest build settings; a dir's mtime catches a deleted
# or renamed file. A failed scan names a missing prerequisite, so make stops.
go_local_srcs = $(shell GOWORK=off CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go list -deps -f '{{if and .Module .Module.Main}}{{.Dir}} {{range .GoFiles}}{{$$.Dir}}/{{.}} {{end}}{{range .EmbedFiles}}{{$$.Dir}}/{{.}} {{end}}{{end}}' $(1) || echo go-list-failed-for-$(1))
.SECONDEXPANSION:
build-sqs-store-microvm-image: $(SQS_STORE_MICROVM_ARTIFACTS) ## Build the SQS store microVM kernel and initramfs
# The SQS microVM image: one guest running its own SQS nats-server and the SQS
# worker as separate processes. A separate artifact from the LB image above.
# The shared SQS worker microVM image: the store image's /init and kernel with
# the worker binary instead of nats-server. Not installed or released yet.
SQS_WORKER_MICROVM_OUT_DIR := $(MICROVM_OUT_DIR)/sqs-worker
SQS_WORKER_MICROVM_ARTIFACTS := $(SQS_WORKER_MICROVM_OUT_DIR)/vmlinuz $(SQS_WORKER_MICROVM_OUT_DIR)/initramfs.cpio.gz
SQS_WORKER_MICROVM_INPUTS := scripts/build-sqs-store-microvm-image.sh scripts/lib/sqs-store-microvm-publish.sh $(SQS_WORKER_MICROVM_OUT_DIR)/init.sh $(SQS_WORKER_MICROVM_OUT_DIR)/inittab $(addprefix $(SQS_WORKER_MICROVM_OUT_DIR)/,sqs-worker-lib.sh sqs-worker-prepare sqs-worker-store sqs-worker-ready sqs-worker-stop) bin/nats-server bin/sqs-worker
SQS_WORKER_MICROVM_INPUTS := scripts/build-sqs-store-microvm-image.sh scripts/lib/sqs-store-microvm-publish.sh $(SQS_STORE_MICROVM_OUT_DIR)/init.sh $(SQS_WORKER_MICROVM_OUT_DIR)/inittab $(addprefix $(SQS_WORKER_MICROVM_OUT_DIR)/,sqs-worker-lib.sh sqs-worker-prepare sqs-worker-ready sqs-worker-stop) bin/sqs-worker
$(SQS_WORKER_MICROVM_ARTIFACTS) &: $(SQS_WORKER_MICROVM_INPUTS)
./scripts/build-sqs-store-microvm-image.sh
SQS_MICROVM_PROFILE=worker ./scripts/build-sqs-store-microvm-image.sh
bin/sqs-worker: go.mod go.sum $$(call go_local_srcs,./cmd/sqs-worker)
bin/sqs-worker: go.mod go.sum $(shell find cmd/sqs-worker spinifex/agents/sqs -name '*.go' ! -name '*_test.go')
@mkdir -p bin
GOWORK=off CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -trimpath -ldflags '-s -w' -o $@ ./cmd/sqs-worker
build-sqs-worker-microvm-image: $(SQS_WORKER_MICROVM_ARTIFACTS) ## Build the SQS microVM kernel and initramfs
build-sqs-worker-microvm-image: $(SQS_WORKER_MICROVM_ARTIFACTS) ## Build the shared SQS worker microVM kernel and initramfs
# qboot is the microvm firmware. The source tarball ships with each release
# because qboot is GPL-2.0.
@@ -191,7 +196,13 @@ $(QBOOT_ARTIFACTS) &: scripts/build-qboot.sh
build-microvm-image: $(MICROVM_ARTIFACTS) $(QBOOT_ARTIFACTS) ## Build microVM kernel, initramfs and qboot firmware (incremental — skips when up to date)
install-sqs-worker-microvm: $(SQS_WORKER_MICROVM_ARTIFACTS) ## Install the SQS microVM image to /usr/share/spinifex/microvm/sqs-worker/
install-sqs-store-microvm: $(SQS_STORE_MICROVM_ARTIFACTS) ## Install the SQS store microVM image to /usr/share/spinifex/microvm/sqs-store/
sudo install -d /usr/share/spinifex/microvm/sqs-store
sudo rm -f /usr/share/spinifex/microvm/sqs-store/initramfs.cpio.gz
sudo install -m 0644 $(SQS_STORE_MICROVM_OUT_DIR)/vmlinuz /usr/share/spinifex/microvm/sqs-store/vmlinuz
sudo install -m 0644 $(SQS_STORE_MICROVM_OUT_DIR)/initramfs.cpio.gz /usr/share/spinifex/microvm/sqs-store/initramfs.cpio.gz
install-sqs-worker-microvm: $(SQS_WORKER_MICROVM_ARTIFACTS) ## Install the SQS worker microVM image to /usr/share/spinifex/microvm/sqs-worker/
sudo install -d /usr/share/spinifex/microvm/sqs-worker
sudo rm -f /usr/share/spinifex/microvm/sqs-worker/initramfs.cpio.gz
sudo install -m 0644 $(SQS_WORKER_MICROVM_OUT_DIR)/vmlinuz /usr/share/spinifex/microvm/sqs-worker/vmlinuz
@@ -503,6 +514,7 @@ distro-amd64:
echo "[distro-amd64] WARNING: microvm artifacts missing — tarball will not include them"; \
echo "[distro-amd64] run 'make build-microvm-image' before 'make distro-amd64'"; \
fi
./scripts/lib/stage-sqs-store-microvm.sh $(SQS_STORE_MICROVM_OUT_DIR) dist/amd64
./scripts/lib/stage-sqs-store-microvm.sh $(SQS_WORKER_MICROVM_OUT_DIR) dist/amd64 sqs-worker
tar -czf dist/spinifex-$(VERSION)-linux-amd64.tar.gz -C dist/amd64 .
sha256sum dist/spinifex-$(VERSION)-linux-amd64.tar.gz > dist/spinifex-$(VERSION)-linux-amd64.tar.gz.sha256
@@ -522,7 +534,7 @@ distro-arm64:
distro-clean:
rm -rf dist/
.PHONY: test-package-check build build-ui test-ui-e2e ui-e2e-container ui-baselines build-installer build-lb-agent build-ecs-agent build-system-image build-eks-node-image import-eks-node-image publish-eks-node-image build-ecs-node-image import-ecs-node-image build-rds-postgres-image import-rds-postgres-image build-rds-mariadb-image import-rds-mariadb-image build-microvm-image build-sqs-worker-microvm-image install-microvm install-sqs-worker-microvm go_build preflight test test-cover test-race diff-coverage bench fuzz test-actions test-images test-build-scripts test-harness test-integration generate-aws-model-coverage aws-model-coverage test-segscan-oracle \
.PHONY: test-package-check build build-ui test-ui-e2e ui-e2e-container ui-baselines build-installer build-lb-agent build-ecs-agent build-system-image build-eks-node-image import-eks-node-image publish-eks-node-image build-ecs-node-image import-ecs-node-image build-rds-postgres-image import-rds-postgres-image build-rds-mariadb-image import-rds-mariadb-image build-microvm-image build-sqs-store-microvm-image build-sqs-worker-microvm-image install-microvm install-sqs-store-microvm install-sqs-worker-microvm go_build preflight test test-cover test-race diff-coverage bench fuzz test-actions test-images test-build-scripts test-harness test-integration generate-aws-model-coverage aws-model-coverage test-segscan-oracle \
deploy reinstall clean \
install-system install-go install-aws quickinstall \
lint fix govulncheck nilaway \
-1
View File
@@ -38,7 +38,6 @@ var allowlist = []allowance{
{From: "cmd/aws-model-coverage", To: "domains/ecs/awsapi", Debt: "cmd/aws-model-coverage", Reason: "reads declared service inventories; named exception, must not construct a registration"},
{From: "cmd/aws-model-coverage", To: "domains/eks/awsapi", Debt: "cmd/aws-model-coverage", Reason: "reads declared service inventories; named exception, must not construct a registration"},
{From: "cmd/aws-model-coverage", To: "domains/iam/awsapi", Debt: "cmd/aws-model-coverage", Reason: "reads declared service inventories; named exception, must not construct a registration"},
{From: "cmd/aws-model-coverage", To: "domains/sqs/awsapi", Debt: "cmd/aws-model-coverage", Reason: "reads declared service inventories; named exception, must not construct a registration"},
{From: "cmd/aws-model-coverage", To: "domains/sts/awsapi", Debt: "cmd/aws-model-coverage", Reason: "reads declared service inventories; named exception, must not construct a registration"},
{From: "runtime/daemon", To: "domains/ec2/awsapi/instance", Debt: "Instance adapter consumers outside EC2", Reason: "runtime/daemon/eks_cp_control.go and rds_deps.go call the instance adapter; needs an EC2 capability"},
+19 -17
View File
@@ -192,29 +192,30 @@ func TestADR0001_LayeringCheckerDetectsFaults(t *testing.T) {
}
}
// TestADR0001_R2_IngressRejectsRuntimeOperatorAndDomains: the request pipeline
// lives under ingress/aws, so it must not import the daemon, any runtime or
// operator package, or a domain implementation.
func TestADR0001_R2_IngressRejectsRuntimeOperatorAndDomains(t *testing.T) {
r := ruleByID("R2_IngressImportsNoImplementation")
from := "spinifex/ingress/aws/pipeline"
f, ok := classify(from)
if !ok || !r.subject(f, from) {
t.Fatalf("%s is not governed by R2", from)
// TestADR0001_InterimDaemonDirectionCoversRuntimeDaemon: the daemon left its
// legacy root, so gateway importing runtime/daemon must still be rejected, and
// the former handlers packages are held to it by R5 at their domain homes.
func TestADR0001_InterimDaemonDirectionCoversRuntimeDaemon(t *testing.T) {
daemon, _ := classify("spinifex/runtime/daemon")
checks := []struct{ rule, from string }{
{"Interim_DaemonGatewayHandlersDirection", "spinifex/gateway"},
{"R5_DomainImportsNoRuntimeOrOperator", "spinifex/domains/ecs"},
}
for _, to := range []string{
"spinifex/runtime/daemon", "spinifex/runtime/host/process", "spinifex/runtime/roles/awsgw",
"spinifex/operator/admin", "spinifex/domains/ecs", "spinifex/domains/iam/awsapi",
} {
target, _ := classify(to)
if !r.forbids(f, target, from) {
t.Errorf("%s -> %s is not rejected by R2", from, to)
for _, c := range checks {
r := ruleByID(c.rule)
f, _ := classify(c.from)
if !r.subject(f, c.from) || !r.forbids(f, daemon, c.from) {
t.Errorf("%s -> runtime/daemon is not rejected by %s", c.from, c.rule)
}
}
other, _ := classify("spinifex/runtime/host/process")
if gw, _ := classify("spinifex/gateway"); ruleByID("Interim_DaemonGatewayHandlersDirection").forbids(gw, other, "spinifex/gateway") {
t.Error("gateway -> runtime/host/process is rejected, but it is not the daemon")
}
}
// TestADR0002_S7_RequestRecordRejectsOutsiders: the request record is readable
// by packages under ingress/aws only.
// by the gateway and ingress/aws only.
func TestADR0002_S7_RequestRecordRejectsOutsiders(t *testing.T) {
record, ok := classify("spinifex/ingress/aws/reqctx")
if !ok || !record.requestRecord {
@@ -225,6 +226,7 @@ func TestADR0002_S7_RequestRecordRejectsOutsiders(t *testing.T) {
"domains/ecs/awsapi": true,
"runtime/roles/awsgw": true,
"operator/admin": true,
"gateway": false,
"ingress/aws/dispatch": false,
} {
from, ok := classify(fullPath(name))
+19 -7
View File
@@ -29,11 +29,12 @@ const (
type domainKind int
const (
notDomain domainKind = iota
domainImpl // domains/<d>/... resource implementation
domainAWSAPI // domains/<d>/awsapi/... AWS action adapters
domainProj // domains/<d>/projection/... authorised view
legacyImpl // handlers/<d>/... legacy domain implementation
notDomain domainKind = iota
domainImpl // domains/<d>/... resource implementation
domainAWSAPI // domains/<d>/awsapi/... AWS action adapters
domainProj // domains/<d>/projection/... authorised view
legacyImpl // handlers/<d>/... legacy domain implementation
legacyAdapter // gateway/<d>/... legacy AWS action adapters
)
type class struct {
@@ -59,8 +60,17 @@ func (c class) isLegacy(roots ...string) bool {
return slices.Contains(roots, c.legacy)
}
// legacyRoots are pre-ADR-0001 application roots still standing. None remain.
var legacyRoots = map[string]bool{}
// legacyRoots are pre-ADR-0001 application roots still standing.
var legacyRoots = map[string]bool{
"gateway": true,
}
// gatewayDomains names the domain each gateway/<service> adapter belongs to;
// Bedrock is the AWS surface of the Ochre domain.
var gatewayDomains = map[string]string{
"bedrock": "ochre", "ecs": "ecs", "eks": "eks", "elbv2": "elbv2", "iam": "iam",
"rds": "rds", "sts": "sts",
}
// classify maps a module-relative package directory to its layer. ok is false
// for a package that has no deliberate classification.
@@ -132,6 +142,8 @@ func classifyApp(app []string) (class, bool) {
switch {
case app[0] == "handlers" && len(app) >= 2:
c.domain, c.kind = app[1], legacyImpl
case app[0] == "gateway" && len(app) >= 2 && gatewayDomains[app[1]] != "":
c.domain, c.kind = gatewayDomains[app[1]], legacyAdapter
}
return c, true
}
+7 -2
View File
@@ -9,11 +9,13 @@ import (
// frozenLegacyPackages is every production package still under a legacy root.
// It only shrinks: a new entry is a new legacy package, a missing one is a
// completed move whose line must be deleted.
var frozenLegacyPackages = []string{}
var frozenLegacyPackages = []string{
"gateway",
}
// retiredLegacyRoots were legacy roots whose every package has moved to its
// ADR-0001 target; recreating one would reopen a closed migration row.
var retiredLegacyRoots = []string{"accountteardown", "admin", "daemon", "gateway", "handlers", "lbagent", "services", "utils", "vpcd"}
var retiredLegacyRoots = []string{"accountteardown", "admin", "daemon", "handlers", "lbagent", "services", "utils", "vpcd"}
// TestADR0001_LegacyRootsFrozen: the current-to-target map gives every legacy
// root a destination, so none may gain a package and no retired root may return.
@@ -33,6 +35,9 @@ func TestADR0001_LegacyRootsFrozen(t *testing.T) {
present[name] = true
}
}
if len(present) == 0 {
t.Fatal("found no legacy packages; the freeze would pass vacuously")
}
frozen := map[string]bool{}
for _, p := range frozenLegacyPackages {
frozen[p] = true
+21 -4
View File
@@ -126,8 +126,10 @@ var rules = []rule{
case t.layer == layerAgents:
return true
case t.layer == layerIngress:
return f.kind != domainAWSAPI
case t.isLegacy("handlers"):
return f.kind != domainAWSAPI && f.kind != legacyAdapter
case t.isLegacy("gateway") && !t.domainShaped():
return true
case t.isLegacy("handlers", "gateway"):
return f.layer == layerDomain
}
return false
@@ -154,14 +156,29 @@ var rules = []rule{
},
{
id: "S7_RequestRecordPrivateToIngress",
clause: "ADR-0002:S7: the request record is private to ingress, so no domain, runtime or operator package reads it; only packages under ingress/aws may import ingress/aws/reqctx",
clause: "ADR-0002:S7: the request record is private to ingress, so no domain, runtime or operator package reads it; only the gateway and packages under ingress/aws may import ingress/aws/reqctx",
hint: "receive what you need through dispatch.Invocation instead of the request record",
subject: func(_ class, _ string) bool { return true },
forbids: func(_, t class, fromName string) bool {
if !t.requestRecord {
return false
}
return !strings.HasPrefix(fromName, "ingress/aws/")
return fromName != "gateway" && !strings.HasPrefix(fromName, "ingress/aws/")
},
},
{
id: "Interim_DaemonGatewayHandlersDirection",
clause: "during migration the existing daemon -> gateway -> handlers rule remains the guardrail (Q-20, Q-29)",
hint: "keep the dependency pointing daemon -> gateway -> handlers; invert it through a callback or capability",
subject: func(f class, _ string) bool { return f.isLegacy("handlers", "gateway") },
forbids: func(f, t class, _ string) bool {
switch {
case f.isLegacy("handlers"):
return t.isLegacy("gateway") || t.daemon
case f.isLegacy("gateway"):
return t.daemon
}
return false
},
},
}
+1 -1
View File
@@ -1,6 +1,6 @@
ARG VERSION=dev
FROM golang:1.27.3-bookworm@sha256:d9b6a1beb9474adc2a1ce842573639b44b2a0dd49b6669f51d3821baf9ec37c9 AS builder
FROM golang:1.27.3-bookworm@sha256:972b4110ba0f47539a975c31e52c64765e1ff19f2d74469ae4a595c6676e80a5 AS builder
ARG VERSION
@@ -33,10 +33,6 @@ load_mod failover || true
load_mod net_failover || true
load_mod virtio_net || true
# The guest's SQS store listens on 127.0.0.1 and the worker dials it there;
# the kernel leaves lo down, so bring it up before init starts either.
ip link set lo up
NETCFG=/sys/firmware/qemu_fw_cfg/by_name/opt/spinifex/netcfg/raw
i=0
while [ "$i" -lt 25 ]; do
@@ -91,8 +87,8 @@ for n in 0 1 2 3 4 5; do
[ -n "$rdst" ] && [ -n "$rvia" ] && ip route add "$rdst" via "$rvia" dev "$iface"
done
# The image takes no lb-agent environment. The worker reads the cluster CA
# from fw_cfg itself; this copy only serves other tools in the guest.
# The store image takes no lb-agent environment. A CA bundle is optional here:
# nothing in this image dials out yet.
CA_CERT=/sys/firmware/qemu_fw_cfg/by_name/opt/spinifex/ca-cert/raw
mkdir -p /etc/ssl/certs
[ -f "$CA_CERT" ] && cp "$CA_CERT" /etc/ssl/certs/ca-certificates.crt
+10
View File
@@ -0,0 +1,10 @@
# The power button loads here, not in /init: busybox init has its SIGUSR2
# (poweroff) handler installed before sysinit runs, so a press can never reach
# the pre-exec shell PID 1. A press before this line is lost; the host re-sends.
::sysinit:/sbin/modprobe tiny_power_button power_signal=12
::sysinit:/usr/local/lib/sqs-store/sqs-store-prepare
::respawn:/usr/local/lib/sqs-store/sqs-store-run
::ctrlaltdel:/sbin/reboot
# One action: the store stops and its disk unmounts first, and the generic
# umount -a -r runs only if the store is gone.
::shutdown:/usr/local/lib/sqs-store/sqs-store-stop
+224
View File
@@ -0,0 +1,224 @@
# sqs-store-lib.sh — shared by the SQS store guest's prepare, run and stop
# steps. Sourced, never executed. Every path and command is overridable so the
# host test can drive the same code against a fake fw_cfg, sysfs and tools.
#
# Guest contract, read from fw_cfg under opt/spinifex/sqs-store/:
# data-serial virtio-blk serial of the already-formatted ext4 data disk
# listen-addr IPv4 address on a configured guest NIC; the only listener
# client-nkey NATS user NKey public key allowed to connect; never a seed
# Anything absent, malformed or ambiguous refuses the store. Nothing formats.
SQS_FWCFG=${SQS_FWCFG:-/sys/firmware/qemu_fw_cfg/by_name/opt/spinifex/sqs-store}
SQS_SYSBLOCK=${SQS_SYSBLOCK:-/sys/block}
SQS_DEVDIR=${SQS_DEVDIR:-/dev}
# The host test cannot create block nodes, so it relaxes this to -e.
SQS_NODE_TEST=${SQS_NODE_TEST:--b}
SQS_RUN=${SQS_RUN:-/run/sqs-store}
SQS_DATA=${SQS_DATA:-/data}
SQS_CONSOLE=${SQS_CONSOLE:-/dev/console}
SQS_MOUNTS=${SQS_MOUNTS:-/proc/mounts}
SQS_PORT=${SQS_PORT:-4222}
SQS_STOP_BUDGET_TENTHS=${SQS_STOP_BUDGET_TENTHS:-120}
NATS_BIN=${NATS_BIN:-/usr/local/bin/nats-server}
E2FSCK=${E2FSCK:-e2fsck}
MOUNT=${MOUNT:-mount}
UMOUNT=${UMOUNT:-umount}
IP=${IP:-ip}
sqs_log() { echo "[sqs-store] $*" >> "$SQS_CONSOLE"; }
sqs_fatal() { sqs_log "FATAL: $*"; return 1; }
# sqs_fwcfg <name>: print one fw_cfg value, refusing a missing, empty, oversized
# or multi-token file. The value is not echoed on failure.
sqs_fwcfg() {
f="$SQS_FWCFG/$1/raw"
[ -f "$f" ] || { sqs_fatal "fw_cfg $1 not supplied"; return 1; }
[ "$(wc -c < "$f")" -le 128 ] || { sqs_fatal "fw_cfg $1 is oversized"; return 1; }
v=$(cat "$f")
case "$v" in
"" | *[[:space:]]*) sqs_fatal "fw_cfg $1 must be exactly one value"; return 1 ;;
esac
printf '%s' "$v"
}
# A volume serial is the volume ID with dashes stripped: vol + 17 hex.
sqs_valid_serial() {
[ "${#1}" -eq 20 ] || return 1
case "$1" in vol*) ;; *) return 1 ;; esac
case "${1#vol}" in *[!0-9a-f]*) return 1 ;; esac
}
# A NATS user public NKey: "U" then 55 base32 characters. The checksum is
# left to the pinned server's own config test.
sqs_valid_user_nkey() {
[ "${#1}" -eq 56 ] || return 1
case "$1" in U*) ;; *) return 1 ;; esac
case "$1" in *[!A-Z2-7]*) return 1 ;; esac
}
sqs_valid_ipv4() {
case "$1" in *[!0-9.]* | .* | *. | *..*) return 1 ;; esac
old_ifs=$IFS; IFS=.
# shellcheck disable=SC2086 # split on dots on purpose
set -- $1
IFS=$old_ifs
[ "$#" -eq 4 ] || return 1
for o in "$@"; do
[ "${#o}" -le 3 ] && [ "$o" -le 255 ] || return 1
case "$o" in 0?*) return 1 ;; esac
done
}
# sqs_find_disk <serial>: print the /dev node of the one block device whose
# sysfs serial matches exactly. Zero or several matches refuse.
sqs_find_disk() {
found="" n=0
for b in "$SQS_SYSBLOCK"/*; do
[ -r "$b/serial" ] || continue
[ "$(cat "$b/serial")" = "$1" ] || continue
found=${b##*/}
n=$((n + 1))
done
[ "$n" -eq 1 ] || { sqs_fatal "expected one disk with serial $1, found $n"; return 1; }
test "$SQS_NODE_TEST" "$SQS_DEVDIR/$found" || { sqs_fatal "disk $found has no block device node"; return 1; }
printf '%s' "$SQS_DEVDIR/$found"
}
# sqs_has_addr <ipv4>: the address is assigned to a non-loopback interface.
sqs_has_addr() {
"$IP" -o -4 addr show 2>/dev/null \
| awk -v a="$1" '$2 != "lo" { split($4, p, "/"); if (p[1] == a) f = 1 } END { exit !f }'
}
# sqs_alive <pid>: running or stopped, not exited (a zombie counts as exited).
sqs_alive() { [ -r "/proc/$1/status" ] && ! grep -q '^State:[[:space:]]*Z' "/proc/$1/status" 2>/dev/null; }
# sqs_starttime <pid>: kernel start time (stat field 22). It survives exec, and
# pairing it with the pid reduces PID-reuse risk; without a pidfd the check and
# the signal stay separate steps, so it is not an absolute guarantee.
sqs_starttime() { sed 's/.*) //' "/proc/$1/stat" 2>/dev/null | cut -d' ' -f20; }
# sqs_same <pid> <starttime>: the recorded process is still alive.
sqs_same() { [ -n "$2" ] && sqs_alive "$1" && [ "$(sqs_starttime "$1")" = "$2" ]; }
# sqs_write_config <addr> <nkey> <file>: the dedicated server's config. Its
# max_payload admits a 1 MiB SQS message with its headers (ADR-0011:S4); the
# 1MB NATS default does not.
sqs_write_config() {
cat > "$3" <<EOF
listen: "$1:$SQS_PORT"
server_name: "sqs-store"
max_payload: 2MiB
jetstream {
store_dir: "$SQS_DATA"
sync_interval: always
}
authorization {
users = [ { nkey: "$2" } ]
}
EOF
}
# sqs_prepare: validate the contract, check and mount the data disk, write the
# server config and mark the store ready. Returns non-zero, unready, on any refusal.
sqs_prepare() {
rm -f "$SQS_RUN/ready"
mkdir -p "$SQS_RUN"
serial=$(sqs_fwcfg data-serial) || return 1
sqs_valid_serial "$serial" || { sqs_fatal "data-serial is not a volume serial"; return 1; }
addr=$(sqs_fwcfg listen-addr) || return 1
sqs_valid_ipv4 "$addr" || { sqs_fatal "listen-addr is not an IPv4 address"; return 1; }
nkey=$(sqs_fwcfg client-nkey) || return 1
sqs_valid_user_nkey "$nkey" || { sqs_fatal "client-nkey is not a user public NKey"; return 1; }
sqs_has_addr "$addr" || { sqs_fatal "listen-addr $addr is not on any guest NIC"; return 1; }
# The server checks the config, including the NKey checksum, before the
# disk is touched.
umask 077
sqs_write_config "$addr" "$nkey" "$SQS_RUN/nats.conf" || return 1
if ! "$NATS_BIN" -t -c "$SQS_RUN/nats.conf" > "$SQS_RUN/config-test.out" 2>&1; then
sqs_fatal "server config test failed: $(sed 's/U[A-Z2-7]\{55\}/<nkey>/g' "$SQS_RUN/config-test.out" | tr '\n' ' ')"
return 1
fi
dev=$(sqs_find_disk "$serial") || return 1
rc=0
"$E2FSCK" -p "$dev" > "$SQS_RUN/fsck.out" 2>&1 || rc=$?
sqs_log "e2fsck -p $dev rc=$rc"
# 0 clean, 1 corrected: proceed. Anything higher, including a disk that is
# not ext4 at all, refuses; the guest never formats.
[ "$rc" -le 1 ] || { sqs_fatal "e2fsck refused $dev: $(tr '\n' ' ' < "$SQS_RUN/fsck.out")"; return 1; }
mkdir -p "$SQS_DATA"
"$MOUNT" -t ext4 -o nodev,nosuid,noexec "$dev" "$SQS_DATA" || { sqs_fatal "mount $dev failed"; return 1; }
: > "$SQS_RUN/ready"
sqs_log "prepared: $dev mounted at $SQS_DATA, config written for $addr:$SQS_PORT; server not started yet"
}
# sqs_run: the respawned supervisor body. Its identity is recorded before the
# stopping check: a stop that misses it set stopping first, so this parks; a
# stop that reads it signals this process whether or not it has exec'd yet.
sqs_run() {
mkdir -p "$SQS_RUN"
echo "$$ $(sqs_starttime $$)" > "$SQS_RUN/pid"
if [ -e "$SQS_RUN/stopping" ]; then
sqs_log "run: shutdown in progress, not starting"
return 2
fi
if [ ! -e "$SQS_RUN/ready" ]; then
sqs_log "run: store not prepared, not starting"
return 2
fi
sqs_log "run: starting pid=$$"
# ADR-0011:S11 bounds the store's Go heap; the guest must be larger.
GOMEMLIMIT=512MiB exec "$NATS_BIN" -c "$SQS_RUN/nats.conf"
}
# sqs_stop: first shutdown action. Block any respawn, SIGTERM the recorded run
# process (a server, or a start not yet exec'd), SIGKILL only after the budget,
# then sync and unmount. Each signal rechecks the identity; a survivor keeps the disk mounted.
sqs_stop() {
mkdir -p "$SQS_RUN"
: > "$SQS_RUN/stopping"
sqs_log "stop: begin"
rec=$(cat "$SQS_RUN/pid" 2>/dev/null || true)
pid=${rec%% *} st=${rec#* }
[ "$st" != "$rec" ] || st=""
if [ -n "$pid" ] && sqs_same "$pid" "$st"; then
kill -TERM "$pid" 2>/dev/null || true
sqs_log "stop: SIGTERM sent to run pid=$pid"
i=0
while sqs_same "$pid" "$st" && [ "$i" -lt "$SQS_STOP_BUDGET_TENTHS" ]; do sleep 0.1; i=$((i + 1)); done
if sqs_same "$pid" "$st"; then
sqs_log "stop: run pid=$pid still running after $((SQS_STOP_BUDGET_TENTHS / 10))s, SIGKILL"
kill -KILL "$pid" 2>/dev/null || true
j=0
while sqs_same "$pid" "$st" && [ "$j" -lt 30 ]; do sleep 0.1; j=$((j + 1)); done
fi
if sqs_same "$pid" "$st"; then
sqs_log "stop: run pid=$pid STILL ALIVE, leaving $SQS_DATA mounted"
return 1
fi
sqs_log "stop: store exited after $((i / 10)).$((i % 10))s"
else
sqs_log "stop: no running store"
fi
sync
if grep -q " $SQS_DATA " "$SQS_MOUNTS" 2>/dev/null; then
rc=0
"$UMOUNT" "$SQS_DATA" || rc=$?
sqs_log "stop: umount $SQS_DATA rc=$rc"
fi
}
# sqs_shutdown: the only shutdown action. The generic read-only remount runs
# only after the store stopped; a surviving writer keeps every mount as it is.
sqs_shutdown() {
if ! sqs_stop; then
sync
sqs_log "shutdown: store survived, skipping umount -a -r"
return 1
fi
rc=0
"$UMOUNT" -a -r || rc=$?
echo "[init] shutdown: umount -a -r rc=$rc" >> "$SQS_CONSOLE"
}
+9
View File
@@ -0,0 +1,9 @@
#!/bin/sh
# sysinit: load the disk drivers, then validate the guest contract, check and
# mount the data disk and write the server config. A refusal leaves the store
# unprepared, so the run step never starts it.
. /usr/local/lib/sqs-store/sqs-store-lib.sh
modprobe virtio_blk 2>/dev/null || true
modprobe ext4 2>/dev/null || true
sqs_prepare || sqs_log "store refused; it will not start"
exit 0
+6
View File
@@ -0,0 +1,6 @@
#!/bin/sh
# Respawned by init. A store that must not run parks here instead of exiting,
# so init does not respawn it in a tight loop.
. /usr/local/lib/sqs-store/sqs-store-lib.sh
sqs_run
exec sleep 2147483647
+5
View File
@@ -0,0 +1,5 @@
#!/bin/sh
# The only shutdown action: stop the store and unmount its disk, then run the
# generic read-only remount only if the store is gone.
. /usr/local/lib/sqs-store/sqs-store-lib.sh
sqs_shutdown
-1
View File
@@ -3,7 +3,6 @@
# the pre-exec shell PID 1. A press before this line is lost; the host re-sends.
::sysinit:/sbin/modprobe tiny_power_button power_signal=12
::sysinit:/usr/local/lib/sqs-worker/sqs-worker-prepare
::respawn:/usr/local/lib/sqs-worker/sqs-worker-store
::respawn:/usr/local/lib/sqs-worker/sqs-worker-ready
::ctrlaltdel:/sbin/reboot
::shutdown:/usr/local/lib/sqs-worker/sqs-worker-stop
+48 -136
View File
@@ -1,23 +1,19 @@
# sqs-worker-lib.sh — shared by the SQS guest's store, readiness and stop
# sqs-worker-lib.sh — shared by the SQS worker guest's readiness and stop
# steps. Sourced, never executed. Every path and command is overridable so the
# host test can drive the same code against test-owned processes and stub tools.
#
# The guest runs its own SQS nats-server on loopback and the worker beside it.
# The server starts only after boot settled the receipt key; store-ready exists
# only while the running watcher's last check of that server passed.
# store-ready exists only while the running watcher's last check passed. A
# check proves the store accepted the worker key, not the store server's
# identity, and a store failure is noticed up to one interval plus call time late.
SQSW_RUN=${SQSW_RUN:-/run/sqs-worker}
SQSW_DATA=${SQSW_DATA:-/data}
SQSW_CONSOLE=${SQSW_CONSOLE:-/dev/console}
SQSW_MOUNTS=${SQSW_MOUNTS:-/proc/mounts}
SQSW_BIN=${SQSW_BIN:-/usr/local/bin/sqs-worker}
SQSW_LIB=${SQSW_LIB:-/usr/local/lib/sqs-worker/sqs-worker-lib.sh}
SQSW_NATS_BIN=${SQSW_NATS_BIN:-/usr/local/bin/nats-server}
SQSW_STORE_PORT=${SQSW_STORE_PORT:-4222}
SQSW_STOP_BUDGET_TENTHS=${SQSW_STOP_BUDGET_TENTHS:-100}
SQSW_RESPAWN_GAP=${SQSW_RESPAWN_GAP:-5}
SQSW_PARK=${SQSW_PARK:-sleep 2147483647}
SQSW_BOOT_RETRY=${SQSW_BOOT_RETRY:-30}
SQSW_PARK_INTERVAL=${SQSW_PARK_INTERVAL:-30}
UMOUNT=${UMOUNT:-umount}
sqsw_log() { echo "[sqs-worker] $*" >> "$SQSW_CONSOLE"; }
@@ -32,102 +28,28 @@ sqsw_starttime() { sed 's/.*) //' "/proc/$1/stat" 2>/dev/null | cut -d' ' -f20;
# sqsw_same <pid> <starttime>: the recorded process is still alive.
sqsw_same() { [ -n "$2" ] && sqsw_alive "$1" && [ "$(sqsw_starttime "$1")" = "$2" ]; }
# sqsw_write_config <file>: the guest's own server. It listens only on
# loopback, with no monitor, route or cluster listener. Its max_payload admits
# a 1 MiB SQS message with its headers (ADR-0011:S4); the 1MB default does not.
sqsw_write_config() {
cat > "$1" <<CONF
listen: "127.0.0.1:$SQSW_STORE_PORT"
server_name: "sqs-store"
max_payload: 2MiB
jetstream {
store_dir: "$SQSW_DATA/jetstream"
sync_interval: always
}
CONF
}
# sqsw_prepare_store: write and test the server config once boot has settled
# the receipt key. Returns non-zero, leaving the store unstarted, otherwise.
sqsw_prepare_store() {
rm -f "$SQSW_RUN/store.conf"
[ -s "$SQSW_RUN/receipt-ready" ] || { sqsw_log "store: receipt key not settled, store not prepared"; return 1; }
umask 077
sqsw_write_config "$SQSW_RUN/store.conf.new" || return 1
if ! "$SQSW_NATS_BIN" -t -c "$SQSW_RUN/store.conf.new" > "$SQSW_RUN/config-test.out" 2>&1; then
sqsw_log "store: FATAL: server config test failed: $(tr '\n' ' ' < "$SQSW_RUN/config-test.out")"
return 1
fi
mv "$SQSW_RUN/store.conf.new" "$SQSW_RUN/store.conf"
sqsw_log "store: config written for 127.0.0.1:$SQSW_STORE_PORT; server not started yet"
}
# sqsw_park <why>: init respawns a step that exits, so a step that must not run
# logs once and waits for shutdown instead of looping.
sqsw_park() {
sqsw_log "$1"
exec $SQSW_PARK
}
# sqsw_record <name>: record this process's identity before the stopping
# check, so a stop that misses the record has already set stopping.
sqsw_record() { echo "$$ $(sqsw_starttime $$)" > "$SQSW_RUN/$1.pid"; }
# sqsw_boot: settle the disk and receipt key once per guest boot. A transient
# failure (exit 75) waits and exits so init runs it again; boot never remounts
# a disk already mounted. Any other failure is final for this guest boot.
sqsw_boot() {
[ ! -e "$SQSW_RUN/boot-refused" ] || sqsw_park "store: boot refused, not starting"
[ -s "$SQSW_RUN/receipt-ready" ] && return 0
# The boot child records itself before its stopping check, as the other
# steps do, so shutdown either sees its record or it never starts.
SQSW_RUN="$SQSW_RUN" SQSW_BIN="$SQSW_BIN" sh -c '. "$1"; sqsw_record boot; [ ! -e "$SQSW_RUN/stopping" ] || exit 75; exec "$SQSW_BIN" boot' \
sh "$SQSW_LIB" >> "$SQSW_CONSOLE" 2>&1 &
child=$!
rc=0
wait "$child" || rc=$?
rm -f "$SQSW_RUN/boot.pid"
[ ! -e "$SQSW_RUN/stopping" ] || sqsw_park "store: shutdown during boot, not starting"
case "$rc" in
0) return 0 ;;
75) sqsw_log "store: boot not settled, retrying in ${SQSW_BOOT_RETRY}s"
sleep "$SQSW_BOOT_RETRY"
exit 1 ;;
*) : > "$SQSW_RUN/boot-refused"
sqsw_park "store: boot refused (rc=$rc), not starting" ;;
esac
}
# sqsw_store: the respawned server body. It starts only on a prepared config.
sqsw_store() {
mkdir -p "$SQSW_RUN"
sqsw_record store
[ ! -e "$SQSW_RUN/stopping" ] || sqsw_park "store: shutdown in progress, not starting"
sqsw_boot
if [ ! -s "$SQSW_RUN/store.conf" ] && ! sqsw_prepare_store; then
: > "$SQSW_RUN/boot-refused"
sqsw_park "store: not prepared, not starting"
fi
sqsw_log "store: starting pid=$$"
# ADR-0011:S11 bounds the store's Go heap; the guest must be larger.
GOMEMLIMIT=512MiB exec "$SQSW_NATS_BIN" -c "$SQSW_RUN/store.conf"
}
# sqsw_run: the respawned readiness body. A predecessor that was killed or
# crashed could not remove its marker, so every start removes it first.
# crashed could not remove its marker, so every start removes it first. Its
# identity is recorded before the stopping check, as the store's run step does.
sqsw_run() {
mkdir -p "$SQSW_RUN"
sqsw_record ready
echo "$$ $(sqsw_starttime $$)" > "$SQSW_RUN/ready.pid"
if [ -e "$SQSW_RUN/store-ready" ]; then
rm -f "$SQSW_RUN/store-ready"
sqsw_log "ready: removed a store-ready marker left by an earlier watcher"
fi
[ ! -e "$SQSW_RUN/stopping" ] || sqsw_park "ready: shutdown in progress, not starting"
[ ! -e "$SQSW_RUN/boot-refused" ] || sqsw_park "ready: boot refused, readiness not started"
# Boot may still be retrying; check again after a gap rather than parking.
if [ ! -s "$SQSW_RUN/store.conf" ]; then
sleep "$SQSW_RESPAWN_GAP"
exit 0
# Returning here would make init respawn and log at once, forever. Park
# instead, logging once: shutdown stops this recorded process, and a key
# that appears later lets the watcher start.
if [ -e "$SQSW_RUN/stopping" ] || [ ! -s "$SQSW_RUN/public-key" ]; then
if [ -e "$SQSW_RUN/stopping" ]; then
sqsw_log "ready: shutdown in progress, not starting"
else
sqsw_log "ready: no worker key; readiness not started, waiting"
fi
while [ -e "$SQSW_RUN/stopping" ] || [ ! -s "$SQSW_RUN/public-key" ]; do
sleep "$SQSW_PARK_INTERVAL"
done
fi
# Init respawns at once; a watcher that keeps exiting restarts at most
# once per gap instead of in a tight loop.
@@ -140,47 +62,37 @@ sqsw_run() {
exec "$SQSW_BIN" ready >> "$SQSW_CONSOLE" 2>&1
}
# sqsw_end <name>: SIGTERM the recorded process, SIGKILL only after the
# budget. Returns non-zero while it is still alive.
sqsw_end() {
rec=$(cat "$SQSW_RUN/$1.pid" 2>/dev/null || true)
pid=${rec%% *} st=${rec#* }
[ "$st" != "$rec" ] || st=""
if [ -z "$pid" ] || ! sqsw_same "$pid" "$st"; then
sqsw_log "stop: no running $1"
return 0
fi
kill -TERM "$pid" 2>/dev/null || true
sqsw_log "stop: SIGTERM sent to $1 pid=$pid"
i=0
while sqsw_same "$pid" "$st" && [ "$i" -lt "$SQSW_STOP_BUDGET_TENTHS" ]; do sleep 0.1; i=$((i + 1)); done
if sqsw_same "$pid" "$st"; then
sqsw_log "stop: $1 pid=$pid still running after $((SQSW_STOP_BUDGET_TENTHS / 10))s, SIGKILL"
kill -KILL "$pid" 2>/dev/null || true
j=0
while sqsw_same "$pid" "$st" && [ "$j" -lt 30 ]; do sleep 0.1; j=$((j + 1)); done
fi
if sqsw_same "$pid" "$st"; then
sqsw_log "stop: $1 pid=$pid STILL ALIVE"
return 1
fi
sqsw_log "stop: $1 pid=$pid exited"
rm -f "$SQSW_RUN/$1.pid"
}
# sqsw_stop: block any respawn, drop the marker, stop the watcher, the server
# and any boot in progress, and unmount the disk only once all are confirmed
# gone. A survivor keeps the disk mounted.
# sqsw_stop: block any respawn, drop the marker, SIGTERM the recorded watcher,
# SIGKILL only after the budget, and unmount the worker disk only once the
# watcher is confirmed gone. A survivor keeps the disk mounted.
sqsw_stop() {
mkdir -p "$SQSW_RUN"
: > "$SQSW_RUN/stopping"
rm -f "$SQSW_RUN/store-ready"
if ! sqsw_end ready || ! sqsw_end store || ! sqsw_end boot; then
rm -f "$SQSW_RUN/store-ready"
sqsw_log "stop: leaving $SQSW_DATA mounted"
return 1
rec=$(cat "$SQSW_RUN/ready.pid" 2>/dev/null || true)
pid=${rec%% *} st=${rec#* }
[ "$st" != "$rec" ] || st=""
if [ -n "$pid" ] && sqsw_same "$pid" "$st"; then
kill -TERM "$pid" 2>/dev/null || true
sqsw_log "stop: SIGTERM sent to readiness watcher pid=$pid"
i=0
while sqsw_same "$pid" "$st" && [ "$i" -lt "$SQSW_STOP_BUDGET_TENTHS" ]; do sleep 0.1; i=$((i + 1)); done
if sqsw_same "$pid" "$st"; then
sqsw_log "stop: readiness watcher pid=$pid still running after $((SQSW_STOP_BUDGET_TENTHS / 10))s, SIGKILL"
kill -KILL "$pid" 2>/dev/null || true
j=0
while sqsw_same "$pid" "$st" && [ "$j" -lt 30 ]; do sleep 0.1; j=$((j + 1)); done
fi
if sqsw_same "$pid" "$st"; then
rm -f "$SQSW_RUN/store-ready"
sqsw_log "stop: readiness watcher pid=$pid STILL ALIVE, leaving $SQSW_DATA mounted"
return 1
fi
sqsw_log "stop: readiness watcher pid=$pid exited"
else
sqsw_log "stop: no running readiness watcher"
fi
rm -f "$SQSW_RUN/store-ready"
rm -f "$SQSW_RUN/store-ready" "$SQSW_RUN/ready.pid"
sync
if grep -q " $SQSW_DATA " "$SQSW_MOUNTS" 2>/dev/null; then
rc=0
@@ -190,11 +102,11 @@ sqsw_stop() {
}
# sqsw_shutdown: the only shutdown action. The generic read-only remount runs
# only after both processes stopped; a survivor keeps every mount as it is.
# only after the watcher stopped; a survivor keeps every mount as it is.
sqsw_shutdown() {
if ! sqsw_stop; then
sync
sqsw_log "shutdown: a guest process survived, skipping umount -a -r"
sqsw_log "shutdown: readiness watcher survived, skipping umount -a -r"
return 1
fi
rc=0
+4 -5
View File
@@ -1,9 +1,8 @@
#!/bin/sh
# sysinit: load the disk drivers and clear this boot's markers. The respawned
# store step mounts the disk, settles the receipt key and starts the store.
. /usr/local/lib/sqs-worker/sqs-worker-lib.sh
# sysinit: load the disk drivers, then select, check and mount the worker disk
# and load or create the worker's store key on it. Any refusal is logged; the
# key is never read or created on a disk that is not proven mounted.
modprobe virtio_blk 2>/dev/null || true
modprobe ext4 2>/dev/null || true
mkdir -p "$SQSW_RUN"
rm -f "$SQSW_RUN/receipt-ready" "$SQSW_RUN/store.conf" "$SQSW_RUN/boot-refused"
/usr/local/bin/sqs-worker boot >> /dev/console 2>&1 || echo "[sqs-worker] boot refused" >> /dev/console
exit 0
+3 -2
View File
@@ -1,5 +1,6 @@
#!/bin/sh
# Respawned by init: the worker, reporting ready only while the local store
# answers. A watcher that must not run parks instead of exiting.
# Respawned by init. A watcher that must not run parks here instead of exiting,
# so init does not respawn it in a tight loop.
. /usr/local/lib/sqs-worker/sqs-worker-lib.sh
sqsw_run
exec sleep 2147483647
+2 -2
View File
@@ -1,5 +1,5 @@
#!/bin/sh
# The only shutdown action: stop the worker, then the store, and unmount the
# data disk, then run the generic read-only remount only if both are gone.
# The only shutdown action: stop the readiness watcher and unmount the worker
# disk, then run the generic read-only remount only if the watcher is gone.
. /usr/local/lib/sqs-worker/sqs-worker-lib.sh
sqsw_shutdown
@@ -1,5 +0,0 @@
#!/bin/sh
# Respawned by init: the guest's own SQS nats-server, started only on a
# prepared config. A store that must not run parks instead of exiting.
. /usr/local/lib/sqs-worker/sqs-worker-lib.sh
sqsw_store
+2 -3
View File
@@ -17,8 +17,8 @@ import (
iamawsapi "github.com/mulgadc/spinifex/spinifex/domains/iam/awsapi"
rdsawsapi "github.com/mulgadc/spinifex/spinifex/domains/rds/awsapi"
route53awsapi "github.com/mulgadc/spinifex/spinifex/domains/route53/awsapi"
sqsawsapi "github.com/mulgadc/spinifex/spinifex/domains/sqs/awsapi"
stsawsapi "github.com/mulgadc/spinifex/spinifex/domains/sts/awsapi"
"github.com/mulgadc/spinifex/spinifex/gateway"
"github.com/mulgadc/spinifex/spinifex/ingress/aws/dispatch"
_ "github.com/mulgadc/bluebottle/pkg/fipsboot"
@@ -57,7 +57,7 @@ func fail(err error) {
func compareAll() ([]awsmodel.OperationCoverage, error) {
// Registered services contribute their declared inventory, which needs no
// live dependencies; nothing here serves a request.
inventories := dispatch.AWSOperationInventory(map[string]dispatch.Inventory{
inventories := gateway.AWSOperationInventory(map[string]dispatch.Inventory{
awsapi.ServiceName: awsapi.OperationInventory(),
acmawsapi.ServiceName: acmawsapi.OperationInventory(),
route53awsapi.ServiceName: route53awsapi.OperationInventory(),
@@ -67,7 +67,6 @@ func compareAll() ([]awsmodel.OperationCoverage, error) {
ecsawsapi.ServiceName: ecsawsapi.OperationInventory(),
eksawsapi.ServiceName: eksawsapi.OperationInventory(),
iamawsapi.ServiceName: iamawsapi.OperationInventory(),
sqsawsapi.ServiceName: sqsawsapi.OperationInventory(),
stsawsapi.ServiceName: stsawsapi.OperationInventory(),
})
// The loop below walks the embedded models, so a served service with no
+1 -2
View File
@@ -6,7 +6,6 @@
//
// eks-gateway-fetch -resource addons # GET /clusters/{cluster}/internal-addons/{accountId}
// eks-gateway-fetch -resource recovery -instance-id i-… # GET /clusters/{cluster}/internal-recovery/{accountId}/{instanceId}
// eks-gateway-fetch -resource workers # GET /clusters/{cluster}/internal-workers/{accountId}
//
// Flags default to environment variables seeded by cloud-init:
// EKS_GATEWAY_URL, EKS_GATEWAY_CA, EKS_ACCESS_KEY, EKS_SECRET_KEY, EKS_REGION,
@@ -34,7 +33,7 @@ func main() {
flag.StringVar(&cfg.AccountID, "account-id", os.Getenv("EKS_ACCOUNT_ID"), "Cluster account ID")
flag.StringVar(&cfg.ClusterName, "cluster", os.Getenv("EKS_CLUSTER_NAME"), "Cluster name")
flag.StringVar(&cfg.InstanceID, "instance-id", os.Getenv("EKS_INSTANCE_ID"), "This member's EC2 instance ID (recovery resource)")
flag.StringVar(&cfg.Resource, "resource", "addons", "Resource to fetch: addons | recovery | workers")
flag.StringVar(&cfg.Resource, "resource", "addons", "Resource to fetch: addons | recovery")
flag.Parse()
if err := gatewayfetch.Run(context.Background(), cfg); err != nil {
-32
View File
@@ -1,32 +0,0 @@
// Command eks-pod-identity-webhook is the IRSA mutating admission webhook run
// on every EKS control-plane server; the implementation is in
// agents/eks/podidentity. It listens on loopback only and is called by the
// local kube-apiserver.
package main
import (
"context"
"log/slog"
"os"
"os/signal"
"syscall"
_ "github.com/mulgadc/bluebottle/pkg/fipsboot"
"github.com/mulgadc/spinifex/spinifex/agents/eks/podidentity"
)
func main() {
log := slog.New(slog.NewTextHandler(os.Stderr, nil))
cfg, err := podidentity.ParseServiceConfig(os.Args[1:], os.Getenv, os.Stderr)
if err != nil {
log.Error("invalid configuration", "error", err)
os.Exit(2)
}
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
defer stop()
if err := podidentity.Run(ctx, cfg, log); err != nil {
log.Error("pod identity webhook stopped", "error", err)
os.Exit(1)
}
}
+24 -44
View File
@@ -1,22 +1,17 @@
// sqs-worker runs inside the SQS microVM, next to its own SQS NATS server.
// sqs-worker runs inside the shared SQS worker microVM.
//
// Usage:
//
// sqs-worker boot
// sqs-worker ready
//
// boot selects and mounts the guest's data disk named by fw_cfg, asks the
// gateway for its receipt key decision with its instance-role credentials,
// then loads the recorded key, or creates one on a new volume and records its
// fingerprint, before the guest's SQS store first starts on that disk. ready
// then keeps checking the guest's own loopback JetStream, keeping a readiness
// marker only while it answers, and serves SQS owner calls against it, pulling
// them from the gateway with the same credentials.
//
// Serving is refused unless the gateway reports a single-node host cluster.
// A disk whose receipt key is missing or does not match the recorded one
// refuses to serve; recovering it needs a procedure not yet designed, and
// nothing deletes or replaces the volume.
// boot selects and mounts the worker's data disk named by fw_cfg, asks the
// gateway for its current first-store decision with its instance-role
// credentials, then loads or creates the store key on that disk and
// publishes only the public key. ready then keeps checking the bound store's
// JetStream with that key until it is stopped, keeping a readiness marker only
// while the check passes; it proves the store accepted the worker key, not
// the store server's identity.
package main
import (
@@ -28,14 +23,10 @@ import (
"syscall"
"time"
"github.com/nats-io/nats.go/jetstream"
"github.com/mulgadc/spinifex/internal/gwsign"
"github.com/mulgadc/spinifex/internal/sqsworkergw"
"github.com/mulgadc/spinifex/spinifex/agents/sqs/workerboot"
"github.com/mulgadc/spinifex/spinifex/agents/sqs/workerkey"
"github.com/mulgadc/spinifex/spinifex/domains/sqs/workerserve"
"github.com/mulgadc/spinifex/spinifex/foundation/state/clustersize"
)
// The cluster CA the daemon supplies; the only root the client trusts.
@@ -45,11 +36,7 @@ const caPath = "/sys/firmware/qemu_fw_cfg/by_name/opt/spinifex/ca-cert/raw"
// no static-key fallback.
func newClaimer() workerboot.NewClaimer {
return func(region, gatewayURL string) (workerkey.Claimer, error) {
c, err := newClient(region, gatewayURL)
if err != nil {
return nil, err
}
return workerboot.GatewayClaimer{Client: c}, nil
return newClient(region, gatewayURL)
}
}
@@ -61,6 +48,13 @@ func newClient(region, gatewayURL string) (*sqsworkergw.Client, error) {
return sqsworkergw.New(gatewayURL, region, ca, gwsign.NewInstanceRole())
}
type describer struct{ c *sqsworkergw.Client }
func (d describer) DescribeStore(ctx context.Context) (workerboot.Store, error) {
st, err := d.c.DescribeStore(ctx)
return workerboot.Store(st), err
}
func main() {
if len(os.Args) != 2 || (os.Args[1] != "boot" && os.Args[1] != "ready") {
fmt.Fprintln(os.Stderr, "usage: sqs-worker boot|ready")
@@ -72,18 +66,11 @@ func main() {
run = ready
}
if err := run(logf); err != nil {
if os.Args[1] == "boot" && workerboot.Transient(err) {
logf("boot not settled, will retry: " + err.Error())
os.Exit(exitTransient)
}
logf("FATAL: " + err.Error())
os.Exit(1)
}
}
// exitTransient tells the guest's supervisor that boot may be run again.
const exitTransient = 75
func config(logf func(string)) workerboot.Config {
return workerboot.Config{
FwCfgDir: "/sys/firmware/qemu_fw_cfg/by_name/opt/spinifex/sqs-worker",
@@ -112,26 +99,19 @@ func boot(logf func(string)) error {
func ready(logf func(string)) error {
ctx, cancel := signal.NotifyContext(context.Background(), syscall.SIGTERM, syscall.SIGINT)
defer cancel()
// The store is one guest, so its streams and buckets are R1.
if err := clustersize.Declare(1); err != nil {
return err
}
err := workerboot.Watch(ctx, workerboot.ReadyConfig{
Config: config(logf),
Config: config(logf),
NewDescriber: func(region, gatewayURL string) (workerboot.StoreDescriber, error) {
c, err := newClient(region, gatewayURL)
if err != nil {
return nil, err
}
return describer{c}, nil
},
Checker: workerboot.NATSChecker{},
CallTimeout: 30 * time.Second,
Refresh: 30 * time.Second,
Refused: 5 * time.Minute,
Serve: &workerboot.ServeConfig{
Connector: workerboot.NATSConnector{},
Run: func(ctx context.Context, region, gatewayURL string, js jetstream.JetStream, receiptKey []byte) error {
c, err := newClient(region, gatewayURL)
if err != nil {
return err
}
return workerserve.Run(ctx, c, js, receiptKey, logf)
},
},
})
if errors.Is(err, context.Canceled) {
return nil
+3 -13
View File
@@ -10,7 +10,7 @@ quota state.
a request/reply route on which a gateway, in the `awsgw-quota-seal` queue
group, irreversibly seals one account's quota override record.
`domains/admission/quota` owns it: `awsgw` serves it.
`operator/accountlifecycle` is the consumer: it calls this subject to fence every
`accountteardown` is the consumer: it calls this subject to fence every
override writer at the quota owner before the account record is removed.
The request names the account in its body (`account_id`) and may carry an
@@ -31,20 +31,10 @@ Any current gateway may answer; the leader-accepted revision is the evidence
whichever one does, and the override bucket is opened at seal time even on a
gateway whose quotas were disabled at startup.
## Seal-quota-override capability
`AccountQuotaOverrideSealCapabilitySubject(node)` (`admission.v1.AccountQuotaOverrideSealCapability.<node>`) is a per-node, read-only request/reply route on which `operator/accountlifecycle` asks one configured gateway whether it supports the quota override seal, immediately before every seal attempt.
The quota owner (`domains/admission/quota`) owns it: `awsgw` serves it with no queue group, so a request addressed to one node reaches only subscribers configured as that node, and two gateways sharing a node ID both answer.
The consumer's quota seal reaper probes every configured node that runs `awsgw` and sends no seal request unless every one answers compatible.
`AccountQuotaOverrideSealCapabilityVersion` is a claim about the whole binary's writers, not about the responder: version 1 means every quota override writer in the binary is revision-guarded and refuses a sealed record, and the binary serves the seal subject.
A gateway binary without this responder has no subscriber, so the probe for that node reads as incomplete (`no-reply`), the same as a stopped or partitioned node.
The reply proves nothing beyond the moment it was sent: a gateway downgraded or replaced after a successful probe is outside the guarantee.
### Compatibility boundary
The subject name carries the contract major; `account_quota_seal_test.go` and `account_quota_seal_capability_test.go`
preserve the version-1 JSON shapes and the subject builder.
The subject name carries the contract major; `account_quota_seal_test.go`
preserves the version-1 JSON shape.
Producer and consumer are in the coordinated Spinifex release, so the window
is N and N-1.
A gateway that predates the subject has no subscription, so the request gets
@@ -1,39 +0,0 @@
package admissionv1
import "strings"
// AccountQuotaOverrideSealCapabilityVersion is the version this binary reports.
// Version 1: every quota override writer in the binary is revision-guarded and
// refuses a sealed record, and the seal subject is served.
const AccountQuotaOverrideSealCapabilityVersion = 1
// AccountQuotaOverrideSealCapabilitySubject is one gateway's read-only
// seal-capability route, addressed per node so a probe reaches exactly the
// node it asks about.
func AccountQuotaOverrideSealCapabilitySubject(node string) string {
return "admission.v1.AccountQuotaOverrideSealCapability." + node
}
// ValidSealCapabilityNode reports whether node is exactly one NATS subject
// token. Responder and probe both check it before building a subject, so
// neither can address a wildcard or another node's route.
func ValidSealCapabilityNode(node string) bool {
return node != "" && !strings.ContainsAny(node, ".*>\t\n\r ")
}
// AccountQuotaOverrideSealCapabilityRequest asks node whether it supports the
// quota override seal. CorrelationRef is an opaque caller reference echoed for
// logs; it grants nothing and is never a fence or authority to write.
type AccountQuotaOverrideSealCapabilityRequest struct {
Node string `json:"node"`
CorrelationRef string `json:"correlation_ref,omitempty"`
}
// AccountQuotaOverrideSealCapabilityReply reports the responding node's
// capability version. It carries no account data and proves nothing beyond
// the moment it was sent.
type AccountQuotaOverrideSealCapabilityReply struct {
Node string `json:"node"`
CapabilityVersion int `json:"capability_version"`
CorrelationRef string `json:"correlation_ref,omitempty"`
}
@@ -1,43 +0,0 @@
package admissionv1
import (
"encoding/json"
"testing"
"github.com/stretchr/testify/require"
)
func TestAccountQuotaOverrideSealCapabilitySubject(t *testing.T) {
require.Equal(t, "admission.v1.AccountQuotaOverrideSealCapability.node-1", AccountQuotaOverrideSealCapabilitySubject("node-1"))
require.Equal(t, "admission.v1.AccountQuotaOverrideSealCapability.node-2", AccountQuotaOverrideSealCapabilitySubject("node-2"))
}
func TestAccountQuotaOverrideSealCapabilityRequestJSONContract(t *testing.T) {
data, err := json.Marshal(AccountQuotaOverrideSealCapabilityRequest{Node: "node-1", CorrelationRef: "req-1"})
require.NoError(t, err)
require.JSONEq(t, `{"node":"node-1","correlation_ref":"req-1"}`, string(data))
data, err = json.Marshal(AccountQuotaOverrideSealCapabilityRequest{Node: "node-1"})
require.NoError(t, err)
require.JSONEq(t, `{"node":"node-1"}`, string(data))
}
func TestAccountQuotaOverrideSealCapabilityReplyJSONContract(t *testing.T) {
reply := AccountQuotaOverrideSealCapabilityReply{Node: "node-1", CapabilityVersion: 1, CorrelationRef: "req-1"}
data, err := json.Marshal(reply)
require.NoError(t, err)
require.JSONEq(t, `{"node":"node-1","capability_version":1,"correlation_ref":"req-1"}`, string(data))
data, err = json.Marshal(AccountQuotaOverrideSealCapabilityReply{Node: "node-1", CapabilityVersion: 1})
require.NoError(t, err)
require.JSONEq(t, `{"node":"node-1","capability_version":1}`, string(data))
}
func TestValidSealCapabilityNode(t *testing.T) {
for _, node := range []string{"node-1", "node_2", "n3"} {
require.True(t, ValidSealCapabilityNode(node), node)
}
for _, node := range []string{"", "a.b", "*", ">", "node-*", "node >", "node\t1", "node\n1"} {
require.False(t, ValidSealCapabilityNode(node), "%q", node)
}
}
+6 -20
View File
@@ -155,7 +155,7 @@ Operational commands for inspecting cluster state. These fan out NATS requests t
| `spx admin account list --remote` | `--endpoint` (default: this node's AWS gateway), `--region` (default: this node's region), `--ca-bundle` (default: this node's CA) | Same listing read through `POST /admin/ListAccounts` over SigV4 instead of NATS, so an off-cluster operator or the load-test harness sees its tenants without SSH access to a node. Credentials come from the standard AWS chain; the caller must be an IAM user in the super-admin account holding `spinifex:ListAccounts`. |
| `spx admin account quota get <account-id>` | `--endpoint` (default: this node's AWS gateway), `--region` (default: this node's region), `--ca-bundle` (default: this node's CA) | Reads the limits in force for one account through `POST /admin/GetAccountQuota` over SigV4, and reports for each dimension whether it came from the `[quota]` block in `awsgw.toml` (`config`) or from an override stored against the account (`override`). Credentials come from the standard AWS chain; the caller must be an IAM user in the super-admin account holding `spinifex:GetAccountQuota`. |
| `spx admin account quota set <account-id>` | `--vcpus`, `--vpcs`, `--subnets`, `--eips`, `--volumes`, `--volumes-gib`, `--rds-instances`, `--load-balancers`, `--clear`, plus `--endpoint`, `--region`, `--ca-bundle` | Overrides individual limits for one account through `POST /admin/PutAccountQuota`, leaving every dimension not named inheriting the configured baseline. `-1` uncaps a dimension; `0` is a real limit that denies every request on it. `--clear` removes the whole override record and cannot be combined with a limit flag. The account must already exist. Prints the resulting limits, so a caller never has to guess what it applied. The caller must hold `spinifex:PutAccountQuota`. |
| `spx admin account delete <account-id>` | `--dry-run`, `--force`, `--yes` (requires `--name`), `--name` (account name, must match the stored record) | Prints the inventory it is about to remove → confirms the account name (prompted unless `--name` is given) → marks the account `TERMINATING`, which blocks its credentials cluster-wide → deletes its resources in dependency order (compute, attachments, storage, network, platform, identity, account), waiting for each stage to actually drain before starting the next → seals the account's quota override record so no later write can recreate it, which needs every gateway node the cluster configures to answer a read-only probe first (otherwise nothing is sealed and the account stays `TERMINATING`) → releases the name reservation and quota counter → deletes the account record. Anything that will not delete is reported as **stuck** with the reason, and the account stays `TERMINATING` so the residue keeps an owner. `000000000000` and `000000000001` are refused. `--dry-run` lists and changes nothing. The compute stage reaps ECS clusters, EKS nodegroups and clusters, and RDS instances before it reaps plain instances, because their backing VMs belong to the system account and a tenant-scoped instance listing cannot see them. The storage stage reaps the account's S3 buckets: uploads in flight are aborted, then the objects are removed a page at a time, then the bucket. Only buckets the account owns are ever listed, so the shared platform buckets are out of reach rather than filtered out. Platform-managed serving endpoints are reaped ahead of instances, because only their own delete releases the ENI and attached data volume alongside the VM, and spot instance requests are cancelled first so a live request cannot launch a replacement for something already terminated. The network stage reaps load balancers, then target groups, then NAT gateways and egress-only internet gateways before the subnets, security groups and addresses they hold. The platform stage reaps ACM certificates. |
| `spx admin account delete <account-id>` | `--dry-run`, `--force`, `--yes` (requires `--name`), `--name` (account name, must match the stored record) | Prints the inventory it is about to remove → confirms the account name (prompted unless `--name` is given) → marks the account `TERMINATING`, which blocks its credentials cluster-wide → deletes its resources in dependency order (compute, attachments, storage, network, platform, identity, account), waiting for each stage to actually drain before starting the next → releases the name reservation and quota counter → deletes the account record. Anything that will not delete is reported as **stuck** with the reason, and the account stays `TERMINATING` so the residue keeps an owner. `000000000000` and `000000000001` are refused. `--dry-run` lists and changes nothing. The compute stage reaps ECS clusters, EKS nodegroups and clusters, and RDS instances before it reaps plain instances, because their backing VMs belong to the system account and a tenant-scoped instance listing cannot see them. The storage stage reaps the account's S3 buckets: uploads in flight are aborted, then the objects are removed a page at a time, then the bucket. Only buckets the account owns are ever listed, so the shared platform buckets are out of reach rather than filtered out. Platform-managed serving endpoints are reaped ahead of instances, because only their own delete releases the ENI and attached data volume alongside the VM, and spot instance requests are cancelled first so a live request cannot launch a replacement for something already terminated. The network stage reaps load balancers, then target groups, then NAT gateways and egress-only internet gateways before the subnets, security groups and addresses they hold. The platform stage reaps ACM certificates. |
| `spx admin account delete --force` | as above | Escalates only for resources the ordinary API refuses: it clears a volume attachment in the control plane without the guest's cooperation (`ec2.ForceDetachVolume`), hard-destroys after a graceful attempt times out, and treats an already-missing resource as deleted. It never reorders the stages and never widens scope beyond the account. Use it once the guest is being destroyed — this is the escape from a volume stuck attached to an instance that will not terminate, where neither can otherwise be deleted. |
| `spx admin account delete --remote` | as above, plus `--endpoint`, `--region`, `--ca-bundle`, `--client-token` | Same teardown driven through `POST /admin/DeleteAccount` over SigV4 instead of NATS, then follows `DescribeAccountDeletion` to completion, printing each stage as it lands. Credentials come from the standard AWS chain; the caller must be an IAM user in the super-admin account holding `spinifex:DeleteAccount` and `spinifex:DescribeAccountDeletion`. |
| `spx admin principal create <name>` | `--grant` (repeatable or comma-separated; default: every admin method — `CreateAccount`, `DeleteAccount`, `DescribeAccountDeletion`, `GetAccountQuota`, `ListAccounts`, `PutAccountQuota`) | Creates an IAM user in the super-admin account with an inline `spx-admin-methods` policy allowing the requested methods, revokes any access key left from a previous run, and mints one new key. Each method is named rather than granted as `spinifex:*`, so a later addition to the admin surface is not authorised by an existing key. The principal's grants are exactly that one policy: re-running with fewer `--grant` values narrows it, and any other inline policy on the user is removed, so a rename cannot leave a wider grant behind. The secret is printed once; store it in a secret manager or an AWS profile, never in a config file. `admin` is refused as a name — it is the account's own administrator and rotating its key would revoke the bootstrap credential. |
@@ -194,32 +194,18 @@ Operational commands for inspecting cluster state. These fan out NATS requests t
### SQS System Instances
These commands enable SQS on a single-node cluster and start its one system VM.
A bound VM is not a ready service: `spx admin sqs status` reports readiness.
These commands start the first SQS system instances on one node. They do not serve the SQS API, and a bound VM is not a ready service.
The SQS VM runs two processes on one encrypted data volume: the Region's dedicated SQS NATS server, which listens only on the guest's loopback address and is separate from platform NATS, and the worker, which serves SQS owner calls from that local store. The worker starts serving only after the local store answers and the receipt-signing key on the volume matches the fingerprint the platform recorded.
SQS supports only a fresh single-node install. A worker record without a supported launch contract, or any record of the separate SQS store VM from an earlier dev build, cannot be upgraded: `enable` refuses it, and any existing volume is kept, not inspected, emptied or replaced. An existing separate store VM is refused on relaunch with its volume kept, and an existing worker VM is not restarted into a supported mode; upgrading such an install is unsupported.
**Do not put tenant production data in SQS.** SQS-managed per-message encryption is not implemented: `GetQueueAttributes` reports `SqsManagedSseEnabled=false`, and a request to enable SSE-SQS or SSE-KMS is rejected.
The SQS data volume must be encrypted, which is a separate layer under every queue and not SSE.
There is no `DeleteQueue` or `PurgeQueue`, so a queue and its unexpired messages cannot be removed once created.
Retention is checked when a message is received, against the queue's retention at that moment, and nothing deletes an expired message: the store drops a body only at its age backstop of 14 days and 1 hour.
So a message past a short retention can be delivered again if `MessageRetentionPeriod` is later raised while its body is still stored.
Account termination does not yet remove SQS queues or messages, and there is no disable: the VM's volume, receipt key and data are kept.
Each sends a signed `spinifex` admin action (`EnableSQS`, `DescribeSQS`, `EnsureSQSWorker`) to the AWS gateway using the standard AWS credential chain, for example `AWS_PROFILE=spinifex`. The caller must be in the super-admin account (`000000000001`) and hold `spinifex:<Action>`. The VM, volume, private VPC and ENI belong to the system account (`000000000000`). `--endpoint`, `--region` and `--ca-bundle` default to this node's gateway, region and `config/ca.pem`; off a node, `--endpoint` and `--region` are required. `ensure-worker` and `enable` wait up to 11 minutes, and `status` up to 1 minute.
Both send a signed `spinifex` admin action (`EnsureSQSWorker`, `EnsureSQSStore`) to the AWS gateway using the standard AWS credential chain, for example `AWS_PROFILE=spinifex`. The caller must be in the super-admin account (`000000000001`) and hold `spinifex:<Action>`. The VMs, volumes, private VPC and ENIs belong to the system account (`000000000000`). `--endpoint`, `--region` and `--ca-bundle` default to this node's gateway, region and `config/ca.pem`; off a node, `--endpoint` and `--region` are required. Each call waits up to 11 minutes.
| Command | Flags | Description |
|---------|-------|-------------|
| `spx admin sqs enable` | `--node` (required), `--instance-type` (required, more than 512 MiB memory), `--volume-size-gib` (required, 1–16384), `--endpoint`, `--region`, `--ca-bundle` | Records SQS as enabled for the Region, pinned to `--node`, then launches or reports the SQS VM there. The desired state is recorded first, so rerunning the command resumes an interrupted enable. More than one node in the cluster is refused before anything is recorded. A worker record with a missing or unsupported launch contract, or a record of the separate store VM, is also refused before anything is recorded or launched; any existing volume is kept, and upgrading it is not supported. |
| `spx admin sqs status` | `--endpoint`, `--region`, `--ca-bundle` | Shows the requested state, node, generation, VM state, receipt key state, whether a worker is polling this gateway, whether this gateway serves the SQS API, and `Ready`, which is true only when all of them hold. `Unsupported` names a record whose missing or unsupported launch contract blocks enable. |
| `spx admin sqs ensure-worker` | `--node` (required), `--instance-type` (required, more than 512 MiB memory), `--volume-size-gib` (required, 1–16384), `--endpoint`, `--region`, `--ca-bundle` | Launches the Region's one SQS VM on `--node`, or reports the VM already bound there: node, region, instance, instance type and generation. The VM is pinned to its first node; another node is refused with `InvalidParameterValue`, and there is no takeover. It gets a private system VPC with one /24 subnet and no internet gateway, NAT or Elastic IP, and an encrypted system-account data volume of `--volume-size-gib` that holds the queue data and the receipt-signing key the guest creates on first boot. The platform records only the key's fingerprint. A different `--instance-type` is accepted only while no volume, network interface or launch step has been attempted; once any has, it is `IncorrectState`, whether or not the step completed. A different `--volume-size-gib` than the recorded one is `IncorrectState`. |
| `spx admin sqs ensure-worker` | `--node` (required), `--instance-type` (required), `--endpoint`, `--region`, `--ca-bundle` | Launches the Region's one SQS worker VM on `--node`, or reports the VM already bound there: node, region, instance, instance type and generation. The worker is pinned to its first node; another node is refused with `InvalidParameterValue`, and there is no takeover. It gets a private system VPC with one /24 subnet and no internet gateway, NAT or Elastic IP, and an encrypted 1 GiB system-account data volume on which the guest generates its own store key. The platform records only the public key. A different `--instance-type` is accepted only while no volume, network interface or launch step has been attempted; once any has, it is `IncorrectState`, whether or not the step completed. |
| `spx admin sqs ensure-store` | `--node` (required, the worker's node), `--instance-type` (required, more than 512 MiB memory), `--volume-size-gib` (required, 1–16384), `--endpoint`, `--region`, `--ca-bundle` | Launches the Region's SQS store VM on the worker's node with an encrypted system-account data volume, or reports the VM already bound there: node, region, instance, instance type and address. Needs the worker bound with its public key recorded, else `IncorrectState`. The store accepts only that key. A different size, type or key than the first call is `IncorrectState`. |
`ServiceUnavailable` never proves that no SQS resources exist, because an earlier attempt may already have created some. Its message says what it does establish. When no node serves the subject, this request was not delivered. When a volume create was not sent, that particular create did not happen. When the node cannot boot the guest yet or its launcher lacks a capability, this request stopped before allocating or recording anything. When the gateway times out waiting for the node's reply, nothing is known: the launch may still be running, so never assume it created nothing. In every case, call the command again with the same arguments. A retry first checks the durable record of the attempt and continues only when that is safe; it can proceed to the first launch after a create that was proven unsent, and otherwise reports the recorded outcome as bound or `IncorrectState`, or returns `ServiceUnavailable` again. `IncorrectState` from a volume, network interface or launch step means that step was recorded as sent but its outcome was never observed. It is never resent, the message names the volume when its ID is known, and no tool reconciles it yet: recovery is manual.
`bound` says only that the VM was launched for the current record. The worker is ready only when the guest's local store answers, and it serves only while its receipt key matches the recorded fingerprint; a missing or different key once one is recorded refuses serving and is never replaced.
Real VPC routing, IMDS and gateway TLS from the guest have not been tested on a live host.
`bound` says only that the VM was launched for the current record. The worker decides store readiness itself, by checking that the store is bound to its own public key and that a NATS connection with that key succeeds; nothing reports that readiness to the operator. The worker-to-store connection does not authenticate the store server. Real VPC routing, IMDS and gateway TLS from the guest, and worker-to-store traffic on a real interface have not been tested.
## Private Admin API
+166 -45
View File
@@ -33,32 +33,67 @@ The gateway (`spinifex/runtime/roles/awsgw/awsgw.go`) is the entry point:
// Connect to NATS (retries while the local broker comes up)
natsConn, err := natsmsg.ConnectNATSWithRetry(...)
// Load IAM (master key + JetStream KV), then compose the gateway: composeGateway
// sets every production hook and wires the service registry from each domain's
// awsapi registration.
gw, err := composeGateway(gatewayInputs{
NATS: natsConn,
IAM: iamService,
Credentials: credentials,
Admission: admission,
// Load IAM (master key + JetStream KV) and create the gateway
gw := gateway.GatewayConfig{
NATSConn: natsConn,
Config: nodeConfig.AWSGW.Config,
IAMService: iamService,
// ...
}, ecrControlPlane, acmControlPlane, ec2ControlPlane /* ... */)
}
// Serve over TLS
server.ListenAndServeTLS("", "")
```
Request routing (`spinifex/ingress/aws/pipeline/gateway.go`):
Request routing (`spinifex/gateway/gateway.go`):
1. **Public routes**: `/oidc/eks/...` and `/v2` (the ECR registry) are mounted ahead of SigV4 by `runtime/roles/awsgw`
2. **Authentication**: SigV4 middleware validates AWS credentials and resolves the account ID
3. **Throttling**: Per-account+action token bucket rejects bursts post-auth
4. **Service Detection**: Extracts service name from the Authorization header
5. **Action Dispatch**: Every service is a registration (`ingress/aws/dispatch`) built by its domain's `awsapi.NewRegistration` and wired by `runtime/roles/awsgw`. The pipeline dispatches by registered service name and holds no per-service code. The private `/admin/<Method>` surface dispatches to the registration `awsgw` names as `Admin.Service`.
1. **Authentication**: SigV4 middleware validates AWS credentials and resolves the account ID
2. **Throttling**: Per-account+action token bucket rejects bursts post-auth
3. **Service Detection**: Extracts service name from the Authorization header
4. **Action Dispatch**: Routes to service-specific handler. A service registered through the ingress dispatch registry (`ingress/aws/dispatch`, wired by `runtime/roles/awsgw`) is dispatched by its registration; currently those are the ECR and ACM control planes, registered from `domains/ecr/awsapi` and `domains/acm/awsapi`. Every other service uses the legacy switch:
```go
switch svc {
case "ec2":
err = gw.EC2_Request(w, r)
case "iam":
err = gw.IAM_Request(w, r)
case "sts":
err = gw.STS_Request(w, r)
case "elasticloadbalancing":
err = gw.ELBv2_Request(w, r)
case "eks":
err = gw.EKS_Request(w, r)
case "bedrock":
err = gw.Bedrock_Request(w, r)
// ... bedrock-runtime, bedrock-agent, bedrock-agent-runtime
case "ecs":
err = gw.ECS_Request(w, r)
case "rds":
err = gw.RDS_Request(w, r)
case "tagging":
err = gw.Tagging_Request(w, r)
case "spinifex":
err = gw.Spinifex_Request(w, r)
}
```
### 3. EC2 Handler
The EC2 registration (`spinifex/domains/ec2/awsapi`, `NewRegistration`) parses the `Action` parameter and looks the handler up in its action table. The account ID resolved by SigV4 auth is threaded into every handler so requests are scoped per IAM principal. Each action's handler is built from the owning EC2 resource package (`domains/ec2/instance`, `volume`, `vpc` and so on), with PassRole, quota and client-token idempotency wrapped on at the action table rather than inside the resource.
The EC2 handler (`spinifex/gateway/ec2.go`) parses the `Action` parameter and delegates to specific handlers via the generic `ec2Handler` and `ec2HandlerWithReq` wrappers. The account ID resolved by SigV4 auth is threaded into every handler so requests are scoped per IAM principal:
```go
"RunInstances": ec2HandlerWithReq(func(ctx context.Context, input *ec2.RunInstancesInput, gw *GatewayConfig, accountID string, r *http.Request) (any, error) {
// ... PassRole and launch-quota checks, then the vCPU quota charge
return ec2instanceapi.RunInstances(ctx, input, gw.NATSConn, gw.IAMService, accountID, passRoleCheck, launchQuotaCheck, gw.ExpectedNodes)
}),
"DescribeInstances": ec2Handler(func(ctx context.Context, input *ec2.DescribeInstancesInput, gw *GatewayConfig, accountID string) (any, error) {
return ec2instanceapi.DescribeInstancesChecked(ctx, input, gw.NATSConn, 0, gw.NodeIDs, accountID)
}),
// ... + volumes, snapshots, VPCs, subnets, route tables, IGWs, NAT gateways,
// security groups, network interfaces, elastic IPs, placement groups, key pairs,
// images, tags, capacity reservations, spot instances
```
### 4. NATS Messaging
@@ -203,14 +238,12 @@ Viperblock responds with an NBD URI that QEMU uses to access the block device.
A direct-boot microVM keeps the launcher's saved `vm.Config` as a clean base, and `startQEMU` adds data volumes only to a launch-only copy of it (`runtime/compute/vm/lifecycle.go`).
At each start the daemon takes one snapshot of the instance's already-mounted data requests, probes each NBD or host block export from that snapshot, and builds the command from the same snapshot, so the launched endpoint is the one that was checked and is never written back into the saved base.
The microVM boots with `pcie=on`, so its NICs are `virtio-net-pci` devices with MSI-X interrupts instead of `virtio-net-device` MMIO devices.
Each data volume becomes a `virtio-blk-pci` device on the default PCIe bus with the same id, node name, I/O thread, serial and `werror=stop,rerror=stop` policy as the q35 device, and takes no PCIe hot-plug port.
A microVM started without `pcie=on` keeps the `virtio-*-device` MMIO transports.
Each data volume becomes a `virtio-blk-device` on the MMIO bus with the same id, node name, I/O thread, serial and `werror=stop,rerror=stop` policy as the PCI device, and takes no PCIe hot-plug port.
Boot and EFI requests are rejected on this path, and a direct-boot instance with no data requests launches its base unchanged.
Disk-boot (q35) instances keep the existing PCI hot-plug path.
Hot-attaching a volume to a running direct-boot microVM is not supported, because the microVM has no PCIe hot-plug root port to plug into.
Hot-attaching a volume to a running direct-boot microVM is not supported, because the MMIO bus has no PCIe root port to plug into.
`AttachVolume` refuses it before mounting the volume or changing any instance or volume state, and the EC2 API returns `UnsupportedOperation`.
This covers only requests that are already mounted when the instance starts; nothing yet supplies data volumes to a system instance, and the path is verified with fake-QEMU unit tests, not with a real EBS volume in a guest.
@@ -278,8 +311,8 @@ storage-provider contract.
### SQS message store (internal)
`domains/sqs/message` defines the store-neutral interface the SQS domain reaches its message store through (ADR-0011:S6), with a JetStream adapter in `domains/sqs/message/jetstream` and a shared conformance suite in `domains/sqs/message/conformance`.
It is used only by the SQS worker inside the SQS system instance and does not itself deliver an SQS API.
Spinifex ships no host service, unit, user or firewall rule for the dedicated JetStream server; the system instance that runs it is described under *SQS system instance (operator-launched)*.
It is an internal primitive: nothing outside the SQS domain uses it, no daemon or service is wired to it, and it does not itself deliver an SQS API.
The dedicated JetStream server ADR-0011 describes is not deployed by this code, and Spinifex ships no host service, unit, user or firewall rule for it; the guest that could run it is described under *SQS store system instance (not wired)*.
The interface covers durable and idempotent append, body and metadata reads, conditional state writes and deletes, per-namespace counts, namespace enumeration, an ordered change feed, a bounded paginated scan, namespace deletion and a sweep of tombstoned bodies.
A namespace carries an immutable message-shard and state-shard allocation chosen when the queue is created, so a later change in shard counts never moves an existing queue.
@@ -298,36 +331,124 @@ If that consumer delivers nothing for about two seconds while the stream holds a
Coverage is unit and conformance tests on in-process R1 and R3 servers; there is no end-to-end or multi-host validation of this code, and earlier multi-host evidence predates the final feed.
### SQS system instance (operator-launched)
### SQS store system instance (not wired)
One system-owned microVM per Region runs both the SQS store and the SQS worker as separate processes, on one encrypted data volume pinned to one node (ADR-0011 Amendment 5, ADR-0012 Amendment 6).
Only an operator activates it with the super-admin `EnsureSQSWorker` or `EnableSQS` actions; nothing starts it at daemon startup, and platform NATS is neither used nor changed for SQS data.
This first profile is fresh single-node only: a host cluster of more than one node is refused before anything is recorded or allocated, and there is no disable.
Spinifex can build, install, launch, restart and give storage to a dedicated SQS store microVM, but nothing calls any of it.
No SQS API, domain service, admin command or daemon start-up path creates a store, so none of this is in production use.
`make build-sqs-worker-microvm-image` builds the one `sqs-worker` image: the static `sqs-worker` binary, the pinned `nats-server`, the ext4 tools and a network `/init`; releases ship it as `microvm-sqs-worker`, which `setup.sh` installs to `/usr/share/spinifex/microvm/sqs-worker`.
At boot the guest reads three firmware config values under `opt/spinifex/sqs-worker/` (the data disk's serial, its Region and the gateway URL on this node's mgmt bridge) and refuses before touching any disk if one is missing or malformed.
It selects exactly one disk with that serial, runs `e2fsck -p`, mounts it at `/data` and confirms from `/proc/self/mountinfo` that the whole disk is what is mounted; the guest never formats.
The guest image is built by `make build-sqs-store-microvm-image`, separate from the load balancer image, and bundles the `nats-server` version pinned in `go.mod` plus the ext4 tools.
Release tarballs carry it as `microvm-sqs-store/`, and `setup.sh` installs it to `/usr/share/spinifex/microvm/sqs-store/`.
A release without a complete image removes any previously installed one, so a launch is then refused rather than booting a stale image.
The store is a dedicated `nats-server` listening only on `127.0.0.1`, with its JetStream directory at `/data/jetstream` and `GOMEMLIMIT=512MiB` (ADR-0011:S11), so nothing outside the VM can reach it and it needs no network identity of its own.
The store starts only after the receipt key is settled, and the worker connects only after the store answers JetStream account info, so an unavailable store never appears ready.
At boot the guest reads three firmware config values: the data disk's serial, an IPv4 address on one of its interfaces, and one NATS user NKey public key.
It mounts exactly one matching disk at `/data` after `e2fsck -p`, refuses a missing, duplicate, unformatted or unrepairable disk, and never formats.
JetStream keeps its data on `/data` with `sync_interval: always` (ADR-0011:S3), `max_payload: 2MiB` admits a full 1 MiB SQS message with its headers (ADR-0011:S4), and the server listens only on that address and accepts only that key.
On shutdown the store is stopped before `/data` is unmounted; a store that survives SIGKILL leaves every mount in place.
The receipt-signing key lives on the data disk under `/data/sqs-worker-key`, written to a temporary file, synced, renamed and its directory synced before its fingerprint is recorded.
`domains/sqs/workerident` holds the platform's receipt-key state on the worker's identity record, beside its generation, claim, pinned node and volume: `pending` is set only when the record first creates its volume, and `recorded` holds the fingerprint.
Under `pending` the guest may create a key, or reuse a valid key it already wrote before a crash, only while no `/data/jetstream` directory exists; any store data under `pending` refuses, because it proves the volume is not new.
Under `recorded` a missing, unreadable or different key refuses the boot and is never regenerated; a record with no receipt-key state is refused as not proven new.
`RecordReceiptKey` writes the fingerprint by compare-and-swap that re-checks the current instance, generation, claim and volume, and a lost reply is answered by reading the record again, never by a new key.
A replacement worker reuses the pinned volume and keeps its predecessor's receipt-key state.
A launch uses the versioned subject `system.LaunchSQSStore.v1`, which a daemon without this code does not serve, so it fails with no responder instead of booting another guest.
The legacy `system.LaunchInstance` path refuses the store kind on both send and receive.
Every input check runs before capacity is reserved, and the data volume must be an available system-account volume.
It is mounted through the normal EBS path and detached, never deleted, on terminate, which is the protection standard EBS gives today and nothing stronger.
CPU mitigations stay enabled for the store, unlike the load balancer.
A successful launch means the VM is running, not that NATS is ready.
The worker reaches the gateway over TLS 1.3 trusting only the cluster CA, signing with instance-role credentials from the metadata service only.
The internal `spinifex-sqs` actions are `ClaimReceiptKey`, `RecordReceiptKey`, `PollWork` and `CompleteWork`; the gateway admits only an assumed-role session of `sqsWorkerRole` in the system account and resolves the instance through `workerident.Authorize`, a point-in-time check against the current bound record.
The worker pulls tenant work from its own node's gateway relay and posts results back, so no SQS payload crosses platform NATS; the relay holds a bounded, in-memory set of waiting requests and answers `ServiceUnavailable` when it is full or no worker is polling.
The instance record keeps the store's non-secret guest settings so a restart on the node that launched it rewrites the firmware config files.
A relaunch on any other node is refused, and cross-node recovery never claims a store.
Both checks exist only in daemons with this code: an older daemon can still claim and relaunch a store elsewhere, and an explicit start served by another node claims the instance before its relaunch is refused.
`EnsureSQSWorker` takes a `NodeId`, an `InstanceType` and a `VolumeSizeGiB`; the instance type's memory must exceed the store's 512 MiB limit, and a later request for a different type or size is refused as `IncorrectState`.
`EnableSQS` records the Region's desired state, pinned to one node, and then ensures the worker; `status` reports it ready only when the worker is bound, polling and the tenant API is registered.
A store launch record or VM from the earlier separate-store design, or a worker record without a supported launch contract, is refused with its volume kept: enable stops before recording anything, and a relaunch of an old store VM is marked failed rather than started.
`domains/sqs/storevolume` prepares a fresh data volume, because the guest never formats.
It checks for `mkfs.ext4` and `qemu-img` before allocating, creates the volume in `creating`, writes an empty ext4 image to it over NBD on the local node, unpublishes it, and only then makes it `available`.
While a volume is `creating`, attach, delete, modify, force-detach and snapshot are refused.
A volume is deleted only after a confirmed unpublish; any publish error, an unconfirmed unpublish or a failed release keeps it and reports its ID, with its state reported as unknown after a failed release.
Evidence is local: unit tests, the guest shell test and an AWS SDK test that creates a queue and sends, receives and deletes a message through the real worker loop against a real local JetStream store and a restart.
A real guest boot, its metadata route on a real ENI and the signed path on a live node are unproven, Viperblock's at-rest key is held by the platform, and SQS-managed message encryption is not implemented, so the profile must not hold tenant production data.
Evidence is local only: unit tests, real QEMU boots against disposable scratch disks, and one run of the preparer against the `qemunbdd` provider whose volume kept acknowledged writes across a reboot.
Viperblock, volume encryption, the EC2 volume record path, multiple nodes and host reachability of the guest address are not tested.
Credential issuance and rotation, store placement and NATS readiness checks do not exist, and there is no failover: a lost node leaves its store unavailable.
### SQS worker guest (operator-launched)
The shared SQS worker guest holds the SQS store's client credential, so that no platform component does (ADR-0011:S2).
Only an operator activates it, with the super-admin `EnsureSQSWorker` action described below; nothing starts it at daemon startup, and no worker service runs after boot.
`make build-sqs-worker-microvm-image` builds it with the store image's script and network `/init` under `SQS_MICROVM_PROFILE=worker`; it carries the static `sqs-worker` binary and the ext4 tools, no `nats-server`, and an empty `/data` mountpoint.
Releases build it beside the store image and ship it as `microvm-sqs-worker`, which `setup.sh` installs to `/usr/share/spinifex/microvm/sqs-worker` with the store image's rules: a release without it removes an earlier one, and an incomplete one is refused.
At boot `sqs-worker boot` reads three firmware config values under `opt/spinifex/sqs-worker/`: the worker disk's serial, its Region and the gateway URL; none is a first-store decision, because a static value survives a guest reset.
A missing, empty or malformed value refuses before any disk is touched.
It selects exactly one disk with that serial, runs `e2fsck -p`, mounts it at `/data` and then confirms from `/proc/self/mountinfo` that this disk, as a whole ext4 filesystem, is what is mounted there; an empty mountpoint, a foreign mount or a failed check or mount refuses, and the guest never formats.
Only then does it ask the gateway for the current decision with `ClaimFirstStore`, signed by `internal/sqsworkergw` with credentials from the instance metadata service only (`gwsign.NewInstanceRole`, no environment, profile or static-key fallback), over TLS 1.3 that trusts only the cluster CA from `opt/spinifex/ca-cert` and never follows a redirect.
Under that decision `workerkey.Acquire` creates the key on `/data/sqs-worker-key`, owner-only and synced, records only its public key and then uses it, or loads the recorded key; a missing or different key once one is recorded refuses and is never replaced.
The VM can start before its launch binds the instance, so its first calls are legitimately `AccessDenied`; that denial creates nothing and is asked again, as are a lost reply and a `ServiceUnavailable` or `Throttling` refusal, all within one bound (60 attempts 5 seconds apart), after which a worker that was never bound, or was replaced, refuses the boot.
A signature or other refusal, or an answer that is neither "create" nor one valid public key, refuses the boot at once.
`domains/sqs/firststore` is the SQS domain's first-store decision: one record per Region in the `spinifex-sqs-first-store` KV bucket moves from `claimed` to `key-recorded` to `launch-intent` under compare-and-swap, bound to the worker's instance ID, claim ID and generation.
A claim authorizes key creation only while no public key is recorded; once one is, every claim returns only that key, and a store launch intent needs it recorded first.
An older generation, a different identity at the same generation or an unreadable record authorizes nothing, and the record holds no private key material.
Reads are stream message gets answered by the stream leader, never direct gets a lagging replica can answer, and every authorizing answer is also confirmed by a compare-and-swap at the revision it read, so a stale or unavailable read authorizes nothing.
The authenticated caller that will carry this decision must derive the worker's instance ID and generation from authoritative instance state; a generation supplied in a request cannot itself authorize a takeover.
`domains/sqs/workerident` is that authoritative instance state: one record per Region in the `spinifex-sqs-worker-identity` KV bucket holds a monotonic generation, claim ID, pinned node and encrypted data volume, and moves from `launching` to `bound` to `stopped`, or from `launching` to `failed`, under compare-and-swap.
A launch intent is refused while the previous attempt is `launching` or `bound`, including one whose outcome is unknown, so only one caller ever holds an intent to start a VM on the pinned volume.
A replacement takes the next generation only after `FailLaunch` or `RecordStopped` records that nothing holds the volume, and each request names the generation it supersedes, so a replayed request cannot take a generation it never observed; there is no automatic takeover.
The launch path then raises the first-store fence to the successor's generation with `firststore.Fence` before `BeginLaunch`, which refuses a replacement until a leader read shows that fence; the fence only rises, and `firststore` refuses every call from a generation below it.
Only the current claim ID is recorded, so claim IDs are minted fresh per attempt and a VM launch must be idempotent on generation and claim together.
Binding writes the instance's index entry first and then binds the record to that instance.
The index is only a lookup: `Authorize` accepts an instance only while the Region's record is `bound` to exactly that instance, generation and claim, confirmed by a compare-and-swap at the leader-read revision, so an old, orphaned, rewritten or unbound entry authorizes nothing.
A same-instance reboot keeps its instance ID and identity; a replacement takes a new generation, and the first cut refuses another node or data volume and an unencrypted volume, although the encryption flag is the caller's claim and the launch wiring must verify it from the volume's metadata.
Both buckets are opened through `OpenDurableKV`, which refuses an existing bucket whose effective stream configuration could expire, evict, lose, lag or refuse records: non-limits retention, an age, per-message or delete-marker TTL, memory storage, asynchronous flush, a size, count or message-size cap, a discard policy other than discard-new, unacknowledged writes, a sealed stream, a mirror or sources, or fewer replicas than the cluster needs.
`workerkey.Acquire` drives that decision: it syncs a new key to disk, records its public key and only then returns it, so a crash before recording reuses the key already on disk.
`domains/sqs/awsapi/worker` carries the decision to the worker as `ClaimFirstStore` and `RecordWorkerPublicKey`, served under the internal `spinifex-sqs` signing service, which no AWS SDK signs for; the public AmazonSQS action inventory is untouched.
The gateway admits only an assumed-role session of `sqsWorkerRole` in the system account, then the role's policy, and only then resolves the session name, the instance ID STS set, through `workerident.Authorize`; the request carries no identity, and any parameter beyond the action's own is refused.
The first-store identity is the current record's instance, claim and generation, and a worker bound to another Region is refused.
A request that passed `Authorize` just before a confirmed stop and replacement still carries the old generation; the fence was raised before the successor began, so `firststore` refuses it whether or not the successor has claimed yet.
The adapter never logs a caller-chosen parameter name, because one could carry a secret.
`RecordWorkerPublicKey` accepts only a valid public user key, so a seed is refused without being recorded, logged or echoed.
The gateway binds both buckets per request and never creates them, so it provisions nothing and answers `ServiceUnavailable` until a worker launch has created them; the worker launch below ensures the role.
The private key stays on the worker disk; the guest logs and publishes only the public key to `/run/sqs-worker/public-key`.
`domains/sqs/workerlaunch` realizes the worker on the node it runs on, its only node: a bound record is returned as is; otherwise it takes the intent, then on the first launch marks the intent's one volume create, prepares the volume and records its ID, reads the volume's own record and refuses it unless encrypted, ensures the role, finds or creates the network for the claim and reads the recorded store key, and marks the intent dispatched before launching.
A dispatched launch is never sent again: a lost reply or error leaves the intent `launching` and dispatched, and every later call returns `ErrOutcomeUnknown` until the outcome is reconciled, since the launch path is not idempotent.
A failure before dispatch leaves an undispatched intent that the next call resumes with the same claim; a confirmed stop or failure is replaced only after `firststore.Fence` excludes the old generation, reusing the pinned volume.
The launch passes no store decision or key: the guest asks the gateway at boot.
The intent exists before any volume, so concurrent first launches allocate nothing for the losers. A Prepare proven to have sent no create withdraws the mark and returns `ServiceUnavailable`, or `UnsupportedOperation` when the cluster storage key is not configured, and a retry may prepare the volume; a create that may have applied is never sent again, and every later call returns `IncorrectState` until it is reconciled. A volume the call knows of but cannot use is reported with its ID in a `VolumeError` and left in place, never deleted. An intent that has sent no create may be re-typed; one that has keeps its type until replaced. The worker role is created with EC2-only trust, and an existing role is checked for that trust before any policy is granted; the instance profile must carry exactly the worker role, and one holding any other role is refused unchanged.
The daemon's `LaunchSQSWorkerInstance` boots the `sqs-worker` image through the same direct-boot path as the store, with the data volume attached at boot and kept on terminate, a required instance profile, and a required mgmt NIC; it re-reads the volume's own record and refuses one that is not available and encrypted, before anything is allocated.
Its firmware config adds the gateway URL on this node's mgmt bridge and the cluster CA from the daemon's own trust store; the caller cannot supply either, and no value written is secret.
A relaunch rebuilds that config from the VM record on the node that launched it and is refused anywhere else, and membership takeover already skips SQS-managed instances.
`sqsWorkerRole` is ensured in the system account granting exactly `spinifex-sqs:ClaimFirstStore` and `spinifex-sqs:RecordWorkerPublicKey`, from the `domains/sqs/workerrole` contract the gateway gate also reads; an existing role whose trust admits more than the EC2 service is refused.
Evidence is local only: unit tests with a fake firmware config, sysfs and mount table, tests running the boot through the real client against the real `spinifex-sqs` registration and records, and QEMU boots of the built image against disposable scratch disks and a host-side test gateway and metadata service, showing the first boot creates the key, reboots reuse it, a key lost after recording is never replaced, and the seed reaches no log or request.
The worker's network is a private SQS system VPC per Region, built by `systemvpc.EnsurePrivate` from the `[sqs]` supernet (default `10.240.0.0/14`): one VPC and one /24 subnet, with no internet gateway, NAT gateway, EIP, route table or daemon-to-store node port. Each launch claim gets one ENI in that subnet, in the VPC default security group, found again by its claim on a retry; two ENIs for one claim are refused. The launch intent is marked before the one create is sent, so a create whose ENI is not yet visible is never sent again: the launch fails closed with `IncorrectState` until the ENI appears or the outcome is reconciled. Worker-to-store TCP and the guest's route to the metadata service are not yet proven by any traffic test.
The super-admin `EnsureSQSWorker` action on the internal `spinifex` operator service takes a `NodeId`, which must be a node of this cluster and a single subject token, and an `InstanceType`, and sends a request to `sqs.worker.Ensure.v1.<node>` with no queue group, so only that node answers and the worker stays pinned.
That node's daemon re-checks the node the request names and the instance type, and refuses as `ServiceUnavailable` before allocating a volume or network or recording an intent unless the worker image's kernel and initramfs are non-empty files, its mgmt bridge exists and the cluster CA is readable; a call after the image is installed then launches.
It then runs its one composed launcher for its own Region, one launch at a time, in the daemon's own context with a 30-minute bound, so a caller's timeout does not cancel it.
A reply lost to that timeout is reported as `ServiceUnavailable`; calling again reports the bound worker, or `IncorrectState` for a dispatched launch whose outcome is unknown, which is never resent; a Region pinned to another node is refused as `InvalidParameterValue`.
The reply names the bound VM's node, Region, instance ID and generation; bound is not ready, and it says nothing about the guest's boot, its key or the store.
Stop/detach evidence for `RecordStopped` is not wired, the guest's metadata route and gateway TLS on a real ENI and worker-to-store TCP are unproven, and Viperblock's at-rest key is held by the platform.
The worker identity record holds the instance type of its generation; a request for another type is refused as `IncorrectState`, before dispatch and after binding, the reply names the recorded type, and only a replacement after a confirmed stop may change it.
Operators call both actions with `spx admin sqs ensure-worker --node --instance-type` and `spx admin sqs ensure-store --node --instance-type --volume-size-gib`, signed for the `spinifex` service with the standard AWS credential chain like the other `spx admin` gateway commands.
Each command refuses missing inputs before sending anything and reports only the bound VM; a bound VM is not a ready worker or store.
The super-admin `EnsureSQSStore` action takes a `NodeId`, an `InstanceType` and a `VolumeSizeGiB` and sends a request to `sqs.store.Ensure.v1.<node>` with no queue group; only the node the Region's worker is bound to can launch the store.
That node refuses an instance type whose memory does not exceed 512 MiB, the store's `GOMEMLIMIT` under ADR-0011:S11; this is a floor, not a validated size, and no type is defaulted.
It refuses before recording or allocating anything unless the store image and the cluster storage key are present, the worker is bound on this node, and that worker's identity has recorded its public key in the first-store decision, which it then advances to a launch intent.
The store's own launch record in `spinifex-sqs-store-launch` holds only that public key, the claim, the node and the operator's instance type and size, and marks each step before its uncertain create: the volume, the ENI in the worker's private subnet, and the launch.
A later request for a different instance type or size is refused as `IncorrectState`, before dispatch and after binding, and the reply names the type the store was launched with.
A volume preparation that failed before sending any create, such as a missing `mkfs.ext4` or a scratch or format failure, withdraws its mark and returns `ServiceUnavailable`, so a retry may try again once the host is fixed.
Any other marked step whose result is not visible is reported as `IncorrectState` and never resent; a volume that is not encrypted by its own record is refused as `UnsupportedOperation` with its ID kept, and the daemon re-checks the volume's encryption and the guest's size at launch.
The daemon never receives the worker's seed; the store's NATS server accepts only that public key, and the reply's bound VM and address are not readiness, which only the worker can check.
After boot, the worker's long-running `sqs-worker ready` step mounts and verifies its disk again and calls the internal `spinifex-sqs` `DescribeStore` action, which admits only the current worker and returns the bound store's address and recorded public key, or pending.
It loads its key only if it matches that public key, then connects to the store's NATS on port 4222 with it and asks JetStream for account info, each pass bounded to 30 seconds.
The step runs until shutdown with no overall deadline, so a store the operator launches at any time is picked up: it asks again every 5 seconds while the store is pending, not answering or the reply was lost, and re-checks a ready store every 30 seconds.
A pass writes only the address and public key to `/run/sqs-worker/store-ready`; any failed pass removes that file, and so does the step's own exit.
A start that finds shutdown in progress or no worker key logs once and waits in place rather than exiting, so init does not respawn it in a loop; shutdown stops it like a running watcher.
Init respawns the step, at most once every 5 seconds, and every start removes a marker left by a watcher that was killed or crashed before the new watcher runs.
A ready store that fails is noticed up to one re-check interval plus one bounded call later, so the marker can be up to about a minute stale.
Shutdown blocks further respawns, removes the marker, and stops the watcher by its recorded PID and start time, escalating to SIGKILL after a budget; it unmounts the worker disk only once the watcher is confirmed gone, and a survivor leaves every mount in place.
A mismatched key, a store that refuses the key, a key missing from the disk or an unclassified gateway refusal is asked again only every 5 minutes, so it never spins and still recovers if the operator fixes it; a missing key is never replaced.
The NKey authenticates the worker to the store, but nothing authenticates the store to the worker: there is no TLS, so a pass means only that something at that address accepted the key.
This is store readiness for the worker alone; there is no SQS API, and no production sizing or support is claimed.
## Configuration
+4 -6
View File
@@ -60,7 +60,7 @@ intact; ADR-0001 explicitly defers that design.
| Current path | Standing | Target ownership | Rule |
|---|---|---|---|
| `cmd/aws-model-coverage` | Qualification generator | Thin main; reusable code in `internal/tooling` | Produces the dispatch-versus-model inventory; never imported by product packages. Debt: it reads each domain's declared `OperationInventory` from `domains/<service>/awsapi` (ACM, EC2, ECR, ECS, EKS, ELBv2, IAM, RDS, Route 53, SQS, STS) and never constructs a registration. |
| `cmd/aws-model-coverage` | Qualification generator | Thin main; reusable code in `internal/tooling` | Produces the dispatch-versus-model inventory; never imported by product packages. |
| `cmd/dhcptest` | Development diagnostic | Thin main; reusable code in `internal/tooling` if extraction is useful | It is a DHCP probe, not a supported `spx admin` command. |
| `cmd/spx-loadgen` | Qualification/load tool | Thin main; reusable code in `internal/testkit` | It drives test load and is not shipped to nodes. |
| `tests/e2e/*/cmd/*`, `tests/bench/*/cmd/*` | Test and benchmark-private tools | Remain test-private | They are invoked by a test or benchmark harness, not a product process. |
@@ -369,9 +369,7 @@ while the branch is in flight; they do not describe the umbrella repository's
| `spinifex#1355` | `spinifex/gateway/ec2.go` (`EC2_Request` in production, the legacy service switch, its `supportedServices` and JSON tables) and the `Quota` field of `GatewayConfig` | `spinifex/domains/ec2/awsapi` (`NewRegistration`, `OperationInventory`, `ServiceName`), composed by `spinifex/runtime/roles/awsgw` (`ec2.go`: `newEC2Registration`, `advertisedEndpoint`; `ec2quota`) | Slice 9b of the EC2 move: EC2 registers through the ADR-0002 seam as service `ec2` and the legacy gateway switch is empty. The EC2 query-protocol error envelope is kept through `ErrorEnvelopeXML`, and the policy denial match is now `*dispatch.PolicyDenialError`. The characterisation tests committed first also pin the Authorize service name and action, the trace span and audit action, the throttle key (account plus action) and the cluster-unavailable gate, which runs before policy and action lookup for gateway-local and NATS-backed actions alike. They and the 9a tests pass unchanged apart from setup: STS and EC2 test setups now register the services they sign as, and the two tests that claimed a legacy service name are removed because no legacy name remains. `EC2_Request` survives only as a test shim. Allowlist changes are two deletions (the `gateway` to `domains/ec2/awsapi` and `domains/ec2/awsapi/instance` rows) and one addition, the per-service model-coverage row `cmd/aws-model-coverage` to `domains/ec2/awsapi`. `startInstanceStatus` stays in `awsgw.go` because ADR-0007:S6 records the raw instance-state uses by file. No change to `dispatch.Actor` or `dispatch.Invocation`. Live on env14: DescribeRegions, DescribeAvailabilityZones and DescribeAccountAttributes, an idempotent CreateVolume replay and its parameter mismatch, the DryRun and InvalidVolume.NotFound errors, a no-policy user refused with UnauthorizedOperation (including on the gateway-local DescribeRegions), an assumed-role session allowed and refused per its policy, and one RunInstances to running then terminated. The gateway request logs carry `service=ec2` and the per-action label. Not exercised live: a throttled request (the default limits are not reachable from one client; the throttle key is pinned by the characterisation tests) and an agent-signed EC2 call. |
| `spinifex#1363` | `spinifex/gateway` context keys (`ctxService`, `ctxRegion`, `ctxAction`, the principal and access key id keys, and the unread `ctxQueryArgs`), the duplicate `callerPrincipal` and `principalContext` types, `gateway/caller_arn.go` (`buildCallerARN`), and the direct `policyauth`, `accountadmission` and NATS-connection reads in the auth pipeline | `spinifex/ingress/aws/reqctx` (the request record), `credverify.CallerARN`, and the gateway-consumed capabilities `PolicyEvaluator`, `AccountAdmission` and `Readiness`, composed by `spinifex/runtime/roles/awsgw` (`clusterready`) | Slice 11a of the gateway removal: the request-context record and the ADR-0002:S8 capabilities, with no behaviour change. The record is a copy-on-write value under one context key; the principal is sealed once and a second `WithPrincipal` keeps the first caller. The layering rule `S7_RequestRecordPrivateToIngress` lets only `gateway` and `ingress/aws/*` import `reqctx`, with a fault-injection test, and slice 11c moves the package to `ingress/aws/internal/reqctx` and drops the rule. The caller ARN is built once in `credverify` (root cases kept) and filled on `Principal.CallerARN`. The policy evaluator, account admission (`credential.ErrAccountNotActive`, with `accountadmission.ErrNotActive` an alias) and cluster readiness (`Available() bool`) are interfaces the gateway consumes and fails closed on when nil. The step 1 characterisation tests (throttle against both readiness gates and the policy gate for ECS and IAM, `/admin` skipping `GetService` and the second gate, the nil-connection difference between the two gates, five error writers each minting a request id that differs from the audit record, and the credential-scope region reaching only the span) pass unchanged apart from setup. No allowlist rows change, and there is no change to `dispatch.Actor` or `dispatch.Invocation`. Debt for slice 11c: the test-only `defaultCapabilities` seam (a package variable set by a test `init` so about 60 gateway test files can build a config from `IAMService` alone), the nil-connection case of the early readiness gate, the remaining `NATSConn` readers (`DiscoverActiveNodes` and the field that `awsgw` and the integration harness set), and the `IAMService`, `STSService`, `Config`, `RootCAs` and `InternalSuffix` fields of `GatewayConfig`, which no production code reads. Live on env14: DescribeRegions, DescribeAvailabilityZones and DescribeAccountAttributes, an idempotent CreateVolume replay and its parameter mismatch, the DryRun and InvalidVolume.NotFound errors, a bad access key refused with InvalidClientTokenId on a Query service and InvalidClientTokenIdException on a JSON service, a no-policy user refused with UnauthorizedOperation, and an assumed-role session allowed and refused per its policy. Not exercised live: a suspended-account refusal, a throttled request and the NATS-down 503, which the step 1 tests pin. |
| `spinifex#1378` | `spinifex/gateway` `oidc_discovery.go` and `registry.go` (the `/oidc/eks` routes and the `/v2` mount), the `DiscoverActiveNodes` method and its cache, and the `GatewayConfig` fields `Config`, `RootCAs`, `InternalSuffix`, `Version`, `Commit`, `IAMService`, `STSService`, `NATSConn` and `ExpectedNodes` | `spinifex/runtime/roles/awsgw` (`PublicRoutes`, `NodeDiscovery.ActiveNodes`, `composeGateway`); the gateway keeps one `PublicRoutes` mount hook and the `Admin` registration name supplied by the role |
| `spinifex#1384` | `spinifex/gateway` (`GatewayConfig`, the SigV4 and policy middleware, `Request`, the admin dispatch, `AWSOperationInventory`) | `spinifex/ingress/aws/pipeline` (`Config`) and `spinifex/ingress/aws/dispatch` (`AWSOperationInventory`); the gateway layering rows are retired and R2 binds the pipeline |
| `spinifex#1334` | `spinifex/gateway/oidc_discovery.go` (`eksAccountKV`, the discovery-document and JWKS read and construction) | `spinifex/domains/eks/cluster` (`OIDCPublisher`, `OIDCDiscoveryDocument`), composed by `spinifex/runtime/roles/awsgw` | The EKS domain now reads its own account bucket and builds the per-cluster discovery document and JWKS responses (ADR-0005:S4), and the gateway only maps the unauthenticated `/oidc/eks/...` routes onto an `OIDCPublisher` interface it is handed. Responses are byte-identical, pinned by `oidc_discovery_current_behaviour_test.go`, whose assertions are unchanged. The gateway no longer imports `domains/eks/cluster` in production code. `STS` keeps its own `ClusterJWKSResolver` (`OIDCJWKSReader`), untouched. Recorded debt, not repaired here: a stored JWKS is served verbatim with 200 even when it is not valid JSON. |
| `spinifex#1245`, `spinifex#1390` | — | `contracts/admission/v1` | The quota override seal route (`admission.v1.SealAccountQuotaOverride`, served by `awsgw`) and the per-gateway read-only capability probe (`admission.v1.AccountQuotaOverrideSealCapability.<node>`) are an explicit versioned cross-process contract between the quota owner and `operator/accountlifecycle`. The consumer wiring is new: `NewClusterEngine` takes a `ClusterNodes` value with separate daemon and gateway node sets, `ClusterConfig.NodesRunning` is the single definition of which nodes run a service, and the seal reaper runs in `StageAccount` after the EC2 settings seal. A termination whose gateway probe is incomplete seals nothing and leaves the account `TERMINATING`. Contract, responder, probe and reaper tests pin the subjects, JSON shapes and fail-closed cases; `TestAccountTermination_QuotaOverrideEndsSealed` replaces the former characterization that overrides survive teardown. |
The EC2 contract row is intentionally different from the directory moves: it creates
a compatibility boundary. `ec2.cmd.*` retains its deployed one-token NATS
subscription and permission shape, while callers use
@@ -489,8 +487,8 @@ Enforcement: the owner decided the layering test is blocking now, with a ratchet
- `domains/rds/instance` → `domains/rds/parametergroup` (legacy edges): `parameterGroupReferences` binds `Dependants` by scanning instance records; this clears when the RDS instance gains its own owner and answers `Dependants` from it. `tags.go` writes the meta record through its generic tag registry; this clears when tagging invokes a parameter-group-owned tag mutation capability, and moving the tagging adapter to `domains/rds/awsapi` alone does not clear it. The Modify handler still calls `propagateParameterGroup` after the owner's write; propagation is instance-side realization and stays legacy until it is designed as asynchronous realization (group generation, instance staleness, retry). `parameterGroupValues` returns the persisted `Record` and overrides to instance code; when the instance owner is extracted, replace them with a consumer-owned resolved-parameters projection.
- `domains/ecs` → `domains/ecs/taskdefinition` (owner bypass): `tags.go` reads and writes the revision record through its generic tag registry; this clears when ECS tagging invokes an owner-provided tag mutation capability, and moving `tags.go` to `domains/ecs/awsapi` alone does not clear it. `taskDefinitionResolver` returns the persisted `Record` to task and service launch code; when the task and service owners are extracted, replace the record crossing that boundary with a consumer-owned resolved task-definition (launch-spec) projection. Moving packages has not made the record private.
- `domains/ecs/taskdefinition` (deliberate duplication, not debt): private `getJSON`, `putJSON` and `keysWithPrefix` copy the raw-KV helpers in `domains/ecs` so the extraction keeps ECS's get/put/list error and recovery semantics. Revisit only if a later, tested state abstraction proves it preserves those semantics; do not migrate ECS to `kvstore` merely to remove them.
- `domains/eks/cluster` → `domains/eks/access` (legacy edges): `ResolveTokenReview` opens the EKS bucket in the gateway process and reads records through the exported `access.Get`; this clears when token review asks the owner across a contract rather than opening the bucket itself. `DeleteClusterChildren` erases access-entry keys as part of its blanket cluster sweep; this clears when cluster delete invokes an owner-provided removal of the cluster's entries. The owner returns the persisted `Record` to the AWS adapter and token review; replace it with consumer-owned projections when those consumers are extracted.
- `domains/eks/cluster` → `domains/eks/addon` (legacy edges): `DeleteClusterChildren` erases add-on records and manifests in its blanket cluster sweep; this clears when cluster delete invokes an add-on-owned teardown capability. `stageGPUDeviceAddon` is a temporary node-group-to-add-on orchestration edge: best-effort, errors logged and swallowed, never undone on node-group delete; it clears when the node-group owner is extracted and the dependency is designed. `ClusterReconciler` hosts the add-on status subscription; it clears when add-on delivery observation has its own host under the lifecycle correction. Cluster existence is checked in the handler, not through an owner-declared capability; it moves into the owner when cluster admission is defined (today status is ignored). Guest wire types (`AddonStatusReport`, `AddonDeliveryPhase`, `AddonStatusSubject`, `StagedAddonManifest`, `InternalAddonsResponse`) are owned by `contracts/eks/v1`, which `domains/eks/cluster`, `gateway/eks` and `agents/eks/gatewayfetch` import directly.
- `domains/eks/cluster` → `domains/eks/access` (legacy edges): `ResolveTokenReview` opens the EKS bucket in the gateway process and reads records through the exported `access.Get`; this clears when token review asks the owner across a contract rather than opening the bucket itself. `DeleteClusterPrefix` erases access-entry keys as part of its blanket cluster sweep; this clears when cluster delete invokes an owner-provided removal of the cluster's entries. The owner returns the persisted `Record` to the AWS adapter and token review; replace it with consumer-owned projections when those consumers are extracted.
- `domains/eks/cluster` → `domains/eks/addon` (legacy edges): `DeleteClusterPrefix` erases add-on records and manifests in its blanket cluster sweep; this clears when cluster delete invokes an add-on-owned teardown capability. `stageGPUDeviceAddon` is a temporary node-group-to-add-on orchestration edge: best-effort, errors logged and swallowed, never undone on node-group delete; it clears when the node-group owner is extracted and the dependency is designed. `ClusterReconciler` hosts the add-on status subscription; it clears when add-on delivery observation has its own host under the lifecycle correction. Cluster existence is checked in the handler, not through an owner-declared capability; it moves into the owner when cluster admission is defined (today status is ignored). Guest wire types (`AddonStatusReport`, `AddonDeliveryPhase`, `AddonStatusSubject`, `StagedAddonManifest`, `InternalAddonsResponse`) are owned by `contracts/eks/v1`, which `domains/eks/cluster`, `gateway/eks` and `agents/eks/gatewayfetch` import directly.
- `domains/eks/cluster` and `domains/eks/awsapi` → `domains/eks/addon` (further edges): the AWS adapter reads the persisted `Record` through `addon.Get`/`addon.List`; this clears when the owner returns a view and the add-on adapter moves to `domains/eks/awsapi`. The guest route reaches the owner through the `handlers_eks` NATS RPC (`ListStagedAddonManifests`); this clears with per-resource commands behind EKS registration. The add-on ARN is derived in both the owner and `domains/eks/awsapi/authz.go`; this clears when authorization obtains the ARN from the owner, which an incarnation suffix would require. The owner's `Manifest`/`Report` beside the `contracts/eks/v1` types is deliberate duplication, not debt.
- `agents/rds/agent` (dependency, narrowed): engine metadata now arrives through the agent-declared `EngineCatalog`, but the agent still imports `domains/rds/instance` for the command protocol, wire and record types (`Command*`, `Parameter`, `EngineHealth*`, `BootstrapMode*`) and API output types. This clears when those move behind a guest/controller wire contract.
- `domains/rds/awsapi` → `domains/rds/instance` (`InstanceSizing`): the gateway obtains its `Sizing` through the RDS handler package's adapter rather than its own composition root. This clears when the gateway's RDS composition moves to `runtime/roles/awsgw` and builds `Sizing` there.
@@ -689,7 +687,7 @@ series above; "needs decision" is open.
| `handlers/rds` | — | Done. The remaining RDS instance resource moved to `domains/rds/instance` (package `instance`) in `spinifex#1251` with no API, NATS subject, storage or behaviour change, fulfilling ADR-0001's organise-by-resource condition alongside the already-extracted subnet group, parameter group and engine catalogue. The ADR-0004 blocked-dependency imports (quota admission, the duplicated expected-node fan-out, the IAM ensurer and the Bluebottle master key) remain dependency-rule debt, with their allowlist entries now sourced from `domains/rds/instance`. |
| `handlers/elbv2` | — | Done. The implementation moved to `domains/elbv2` (package `elbv2`) with no API, NATS subject, storage or behaviour change. Q-30's data-plane boundary remains deferred debt; the amended ADR-0001 row makes it not a relocation prerequisite. Its cross-domain, IAM and `agents/elbv2` imports remain dependency-rule debt, with their allowlist entries now sourced from `domains/elbv2`. |
| `handlers/iam/mock` | — | Done. Moved with the implementation to `domains/iam/mock`. |
| `gateway` (root) | Generic HTTP route assembly, SigV4 authentication, the policy gate, request-audit middleware and request-context keys. | Done and deleted. The request pipeline moved into `ingress/aws/pipeline` (as `pipeline.Config`) and the operation inventory into `ingress/aws/dispatch` in `spinifex#1384`; the `/oidc`, `/v2` and node-discovery code had already moved to `runtime/roles/awsgw` in `spinifex#1378`. The layering test binds the pipeline with R2 and `gateway` is a retired legacy root. |
| `gateway` (root) | Generic HTTP route assembly, SigV4/registry authentication, the policy gate, request-audit middleware, request-context keys, and the legacy per-service dispatch switch for every service not yet registered. | Deferred: the ADR-0002 phases above (auth/IAM and request-context boundary design) gate moving the audit, context-key and legacy-dispatch mechanics. |
| `gateway/bedrock` | — | Done. The whole package moved into `domains/ochre` in `spinifex#1266` with its package-private core and tests intact and no behaviour change. Separating the action adapters into `domains/ochre/awsapi` remains deferred, recorded as `domains/ochre` (action adapters) debt above. The action adapters then moved to `domains/ochre/awsapi` behind one ADR-0002 registration in `spinifex#1351`. |
| `gateway/{iam,sts,eks,rds}` | — | Done. The action adapters moved to `domains/{iam,sts,eks,rds}/awsapi` in `spinifex#1265` with no API, NATS subject, storage or behaviour change. ADR-0001 does not make ADR-0002 registration a prerequisite for relocating a domain's adapters, as the EC2 adapter moves already showed; the root `gateway` dispatch that still imports them is recorded `gateway` (service registration) debt. |
| `gateway/{ecs,elbv2}` | — | Done. The action adapters moved to `domains/{ecs,elbv2}/awsapi` in `spinifex#1273` with no API, NATS subject, storage or behaviour change; the root `gateway` dispatch that still imports them is recorded `gateway` (service registration) debt. |
-47
View File
@@ -96,53 +96,6 @@ Limits:
The load balancer VM reports target health through its own host's gateway, so while that gateway is down the unhealthy control plane stays in rotation.
- Not measured: more than one control plane per host, a physical-node failure instead of a stopped gateway, and the target health state as `DescribeTargetHealth` reports it, because the load balancer and control-plane VMs belong to the system account.
### IAM roles for service accounts in your workloads
A pod gets an IAM role by running under a ServiceAccount that carries the role ARN in an annotation.
No pod-spec wiring is needed.
```yaml
apiVersion: v1
kind: ServiceAccount
metadata:
name: app
annotations:
eks.amazonaws.com/role-arn: arn:aws:iam::123456789012:role/app-role
```
Every control plane runs a mutating webhook that acts on pod creation.
When the pod's ServiceAccount has the annotation, the webhook adds these to each container and init container:
- `AWS_ROLE_ARN` and `AWS_WEB_IDENTITY_TOKEN_FILE`, with a projected token volume named `aws-iam-token` mounted at `/var/run/secrets/eks.amazonaws.com/serviceaccount`.
- `AWS_REGION`, `AWS_DEFAULT_REGION` and `AWS_STS_REGIONAL_ENDPOINTS`.
- `AWS_ENDPOINT_URL_STS`, so the SDK calls the Spinifex STS endpoint.
- `AWS_CA_BUNDLE`, pointing at `/etc/spinifex/ca-bundle/ca.pem`, a ConfigMap named `spinifex-ca-bundle` that is created in every namespace and holds the system roots plus the gateway CA.
- `SSL_CERT_FILE` with the same path, only when the container does not set it. Clients such as the AWS Load Balancer Controller read this variable instead of `AWS_CA_BUNDLE`.
Your workload still sets `AWS_ENDPOINT_URL` (or the per-service variable) for every service other than STS.
The token audience defaults to `sts.amazonaws.com`.
The expiry defaults to a value between 79200 and 86400 seconds and is clamped to 600 to 86400 seconds.
Set them with the `eks.amazonaws.com/audience` and `eks.amazonaws.com/token-expiration` annotations on the ServiceAccount.
The `token-expiration` annotation on the pod wins over the ServiceAccount.
A container is left alone when it already has the role, region and regional STS settings, which is how the managed add-ons and your own explicit wiring are skipped.
When some are present, the missing ones and the mount are added.
Containers named in the pod's `eks.amazonaws.com/skip-containers` annotation (comma separated) are not touched.
A pod whose ServiceAccount has no role annotation is not changed.
The webhook fails open: if it cannot decide, the pod is created without injection.
Each injection, skip and failure is logged with the namespace, pod and reason in the control-plane console log.
Because it fails open, a pod that needs a role but starts without credentials is the symptom to look for, and the console log says why.
The webhook listens on `127.0.0.1:8445` of each control plane, and each API server calls its own.
Port 8444 is a different listener, the readiness check described above.
Existing clusters get the webhook only when their control planes are replaced by the current `eks-node` image.
No user-facing operation replaces a control plane, and automatic replacement does not currently work when the replacement reuses the failed member's IP.
Recreate the cluster to adopt it.
User-workload injection through a cluster created with an older image is not measured.
## Prerequisites
> [!IMPORTANT]
-80
View File
@@ -3978,86 +3978,6 @@
"extra": []
}
},
{
"service": "sqs",
"api_version": "2012-11-05",
"opaque": false,
"note": "",
"operations": {
"modelled": [
"AddPermission",
"CancelMessageMoveTask",
"ChangeMessageVisibility",
"ChangeMessageVisibilityBatch",
"CreateQueue",
"DeleteMessage",
"DeleteMessageBatch",
"DeleteQueue",
"GetQueueAttributes",
"GetQueueUrl",
"ListDeadLetterSourceQueues",
"ListMessageMoveTasks",
"ListQueueTags",
"ListQueues",
"PurgeQueue",
"ReceiveMessage",
"RemovePermission",
"SendMessage",
"SendMessageBatch",
"SetQueueAttributes",
"StartMessageMoveTask",
"TagQueue",
"UntagQueue"
],
"registered": [
"ChangeMessageVisibility",
"ChangeMessageVisibilityBatch",
"CreateQueue",
"DeleteMessage",
"DeleteMessageBatch",
"GetQueueAttributes",
"GetQueueUrl",
"ListQueueTags",
"ListQueues",
"ReceiveMessage",
"SendMessage",
"SendMessageBatch",
"SetQueueAttributes",
"TagQueue",
"UntagQueue"
],
"implemented": [
"ChangeMessageVisibility",
"ChangeMessageVisibilityBatch",
"CreateQueue",
"DeleteMessage",
"DeleteMessageBatch",
"GetQueueAttributes",
"GetQueueUrl",
"ListQueueTags",
"ListQueues",
"ReceiveMessage",
"SendMessage",
"SendMessageBatch",
"SetQueueAttributes",
"TagQueue",
"UntagQueue"
],
"stubbed": [],
"unsupported": [],
"missing": [
"AddPermission",
"CancelMessageMoveTask",
"DeleteQueue",
"ListDeadLetterSourceQueues",
"ListMessageMoveTasks",
"PurgeQueue",
"RemovePermission",
"StartMessageMoveTask"
],
"extra": []
}
},
{
"service": "sts",
"api_version": "2011-06-15",
-15
View File
@@ -1441,21 +1441,6 @@ request-payment: Requester Pays shifts transfer charges to the caller, which nee
- `GetBucketRequestPayment`
- `PutBucketRequestPayment`
## sqs
`2012-11-05` — 15 of 23 modelled operations implemented (65.2%), 0 stubbed, 0 not applicable, 0 registered outside the pinned model.
### Not implemented
- `AddPermission`
- `CancelMessageMoveTask`
- `DeleteQueue`
- `ListDeadLetterSourceQueues`
- `ListMessageMoveTasks`
- `PurgeQueue`
- `RemovePermission`
- `StartMessageMoveTask`
## sts
`2011-06-15` — 5 of 11 modelled operations implemented (45.5%), 0 stubbed, 0 not applicable, 0 registered outside the pinned model.
+1 -1
View File
@@ -164,7 +164,7 @@ On Oracle Linux the installer makes these changes, which Debian hosts do not nee
- **Repositories.** It enables `ol10_UEKR8` and `ol10_kvm_utils`, and adds `qemu-kvm*` and `qemu-img*` to the `excludepkgs` of `ol10_appstream`, keeping any excludes already set, so a later AppStream build can never replace the KVM Utilities QEMU.
- **NFV SIG.** It writes `/etc/yum.repos.d/spinifex-nfv-sig.repo` with an `includepkgs=` list of the OVS and OVN packages, and imports the SIG key to `/etc/pki/rpm-gpg/RPM-GPG-KEY-CentOS-SIG-NFV` only when its fingerprint is `35154228174901BEFA8E69A621465E289D2A76A7`.
- **OVS and OVN user.** Both run as root, as on Debian, through `OVS_USER_ID` and `OVN_USER_ID` in `/etc/sysconfig/openvswitch` and `/etc/sysconfig/ovn`.
- **DNS.** Where systemd-resolved is installed but not running, it saves `/etc/resolv.conf` to `/var/lib/spinifex-reset/resolv.conf.orig`, starts resolved behind NetworkManager, tells NetworkManager in `/etc/NetworkManager/conf.d/90-spinifex-dns.conf` to leave `/etc/resolv.conf` alone, and links `/etc/resolv.conf` to the resolved stub. It then checks that names still resolve and stops, printing the commands that put DNS back, if they do not.
- **DNS.** Where systemd-resolved is installed but not running, it saves `/etc/resolv.conf` to `/var/lib/spinifex-reset/resolv.conf.orig`, starts resolved behind NetworkManager and links `/etc/resolv.conf` to the resolved stub. It then checks that names still resolve and stops, printing the commands that put DNS back, if they do not.
- **chrony.** It appends `confdir /etc/chrony.d` to `/etc/chrony.conf` once, and writes its step policy to `/etc/chrony.d/spinifex.conf`.
- **`spx` under `sudo`.** Oracle Linux's `sudo` path leaves out `/usr/local/bin`, so the installer links `/usr/bin/spx` to `/usr/local/bin/spx`.
@@ -16,16 +16,16 @@ Create, update, delete and policy association complete synchronously within the
|---|---|---|
| Identity | Cluster name plus principal ARN. Key and ARN suffix use `sha256hex(principalARN)` (`domains/eks/access/store.go` `PrincipalARNHash`). ARN `arn:aws:eks:{region}:{account}:access-entry/{cluster}/{hash}` (`foundation/aws/arn/eks.go`); the AWS ARN shape differs and is not verified. | `handlers/eks/service_impl_test.go` `TestAccessEntry_CreateDescribeListDelete`, `domains/eks/access/store_test.go` `TestKeyPaths`, `gateway/eks/authz_test.go` `TestResourceARNsAccessEntryMatchesHandler` |
| Scope | Account and Region, within one cluster: keys live under `clusters/{cluster}/access-entries/` in the `eks-account-{account}` bucket. | `handlers/eks/access_lifecycle_test.go` `TestAccessEntryPersistedLayout_TopLevelAndNestedFieldNames`, `TestAccessEntryRecord_DecodesHandWrittenJSON`, `handlers/eks/create_cluster_test.go` `TestCreateCluster_SeedsCreatorAdminAccessEntry` |
| Durable owner and record | `domains/eks/access` (`Owner`) is the only writer of `Record` with nested `AssociatedPolicy` and `Scope`: Create, SeedCreatorAdmin (cluster launch), Update, Associate, Disassociate and Delete. Keys and field names are unchanged by the move. `DeleteClusterChildren` (`domains/eks/cluster/cluster_state.go`) still erases entries with the cluster, and the gateway process reads records through the exported `access.Get` in `ResolveTokenReview` (`handlers/eks/token_review.go`). | `handlers/eks/access_lifecycle_test.go` `TestAccessEntryPersistedLayout_TopLevelAndNestedFieldNames`, `TestAccessEntryRecord_DecodesHandWrittenJSON`, `domains/eks/access/owner_test.go`, `domains/eks/access/store_test.go`, `handlers/eks/create_cluster_test.go` `TestCreateCluster_SeedsCreatorAdminAccessEntry` |
| Durable owner and record | `domains/eks/access` (`Owner`) is the only writer of `Record` with nested `AssociatedPolicy` and `Scope`: Create, SeedCreatorAdmin (cluster launch), Update, Associate, Disassociate and Delete. Keys and field names are unchanged by the move. `DeleteClusterPrefix` (`handlers/eks/cluster_state.go`) still erases entries with the cluster, and the gateway process reads records through the exported `access.Get` in `ResolveTokenReview` (`handlers/eks/token_review.go`). | `handlers/eks/access_lifecycle_test.go` `TestAccessEntryPersistedLayout_TopLevelAndNestedFieldNames`, `TestAccessEntryRecord_DecodesHandWrittenJSON`, `domains/eks/access/owner_test.go`, `domains/eks/access/store_test.go`, `handlers/eks/create_cluster_test.go` `TestCreateCluster_SeedsCreatorAdminAccessEntry` |
| AWS adapter | `handlers/eks` keeps request validation (entry type, supported policy, scope), the cluster existence check (`acctKVForCluster`), awserrors mapping and the AWS projection. `gateway/eks/authz.go` uses `access.PrincipalARNHash`. | `handlers/eks/service_impl_test.go` access cases, `gateway/eks/authz_test.go` `TestResourceARNsAccessEntryMatchesHandler` |
| Desired state | Principal, Kubernetes username and groups, type (`STANDARD` only), tags (set at create only) and cluster-scoped access policy associations. | `handlers/eks/service_impl_test.go` `TestAccessEntry_RejectsNodeType`, `TestAccessPolicy_AssociateRejectsUnsupportedPolicyAndScope`, `TestAccessPolicy_AssociateRejectsNamespaceScope`, `handlers/eks/access_lifecycle_test.go` `TestAccessEntry_UpdateWithNoFieldsKeepsUsernameAndGroups` |
| Generation | None. Update, associate and disassociate retry against the KV revision; create and delete do not. | |
| Observed state | Not tracked; the record is the only state. | |
| Readiness | Usable by token review as soon as the write returns. | `handlers/eks/token_review_test.go` `TestResolveTokenReview_AuthenticatesViaVerifyAndKV`, `TestResolveTokenReview_NoAccessEntryDenies`, `handlers/eks/service_impl_test.go` `TestAccessPolicy_DisassociateRemovesProjectedGroup` |
| Idempotency | `clientRequestToken` is ignored. Update always advances `modifiedAt`, even when nothing changes. A duplicate create returns `ResourceInUseException`; a repeated delete returns `ResourceNotFoundException`; disassociating an unbound policy succeeds without change. Internal NATS redelivery of create returns `ResourceInUseException` to the redelivered request. | `handlers/eks/service_impl_test.go` `TestAccessEntry_CreateDescribeListDelete`, `TestAccessEntry_DescribeDeleteMissingIsNotFound`, `handlers/eks/access_lifecycle_test.go` `TestAccessPolicy_DisassociateUnassociatedLeavesRecordUnchanged`, `TestAccessPolicy_AssociateWithNoEntryIsNotFound` |
| Dependencies | Every action requires that the cluster's meta record exists (`acctKVForCluster`). `CreateAccessEntry` also refuses a `DELETING` cluster with `ResourceInUseException`, since a recorded delete admits no new dependant; no other status is read, so actions succeed on a `CREATING` or `FAILED` cluster. Token review does not check the cluster at all. The principal is not checked against IAM. | `handlers/eks/service_impl_test.go` `TestAccessEntry_UnknownClusterIsNotFound`, `handlers/eks/access_lifecycle_test.go` `TestAccessEntryActionsIgnoreFailedClusterStatus`, `delete_cluster_async_test.go` `TestS2_EKSDeletingClusterAdmitsNoNewDependants`, `TestResolveTokenReview_AuthenticatesWithoutClusterMeta` |
| Dependencies | Every action requires only that the cluster's meta record exists (`acctKVForCluster`); cluster status is not read, so actions succeed on a `CREATING`, `FAILED` or `DELETING` cluster. Token review does not check the cluster at all. The principal is not checked against IAM. | `handlers/eks/service_impl_test.go` `TestAccessEntry_UnknownClusterIsNotFound`, `handlers/eks/access_lifecycle_test.go` `TestAccessEntryActionsIgnoreClusterLifecycleStatus`, `TestResolveTokenReview_AuthenticatesWithoutClusterMeta` |
| Authorization | Gateway authz derives the access-entry ARN with `PrincipalARNHash` (`gateway/eks/authz.go`). No `iam:PassRole`. The STS step of token review is the existing `eks.VerifyToken` NATS contract served by awsgw. | `gateway/eks/authz_test.go` `TestResourceARNsAccessEntryMatchesHandler` |
| Deletion ordering | Delete is get-then-delete, not conditional. Cluster delete erases entries with the cluster's other child records, before the meta, only after all infrastructure teardown succeeds; on failure they survive while the cluster stays `DELETING`. | `handlers/eks/access_lifecycle_test.go` `TestDeleteClusterChildren_SweepsAccessEntriesScopedToCluster` |
| Deletion ordering | Delete is get-then-delete, not conditional. Cluster delete erases entries in the prefix purge only after all infrastructure teardown succeeds; on failure they survive while the cluster stays `DELETING`. | `handlers/eks/access_lifecycle_test.go` `TestDeleteClusterPrefix_SweepsAccessEntriesScopedToCluster` |
| Restart and interruption | The prefix purge deletes keys in listing order; an interruption after the meta key is gone leaves entries that a new cluster of the same name inherits. Reclaiming a `FAILED` cluster and a failed launch purge with the meta kept, so the recreated cluster inherits the failed attempt's entries. | none |
| End-to-end | `tests/e2e/eks/eks_test.go` `AccessEntry` and `GetToken`; `tests/integration/cross_tenant_test.go`. No Terraform apply/destroy evidence for `aws_eks_access_entry` or `aws_eks_access_policy_association`. | |
@@ -33,7 +33,7 @@ Create, update, delete and policy association complete synchronously within the
1. Create is get-then-put with no exclusive create, so concurrent creates for one principal both succeed and the later write wins.
2. The creator-admin seed at cluster launch is an unconditional put after the cluster meta is final, so it replaces an entry for the same principal created while the cluster was `CREATING`, including its policy associations.
3. Access actions other than `CreateAccessEntry` do not check cluster status (see dependencies row), and opening the bucket creates it for an account that has none.
3. Access actions do not check cluster status (see dependencies row), and opening the bucket creates it for an account that has none.
4. Entries can outlive their cluster (interrupted prefix purge) or carry over to a recreated cluster (reclaim and failed launch keep the meta and do not purge entries separately).
5. Token review authenticates against an entry without checking the cluster exists or its status.
6. `ResolveTokenReview` opens the EKS bucket and reads the record in the gateway process through the exported `access.Get`, outside the owner.
+9 -9
View File
@@ -15,10 +15,10 @@ There is no generation, so the record mixes desired fields with the latest obser
| Item | Present behaviour | Evidence |
|---|---|---|
| Identity | Cluster name plus add-on name; one add-on per name per cluster. ARN `arn:aws:eks:{region}:{account}:addon/{cluster}/{addon}` (`foundation/aws/arn/eks.go` `FormatEKSAddon`), deterministic by name, with no per-incarnation suffix. The gateway authorizer derives the same ARN (`domains/eks/awsapi/authz.go` `addonARN`). | `handlers/eks/addon_lifecycle_test.go` `TestAddonPersistedLayout_RecordAndManifestKeysAndFieldNames`, `domains/eks/awsapi/authz_test.go` `TestResourceARNsFidelity` |
| Scope | Account and Region, within one cluster: keys live under `clusters/{cluster}/addons/` in the `eks-account-{account}` bucket. | `TestAddonPersistedLayout_RecordAndManifestKeysAndFieldNames`, `TestDeleteClusterChildren_SweepsAddonRecordsAndManifestsScopedToCluster` |
| Identity | Cluster name plus add-on name; one add-on per name per cluster. ARN `arn:aws:eks:{region}:{account}:addon/{cluster}/{addon}` (`foundation/aws/arn/eks.go` `FormatEKSAddon`), deterministic by name, with no per-incarnation suffix. The gateway authorizer derives the same ARN (`gateway/eks/authz.go` `addonARN`). | `handlers/eks/addon_lifecycle_test.go` `TestAddonPersistedLayout_RecordAndManifestKeysAndFieldNames`, `gateway/eks/authz_test.go` `TestResourceARNsFidelity` |
| Scope | Account and Region, within one cluster: keys live under `clusters/{cluster}/addons/` in the `eks-account-{account}` bucket. | `TestAddonPersistedLayout_RecordAndManifestKeysAndFieldNames`, `TestDeleteClusterPrefix_SweepsAddonRecordsAndManifestsScopedToCluster` |
| Durable record | `clusters/{cluster}/addons/{addon}` holds `Record` (`domains/eks/addon/record.go`): `addonName`, `addonVersion`, `status`, `serviceAccountRoleArn`, `configurationValues`, `health`, `arn`, `tags`, `createdAt`, `modifiedAt` (the optional ones `omitempty`). `clusters/{cluster}/addons/{addon}/manifest` holds `Manifest` (`domains/eks/addon/record.go`): `addonName`, `addonVersion`, `serviceAccountRoleArn`, `configurationValues`. Listing skips the manifest sub-key by segment count. | `TestAddonPersistedLayout_RecordAndManifestKeysAndFieldNames`, `TestAddonRecord_DecodesHandWrittenJSON`, `domains/eks/addon/store_test.go` `TestRecord_RoundTrip`, `TestList_SkipsManifestSubKeys` |
| Writers | Record: the owner in `domains/eks/addon/owner.go`, reached from the `CreateAddon`, `UpdateAddon` and `DeleteAddon` handlers, the cluster reconciler's report relay and the GPU node group launch: `Owner.Create` (unconditional put), `Owner.Update` and its failure marking (revision CAS), `Owner.Delete` (get then delete), `Owner.ApplyReport` (revision CAS), `Owner.EnsureGPUDevicePlugin` through `Owner.Create`; and the cluster prefix purge (`DeleteClusterChildren`). Manifest: `StagingInstaller.Install` (unconditional put on create and update), `StagingInstaller.Uninstall` (`domains/eks/addon/delivery.go`), `Owner.Delete` and the prefix purge. | `TestAddonPersistedLayout_RecordAndManifestKeysAndFieldNames`, `TestUpdateAddon_ReportsSuccessfulWhileRecordIsUpdating`, `TestDeleteAddon_ReturnsDeletingAfterErasingRecordAndManifest`, `TestDeleteClusterChildren_SweepsAddonRecordsAndManifestsScopedToCluster`, `domains/eks/addon/store_test.go` `TestCasUpdate`, `TestDeleteRecord_RemovesRecordAndManifest` |
| Writers | Record: the owner in `domains/eks/addon/owner.go`, reached from the `CreateAddon`, `UpdateAddon` and `DeleteAddon` handlers, the cluster reconciler's report relay and the GPU node group launch: `Owner.Create` (unconditional put), `Owner.Update` and its failure marking (revision CAS), `Owner.Delete` (get then delete), `Owner.ApplyReport` (revision CAS), `Owner.EnsureGPUDevicePlugin` through `Owner.Create`; and the cluster prefix purge (`DeleteClusterPrefix`). Manifest: `StagingInstaller.Install` (unconditional put on create and update), `StagingInstaller.Uninstall` (`domains/eks/addon/delivery.go`), `Owner.Delete` and the prefix purge. | `TestAddonPersistedLayout_RecordAndManifestKeysAndFieldNames`, `TestUpdateAddon_ReportsSuccessfulWhileRecordIsUpdating`, `TestDeleteAddon_ReturnsDeletingAfterErasingRecordAndManifest`, `TestDeleteClusterPrefix_SweepsAddonRecordsAndManifestsScopedToCluster`, `domains/eks/addon/store_test.go` `TestCasUpdate`, `TestDeleteRecord_RemovesRecordAndManifest` |
| Readers | `ListAddons`, `DescribeAddon`, `CreateAddon` (existence check) and the reconciler read the record. `ListStagedAddonManifests` reads every `/manifest` key for the cluster (`addon.ListManifests`); it is served on `eks.ListStagedAddonManifests` (`daemon/daemon.go`) and reached only through the gateway's internal-addons route. | `handlers/eks/addons_test.go` `TestAddon_DescribeListDelete`, `TestListStagedAddonManifests` |
| Catalogue | Static in-binary catalogue (`domains/eks/addon/catalog.go`): `aws-load-balancer-controller`, `argocd`, `aws-ebs-csi-driver`, and hidden-but-creatable `nvidia-device-plugin` and `spinifex-noop`; one version each, the first being the default. Create and update reject unknown names and versions with `InvalidParameterValue`. | `domains/eks/addon/catalog_test.go`, `handlers/eks/addons_test.go` `TestDescribeAddonVersions_ReturnsCatalog`, `TestCreateAddon_UnknownAddonRejected`, `TestCreateAddon_UnknownVersionRejected`, `TestUpdateAddon_UnknownVersionRejected` |
| Desired state | Version, `serviceAccountRoleArn`, `configurationValues` and create-time tags. `resolveConflicts`, `podIdentityAssociations` and `clientRequestToken` are accepted and dropped; `preserve` on delete is ignored. `configurationValues` reaches the guest but the agent discards it (`mulga-eks-addon-sync.sh` `sync_once`), so it has no effect. | `TestAddonPersistedLayout_RecordAndManifestKeysAndFieldNames`, `TestDeleteAddon_ReturnsDeletingAfterErasingRecordAndManifest` |
@@ -29,17 +29,17 @@ There is no generation, so the record mixes desired fields with the latest obser
| Readiness | `ready` maps any non-`ACTIVE` status to `ACTIVE` and clears `health`; `applied` and unknown phases change nothing. The agent reports `ready` when K3s records the add-on's `gvks` annotation, or after a 90 s grace once the bundle's workloads are rolled out; the check is per rendered file, not per version. | `domains/eks/addon/owner_test.go` `TestNextStatus`, `TestOwnerApplyReport`, `handlers/eks/addon_status_reconciler_test.go` `TestApplyAddonStatusReport`, `tests/e2e/eks/eks_test.go` `TestEKS/AddonDelivery` |
| Failure | Staging failure on create or update marks the record `CREATE_FAILED` with the error as `health` and returns the raw error. A `failed` report maps `ACTIVE` to `DEGRADED`, keeps `DEGRADED` and `CREATE_FAILED`, and maps anything else (including `UPDATING`) to `CREATE_FAILED`. Neither failure state is terminal: a later `ready` report returns the add-on to `ACTIVE`. | `TestCreateAddon_InstallerFailureLeavesDurableCreateFailedRecord`, `TestUpdateAddon_InstallerFailureMarksCreateFailed`, `TestAddonStatusReport_MismatchedVersionIsAccepted`, `handlers/eks/addons_test.go` `TestCreateAddon_InstallerFailureMarksCreateFailed` |
| Delete | Read the record, build a `DELETING` response from it, delete the manifest, then delete the record and manifest again (`Owner.Delete`). Both keys are gone when the call returns. The guest later notices the missing manifest, removes the rendered file and runs `kubectl delete --wait=false`; it sends no report, so nothing observes removal. | `TestDeleteAddon_ReturnsDeletingAfterErasingRecordAndManifest`, `TestListStagedAddonManifests`, `tests/e2e/eks/eks_test.go` `TestEKS/AddonDelivery` |
| Guest fetch | `GET /clusters/{cluster}/internal-addons/{accountId}` (`domains/eks/awsapi/internal_addons.go`, `ListInternalAddons`) is admitted only for a control-plane agent serving that cluster (`AuthorizeInternal`), relays to `eks.ListStagedAddonManifests`, and wraps the result as `contracts/eks/v1` `InternalAddonsResponse`. The body is written by the SDK REST-JSON marshaller, which ignores the `json` tags: it is `{"Addons":[{"AddonName","AddonVersion","ServiceAccountRoleArn","ConfigurationValues"}]}` with empty strings present, sorted by add-on name. | `domains/eks/awsapi/gateway_wire_test.go` `TestEKSRequest_InternalAddonsWireBody`, `contracts/eks/v1` `TestInternalAddonsResponse_RESTJSONBody`, `domains/eks/awsapi/gateway_authz_test.go` `TestEKSRequest_InternalRoutesAdmitTheCPAgentPrincipal`, `domains/eks/awsapi/internal_authz_test.go` `TestAuthorizeInternal_RejectsClusterTheCallerDoesNotServe` |
| Guest fetch | `GET /clusters/{cluster}/internal-addons/{accountId}` (`gateway/eks.go`, `ListInternalAddons`) is admitted only for a control-plane agent serving that cluster (`AuthorizeInternal`), relays to `eks.ListStagedAddonManifests`, and wraps the result as `contracts/eks/v1` `InternalAddonsResponse`. The body is written by the SDK REST-JSON marshaller, which ignores the `json` tags: it is `{"Addons":[{"AddonName","AddonVersion","ServiceAccountRoleArn","ConfigurationValues"}]}` with empty strings present, sorted by add-on name. | `gateway/eks_addon_wire_test.go` `TestEKSRequest_InternalAddonsWireBody`, `contracts/eks/v1` `TestInternalAddonsResponse_RESTJSONBody`, `gateway/eks_authz_test.go` `TestEKSRequest_InternalRoutesAdmitTheCPAgentPrincipal`, `gateway/eks/internal_authz_test.go` `TestAuthorizeInternal_RejectsClusterTheCallerDoesNotServe` |
| TSV conversion | `eks-gateway-fetch -resource addons` (`agents/eks/gatewayfetch`) decodes the body into its own copy of the shape (matching the Go field names only because `encoding/json` is case-insensitive) and writes `name\tversion\troleArn\tbase64(config)` per add-on; an empty set writes nothing. It retries the GET up to 30 times at 5 s. | `agents/eks/gatewayfetch/addons_wire_test.go` `TestEmitAddonsTSV_GatewayWireBody`, `agents/eks/gatewayfetch/emit_test.go` `TestEmitAddonsTSV`, `TestEmitAddonsTSV_Empty`, `agents/eks/gatewayfetch/fetch_test.go` `TestRun_RendersAddons`, `TestRun_FailsAfterAttemptBudget` |
| Guest render and report | Every 30 s (`ADDON_SYNC_INTERVAL`) the agent renders each staged add-on, substituting the role ARN into web-identity env when set, then publishes `{"addon","version","phase","message","ts"}` through `eks-gateway-publish -channel addon`. The gateway relays the raw payload to core NATS `eks.addon.{account}.{cluster}.status` (`domains/eks/awsapi/internal_publish.go`). A fetch failure keeps the current render. | `domains/eks/awsapi/internal_publish_test.go` `TestPublishInternal_RelaysToSubjects`, `agents/eks/gatewaypublish/publish_test.go` `TestRun_PublishesWrappedPayload`, `TestRun_FailsAfterAttemptBudget` |
| Guest render and report | Every 30 s (`ADDON_SYNC_INTERVAL`) the agent renders each staged add-on, substituting the role ARN into web-identity env when set, then publishes `{"addon","version","phase","message","ts"}` through `eks-gateway-publish -channel addon`. The gateway relays the raw payload to core NATS `eks.addon.{account}.{cluster}.status` (`gateway/eks/internal_publish.go`). A fetch failure keeps the current render. | `gateway/eks/internal_publish_test.go` `TestPublishInternal_RelaysToSubjects`, `agents/eks/gatewaypublish/publish_test.go` `TestRun_PublishesWrappedPayload`, `TestRun_FailsAfterAttemptBudget` |
| Retry meaning | Reports are level-triggered: each tick re-sends the current phase for every staged add-on, so a lost report is repeated on the next tick. `eks-gateway-publish` retries the POST up to 30 times at 5 s and the agent ignores its failure. Core NATS keeps nothing: a report published while no reconciler holds the cluster's lease, or after the reconciler stops on a terminal cluster status, is dropped. | none |
| Report ordering | The reconciler subscribes plainly (no queue group) on the lease holder and applies reports in arrival order through a revision CAS. Nothing orders a report against a later update: a report produced for the previous version settles the new `UPDATING` record, and a stale `failed` turns it `CREATE_FAILED`. A report for a deleted add-on is a no-op. | `TestAddonStatusReport_MismatchedVersionIsAccepted`, `TestDeleteAddon_ReturnsDeletingAfterErasingRecordAndManifest` |
| Cross-resource dependency | A GPU node group's launch calls `stageGPUDeviceAddon`, which checks the cluster exists and calls `Owner.EnsureGPUDevicePlugin` for `nvidia-device-plugin`; an existing record is a no-op and every other error is logged only. Deleting the node group never unstages it, and an existing record in any status, including `CREATE_FAILED`, blocks re-staging. | `handlers/eks/nodegroup_test.go` `TestCreateNodegroup_GPUNodegroupStagesDevicePluginAddon`, `domains/eks/addon/owner_test.go` `TestOwnerEnsureGPUDevicePlugin_StagesDefaultVersionOnceAndKeepsAnyExistingRecord`, `TestCreateNodegroup_NonGPUNodegroupDoesNotStageDevicePluginAddon`, `tests/e2e/gpu/eks_gpu_test.go` `TestEKSGPUPodExposure` |
| Idempotency | `clientRequestToken` is ignored. A duplicate create, including an SDK retry with the same token, returns `ResourceInUseException`. A repeated update re-stages and returns another `Successful` with the same id. A repeated delete returns `ResourceNotFoundException`. | `TestCreateAddon_RepeatedClientRequestTokenIsResourceInUse`, `TestUpdateAddon_ReportsSuccessfulWhileRecordIsUpdating`, `TestDeleteAddon_ReturnsDeletingAfterErasingRecordAndManifest`, `handlers/eks/addons_test.go` `TestCreateAddon_CreatesStagingAndDefaultsVersion` |
| Absent resource | An unknown cluster returns `ResourceNotFoundException` for every action, including the internal manifest listing. A missing add-on returns `ResourceNotFoundException` from describe, update and delete. | `handlers/eks/addons_test.go` `TestCreateAddon_UnknownClusterIsNotFound`, `TestDescribeDeleteAddon_MissingIsNotFound`, `TestUpdateAddon_MissingIsNotFound`, `TestListStagedAddonManifests` |
| Dependencies | `CreateAddon` checks the cluster meta exists and refuses a `DELETING` cluster with `ResourceInUseException`, since a recorded delete admits no new dependant; other statuses are not read. | `TestCreateAddonRefusesOnlyADeletingCluster` |
| Authorization | Gateway policy uses the add-on ARN above. `serviceAccountRoleArn` requires `iam:PassRole` on `CreateAddon` and `UpdateAddon` (`domains/eks/awsapi/passrole.go`), the same check `CreateCluster`/`CreateNodegroup` apply, including that the role exists. Tag actions on an add-on ARN return `NotImplemented`. | `domains/eks/awsapi/gateway_wire_test.go` `TestPassedRoleARNs_AddonServiceAccountRoleIsChecked`, `domains/eks/awsapi/gateway_passrole_test.go` `TestEKSCreateAddon_PassRole`, `TestEKSUpdateAddon_PassRole`, `domains/eks/awsapi/authz_test.go` `TestResourceARNsFidelity` |
| Cluster deletion | Add-on records and manifests are erased only by the cluster prefix purge, after the cluster's infrastructure teardown succeeds; there is no per-add-on delete. Reclaiming a failed cluster keeps the meta and does not purge, so the recreated cluster inherits the add-ons. | `TestDeleteClusterChildren_SweepsAddonRecordsAndManifestsScopedToCluster`, `handlers/eks/cluster_state_test.go` `TestDeleteClusterChildren_SweepsEveryKey` |
| Dependencies | Only the cluster meta's existence is checked; cluster status is not read, so add-ons can be created on a `DELETING` cluster. | `TestAddonActionsIgnoreClusterLifecycleStatus` |
| Authorization | Gateway policy uses the add-on ARN above. `serviceAccountRoleArn` requires `iam:PassRole` on `CreateAddon` and `UpdateAddon` (`gateway/eks/passrole.go`), the same check `CreateCluster`/`CreateNodegroup` apply, including that the role exists. Tag actions on an add-on ARN return `NotImplemented`. | `gateway/eks_addon_wire_test.go` `TestPassedRoleARNs_AddonServiceAccountRoleIsChecked`, `gateway/eks_passrole_test.go` `TestEKSCreateAddon_PassRole`, `TestEKSUpdateAddon_PassRole`, `gateway/eks/authz_test.go` `TestResourceARNsFidelity` |
| Cluster deletion | Add-on records and manifests are erased only by the cluster prefix purge, after the cluster's infrastructure teardown succeeds; there is no per-add-on delete. Reclaiming a failed cluster keeps the meta and does not purge, so the recreated cluster inherits the add-ons. | `TestDeleteClusterPrefix_SweepsAddonRecordsAndManifestsScopedToCluster`, `handlers/eks/cluster_state_test.go` `TestDeleteClusterPrefix_SweepsEveryKey` |
| Restart and interruption | Create interrupted between the record put and staging leaves a `CREATING` record with no manifest that nothing re-stages and that blocks a retry. Delete interrupted after unstaging leaves a record in its previous status while the guest removes the software. An update interrupted after the CAS leaves `UPDATING` with the previous manifest. A daemon restart loses nothing durable, but reports sent while no reconciler runs are lost until the next tick. | none |
| End-to-end | `tests/e2e/eks/eks_test.go` `TestEKS/AddonDelivery` (create to `ACTIVE`, objects present, delete removes the namespace) and `tests/e2e/gpu/eks_gpu_test.go` `TestEKSGPUPodExposure`; `tests/integration/cross_tenant_test.go` `TestCrossTenant_EveryScopedActionIsAccountedFor`. No E2E for update, failure, configuration or the role. No Terraform apply/destroy evidence for `aws_eks_addon`. | |
@@ -52,7 +52,7 @@ There is no generation, so the record mixes desired fields with the latest obser
5. `DELETING` exists only in the delete response; the record and manifest are erased before any guest removal, so `DescribeAddon` returns not found immediately.
6. No observed deletion acknowledgement: the agent removes objects with `--wait=false` and reports nothing.
7. `configurationValues` is stored, staged and transported but discarded by the agent; `resolveConflicts`, `preserve` and `podIdentityAssociations` are accepted and never used.
8. Fixed: `serviceAccountRoleArn` now requires `iam:PassRole` on `CreateAddon` and `UpdateAddon`, matching `CreateCluster`/`CreateNodegroup` (`domains/eks/awsapi/passrole.go`).
8. Fixed: `serviceAccountRoleArn` now requires `iam:PassRole` on `CreateAddon` and `UpdateAddon`, matching `CreateCluster`/`CreateNodegroup` (`gateway/eks/passrole.go`).
9. AWS `UPDATE_FAILED` and `DELETE_FAILED` are not represented: a failed update becomes `CREATE_FAILED` and a failed delete leaves the prior status.
10. The add-on ARN lacks the per-incarnation suffix AWS appends, so a recreated add-on reuses its predecessor's ARN. This is a compatibility gap; extraction must keep the current ARN.
11. The guest/controller wire shape is versioned as `contracts/eks/v1` (subject, delivery phases, status report, staged manifest, internal-addons body), used directly by the handler, gateway and `agents/eks/gatewayfetch`. The add-on owner keeps its own `Manifest` and `Report`, and the shell agent still builds the report with `printf`. The HTTP body uses Go field names because the REST-JSON marshaller ignores the `json` tags, and the agent decodes it only through case-insensitive matching; the contract pins that form rather than changing it.
@@ -1,42 +0,0 @@
# EKS cluster: delete lifecycle contract
Contract under ADR-0003 S1 for the delete half of an EKS cluster's lifecycle, the half ADR-0003 S8 constrains.
Create, update and the control-plane reconciler are out of its scope and keep the observed gaps recorded elsewhere.
Paths are relative to `spinifex/` unless they start with `tests/`, which is relative to the spinifex repo root.
Test names without a path are in `domains/eks/cluster/`.
## Scope
A cluster is a composite resource: its record at `clusters/{name}/meta` in the `eks-account-{account}` bucket names the dependants a delete must remove.
In the system account those are the control-plane VMs and their root volumes, the endpoint load balancer with its target groups, the managed control-plane VPC and its network, and the spread placement group.
In the customer account they are the egress address, the private-endpoint interface and security group, the cluster security groups, a cluster-owned internet gateway, and load balancers the in-cluster controller created.
Node group workers and CSI-provisioned EBS volumes are never removed by a cluster delete.
## Contract
| Item | Behaviour | Evidence |
|---|---|---|
| Request | `DeleteCluster` validates, answers `ResourceNotFoundException` for an absent cluster, answers a `DELETING` cluster from its record, refuses a cluster with node groups with `ResourceInUseException`, records `DELETING` and returns. It waits for no teardown step. | `TestS8_EKSDeleteClusterReturnsBeforeTheTeardownCompletes`, `TestRLC1_EKSDeleteClusterNotFoundOnAbsent`, `TestS8_EKSDeleteClusterRefusesWhileNodegroupsExist`, `tests/e2e/eks` `TestEKS/DeleteCluster` |
| Obligation | `DELETING` with `DeletingSince` on the record is the tenant's decision. No status writer moves a record off it, and a create launch still running adds its refs to it instead of overwriting it. | `TestS2_EKSDeletingIsStickyForStatusWriters`, `TestS2_EKSLaunchWriteAfterDeleteNeitherRevertsNorRecreates` |
| Retry | A repeat delete answers `DELETING` from the record and starts nothing. | `TestS8_EKSSecondDeleteAnswersFromTheRecordWithoutASecondTeardown` |
| Convergence | `convergeClusterDeletion` (`domains/eks/cluster/cluster_teardown.go`), started by the request and backed by `EKSDeletingReaper`, runs under a per-cluster in-process gate and the cross-node teardown lease, renewed on the service context. It acts only on a record that is `DELETING` when re-read. | `TestS8_EKSKickAndSameNodeSweepRunOneTeardown`, `TestS8_EKSTeardownLeaseOutlivesTheRequest`, `TestS7_EKSTeardownActsOnlyOnARecordedDelete` |
| Retained cleanup | A failed step keeps the record `DELETING` with its refs and the error. The record is removed last, after its child records, and only if no ref appeared that the pass did not tear down. A record with a node group is never removed; a record that is not `DELETING` is refused before anything else is checked. | `TestRLC2_EKSBillableTeardownBeforeKVSweep`, `TestS2_EKSTeardownRemovesTheMetaLast`, `TestS2_EKSTeardownDoesNotSweepARecordChangedDuringTeardown`, `TestS7_EKSTeardownNeverRemovesAClusterWithNodegroupWorkers` |
| Recovery | A teardown interrupted by a crash or a lost lease is finished by the reaper on any node. Failed passes back off from 90 s, doubling to a 30 min cap, and are never abandoned; `DeleteReapExhausted` only flags a long failure for an operator. | `TestS2_EKSTeardownResumesAfterAnInterruptedConverge`, `TestRLC4_DeletingReaperReDrivesWedgedTeardown`, `TestDeletingReaper_KeepsRetryingPastTheOldAttemptLimit`, `TestDeletingReaper_ResumesAnAlreadyExhaustedRecord` |
| Visibility | `DescribeCluster` and `ListClusters` show the cluster `DELETING` until the record is removed, then `ResourceNotFoundException`. A failed pass shows one `INTERNAL_FAILURE` health issue naming the step, never the internal error. | `TestDescribeCluster_StuckDeleteShowsTheStepNotTheError`, `TestRLC2_EKSBillableTeardownBeforeKVSweep` |
| New dependants | `CreateNodegroup` requires `ACTIVE`; `CreateAddon` and `CreateAccessEntry` refuse a `DELETING` cluster with `ResourceInUseException`. | `TestCreateAddonRefusesOnlyADeletingCluster`, `TestS2_EKSDeletingClusterAdmitsNoNewDependants` |
| Subject budgets | Every `eks.*` request subject is classified by its handler's work in `eksSubjects` (`domains/eks/cluster/service_nats.go`) and keeps the 30 s budget; `eks.DeleteCluster` is request-scaled. A subject the gateway sends or the daemon serves that is not classified fails the table. | `TestS8_EKSSubjectBudgets` |
| Late child records | No child record outlives the cluster or leaks into a later one of the same name. The teardown sweeps child keys again after removing the meta and stops the cluster's bootstrap subscriber, bootstrap refuses to write into a record that is gone or `DELETING`, the launch re-checks its record around seeding the creator's access entry, and a fresh claim clears whatever an earlier cluster left under the name. | `TestS2_EKSLaunchOvertakenByDeleteLeavesNoChildRecords`, `TestS2_EKSBootstrapWritesNothingIntoADeletingCluster`, `TestS2_EKSFreshClaimInheritsNoStaleChildRecords` |
| Launch generation | The create launch's writes are fenced on the record's generation (`CreatedAt` and ARN). An overtaken launch never writes a newer cluster of the same name and releases only the instances it started, by ID. One whose record was swept recreates it as `DELETING` with its refs, so the teardown and reaper own the release. One whose record was marked `FAILED` merges its refs and releases what it built. | `TestS2_EKSOvertakenLaunchNeverWritesANewerClusterOfTheSameName`, `TestS2_EKSOvertakenLaunchHandsItsInfraToTheTeardown`, `TestS2_EKSLaunchMarkedFailedReleasesWhatItBuilt` |
| Running launch | A create launch heartbeats its owner and time on the record (`LaunchOwner`, `LaunchHeartbeat`). The teardown keeps the record while that heartbeat is fresh, so the cluster reads `DELETING` throughout and never `ResourceNotFoundException` then `DELETING` again; the launch hands its refs over at its next write and starts the teardown when it returns. A heartbeat older than 90 s is taken as a crashed launch and no longer waited for, and recreating a swept record stays as the backstop. The launch's OIDC keypair writes are fenced on the record's generation. | `TestS2_EKSDeletedClusterNeverReadsMissingWhileItsLaunchRuns`, `TestS2_EKSCrashedLaunchDoesNotPinTheDelete`, `TestS2_EKSOvertakenLaunchCannotReplaceANewerClustersSigningKey` |
| Bounded passes | One converger run is bounded at 15 minutes, so a hung step cannot hold the gate and lease; the timed-out pass is recorded and backed off. Waiting on node groups is not counted as a failed pass, and deleting the last node group resumes the delete. | `TestS8_EKSHungTeardownStepReleasesTheGate`, `TestS2_EKSLastNodegroupDeletedResumesTheClusterDelete` |
| FAILED reclaim | Re-creating a `FAILED` cluster's name tears the old attempt down under the same gate and lease as a delete, and answers `ResourceInUseException` while another holder has it; the per-node teardown bound does not apply to it. | `TestS2_EKSFailedClusterReclaimRunsUnderTheTeardownLease`, `TestS2_EKSFailedClusterReclaimIsNotRefusedForCapacity` |
| Tenant data | The teardown never deletes a CSI-provisioned volume or terminates a node group worker. | `TestS7_EKSTeardownNeverDeletesCSIVolumesOrWorkers`, `TestS7_EKSTeardownNeverRemovesAClusterWithNodegroupWorkers` |
## Gaps
1. Four `eks.*` handlers still hold their request for work that grows with the resource, and the subject table names them as not conformant: `CreateCluster` (reclaiming a `FAILED` cluster of the same name tears its infra down inline), `DeleteNodegroup` (one awaited terminate per worker), `UpdateNodegroupConfig` (one awaited launch or terminate per worker added or removed) and `RestoreSnapshot` (launches a control-plane VM and fences the old one inline).
2. AWS's answer to a repeat delete on a `DELETING` cluster has not been observed; `DELETING` from the record is the chosen behaviour.
3. Control-plane, load balancer and VPC resources in the system account are keyed by the bare cluster name, so a teardown by name can reach another tenant's same-named cluster.
4. The teardown deletes load balancers the in-cluster controller created in the customer account, which AWS leaves behind.
5. An overtaken launch's late write can still land under a newer cluster of the same name in the instant between that cluster's claim sweep and the old launch's generation check; it is bounded by one write, since the old launch stops at its next fenced write.
6. The `FAILED`-name reclaim purge still runs inside the `CreateCluster` request (gap 1).
@@ -18,7 +18,7 @@ The characterization tests below were written in `handlers/rds` before the move;
## Discovered and corrected divergence
`ValidateMasterUserPassword` built its empty-password error with a bare `errors.New`, which `awserrors` does not resolve, so `CreateDBInstance` without `MasterUserPassword` returned HTTP 500 `ServerInternal` where AWS returns a 400 client error.
It now returns 400 `InvalidParameterValue`; `ingress/aws/pipeline/rds_test.go` `TestRDSRequest_CreateDBInstance_EmptyMasterUserPasswordReturns400` covers the gateway, NATS and RDS service path.
It now returns 400 `InvalidParameterValue`; `gateway/rds_test.go` `TestRDSRequest_CreateDBInstance_EmptyMasterUserPasswordReturns400` covers the gateway, NATS and RDS service path.
## Boundaries kept by the move
+2 -2
View File
@@ -4,13 +4,13 @@
# distroless base (no shell, no package manager) for a small, production-shaped
# image suitable for pushing to ECR and pulling onto EKS workers.
FROM golang:1.27.3-alpine@sha256:d200d2dd76b03a99b52651fd1325aff7dcd35422c35c01bac56571b0507f3deb AS build
FROM golang:1.27.3-alpine@sha256:f2c872bcee949b7be9c490efd02b48a8a2fbf947cdf533f1c58525ec9d8170fd AS build
WORKDIR /src
COPY go.mod ./
COPY main.go ./
RUN CGO_ENABLED=0 GOOS=linux go build -trimpath -ldflags="-s -w" -o /out/spinifex-demo .
FROM gcr.io/distroless/static:nonroot@sha256:25ac8f0c3b2334a9eb05452266bd4697864a8c8ef0ef71e5e6480d7f92868329
FROM gcr.io/distroless/static:nonroot@sha256:18f4cc26c9ffa7f20a359298be7deadac5c607c8e03a02b8119f1a5fc2f82c99
COPY --from=build /out/spinifex-demo /spinifex-demo
EXPOSE 8080
USER nonroot:nonroot
+1 -5
View File
@@ -123,13 +123,9 @@ Internal and internet-facing ALB and NLB, HTTPS listeners, listener rules, `Modi
Unhealthy-target routing: an NLB with an HTTP health check on a TCP target group stops sending traffic to a target that fails it while still accepting TCP, sends traffic again once it recovers, and fails open when every target is unhealthy.
An ALB whose targets all fail their health check keeps serving from them instead of answering 503.
`TestInternalALBPrivateSubnet` launches an internal ALB in a private subnet of a non-default VPC with no internet gateway and no public route.
The load balancer must reach active with a healthy target, which needs the owner stamp on its ENI to resolve, and a client inside the VPC must be served by it on every request.
The client reports on its serial console because the runner has no route into the VPC.
### `eks`, `ecs`, `ecr` — container services
`eks`: cluster create/delete, access entries, addon delivery, `GetToken`, kubeconfig artifacts, the EBS CSI volume path, an IRSA web-identity exchange under the stock AWS trust policy (`StringEquals` on the issuer host-path `:sub`/`:aud`, a mismatched `:sub` denied with `AccessDenied`, a `StringLike` `:sub` granted), and an IRSA pod whose spec names only a ServiceAccount with the role-arn annotation, where the pod-identity webhook injects the token volume, `AWS_*` environment, STS endpoint and CA bundle and the pod assumes the role, while a pod of an unannotated ServiceAccount is left untouched. The unmodified aws-load-balancer-controller chart 1.11.0 installs with the values an AWS user passes plus the gateway endpoint override, the Shield and WAF switches and the single-subnet feature gate, and reconciles an Ingress into an active ALB. Worker IMDS is covered too: a managed nodegroup worker reports IMDSv2 required with hop limit 2, a pod with no IRSA resolves to the node role through IMDS, and a nodegroup built from a launch template takes the template's metadata options. Control-plane isolation is covered as well: no control-plane host registers as a Node, a DaemonSet tolerating every taint runs on workers only, metrics-server is Ready, and logs work for every kube-system pod. `ecs`: clusters, container instances, the three network modes (`awsvpc`, `bridge`, `host`), task-role credentials fetched from the container credentials endpoint, a service behind an ELB, and ENI release on terminate. `ecr`: control-plane CRUD plus a real OCI data plane — docker login/build/push/pull round trips, multi-layer images, and raw blob HTTP.
`eks`: cluster create/delete, access entries, addon delivery, `GetToken`, kubeconfig artifacts, the EBS CSI volume path, an IRSA web-identity exchange under the stock AWS trust policy (`StringEquals` on the issuer host-path `:sub`/`:aud`, a mismatched `:sub` denied with `AccessDenied`, a `StringLike` `:sub` granted), and an IRSA pod wired with a projected SA token. Worker IMDS is covered too: a managed nodegroup worker reports IMDSv2 required with hop limit 2, a pod with no IRSA resolves to the node role through IMDS, and a nodegroup built from a launch template takes the template's metadata options. Control-plane isolation is covered as well: no control-plane host registers as a Node, a DaemonSet tolerating every taint runs on workers only, metrics-server is Ready, and logs work for every kube-system pod. `ecs`: clusters, container instances, the three network modes (`awsvpc`, `bridge`, `host`), task-role credentials fetched from the container credentials endpoint, a service behind an ELB, and ENI release on terminate. `ecr`: control-plane CRUD plus a real OCI data plane — docker login/build/push/pull round trips, multi-layer images, and raw blob HTTP.
### `rds` — managed databases
+33 -30
View File
@@ -4,15 +4,15 @@ go 1.27.3
require (
github.com/aws/aws-sdk-go v1.55.8
github.com/aws/aws-sdk-go-v2 v1.47.5
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.25
github.com/aws/aws-sdk-go-v2/config v1.33.12
github.com/aws/aws-sdk-go-v2/credentials v1.20.12
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.20.5
github.com/aws/aws-sdk-go-v2/service/lambda v1.113.4
github.com/aws/aws-sdk-go-v2/service/s3 v1.114.6
github.com/aws/aws-sdk-go-v2/service/sqs v1.52.7
github.com/aws/smithy-go v1.28.7
github.com/aws/aws-sdk-go-v2 v1.47.4
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.24
github.com/aws/aws-sdk-go-v2/config v1.33.9
github.com/aws/aws-sdk-go-v2/credentials v1.20.9
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.20.3
github.com/aws/aws-sdk-go-v2/service/lambda v1.113.3
github.com/aws/aws-sdk-go-v2/service/s3 v1.114.4
github.com/aws/aws-sdk-go-v2/service/sqs v1.52.6
github.com/aws/smithy-go v1.28.6
github.com/cenkalti/backoff/v4 v4.3.0
github.com/charmbracelet/bubbles v1.0.0
github.com/charmbracelet/bubbletea v1.3.10
@@ -31,8 +31,8 @@ require (
github.com/mulgadc/northstar v1.23.0
github.com/mulgadc/predastore v1.23.0
github.com/mulgadc/viperblock v1.23.0
github.com/nats-io/nats-server/v2 v2.15.2
github.com/nats-io/nats.go v1.55.0
github.com/nats-io/nats-server/v2 v2.15.1
github.com/nats-io/nats.go v1.54.0
github.com/nats-io/nkeys v0.4.16
github.com/opencontainers/runtime-spec v1.3.0
github.com/oracle/oci-go-sdk/v65 v65.128.0
@@ -43,9 +43,9 @@ require (
github.com/spf13/pflag v1.0.10
github.com/spf13/viper v1.21.0
github.com/stretchr/testify v1.12.1
go.etcd.io/etcd/api/v3 v3.7.3
go.etcd.io/etcd/client/pkg/v3 v3.7.3
go.etcd.io/etcd/client/v3 v3.7.3
go.etcd.io/etcd/api/v3 v3.5.35
go.etcd.io/etcd/client/pkg/v3 v3.5.35
go.etcd.io/etcd/client/v3 v3.5.35
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.72.0
go.opentelemetry.io/otel v1.47.0
go.opentelemetry.io/otel/metric v1.47.0
@@ -53,14 +53,14 @@ require (
go.opentelemetry.io/otel/sdk/metric v1.47.0
go.opentelemetry.io/otel/trace v1.47.0
go.uber.org/goleak v1.3.1-0.20260602155401-5f791e3ef83b
go.yaml.in/yaml/v3 v3.0.6
go.yaml.in/yaml/v3 v3.0.5
golang.org/x/crypto v0.58.0
golang.org/x/sync v0.24.0
golang.org/x/sys v0.49.0
golang.org/x/term v0.47.0
golang.org/x/time v0.16.0
golang.org/x/tools v0.52.0
google.golang.org/grpc v1.85.0
google.golang.org/grpc v1.83.2
gopkg.in/ini.v1 v1.67.3
)
@@ -72,17 +72,17 @@ require (
github.com/Microsoft/hcsshim v0.15.0-rc.4 // indirect
github.com/antithesishq/antithesis-sdk-go v0.8.0-default-no-op // indirect
github.com/atotto/clipboard v0.1.4 // indirect
github.com/aws/aws-sdk-go-v2/internal/configsources v1.5.8 // indirect
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.8.8 // indirect
github.com/aws/aws-sdk-go-v2/internal/v4a v1.5.8 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.23 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.11.9 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.14.8 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.20.8 // indirect
github.com/aws/aws-sdk-go-v2/service/signin v1.12.0 // indirect
github.com/aws/aws-sdk-go-v2/service/sso v1.39.0 // indirect
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.44.0 // indirect
github.com/aws/aws-sdk-go-v2/service/sts v1.51.6 // indirect
github.com/aws/aws-sdk-go-v2/internal/configsources v1.5.7 // indirect
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.8.7 // indirect
github.com/aws/aws-sdk-go-v2/internal/v4a v1.5.6 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.21 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.11.7 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.14.6 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.20.6 // indirect
github.com/aws/aws-sdk-go-v2/service/signin v1.10.4 // indirect
github.com/aws/aws-sdk-go-v2/service/sso v1.38.4 // indirect
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.43.4 // indirect
github.com/aws/aws-sdk-go-v2/service/sts v1.51.4 // indirect
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
github.com/beorn7/perks v1.0.1 // indirect
github.com/boltdb/bolt v1.3.1 // indirect
@@ -111,7 +111,7 @@ require (
github.com/containerd/plugin v1.1.0 // indirect
github.com/containerd/ttrpc v1.2.9 // indirect
github.com/containerd/typeurl/v2 v2.3.0 // indirect
github.com/coreos/go-semver v0.3.1 // indirect
github.com/coreos/go-semver v0.3.0 // indirect
github.com/coreos/go-systemd/v22 v22.7.0 // indirect
github.com/dgraph-io/badger/v4 v4.9.6 // indirect
github.com/dgraph-io/ristretto/v2 v2.4.0 // indirect
@@ -128,6 +128,7 @@ require (
github.com/go-playground/validator/v10 v10.30.5 // indirect
github.com/go-viper/mapstructure/v2 v2.5.0 // indirect
github.com/gofrs/flock v0.10.0 // indirect
github.com/gogo/protobuf v1.3.2 // indirect
github.com/golang/protobuf v1.5.4 // indirect
github.com/google/flatbuffers v25.12.19+incompatible // indirect
github.com/google/go-tpm v0.9.8 // indirect
@@ -174,6 +175,7 @@ require (
github.com/opencontainers/image-spec v1.1.1 // indirect
github.com/pierrec/lz4/v4 v4.1.18 // indirect
github.com/pkg/errors v0.9.1 // indirect
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
github.com/prometheus/client_golang v1.24.1 // indirect
github.com/prometheus/client_model v0.6.3 // indirect
github.com/prometheus/common v0.71.0 // indirect
@@ -201,9 +203,10 @@ require (
go.opentelemetry.io/otel/log v1.47.0 // indirect
go.opentelemetry.io/otel/sdk/log v1.47.0 // indirect
go.opentelemetry.io/proto/otlp v1.11.0 // indirect
go.uber.org/multierr v1.11.0 // indirect
go.uber.org/atomic v1.7.0 // indirect
go.uber.org/multierr v1.6.0 // indirect
go.uber.org/nilaway v0.0.0-20260721205819-4a0f7653488a // indirect
go.uber.org/zap v1.27.1 // indirect
go.uber.org/zap v1.17.0 // indirect
golang.org/x/exp/typeparams v0.0.0-20260611194520-c48552f49976 // indirect
golang.org/x/mod v0.42.0 // indirect
golang.org/x/net v0.61.0 // indirect
+86 -61
View File
@@ -22,46 +22,46 @@ github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z
github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI=
github.com/aws/aws-sdk-go v1.55.8 h1:JRmEUbU52aJQZ2AjX4q4Wu7t4uZjOu71uyNmaWlUkJQ=
github.com/aws/aws-sdk-go v1.55.8/go.mod h1:ZkViS9AqA6otK+JBBNH2++sx1sgxrPKcSzPPvQkUtXk=
github.com/aws/aws-sdk-go-v2 v1.47.5 h1:5yA+A+BiXbDq1ZLbYwPViwhEGDMdR0MEhL8Jh2LnLS4=
github.com/aws/aws-sdk-go-v2 v1.47.5/go.mod h1:iNIkogrRvFwm+1OQIUOP2lPBtlXZboOIUWiCa+pHEMw=
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.25 h1:MW0Iql1SOTgQPj8mCxrU/eH/K/AIDWi08ItOsXaWRLU=
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.25/go.mod h1:+ucVYmHYB3JqVueY0E1RHahdXAnKQbIHqybSbvOUbOM=
github.com/aws/aws-sdk-go-v2/config v1.33.12 h1:IV1YARaXahwCqUIyP8IlWsP85Lil+5ozTozDQXPumRk=
github.com/aws/aws-sdk-go-v2/config v1.33.12/go.mod h1:h51R06bzMVwNBRZOX5ylHhNDnkB/azZeq/LvxM6q2pQ=
github.com/aws/aws-sdk-go-v2/credentials v1.20.12 h1:6P7Q7yH/GIwHg6MLKv3ySsBqaCoE1z+C0OTzYMenhEY=
github.com/aws/aws-sdk-go-v2/credentials v1.20.12/go.mod h1:Kw/SaoW/wc7IqJErqXJIzKo8b7Aelxfbuc6XlbrRnsc=
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.20.5 h1:iXjy2SFhGYNB1jWZBhQqq8HbhsbPF23w+/zbMvPXsTg=
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.20.5/go.mod h1:k0RUiBnyXU0AXRJ9tJmY2jQke6XUIX2PNVnWtvgaPtc=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.5.8 h1:gzIWc3DTPfPbWKrQnmL6BV6WD7NPD1htxzgdk/bQ/Z4=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.5.8/go.mod h1:vvUA5cDAZWGo4R14FZPZfXfJCmgsafOWEq78Kg0D+ug=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.8.8 h1:PWv+vG8aCLS8qd+HPdYhK7Vees90aKHWFFQ1AGYD/8E=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.8.8/go.mod h1:xxPZEpflYHJA12366ZGyq66z9dHPCoeCIu99Jck+MPw=
github.com/aws/aws-sdk-go-v2/internal/v4a v1.5.8 h1:WOp5De9iWdEHDPh2Kb0tBP3vGBm/dVAuRGh4a4tqduo=
github.com/aws/aws-sdk-go-v2/internal/v4a v1.5.8/go.mod h1:z8W8YgfhbWl62XqcTtK+CxPrE+iHGRVUOtrrbIOp7ZY=
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.23 h1:MCIR6iZaCKO4UE0hUvoWWLI6JVjppopV64JfAJaMjng=
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.23/go.mod h1:pphWFS+z0MmIPivj8c/6bATd1Z+Av7Ust75DPdEFsPA=
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.11.9 h1:GACgECI422Xt5n4Pojr3O0aocPv8lfH4PJe2Aon5Wv8=
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.11.9/go.mod h1:lPfnRkHpn1qWZJLbVEw10rIqSuCee1pNQTyGkUjAYh8=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.14.8 h1:+PMpWlJYOn2tm7sm6OZ/ZLd4jStl17T7wjNapd1Rczs=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.14.8/go.mod h1:cnG3xZLGQL4c/bt8pjdzlFtZsW7epBBuNS04a5xMjdo=
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.20.8 h1:RS1Wt+K0r4X0ci9Vf4JYTUfHzsQihYA2LgGJeEneKpk=
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.20.8/go.mod h1:adMnTy5kyoUW86jhXnGSVc/mvMQI4GZd1qYWMeaS8M8=
github.com/aws/aws-sdk-go-v2/service/lambda v1.113.4 h1:wvAdq1sNfwuedO1BX8GjY+oZWzoyeD4hhdjJI+4EPnI=
github.com/aws/aws-sdk-go-v2/service/lambda v1.113.4/go.mod h1:HgkYN4d72Fawg3MAJQepmNnROFoN11XYu14hgFfChTY=
github.com/aws/aws-sdk-go-v2/service/s3 v1.114.6 h1:to7+Vr3rLkpYRwVKJ5QRriI0eE306vrVN/6TnVHgxvY=
github.com/aws/aws-sdk-go-v2/service/s3 v1.114.6/go.mod h1:Jc89ASa2k7HMAskj3bSnt8bf+k+p2IApzkoXnEjkgUw=
github.com/aws/aws-sdk-go-v2/service/signin v1.12.0 h1:3vu/eUqh0rjh7Zy4N4YKcpZX4Aud+zinB3VsLhhlQFw=
github.com/aws/aws-sdk-go-v2/service/signin v1.12.0/go.mod h1:wxAk0D2oVBSjuXEIzvcKxTZfM90zticdAbh/VD42L0Y=
github.com/aws/aws-sdk-go-v2/service/sqs v1.52.7 h1:Eip+mZ0wFZY/10e5w89lWhF09Pa8tbbGwxePP3T2iMc=
github.com/aws/aws-sdk-go-v2/service/sqs v1.52.7/go.mod h1:bXp38eBNyvSevFIao60mYcC3pQIAkPYWOddhFVv9z8E=
github.com/aws/aws-sdk-go-v2/service/sso v1.39.0 h1:kIEpD5dUry7VET3Ybl7zj7PPHRB4lb52G9iTz+a1Tqk=
github.com/aws/aws-sdk-go-v2/service/sso v1.39.0/go.mod h1:UGsEYgWz4Gi+46vDQ5zkFLqUgJziyOE2SXjZjgmfmHc=
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.44.0 h1:WfFev+HgTodggcNon5HieiLxxwWwb8Br8R2+nDkixvk=
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.44.0/go.mod h1:gJSdIY6YUzDvqQLcMtPVZgBRHrFwzHryvpE8Igzbxps=
github.com/aws/aws-sdk-go-v2/service/sts v1.51.6 h1:BVjKrtRUtSYKztBCCfE1oAB+d3ViKcW2cKZfserOqMQ=
github.com/aws/aws-sdk-go-v2/service/sts v1.51.6/go.mod h1:eSukFLeLkPMm+1OxDR+F0joauZdyBkF47Ow7MqjZiJw=
github.com/aws/smithy-go v1.28.7 h1:h/n1SHrwVtaDYLz8mR1/Kq4Xh4RaFbFW7bOYckg9mfE=
github.com/aws/smithy-go v1.28.7/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc=
github.com/aws/aws-sdk-go-v2 v1.47.4 h1:bTvk6UdSSSvaQsfLVj1JRDEEqj/PDRwGf1KanaRzau0=
github.com/aws/aws-sdk-go-v2 v1.47.4/go.mod h1:XkrjAZvr705Nytv6PkPUhoSB2W9+vVHBp5oAWpkh8Ak=
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.24 h1:t7FxqJ5KHDeKceyD4HtGaG6z7hMSeVGjs5/4c+L5ArM=
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.24/go.mod h1:5XK6NRVlK8s8yj4zKcM43HLluGA/rAz+azLLvWDMRr0=
github.com/aws/aws-sdk-go-v2/config v1.33.9 h1:Hv6bcQUJVnmuWaKjqD6Ot8sE75NrWsWV20oGT5aWle0=
github.com/aws/aws-sdk-go-v2/config v1.33.9/go.mod h1:ivowmrZNfBQLcg4QkaItgi5wae4Bzot9RzIdbY1hfTg=
github.com/aws/aws-sdk-go-v2/credentials v1.20.9 h1:SNO55PnWhaDafsOcv16lCA0kdoFMl/YlegWoVsU02s0=
github.com/aws/aws-sdk-go-v2/credentials v1.20.9/go.mod h1:+2reOTRtdD3VSBqpRDNwrZIYy8yMCYa7oK3WKTJ/I1I=
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.20.3 h1:gr3b87OLB29jymWPzp/CAyXwQRJVE+sC4Wm0qBIKHsk=
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.20.3/go.mod h1:BRjf4L/Yx2hY54o+ZibPbLJyMMeSwqYQXwcJfqW66a8=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.5.7 h1:fSNAt6hs4qLjb9tMZc6vj3PHlOCYGXK2JjJZ7AW2tf4=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.5.7/go.mod h1:TMav+EuXQu5kNpZZz/6rIC8xx4mCsW+6AKHrjvOSAsE=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.8.7 h1:7NS6kqBjkWIN8P/AEdmtyq6eQGAhN1oHTZdzXclpH6Y=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.8.7/go.mod h1:1PFqtAUsIgF1nEVojzRJSf/v88zjzqrGW+w+UAtkXyQ=
github.com/aws/aws-sdk-go-v2/internal/v4a v1.5.6 h1:Qmn60RWuk9+bd2ddvJJqwf3Cpac3VvGMOdbGemtUhX4=
github.com/aws/aws-sdk-go-v2/internal/v4a v1.5.6/go.mod h1:h/YJhe4AVyQxEUMluph10iqV7mjajH7A9L+BHF5ppis=
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.21 h1:e6YW5uME30TkrElR+4/xcY7NB6CCj2dcv7vtmGXhfhQ=
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.21/go.mod h1:obpHAMP693+f3/A8PkLhIqfK1C+NWWn8zXzqD3OH520=
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.11.7 h1:CwvFlKw0uUScfgOMflXSRZa+ayXgOf6wk7lhyMemQYU=
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.11.7/go.mod h1:4SbD8BB+p+QEnG0aX6DoWwrtk9SufyYXRsUv0nve8LE=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.14.6 h1:LHHdobjo5le1sjJdolitV7uVvYg3wkaeM1Ju2fDgeZQ=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.14.6/go.mod h1:CBGB0evAiaMD3Lk4wPO7LQCcCZLuhzL5Qf/wfEUHQ5w=
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.20.6 h1:mYViDr2azcgxOpRrObZOl631bvavqILoE/L+Kxgbwys=
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.20.6/go.mod h1:OXVdAo9jNhggB+hJRGOyy7WolGTKB6Rg7IIulDeSzPM=
github.com/aws/aws-sdk-go-v2/service/lambda v1.113.3 h1:oSHldse7GDHpdq81XeAJnhIbsdDKP9bMIt00G8W6uXI=
github.com/aws/aws-sdk-go-v2/service/lambda v1.113.3/go.mod h1:Y//K046w4uhiR3o77beo/y52Jv2uEY13iJ+3+jicLO8=
github.com/aws/aws-sdk-go-v2/service/s3 v1.114.4 h1:MrkWRB58Qw5TEkb51QVooVLjlM3IevO2iVXEz74B8pg=
github.com/aws/aws-sdk-go-v2/service/s3 v1.114.4/go.mod h1:yfIxaKRHasvfy6tNEm3/LpqKsupMzQjMaWj9Y0jMVbo=
github.com/aws/aws-sdk-go-v2/service/signin v1.10.4 h1:tvpDjuUvgKURf3Yk4hOaGbZ4QdY+070OWcA5d6LUv2A=
github.com/aws/aws-sdk-go-v2/service/signin v1.10.4/go.mod h1:3ibVKg5Ng82Zby8++jELTD2nHxiPk2DJRLEeW/LKp8M=
github.com/aws/aws-sdk-go-v2/service/sqs v1.52.6 h1:Suq1LNhMBpsmShEv3vO+J3H8HEeK+GwYMJDYLE4IJSs=
github.com/aws/aws-sdk-go-v2/service/sqs v1.52.6/go.mod h1:rHGuzFg4AfVnWZa3/533tn5GQd1LzVkTXI9WMT8LqAc=
github.com/aws/aws-sdk-go-v2/service/sso v1.38.4 h1:ChCtagixSFOxltv0o6E3NT8F7ksvZtZGPwUBynO5Nmk=
github.com/aws/aws-sdk-go-v2/service/sso v1.38.4/go.mod h1:+FEO9jrVMpc9oN3CdAxJbtlQEu3z8UddDPSedpeM3PY=
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.43.4 h1:j6c+HA4AoS0EBEjArPjavFEBrucJEryfrQU0TffmACk=
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.43.4/go.mod h1:RhghaXUk9q66VtOQUX1FdPddQuHnqQZzbB0fEYXmPRA=
github.com/aws/aws-sdk-go-v2/service/sts v1.51.4 h1:OKEfbf2tcfsF2pBk/S9QqNpo6alaAtiBs4hysCsBjPw=
github.com/aws/aws-sdk-go-v2/service/sts v1.51.4/go.mod h1:mCd6qGo9b8hTtfjgsmNkFlzwjyce4owd7HDVFnCLXhM=
github.com/aws/smithy-go v1.28.6 h1:Pj+/sUviNYjow8ELRtNwKHZ9ECeF+7pATX3+shjLIrw=
github.com/aws/smithy-go v1.28.6/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc=
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
@@ -128,8 +128,8 @@ github.com/containerd/ttrpc v1.2.9 h1:ha0ak962T0s3CA/RoZ6S6xiWZQF24GrBaEpiGX1uih
github.com/containerd/ttrpc v1.2.9/go.mod h1:jjtQRwXm4DL3KsHKW8vDiUOV6wO0hi6IPhmJhxU7aEs=
github.com/containerd/typeurl/v2 v2.3.0 h1:HZHPhRWo5XMy3QGQoPrUzbW/2ckwjfweHmOwlkIrPAQ=
github.com/containerd/typeurl/v2 v2.3.0/go.mod h1:Qk+PAdUYArVj41TnGi6rJ+48RF0PkcTc4i/taoBcK0w=
github.com/coreos/go-semver v0.3.1 h1:yi21YpKnrx1gt5R+la8n5WgS0kCrsPp33dmEyHReZr4=
github.com/coreos/go-semver v0.3.1/go.mod h1:irMmmIw/7yzSRPWryHsK7EYSg09caPQL03VsM8rvUec=
github.com/coreos/go-semver v0.3.0 h1:wkHLiw0WNATZnSG7epLsujiMCgPAc9xhjJ4tgnAxmfM=
github.com/coreos/go-semver v0.3.0/go.mod h1:nnelYz7RCh+5ahJtPPxZlU+153eP4D4r3EedlOD2RNk=
github.com/coreos/go-systemd/v22 v22.7.0 h1:LAEzFkke61DFROc7zNLX/WA2i5J8gYqe0rSj9KI28KA=
github.com/coreos/go-systemd/v22 v22.7.0/go.mod h1:xNUYtjHu2EDXbsxz1i41wouACIwT7Ybq9o0BQhMwD0w=
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
@@ -181,6 +181,8 @@ github.com/go-viper/mapstructure/v2 v2.5.0 h1:vM5IJoUAy3d7zRSVtIwQgBj7BiWtMPfmPE
github.com/go-viper/mapstructure/v2 v2.5.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
github.com/gofrs/flock v0.10.0 h1:SHMXenfaB03KbroETaCMtbBg3Yn29v4w1r+tgy4ff4k=
github.com/gofrs/flock v0.10.0/go.mod h1:FirDy1Ing0mI2+kB6wk+vyyAH+e6xiE+EYA0jnzV9jc=
github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
@@ -247,6 +249,8 @@ github.com/josharian/native v1.1.0 h1:uuaP0hAbW7Y4l0ZRQ6C9zfb7Mg1mbFKry/xzDAfmtL
github.com/josharian/native v1.1.0/go.mod h1:7X/raswPFr05uY3HiLlYeyQntB6OO7E/d2Cu7qoaN2w=
github.com/jstemmer/go-junit-report/v2 v2.1.0 h1:X3+hPYlSczH9IMIpSC9CQSZA0L+BipYafciZUWHEmsc=
github.com/jstemmer/go-junit-report/v2 v2.1.0/go.mod h1:mgHVr7VUo5Tn8OLVr1cKnLuEy0M92wdRntM99h7RkgQ=
github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8=
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
github.com/klauspost/compress v1.20.3 h1:zWNoFqq/lSuYQRim3dUXlXeA0IqwuZGicdG5ZTfY5j4=
github.com/klauspost/compress v1.20.3/go.mod h1:LUdAzn7YLVvxLpc7y3V1m40wESHTgc1422pwwBSKYuI=
github.com/klauspost/cpuid/v2 v2.4.1 h1:I9KxadAkvo+QjtcEE2f2tyfEvmIla/T0m+14PrZNl0U=
@@ -313,10 +317,10 @@ github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
github.com/nats-io/jwt/v2 v2.8.2 h1:XXRgB60MSTnqsRwejQurVDs/hcv2dkt+86GjI+I/bMc=
github.com/nats-io/jwt/v2 v2.8.2/go.mod h1:Ag/56sq9OblL4JgdYufDd16Egb17Kr/8WwwuO/forVc=
github.com/nats-io/nats-server/v2 v2.15.2 h1:yLsgt2kslgymvxvjFq1WGWck5hkat2YYfbXvQ145tzU=
github.com/nats-io/nats-server/v2 v2.15.2/go.mod h1:VXHCZgDglV5wO2B8ZE58yG2HzqxQxYUbQqibUh5Xcr8=
github.com/nats-io/nats.go v1.55.0 h1:Ng8QKHdvdHIWoaiq0+d/aH1BWkxvcJxWJ0Y/9BPKx3M=
github.com/nats-io/nats.go v1.55.0/go.mod h1:y+DZoD1oBOYfZTU681eTUiUjI0vbqYGixNVFHcjHJ0k=
github.com/nats-io/nats-server/v2 v2.15.1 h1:fK3AeYFIji1Smng60jB0hVoAPZezA/UzsaI3BqXFNEI=
github.com/nats-io/nats-server/v2 v2.15.1/go.mod h1:yeuYRtKVKIFtmMz2/YPk+6hvmNBnk+WwTUzErBdH6IE=
github.com/nats-io/nats.go v1.54.0 h1:vsXoOxjHp/GmPUN+EcI7uOf/uB+iAP+kEsAFNQN0yzA=
github.com/nats-io/nats.go v1.54.0/go.mod h1:y+DZoD1oBOYfZTU681eTUiUjI0vbqYGixNVFHcjHJ0k=
github.com/nats-io/nkeys v0.4.16 h1:rd5oAuLOb8mnAycB0xleuEBNS1pVVnN0fv/FF34Eypg=
github.com/nats-io/nkeys v0.4.16/go.mod h1:llLgWoI0o4z/Q57q2R1kHfmocyhGV6VG/U18Glg1Afs=
github.com/nats-io/nuid v1.0.1 h1:5iA8DT8V7q8WK2EScv2padNa/rTESc1KdnPw4TC2paw=
@@ -336,6 +340,7 @@ github.com/pelletier/go-toml/v2 v2.4.3/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8
github.com/pierrec/lz4/v4 v4.1.14/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4=
github.com/pierrec/lz4/v4 v4.1.18 h1:xaKrnTkyoqfh1YItXl56+6KJNVYWlEEPuAQW9xsplYQ=
github.com/pierrec/lz4/v4 v4.1.18/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4=
github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
@@ -404,15 +409,17 @@ github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavM
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 h1:ilQV1hzziu+LLM3zUTJ0trRztfwgjqKnBWNtSRkbmwM=
github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78/go.mod h1:aL8wCCfTfSfmXjznFBSZNN13rSJjlIOI1fUNAtF7rmI=
github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
go.etcd.io/bbolt v1.5.0 h1:S7GAl7Fxv12yohbwFfIbQCGDWbQbtDGPET4P/bD4lxU=
go.etcd.io/bbolt v1.5.0/go.mod h1:mkltfYE5aUHQxUct9N9V+Kp7aSjFqjgrhcXIS70Lrdk=
go.etcd.io/etcd/api/v3 v3.7.3 h1:2tumepXe+wi7Wab64uUO71K7ISQyUWLGC1DJbbc59sY=
go.etcd.io/etcd/api/v3 v3.7.3/go.mod h1:HOR9Qn4gb/1/hVAmYprmlLpvTe6ZgfgNS9Z/+Ty65po=
go.etcd.io/etcd/client/pkg/v3 v3.7.3 h1:5zMwNu3ycrzCeA5NwOfQbxFRhqq974chq1DrYMua4QI=
go.etcd.io/etcd/client/pkg/v3 v3.7.3/go.mod h1:/OhzqYrF0U7d1NOk55VMyUQGbYD1WoIe4bJwxb47wOk=
go.etcd.io/etcd/client/v3 v3.7.3 h1:UrXcetgkuSEocaBlQlulQRJlXLEnUAHuBRbHh0lue1Q=
go.etcd.io/etcd/client/v3 v3.7.3/go.mod h1:DcPxpqcpV8WIaRi/2waPCx0/Uq8rQUweDJaDYZk1gEE=
go.etcd.io/etcd/api/v3 v3.5.35 h1:lB//Av9mFrkljv94XDNAY+qiyQA9W6S/TTA2Uwx2jsI=
go.etcd.io/etcd/api/v3 v3.5.35/go.mod h1:z1FGQolu/tpmQ2MPFwQNt7G3cXBiSkTevZBhfOtEArQ=
go.etcd.io/etcd/client/pkg/v3 v3.5.35 h1:nUZutZyJVvsKddK6zddXMBxIhmVi0SzPMBRS0FnNM3o=
go.etcd.io/etcd/client/pkg/v3 v3.5.35/go.mod h1:0MNjMMqajrOI0/l8OLFkJBhE5Vm6BAi7cerKlrO38hw=
go.etcd.io/etcd/client/v3 v3.5.35 h1:dk0aSLjY5D1eOR0SNnPXWOWCVA9yIzJoB8a912qxmoI=
go.etcd.io/etcd/client/v3 v3.5.35/go.mod h1:LjtCl6G5li7PgsGjoQIpYXz/OvSbI1pCxpSp1kqo9P4=
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
go.opentelemetry.io/contrib/bridges/otelslog v0.21.0 h1:/CdgE52i1OEPHhsGUqrggYNRaZy4v4YgVenGd52dRBI=
@@ -449,22 +456,26 @@ go.opentelemetry.io/otel/trace v1.47.0 h1:JOjX/Oci8K94QHddo+bbfya/Ai/nf6/dt9ZfrF
go.opentelemetry.io/otel/trace v1.47.0/go.mod h1:jNaSLa2PZEYFG6fRjJABAu+bw4FS08uDmPg28lTghu0=
go.opentelemetry.io/proto/otlp v1.11.0 h1:5rrYs0Ykyj50sdU/JU0x8etU+LubXWb+gED6TbEdMIk=
go.opentelemetry.io/proto/otlp v1.11.0/go.mod h1:SmVizdCOAm3XBtG1g1NnOdhW6jtddT72hLMhv8VwA8E=
go.uber.org/atomic v1.7.0 h1:ADUqmZGgLDDfbSL9ZmPxKTybcoEYHgpYfELNoN+7hsw=
go.uber.org/atomic v1.7.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc=
go.uber.org/goleak v1.3.1-0.20260602155401-5f791e3ef83b h1:FHYZT12VzXnb6P399kd1i9Ai7aLF+CFSUsOFylDt7Lk=
go.uber.org/goleak v1.3.1-0.20260602155401-5f791e3ef83b/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y=
go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU=
go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
go.uber.org/multierr v1.6.0 h1:y6IPFStTAIT5Ytl7/XYmHvzXQ7S3g/IeZW9hyZ5thw4=
go.uber.org/multierr v1.6.0/go.mod h1:cdWPpRnG4AhwMwsgIHip0KRBQjJy5kYEpYjJxpXp9iU=
go.uber.org/nilaway v0.0.0-20260721205819-4a0f7653488a h1:CsRd824LAzMmlpu9nYv2PHOqPQYv4Zjd4kGM+g0ahS0=
go.uber.org/nilaway v0.0.0-20260721205819-4a0f7653488a/go.mod h1:i9Hi8GeZFLBtUCXP73ZNSHKZ4K2JtxT0pLmo3sbJzlo=
go.uber.org/zap v1.27.1 h1:08RqriUEv8+ArZRYSTXy1LeBScaMpVSTBhCeaZYfMYc=
go.uber.org/zap v1.27.1/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E=
go.uber.org/zap v1.17.0 h1:MTjgFu6ZLKvY6Pvaqk97GlxNBuMpV4Hy/3P6tRGlI2U=
go.uber.org/zap v1.17.0/go.mod h1:MXVU+bhUf/A7Xi2HNOnopQOrmycQ5Ih87HtOu4q5SSo=
go.yaml.in/yaml/v2 v2.4.4 h1:tuyd0P+2Ont/d6e2rl3be67goVK4R6deVxCUX5vyPaQ=
go.yaml.in/yaml/v2 v2.4.4/go.mod h1:gMZqIpDtDqOfM0uNfy0SkpRhvUryYH0Z6wdMYcacYXQ=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
go.yaml.in/yaml/v3 v3.0.6 h1:zcDfvjXEqTQmpjniUBLc91xYJMB2c4QOFxEF/LCEckM=
go.yaml.in/yaml/v3 v3.0.6/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg=
go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw=
go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.58.0 h1:COkYLr4k7nDI4r6QtMML2AfzeIswWHAJthLS6K/M/54=
golang.org/x/crypto v0.58.0/go.mod h1:Xh+kl5A+M0gMiWDytH2Hhr5Z7WbNCpv/eB/EFgfSk+w=
@@ -472,24 +483,33 @@ golang.org/x/exp v0.0.0-20241108190413-2d47ceb2692f h1:XdNn9LlyWAhLVp6P/i8QYBW+h
golang.org/x/exp v0.0.0-20241108190413-2d47ceb2692f/go.mod h1:D5SMRVC3C2/4+F/DB1wZsLRnSNimn2Sp/NPsCrsv8ak=
golang.org/x/exp/typeparams v0.0.0-20260611194520-c48552f49976 h1:GTD/WuaexTazIG/SxLOz4rEKZPDVilmVVC2nz4xhwfE=
golang.org/x/exp/typeparams v0.0.0-20260611194520-c48552f49976/go.mod h1:PqrXSW65cXDZH0k4IeUbhmg/bcAZDbzNz3byBpKCsXo=
golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.42.0 h1:ICeL3t2L30ljSPHHxDnR4fqVcBt/zR2zJAb9juavmQg=
golang.org/x/mod v0.42.0/go.mod h1:u5fzIrRInkL8MaO1cbRkUHT64v303YIT5KEqpNr7jRs=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.61.0 h1:RR+6j/BTBCrggOLugxa+66R8FvoN6MHlzDlZIbN9YFw=
golang.org/x/net v0.61.0/go.mod h1:WgDCOTH8iwtB66T4/GfI94AHo/hhdb7Hah9NDseQfiI=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.24.0 h1:iqiDrGoDgyuvAKrhLQthUEzU/R378N78gXbBjxed7oA=
golang.org/x/sync v0.24.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
@@ -522,6 +542,8 @@ golang.org/x/time v0.16.0 h1:vMb6ptszcQMkcwiRTAuNNU50gom6++Q/6gY2hDM6VDE=
golang.org/x/time v0.16.0/go.mod h1:rVKOqvZeKvrDKTQiAHJ7wmwP0RzleSphoEA9RcdLA0s=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
golang.org/x/tools v0.52.0 h1:ryxg4U63oaUYk7uOLTF37n43f4qgtGvqV4Ioit1BaEs=
@@ -533,14 +555,17 @@ golang.org/x/tools/go/packages/packagestest v0.1.1-deprecated/go.mod h1:RVAQXBGN
golang.org/x/vuln v1.6.0 h1:FeMO9Rm/HwyduOztbvKcOw+zvDEPr4I4aQNSfevFcKY=
golang.org/x/vuln v1.6.0/go.mod h1:bWlG2493/sjR7ksvicBgMrznH3eYQEyK8ifUYBrqUbg=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4=
gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E=
google.golang.org/genproto/googleapis/api v0.0.0-20260928230214-8a89bd6388cc h1:rZHRz0ogq4Pid1IKtA/ivHi8XONVF5tqUQXA2TtgpkE=
google.golang.org/genproto/googleapis/api v0.0.0-20260928230214-8a89bd6388cc/go.mod h1:K/n0XLazCJHjD73YuMyNJsN38BPPy1kfFnKxUXaThFU=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260928230214-8a89bd6388cc h1:4bNTbnb44EqGVy9HaQxSY2jnafSUdgV3qHtedvNpDKg=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260928230214-8a89bd6388cc/go.mod h1:OaIUM3+LpYcK2GXM4FTmhWoIq371Owdr+Cc7/BsYHHc=
google.golang.org/grpc v1.85.0 h1:BPBCfuVRQ1qvBhKDOq9uvQllMvO59rKsYhXpp7Dd8vw=
google.golang.org/grpc v1.85.0/go.mod h1:0rQrOD58yjal/0EaVbGBANindixsCZNQPDV3VKtaKuQ=
google.golang.org/grpc v1.83.2 h1:EManeRomTObA0BU7I8vXgg/78uE5MJ9M8B39EX2WscU=
google.golang.org/grpc v1.83.2/go.mod h1:YPI1hK3kDked6iHvgX3tR0y+nX/qpMFKhPgFsokw1S8=
google.golang.org/protobuf v1.36.12 h1:pJOKDDOyeXErUroCihFAd5LQuwXBSpVnKGrj5o/fwxc=
google.golang.org/protobuf v1.36.12/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
@@ -550,7 +575,7 @@ gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
pgregory.net/rapid v1.3.0 h1:vBvO0VSqti75J1jjYqpgPNBLKMd1+gxa9fYo7vk/Exc=
pgregory.net/rapid v1.3.0/go.mod h1:dPlE4OBBxgXPqkP79flB6sJL1dx5azpI7HQ9MY9Z7uk=
-1
View File
@@ -19,6 +19,5 @@ var modelFiles = map[Service]string{
RDS: "models/rds-2014-10-31.json.gz",
Route53: "models/route-53-2013-04-01.json.gz",
S3: "models/s3-2006-03-01.json.gz",
SQS: "models/sqs-2012-11-05.json.gz",
STS: "models/sts-2011-06-15.json.gz",
}
-1
View File
@@ -36,7 +36,6 @@ const (
RDS Service = "rds"
Route53 Service = "route53"
S3 Service = "s3"
SQS Service = "sqs"
STS Service = "sts"
)
+1 -1
View File
@@ -8,7 +8,7 @@ import (
)
func TestServices(t *testing.T) {
want := []Service{ACM, EC2, ECR, ECS, EKS, ElasticLoadBalancingV2, IAM, RDS, Route53, S3, SQS, STS}
want := []Service{ACM, EC2, ECR, ECS, EKS, ElasticLoadBalancingV2, IAM, RDS, Route53, S3, STS}
if got := Services(); !reflect.DeepEqual(got, want) {
t.Fatalf("Services() = %v, want %v", got, want)
}
Binary file not shown.
+62 -96
View File
@@ -1,6 +1,6 @@
// Package sqsworkergw is the shared SQS worker guest's SigV4 client for the
// gateway's internal spinifex-sqs service. It sends only public values: the
// worker's identity is the signing session, and the receipt key never leaves
// worker's identity is the signing session, and the store seed never leaves
// the guest.
package sqsworkergw
@@ -10,21 +10,20 @@ import (
"crypto/sha256"
"crypto/tls"
"crypto/x509"
"encoding/base64"
"encoding/hex"
"encoding/json"
"encoding/xml"
"errors"
"fmt"
"io"
"maps"
"net"
"net/http"
"net/url"
"strings"
"time"
"github.com/mulgadc/bluebottle/pkg/tlsconfig"
"github.com/mulgadc/spinifex/spinifex/domains/sqs/relay"
"github.com/nats-io/nkeys"
)
const (
@@ -34,12 +33,9 @@ const (
callTimeout = 30 * time.Second
maxResponse = 64 << 10
actionClaim = "ClaimReceiptKey"
actionRecord = "RecordReceiptKey"
actionPoll = "PollWork"
actionComplete = "CompleteWork"
// maxPollResponse fits the gateway's 16 MiB poll batch as base64 XML.
maxPollResponse = 32 << 20
actionClaim = "ClaimFirstStore"
actionDescribe = "DescribeStore"
actionRecord = "RecordWorkerPublicKey"
)
// Signer signs a request in place for service and region.
@@ -47,7 +43,7 @@ type Signer interface {
Sign(r *http.Request, payloadHash, service, region string) error
}
// Client calls the internal SQS worker actions for this worker.
// Client calls the internal first-store actions for this worker.
type Client struct {
baseURL string
region string
@@ -118,72 +114,80 @@ func (e *TransientError) Error() string { return e.Err.Error() }
func (e *TransientError) Unwrap() error { return e.Err }
func (e *TransientError) Retryable() bool { return true }
// ReceiptKey is the worker volume's receipt key initialization as the
// gateway reports it: pending authorizes creating or resuming one, otherwise
// Fingerprint names the key the disk must already hold.
type ReceiptKey struct {
Pending bool
Fingerprint string
// HostNodes is the gateway's declared cluster node count; a gateway
// that does not report one reads as zero.
HostNodes int
}
type claimResult struct {
Pending bool `xml:"Pending"`
Fingerprint string `xml:"Fingerprint"`
HostNodes int `xml:"HostNodes"`
CreateKey bool `xml:"CreateKey"`
PublicKey string `xml:"PublicKey"`
}
// ClaimReceiptKey asks for the receipt key decision. A reply that is neither
// pending nor one valid fingerprint is refused here.
func (c *Client) ClaimReceiptKey(ctx context.Context) (ReceiptKey, error) {
// Claim asks for this worker's current first-store decision. A decision
// that is neither "create" nor one valid public key is refused here.
func (c *Client) Claim(ctx context.Context) (bool, string, error) {
var res claimResult
if err := c.call(ctx, actionClaim, nil, &res); err != nil {
return ReceiptKey{}, err
return false, "", err
}
if res.Pending == (res.Fingerprint != "") || (res.Fingerprint != "" && !ValidFingerprint(res.Fingerprint)) {
return ReceiptKey{}, errors.New("spinifex-sqs ClaimReceiptKey: response is not a valid decision")
switch {
case res.CreateKey && res.PublicKey == "":
return true, "", nil
case !res.CreateKey && nkeys.IsValidPublicUserKey(res.PublicKey):
return false, res.PublicKey, nil
}
return ReceiptKey(res), nil
return false, "", errors.New("spinifex-sqs ClaimFirstStore: response is not a valid decision")
}
// RecordReceiptKey records the key's fingerprint. Recording the same one
// again succeeds, so a lost reply is safe to repeat.
func (c *Client) RecordReceiptKey(ctx context.Context, fingerprint string) error {
if !ValidFingerprint(fingerprint) {
return errors.New("spinifex-sqs RecordReceiptKey: refusing to send a value that is not a fingerprint")
}
return c.call(ctx, actionRecord, url.Values{"Fingerprint": {fingerprint}}, nil)
// Store is the Region's store launch as the gateway reports it. Bound means
// a VM was launched for it, not that it is ready.
type Store struct {
Bound bool
Address string
PublicKey string
}
// ValidFingerprint reports a lowercase hex SHA-256 fingerprint.
func ValidFingerprint(s string) bool {
if len(s) != 2*sha256.Size || strings.ToLower(s) != s {
return false
type describeResult struct {
State string `xml:"State"`
Address string `xml:"Address"`
PublicKey string `xml:"PublicKey"`
}
// DescribeStore reports this worker's store. A bound reply must carry an
// IPv4 address and a valid public key; a pending one must carry neither.
func (c *Client) DescribeStore(ctx context.Context) (Store, error) {
var res describeResult
if err := c.call(ctx, actionDescribe, nil, &res); err != nil {
return Store{}, err
}
_, err := hex.DecodeString(s)
return err == nil
switch {
case res.State == "pending" && res.Address == "" && res.PublicKey == "":
return Store{}, nil
case res.State == "bound" && isIPv4(res.Address) && nkeys.IsValidPublicUserKey(res.PublicKey):
return Store{Bound: true, Address: res.Address, PublicKey: res.PublicKey}, nil
}
return Store{}, errors.New("spinifex-sqs DescribeStore: response is not a valid store state")
}
func isIPv4(s string) bool {
ip := net.ParseIP(s)
return ip != nil && ip.To4() != nil && !strings.Contains(s, ":")
}
// RecordPublicKey records the worker's public key. Anything other than a
// user public key is refused before it is sent.
func (c *Client) RecordPublicKey(ctx context.Context, publicKey string) error {
if !nkeys.IsValidPublicUserKey(publicKey) {
return errors.New("spinifex-sqs RecordWorkerPublicKey: refusing to send a value that is not a user public key")
}
return c.call(ctx, actionRecord, url.Values{"PublicKey": {publicKey}}, nil)
}
func (c *Client) call(ctx context.Context, action string, params url.Values, out any) error {
form := url.Values{"Action": {action}, "Version": {apiVersion}}
maps.Copy(form, params)
return c.do(ctx, action, "", []byte(form.Encode()), "application/x-www-form-urlencoded; charset=utf-8", maxResponse, out)
}
// do signs and sends one request. rawQuery, when set, carries the action and
// its arguments; the body hash is signed either way.
func (c *Client) do(ctx context.Context, action, rawQuery string, body []byte, contentType string, limit int64, out any) error {
target := c.baseURL + "/"
if rawQuery != "" {
target += "?" + rawQuery
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, target, bytes.NewReader(body))
body := []byte(form.Encode())
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL+"/", bytes.NewReader(body))
if err != nil {
return fmt.Errorf("spinifex-sqs %s: build request: %w", action, err)
}
req.Header.Set("Content-Type", contentType)
req.Header.Set("Content-Type", "application/x-www-form-urlencoded; charset=utf-8")
sum := sha256.Sum256(body)
if err := c.signer.Sign(req, hex.EncodeToString(sum[:]), signingService, c.region); err != nil {
return &TransientError{Err: fmt.Errorf("spinifex-sqs %s: sign: %w", action, err)}
@@ -193,7 +197,7 @@ func (c *Client) do(ctx context.Context, action, rawQuery string, body []byte, c
return &TransientError{Err: fmt.Errorf("spinifex-sqs %s: send: %w", action, err)}
}
defer resp.Body.Close()
respBody, err := io.ReadAll(io.LimitReader(resp.Body, limit))
respBody, err := io.ReadAll(io.LimitReader(resp.Body, maxResponse))
if err != nil {
return &TransientError{Err: fmt.Errorf("spinifex-sqs %s: read response: %w", action, err)}
}
@@ -206,44 +210,6 @@ func (c *Client) do(ctx context.Context, action, rawQuery string, body []byte, c
return decodeResult(action, respBody, out)
}
// PollWork long-polls the gateway for owner calls queued for this worker.
// An empty list is a poll that found nothing.
func (c *Client) PollWork(ctx context.Context) ([]relay.Work, error) {
var res struct {
Items []string `xml:"Items>member"`
}
if err := c.do(ctx, actionPoll, "", []byte(url.Values{"Action": {actionPoll}, "Version": {apiVersion}}.Encode()),
"application/x-www-form-urlencoded; charset=utf-8", maxPollResponse, &res); err != nil {
return nil, err
}
out := make([]relay.Work, 0, len(res.Items))
for _, item := range res.Items {
raw, err := base64.StdEncoding.DecodeString(item)
if err != nil {
return nil, errors.New("spinifex-sqs PollWork: item is not base64")
}
var w relay.Work
if err := json.Unmarshal(raw, &w); err != nil || w.ID == "" || w.Op == "" {
return nil, errors.New("spinifex-sqs PollWork: item is not a work item")
}
out = append(out, w)
}
return out, nil
}
// CompleteWork posts an encoded result for work item id and returns the
// gateway's outcome name. Any error leaves delivery unknown.
func (c *Client) CompleteWork(ctx context.Context, id string, result []byte) (string, error) {
q := url.Values{"Action": {actionComplete}, "Version": {apiVersion}, "WorkId": {id}}
var res struct {
Outcome string `xml:"Outcome"`
}
if err := c.do(ctx, actionComplete, q.Encode(), result, "application/json", maxResponse, &res); err != nil {
return "", err
}
return res.Outcome, nil
}
func decodeResult(action string, body []byte, out any) error {
want := action + "Result"
dec := xml.NewDecoder(bytes.NewReader(body))
+95 -36
View File
@@ -10,6 +10,8 @@ import (
"strings"
"sync/atomic"
"testing"
"github.com/nats-io/nkeys"
)
type nopSigner struct{ err error }
@@ -22,10 +24,15 @@ func (s nopSigner) Sign(r *http.Request, _, service, region string) error {
return nil
}
var fp = strings.Repeat("ab", 32)
func claimBody(inner string) string {
return `<ClaimReceiptKeyResponse><ClaimReceiptKeyResult>` + inner + `</ClaimReceiptKeyResult></ClaimReceiptKeyResponse>`
func userKey(t *testing.T) (pub, seed string) {
t.Helper()
kp, err := nkeys.CreateUser()
if err != nil {
t.Fatal(err)
}
p, _ := kp.PublicKey()
s, _ := kp.Seed()
return p, string(s)
}
// server answers with body and status, counting and keeping requests.
@@ -66,56 +73,63 @@ func TestNew_RefusesWithoutHTTPSRegionCAOrSigner(t *testing.T) {
}
func TestClaim_SignsForTheInternalServiceAndRegion(t *testing.T) {
c, _, seen := server(t, 200, claimBody(`<Pending>true</Pending><HostNodes>1</HostNodes>`))
got, err := c.ClaimReceiptKey(context.Background())
if err != nil || !got.Pending || got.Fingerprint != "" || got.HostNodes != 1 {
t.Fatalf("claim = %+v %v", got, err)
c, _, seen := server(t, 200, `<ClaimFirstStoreResponse><ClaimFirstStoreResult><CreateKey>true</CreateKey></ClaimFirstStoreResult></ClaimFirstStoreResponse>`)
create, pub, err := c.Claim(context.Background())
if err != nil || !create || pub != "" {
t.Fatalf("claim = %v %q %v", create, pub, err)
}
if req := (*seen)[0]; !strings.HasPrefix(req, "test spinifex-sqs/ap-southeast-2|") || !strings.Contains(req, "Action=ClaimReceiptKey") {
t.Fatalf("request = %q", req)
if got := (*seen)[0]; !strings.HasPrefix(got, "test spinifex-sqs/ap-southeast-2|") || !strings.Contains(got, "Action=ClaimFirstStore") {
t.Fatalf("request = %q", got)
}
}
// Only pending alone, or exactly one valid fingerprint, is a decision.
// Only "create" or exactly one valid public key is a decision.
func TestClaim_RefusesAnythingButOneDecision(t *testing.T) {
for name, inner := range map[string]string{
"pending with a key": `<Pending>true</Pending><Fingerprint>` + fp + `</Fingerprint>`,
"neither": `<Pending>false</Pending>`,
"uppercase key": `<Pending>false</Pending><Fingerprint>` + strings.ToUpper(fp) + `</Fingerprint>`,
"short key": `<Pending>false</Pending><Fingerprint>` + fp[:62] + `</Fingerprint>`,
"not the claim reply": "",
} {
pub, seed := userKey(t)
cases := map[string]string{
"create with a key": `<CreateKey>true</CreateKey><PublicKey>` + pub + `</PublicKey>`,
"neither": `<CreateKey>false</CreateKey>`,
"seed as key": `<CreateKey>false</CreateKey><PublicKey>` + seed + `</PublicKey>`,
"no result": ``,
}
for name, inner := range cases {
t.Run(name, func(t *testing.T) {
body := claimBody(inner)
body := `<ClaimFirstStoreResponse><ClaimFirstStoreResult>` + inner + `</ClaimFirstStoreResult></ClaimFirstStoreResponse>`
if inner == "" {
body = `<Other/>`
}
c, _, _ := server(t, 200, body)
if _, err := c.ClaimReceiptKey(context.Background()); err == nil {
_, _, err := c.Claim(context.Background())
if err == nil {
t.Fatal("accepted a non-decision")
}
if strings.Contains(err.Error(), seed) {
t.Fatal("the error echoes a seed")
}
})
}
c, _, _ := server(t, 200, claimBody(`<Pending>false</Pending><Fingerprint>`+fp+`</Fingerprint>`))
got, err := c.ClaimReceiptKey(context.Background())
if err != nil || got.Pending || got.Fingerprint != fp {
t.Fatalf("recorded claim = %+v %v", got, err)
}
t.Run("existing", func(t *testing.T) {
c, _, _ := server(t, 200, `<ClaimFirstStoreResponse><ClaimFirstStoreResult><CreateKey>false</CreateKey><PublicKey>`+pub+`</PublicKey></ClaimFirstStoreResult></ClaimFirstStoreResponse>`)
create, got, err := c.Claim(context.Background())
if err != nil || create || got != pub {
t.Fatalf("claim = %v %q %v", create, got, err)
}
})
}
func TestRecordReceiptKey_SendsOnlyAFingerprint(t *testing.T) {
c, n, seen := server(t, 200, `<RecordReceiptKeyResponse/>`)
const raw = "not-a-fingerprint-and-maybe-key-material"
if err := c.RecordReceiptKey(context.Background(), raw); err == nil || strings.Contains(err.Error(), raw) {
t.Fatalf("invalid err = %v", err)
func TestRecordPublicKey_SendsOnlyAPublicKey(t *testing.T) {
pub, seed := userKey(t)
c, n, seen := server(t, 200, `<RecordWorkerPublicKeyResponse/>`)
if err := c.RecordPublicKey(context.Background(), seed); err == nil || strings.Contains(err.Error(), seed) {
t.Fatalf("seed err = %v", err)
}
if n.Load() != 0 {
t.Fatal("a non-fingerprint was sent")
t.Fatal("a seed was sent")
}
if err := c.RecordReceiptKey(context.Background(), fp); err != nil {
if err := c.RecordPublicKey(context.Background(), pub); err != nil {
t.Fatal(err)
}
if !strings.Contains((*seen)[0], "Fingerprint="+fp) {
if !strings.Contains((*seen)[0], "PublicKey="+pub) {
t.Fatalf("request = %q", (*seen)[0])
}
}
@@ -137,7 +151,7 @@ func TestErrors_ClassifiedWithoutEchoingTheBody(t *testing.T) {
}
for _, tc := range cases {
c, _, _ := server(t, tc.status, `<ErrorResponse><Error><Code>`+tc.code+`</Code><Message>secret-ish detail</Message></Error></ErrorResponse>`)
_, err := c.ClaimReceiptKey(context.Background())
_, _, err := c.Claim(context.Background())
api, ok := errors.AsType[*APIError](err)
if !ok || api.Code != tc.code || api.Retryable() != tc.retryable || api.Pending() != (tc.code == "AccessDenied") {
t.Fatalf("%s: err = %v", tc.code, err)
@@ -149,7 +163,7 @@ func TestErrors_ClassifiedWithoutEchoingTheBody(t *testing.T) {
c, _, _ := server(t, 200, "")
c.baseURL = "https://127.0.0.1:1"
_, err := c.ClaimReceiptKey(context.Background())
_, _, err := c.Claim(context.Background())
tr, ok := errors.AsType[*TransientError](err)
if !ok || !tr.Retryable() {
t.Fatalf("unreachable err = %v, want TransientError", err)
@@ -167,10 +181,55 @@ func TestCall_DoesNotFollowRedirects(t *testing.T) {
}))
t.Cleanup(srv.Close)
c.baseURL = srv.URL
if _, err := c.ClaimReceiptKey(context.Background()); err == nil {
if _, _, err := c.Claim(context.Background()); err == nil {
t.Fatal("a redirect was accepted as a decision")
}
if followed.Load() {
t.Fatal("the signed request followed a redirect")
}
}
// Only "pending" with nothing else, or "bound" with an IPv4 address and one
// valid public key, is a store state.
func TestDescribeStore_AcceptsOnlyAValidState(t *testing.T) {
pub, seed := userKey(t)
wrap := func(inner string) string {
return `<DescribeStoreResponse><DescribeStoreResult>` + inner + `</DescribeStoreResult></DescribeStoreResponse>`
}
t.Run("pending", func(t *testing.T) {
c, _, seen := server(t, 200, wrap(`<State>pending</State>`))
st, err := c.DescribeStore(context.Background())
if err != nil || st.Bound {
t.Fatalf("store = %+v %v", st, err)
}
if !strings.Contains((*seen)[0], "Action=DescribeStore") {
t.Fatalf("request = %q", (*seen)[0])
}
})
t.Run("bound", func(t *testing.T) {
c, _, _ := server(t, 200, wrap(`<State>bound</State><Address>10.240.0.9</Address><PublicKey>`+pub+`</PublicKey>`))
st, err := c.DescribeStore(context.Background())
if err != nil || !st.Bound || st.Address != "10.240.0.9" || st.PublicKey != pub {
t.Fatalf("store = %+v %v", st, err)
}
})
for name, inner := range map[string]string{
"pending with address": `<State>pending</State><Address>10.240.0.9</Address>`,
"bound without key": `<State>bound</State><Address>10.240.0.9</Address>`,
"bound with seed": `<State>bound</State><Address>10.240.0.9</Address><PublicKey>` + seed + `</PublicKey>`,
"bound with hostname": `<State>bound</State><Address>store.example</Address><PublicKey>` + pub + `</PublicKey>`,
"bound with IPv6": `<State>bound</State><Address>::ffff:10.240.0.9</Address><PublicKey>` + pub + `</PublicKey>`,
"unknown state": `<State>ready</State>`,
} {
t.Run(name, func(t *testing.T) {
c, _, _ := server(t, 200, wrap(inner))
_, err := c.DescribeStore(context.Background())
if err == nil {
t.Fatal("accepted an invalid store state")
}
if strings.Contains(err.Error(), seed) {
t.Fatal("the error echoes a seed")
}
})
}
}
-7
View File
@@ -1,7 +0,0 @@
// Package deadpid gives tests a PID that no process can hold.
package deadpid
// PID is above the kernel's PID_MAX_LIMIT (4194304), so kill(2) on it always
// fails with ESRCH. A plausible-looking literal such as 99999 is a real PID on
// a busy host, and signalling it terminates whatever process holds it.
const PID = 1 << 30
+15 -6
View File
@@ -11,6 +11,7 @@ import (
"crypto/x509"
"crypto/x509/pkix"
"encoding/pem"
"errors"
"math/big"
"net"
"os"
@@ -69,7 +70,7 @@ func Start(t *testing.T, selfID uint64, members []Member) *Server {
})
s := &Server{Endpoint: "https://" + lis.Addr().String(), CertDir: dir, members: members, selfID: selfID}
gs := grpc.NewServer(grpc.Creds(creds))
pb.RegisterClusterServer(gs, &cluster{s: s})
pb.RegisterClusterServer(gs, &cluster{s})
pb.RegisterMaintenanceServer(gs, &maintenance{s: s})
go func() { _ = gs.Serve(lis) }()
t.Cleanup(gs.Stop)
@@ -90,11 +91,7 @@ func (s *Server) FailWith(err error) {
s.failAll = err
}
type cluster struct {
pb.UnimplementedClusterServer
s *Server
}
type cluster struct{ s *Server }
func (c *cluster) MemberList(context.Context, *pb.MemberListRequest) (*pb.MemberListResponse, error) {
c.s.mu.Lock()
@@ -119,6 +116,18 @@ func (c *cluster) MemberRemove(_ context.Context, req *pb.MemberRemoveRequest) (
return &pb.MemberRemoveResponse{Header: &pb.ResponseHeader{MemberId: c.s.selfID}}, nil
}
func (c *cluster) MemberAdd(context.Context, *pb.MemberAddRequest) (*pb.MemberAddResponse, error) {
return nil, errors.New("not implemented")
}
func (c *cluster) MemberUpdate(context.Context, *pb.MemberUpdateRequest) (*pb.MemberUpdateResponse, error) {
return nil, errors.New("not implemented")
}
func (c *cluster) MemberPromote(context.Context, *pb.MemberPromoteRequest) (*pb.MemberPromoteResponse, error) {
return nil, errors.New("not implemented")
}
type maintenance struct {
pb.UnimplementedMaintenanceServer
-108
View File
@@ -1,108 +0,0 @@
package testutil
import (
"context"
"sync"
"github.com/nats-io/nats.go/jetstream"
)
// StaleReadKV wraps a bucket so that the first Get of a key after a write to it
// returns the entry as it stood before that write, as a lagging replica does
// when it answers a direct get. Later Gets see the current entry.
type StaleReadKV struct {
jetstream.KeyValue
mu sync.Mutex
stale map[string]jetstream.KeyValueEntry
}
var _ jetstream.KeyValue = (*StaleReadKV)(nil)
// NewStaleReadKV wraps kv.
func NewStaleReadKV(kv jetstream.KeyValue) *StaleReadKV {
return &StaleReadKV{KeyValue: kv, stale: map[string]jetstream.KeyValueEntry{}}
}
// remember captures the entry about to be overwritten.
func (k *StaleReadKV) remember(ctx context.Context, key string) jetstream.KeyValueEntry {
prev, err := k.KeyValue.Get(ctx, key)
if err != nil {
return nil
}
return prev
}
func (k *StaleReadKV) arm(key string, prev jetstream.KeyValueEntry) {
if prev == nil {
return
}
k.mu.Lock()
k.stale[key] = prev
k.mu.Unlock()
}
func (k *StaleReadKV) Get(ctx context.Context, key string) (jetstream.KeyValueEntry, error) {
k.mu.Lock()
prev, ok := k.stale[key]
delete(k.stale, key)
k.mu.Unlock()
if ok {
return prev, nil
}
return k.KeyValue.Get(ctx, key)
}
func (k *StaleReadKV) Update(ctx context.Context, key string, value []byte, last uint64) (uint64, error) {
prev := k.remember(ctx, key)
rev, err := k.KeyValue.Update(ctx, key, value, last)
if err == nil {
k.arm(key, prev)
}
return rev, err
}
func (k *StaleReadKV) Put(ctx context.Context, key string, value []byte) (uint64, error) {
prev := k.remember(ctx, key)
rev, err := k.KeyValue.Put(ctx, key, value)
if err == nil {
k.arm(key, prev)
}
return rev, err
}
// RejectingKV wraps a bucket so that writes for which reject returns an error
// fail with it, and every other operation passes through.
type RejectingKV struct {
jetstream.KeyValue
reject func(key string, value []byte) error
}
var _ jetstream.KeyValue = (*RejectingKV)(nil)
// NewRejectingKV wraps kv. reject runs before each Put, Create and Update.
func NewRejectingKV(kv jetstream.KeyValue, reject func(key string, value []byte) error) *RejectingKV {
return &RejectingKV{KeyValue: kv, reject: reject}
}
func (k *RejectingKV) Put(ctx context.Context, key string, value []byte) (uint64, error) {
if err := k.reject(key, value); err != nil {
return 0, err
}
return k.KeyValue.Put(ctx, key, value)
}
func (k *RejectingKV) Create(ctx context.Context, key string, value []byte, opts ...jetstream.KVCreateOpt) (uint64, error) {
if err := k.reject(key, value); err != nil {
return 0, err
}
return k.KeyValue.Create(ctx, key, value, opts...)
}
func (k *RejectingKV) Update(ctx context.Context, key string, value []byte, last uint64) (uint64, error) {
if err := k.reject(key, value); err != nil {
return 0, err
}
return k.KeyValue.Update(ctx, key, value, last)
}
+43 -9
View File
@@ -7,14 +7,17 @@ package pagedstore
import (
"context"
"sort"
"strconv"
"github.com/aws/aws-sdk-go/aws"
"github.com/aws/aws-sdk-go/service/s3"
"github.com/mulgadc/spinifex/spinifex/providers/objectstore"
)
// Store wraps a MemoryObjectStore whose ListObjectsV2 pages like S3, capping
// every page at PageSize entries so small fixtures span several pages.
// Store wraps a MemoryObjectStore and splits ListObjectsV2's answer into
// pages of at most PageSize objects, sorted by key so pagination is
// deterministic across runs (MemoryObjectStore itself iterates a map).
type Store struct {
*objectstore.MemoryObjectStore
@@ -25,20 +28,51 @@ type Store struct {
Calls int
}
// New returns an empty Store that serves at most pageSize entries per page.
// New returns a paging store with the given per-page object limit.
func New(pageSize int) *Store {
return &Store{MemoryObjectStore: objectstore.NewMemoryObjectStore(), PageSize: pageSize}
}
var _ objectstore.ObjectStore = (*Store)(nil)
// ListObjectsV2 lowers the caller's MaxKeys to PageSize and lets the memory
// store page, so tokens, ordering and common prefixes follow S3's rules.
// ListObjectsV2 delegates to MemoryObjectStore for prefix/delimiter matching,
// then slices the sorted result into one page per call, resuming from the
// offset encoded in ContinuationToken.
func (s *Store) ListObjectsV2(ctx context.Context, input *s3.ListObjectsV2Input) (*s3.ListObjectsV2Output, error) {
s.Calls++
page := *input
if page.MaxKeys == nil || *page.MaxKeys > int64(s.PageSize) {
page.MaxKeys = aws.Int64(int64(s.PageSize))
full, err := s.MemoryObjectStore.ListObjectsV2(ctx, input)
if err != nil {
return nil, err
}
return s.MemoryObjectStore.ListObjectsV2(ctx, &page)
sort.Slice(full.Contents, func(i, j int) bool {
return aws.StringValue(full.Contents[i].Key) < aws.StringValue(full.Contents[j].Key)
})
start := 0
if tok := aws.StringValue(input.ContinuationToken); tok != "" {
start, err = strconv.Atoi(tok)
if err != nil {
return nil, err
}
}
end := min(start+s.PageSize, len(full.Contents))
if start > end {
start = end
}
page := &s3.ListObjectsV2Output{
Contents: full.Contents[start:end],
CommonPrefixes: full.CommonPrefixes,
Name: input.Bucket,
KeyCount: aws.Int64(int64(end - start)),
}
if end < len(full.Contents) {
page.IsTruncated = aws.Bool(true)
page.NextContinuationToken = aws.String(strconv.Itoa(end))
} else {
page.IsTruncated = aws.Bool(false)
}
return page, nil
}
+1 -1
View File
@@ -6,7 +6,7 @@ Node installation, dev-environment lifecycle, guest image builds, and verificati
| Script | What it does |
|---|---|
| `setup.sh` | The production binary installer, served from `install.mulgadc.com`. Installs deps, users, sudoers, the host firewall policy, files, systemd units, logrotate and udev rules, and loads `openvswitch`, `geneve`, `vhost_net` and `tun`, listing them in `/etc/modules-load.d/spinifex.conf` for later boots. It masks firewalld wherever it is installed, since the Spinifex nftables policy is the only supported node firewall. It writes a needrestart drop-in so an apt upgrade never restarts the Spinifex, OVS or OVN units; restarting them after an upgrade is left to the operator. An unset `--firewall` keeps the mode recorded in `/etc/spinifex/firewall/mode`, and otherwise arms the policy when firewalld was active. Where systemd-resolved is installed but not running, resolvconf is absent and NetworkManager runs, it hands host DNS to resolved: it saves `/etc/resolv.conf` once to `/var/lib/spinifex-reset/resolv.conf.orig`, writes `/etc/NetworkManager/conf.d/90-spinifex-dns.conf` (`dns=systemd-resolved`, `rc-manager=unmanaged`) so NetworkManager never rewrites `/etc/resolv.conf`, links `/etc/resolv.conf` to the resolved stub, and stops unless the link holds, resolved has upstream servers and the package repository host still resolves. A rerun after that handover adds a missing drop-in, reloads NetworkManager and relinks the stub. It links `/usr/bin/spx` to `/usr/local/bin/spx` where sudo's PATH leaves out `/usr/local/bin`, and takes the nbdkit plugin directory from `nbdkit --dump-config`, stopping when nbdkit cannot say. `SETUP_STAGES` runs a subset; `ISO_BUILD=1` runs it inside the ISO builder's chroot. |
| `setup.sh` | The production binary installer, served from `install.mulgadc.com`. Installs deps, users, sudoers, the host firewall policy, files, systemd units, logrotate and udev rules, and loads `openvswitch`, `geneve`, `vhost_net` and `tun`, listing them in `/etc/modules-load.d/spinifex.conf` for later boots. It masks firewalld wherever it is installed, since the Spinifex nftables policy is the only supported node firewall. It writes a needrestart drop-in so an apt upgrade never restarts the Spinifex, OVS or OVN units; restarting them after an upgrade is left to the operator. An unset `--firewall` keeps the mode recorded in `/etc/spinifex/firewall/mode`, and otherwise arms the policy when firewalld was active. Where systemd-resolved is installed but not running, resolvconf is absent and NetworkManager runs, it hands host DNS to resolved: it saves `/etc/resolv.conf` once to `/var/lib/spinifex-reset/resolv.conf.orig`, links `/etc/resolv.conf` to the resolved stub, and stops unless resolved has upstream servers and the package repository host still resolves. It links `/usr/bin/spx` to `/usr/local/bin/spx` where sudo's PATH leaves out `/usr/local/bin`, and takes the nbdkit plugin directory from `nbdkit --dump-config`, stopping when nbdkit cannot say. `SETUP_STAGES` runs a subset; `ISO_BUILD=1` runs it inside the ISO builder's chroot. |
| `setup-ovn.sh` | Bootstraps a compute node for OVN VPC networking: installs OVN/OVS, creates `br-int`, configures the WAN bridge (bridged, DHCP, or routed-NAT uplink), sets the chassis identity, and applies overlay sysctls. It runs OVS and OVN as root, setting `OVS_USER_ID` and `OVN_USER_ID` where `/etc/sysconfig` holds them, and hooks the socket-permission helper onto `ovs-vswitchd`, `ovn-controller`, `openvswitch-ipsec` and, on EL, `ovn-northd`. `--management` also starts the OVN central services, writes their `ovn-ctl` flags to `/etc/default/ovn-central` (Debian) or `/etc/sysconfig/ovn-northd` (EL), and records the role in `/etc/spinifex/ovn-role`. With clustered databases it returns only once this node's NB and SB members are back in their RAFT clusters under a known leader, and fails after `OVN_RAFT_REJOIN_WAIT` seconds (default 60) otherwise, so reruns one node at a time never take two members of a cluster down. A later run without `--management` refuses while that file, or the `OVN_CTL_OPTS` line older nodes carry in `/etc/default/ovn-central`, records the role. It adds `maxsize 100M` to the OVS and OVN logrotate files, plus `rotate 5` where the file sets no count, and rotates as root, matching the daemons. On a host whose IPsec stack is libreswan it enables, stops and masks `openvswitch-ipsec.service` and `ipsec.service` for the daemon to unmask when IPsec is on, unless `openvswitch-ipsec.service` is already running; elsewhere it enables and starts `openvswitch-ipsec.service`, unless the daemon has masked it to keep IPsec off. The links it creates persist through systemd-networkd where that is enabled, and otherwise through `/etc/spinifex/hostnet.json`, which `spinifex-hostnet.service` (`spx admin hostnet apply`) replays at boot and on every OVS start, starting `ovn-controller` again when an OVS stop took it down; the first run records the choice in `/etc/spinifex/hostnet-backend`. Where NetworkManager runs, it leaves the links Spinifex owns unmanaged. |
| `install-node.sh` | Forms a multi-node cluster across hosts that are already running Spinifex: rebuilds the OVN database in clustered form, runs `spx admin init` on the first host and `spx admin join` on the rest, then verifies. Before touching any host it refuses hosts of different OS families (`distro_os_family`) or `spx` versions, and `--ipsec on` when any host uses libreswan. The scripted form of [install-multi-node](../docs/install/install-multi-node/README.md). Destroys the joining nodes' logical network state and crypto — see its `--help`. |
| `dev-install.sh` | Full local dev setup through the production installer: builds from source, packs a tarball, runs `setup.sh`, initialises the cluster, and starts services under systemd. |
+57 -30
View File
@@ -1,18 +1,22 @@
#!/bin/bash
# build-sqs-store-microvm-image.sh — build the SQS microVM kernel + initramfs:
# one guest running the dedicated SQS nats-server and the SQS worker as separate
# processes, ext4 support, its guest steps and a clean power-down. The guest
# never formats a disk. The LB image (build-microvm-image.sh) is separate.
# build-sqs-store-microvm-image.sh — build the SQS store microVM kernel +
# initramfs: the dedicated nats-server, ext4 support, the store's guest steps
# and a clean power-down. The guest never formats a disk; build/microvm/
# sqs-store/sqs-store-lib.sh states its fw_cfg contract. The LB image
# (build-microvm-image.sh) is separate and unchanged.
#
# SQS_MICROVM_PROFILE=worker builds the shared SQS worker guest instead: the
# same network /init and kernel, build/microvm/sqs-worker's steps and
# $REPO_ROOT/bin/sqs-worker, with no nats-server.
#
# Inputs:
# $REPO_ROOT/build/microvm/sqs-worker/init.sh, inittab
# $REPO_ROOT/build/microvm/sqs-worker/sqs-worker-{lib.sh,prepare,ready,stop}
# $REPO_ROOT/build/microvm/sqs-store/init.sh, inittab
# $REPO_ROOT/build/microvm/sqs-store/sqs-store-{lib.sh,prepare,run,stop}
# $REPO_ROOT/bin/nats-server (make bin/nats-server, the go.mod-pinned version)
# $REPO_ROOT/bin/sqs-worker
#
# Outputs:
# $REPO_ROOT/build/microvm/sqs-worker/vmlinuz
# $REPO_ROOT/build/microvm/sqs-worker/initramfs.cpio.gz
# $REPO_ROOT/build/microvm/sqs-store/vmlinuz
# $REPO_ROOT/build/microvm/sqs-store/initramfs.cpio.gz
#
# Rootfs strategy (first match wins):
# 1. apk available on host → install directly into a temp chroot (Alpine CI/CD)
@@ -21,16 +25,20 @@
set -euo pipefail
REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)"
BUILD_DIR="$REPO_ROOT/build/microvm/sqs-worker"
BUILD_DIR="$REPO_ROOT/build/microvm/sqs-store"
INIT_SH="$BUILD_DIR/init.sh"
INITTAB="$BUILD_DIR/inittab"
NATS_SERVER_BIN="$REPO_ROOT/bin/nats-server"
SQS_WORKER_BIN="$REPO_ROOT/bin/sqs-worker"
PROFILE="${SQS_MICROVM_PROFILE:-worker}"
PROFILE="${SQS_MICROVM_PROFILE:-store}"
case "$PROFILE" in
worker) ;;
*) echo "ERROR: SQS_MICROVM_PROFILE must be worker, not $PROFILE" >&2; exit 1 ;;
store) ;;
worker)
BUILD_DIR="$REPO_ROOT/build/microvm/sqs-worker"
INITTAB="$BUILD_DIR/inittab"
SQS_WORKER_BIN="$REPO_ROOT/bin/sqs-worker"
;;
*) echo "ERROR: SQS_MICROVM_PROFILE must be store or worker, not $PROFILE" >&2; exit 1 ;;
esac
TAG="[build-sqs-$PROFILE-microvm-image]"
@@ -60,14 +68,16 @@ for tool in cpio gzip find fakeroot; do
fi
done
if [ ! -x "$SQS_WORKER_BIN" ]; then
echo "ERROR: $SQS_WORKER_BIN not found — run 'make bin/sqs-worker'" >&2
exit 1
fi
if ! file -b "$SQS_WORKER_BIN" | grep -q "statically linked"; then
echo "ERROR: $SQS_WORKER_BIN is not statically linked — it cannot run on the musl guest" >&2
exit 1
fi
if [ "$PROFILE" = worker ]; then
if [ ! -x "$SQS_WORKER_BIN" ]; then
echo "ERROR: $SQS_WORKER_BIN not found — run 'make bin/sqs-worker'" >&2
exit 1
fi
if ! file -b "$SQS_WORKER_BIN" | grep -q "statically linked"; then
echo "ERROR: $SQS_WORKER_BIN is not statically linked — it cannot run on the musl guest" >&2
exit 1
fi
else
# --- Verify the dedicated nats-server is the go.mod-pinned build ---
# A stale or host-installed binary would ship an unreviewed server version.
NATS_SERVER_PIN=$(awk '$1 == "github.com/nats-io/nats-server/v2" { print $2; exit }' "$REPO_ROOT/go.mod")
@@ -89,6 +99,7 @@ if [ "$nats_server_version" != "$NATS_SERVER_PIN" ]; then
exit 1
fi
echo "$TAG nats-server: $nats_server_version (matches go.mod)"
fi
# --- Create chroot ---
CHROOT_DIR=$(mktemp -d)
@@ -202,23 +213,39 @@ fi
echo "$TAG installing inittab..."
install -m 0644 "$INITTAB" "$CHROOT_DIR/etc/inittab"
echo "$TAG installing worker guest steps..."
for step in sqs-worker-lib.sh sqs-worker-prepare sqs-worker-store sqs-worker-ready sqs-worker-stop; do
if [ "$PROFILE" = worker ]; then
echo "$TAG installing worker guest steps..."
for step in sqs-worker-lib.sh sqs-worker-prepare sqs-worker-ready sqs-worker-stop; do
if [ ! -f "$BUILD_DIR/$step" ]; then
echo "ERROR: $BUILD_DIR/$step not found — cannot build initramfs" >&2
exit 1
fi
done
mkdir -p "$CHROOT_DIR/usr/local/lib/sqs-worker" "$CHROOT_DIR/usr/local/bin"
install -m 0755 "$BUILD_DIR/sqs-worker-lib.sh" "$BUILD_DIR/sqs-worker-prepare" "$BUILD_DIR/sqs-worker-ready" "$BUILD_DIR/sqs-worker-stop" \
"$CHROOT_DIR/usr/local/lib/sqs-worker/"
install -m 0755 "$SQS_WORKER_BIN" "$CHROOT_DIR/usr/local/bin/sqs-worker"
# The mountpoint ships empty; the worker proves its disk is mounted here.
mkdir -p "$CHROOT_DIR/data"
else
# --- Place the store's guest steps ---
echo "$TAG installing store guest steps..."
mkdir -p "$CHROOT_DIR/usr/local/lib/sqs-store"
for step in sqs-store-lib.sh sqs-store-prepare sqs-store-run sqs-store-stop; do
if [ ! -f "$BUILD_DIR/$step" ]; then
echo "ERROR: $BUILD_DIR/$step not found — cannot build initramfs" >&2
exit 1
fi
done
mkdir -p "$CHROOT_DIR/usr/local/lib/sqs-worker" "$CHROOT_DIR/usr/local/bin"
install -m 0755 "$BUILD_DIR/sqs-worker-lib.sh" "$BUILD_DIR/sqs-worker-prepare" "$BUILD_DIR/sqs-worker-store" "$BUILD_DIR/sqs-worker-ready" "$BUILD_DIR/sqs-worker-stop" \
"$CHROOT_DIR/usr/local/lib/sqs-worker/"
install -m 0755 "$SQS_WORKER_BIN" "$CHROOT_DIR/usr/local/bin/sqs-worker"
# The mountpoint ships empty; the worker proves its disk is mounted here.
mkdir -p "$CHROOT_DIR/data"
install -m 0644 "$BUILD_DIR/sqs-store-lib.sh" "$CHROOT_DIR/usr/local/lib/sqs-store/"
install -m 0755 "$BUILD_DIR/sqs-store-prepare" "$BUILD_DIR/sqs-store-run" "$BUILD_DIR/sqs-store-stop" \
"$CHROOT_DIR/usr/local/lib/sqs-store/"
# --- Copy nats-server binary ---
echo "$TAG copying nats-server binary..."
mkdir -p "$CHROOT_DIR/usr/local/bin"
install -m 0755 "$NATS_SERVER_BIN" "$CHROOT_DIR/usr/local/bin/nats-server"
fi
# --- Create required directories for init ---
mkdir -p \
+5 -21
View File
@@ -11,14 +11,12 @@ set -eu
# cron can branch without re-parsing, then disables itself so it runs exactly
# once.
#
# server → eks-token-webhook, k3s (server), eks-pod-identity-webhook,
# k3s-first-boot (bootstrap publish), mulga-eks-state-report,
# mulga-eks-node-gc
# server → eks-token-webhook, k3s (server), k3s-first-boot (bootstrap
# publish), mulga-eks-state-report
# server-join → eks-token-webhook, k3s (server, joins the first server's etcd
# quorum), eks-pod-identity-webhook, mulga-eks-state-report,
# mulga-eks-node-gc. NO k3s-first-boot: the first server
# already publishes the cluster-identical bootstrap artifacts;
# a join re-publish only races the bootstrap bus.
# quorum), mulga-eks-state-report. NO k3s-first-boot: the first
# server already publishes the cluster-identical bootstrap
# artifacts; a join re-publish only races the bootstrap bus.
# agent → k3s-agent
#
# Paths are overridable via env so the selection logic is unit-testable (bats)
@@ -82,14 +80,6 @@ svc_start() {
esac
}
# IRSA injector: every server runs one, called by its own apiserver. It is
# optional (failurePolicy Ignore), so it goes last and a fault only costs
# injection, never the control plane.
svc_optional() {
svc_enable "$1" && svc_start "$1" ||
log "WARN: optional service $1 failed to enable or start; continuing without it"
}
svc_disable() {
case "${INIT_SYSTEM}" in
openrc) rc-update del "$1" default 2>/dev/null || true ;;
@@ -150,7 +140,6 @@ case "${ROLE}" in
# writer owns. HA multi-server addon delivery is tracked separately
# Server-join nodes do not run this setup.
svc_enable mulga-eks-addon-sync
svc_enable mulga-eks-node-gc
svc_start eks-token-webhook
svc_start mulga-eks-k3s-recovery
svc_start k3s
@@ -158,8 +147,6 @@ case "${ROLE}" in
svc_start k3s-first-boot
svc_start mulga-eks-state-report
svc_start mulga-eks-addon-sync
svc_start mulga-eks-node-gc
svc_optional eks-pod-identity-webhook
;;
server-join)
log "configuring server-join role"
@@ -171,14 +158,11 @@ case "${ROLE}" in
svc_enable k3s
svc_enable konnectivity-server
svc_enable mulga-eks-state-report
svc_enable mulga-eks-node-gc
svc_start eks-token-webhook
svc_start mulga-eks-k3s-recovery
svc_start k3s
svc_start konnectivity-server
svc_start mulga-eks-state-report
svc_start mulga-eks-node-gc
svc_optional eks-pod-identity-webhook
;;
agent)
log "configuring agent role"
+1 -38
View File
@@ -22,7 +22,6 @@ for tool in rc-update rc-service; do
cat > "${STUB_OPENRC}/${tool}" <<EOF
#!/bin/sh
echo "${tool} \$*" >> "\${CALLS}"
case "\$*" in *"\${STUB_FAIL:-@none@}"*) exit 1 ;; esac
exit 0
EOF
chmod +x "${STUB_OPENRC}/${tool}"
@@ -33,7 +32,6 @@ mkdir -p "${STUB_SYSTEMD}"
cat > "${STUB_SYSTEMD}/systemctl" <<'EOF'
#!/bin/sh
echo "systemctl $*" >> "${CALLS}"
case "$*" in *"${STUB_FAIL:-@none@}"*) exit 1 ;; esac
exit 0
EOF
chmod +x "${STUB_SYSTEMD}/systemctl"
@@ -131,7 +129,6 @@ for INIT in openrc systemd; do
check "server(${INIT}): enables k3s-first-boot" "yes" "$(present "${CALLS}" "$(enable_cmd "${INIT}" k3s-first-boot)")"
check "server(${INIT}): starts k3s" "yes" "$(present "${CALLS}" "$(start_cmd "${INIT}" k3s)")"
check "server(${INIT}): enables konnectivity-server" "yes" "$(present "${CALLS}" "$(enable_cmd "${INIT}" konnectivity-server)")"
check "server(${INIT}): enables pod-identity-webhook" "yes" "$(present "${CALLS}" "$(enable_cmd "${INIT}" eks-pod-identity-webhook)")"
check "server(${INIT}): enables k3s-recovery" "yes" "$(present "${CALLS}" "$(enable_cmd "${INIT}" mulga-eks-k3s-recovery)")"
# Recovery is enabled (so it also runs pre-k3s on later boots) AND started
# inline now — a restore-snapshot DR seed's directive must apply on this
@@ -144,38 +141,8 @@ for INIT in openrc systemd; do
END { print (rn > 0 && kn > 0 && rn < kn) ? "yes" : "no" }
' "${CALLS}"
)"
check "server(${INIT}): enables node-gc" "yes" "$(present "${CALLS}" "$(enable_cmd "${INIT}" mulga-eks-node-gc)")"
check "server(${INIT}): starts node-gc" "yes" "$(present "${CALLS}" "$(start_cmd "${INIT}" mulga-eks-node-gc)")"
check "server(${INIT}): no k3s-agent" "no" "$(present "${CALLS}" "$(enable_cmd "${INIT}" k3s-agent)")"
check "server(${INIT}): self-disables" "yes" "$(present "${CALLS}" "$(disable_cmd "${INIT}" eks-node-role)")"
check "server(${INIT}): pod-identity-webhook is the last call" "$(start_cmd "${INIT}" eks-pod-identity-webhook)" "$(grep -v "$(disable_cmd "${INIT}" eks-node-role)" "${CALLS}" | tail -n 1)"
check "server(${INIT}): pod-identity-webhook enabled after addon-sync start" "yes" "$(
awk -v a="$(start_cmd "${INIT}" mulga-eks-addon-sync)" -v w="$(enable_cmd "${INIT}" eks-pod-identity-webhook)" '
index($0, a) == 1 { an = NR }
index($0, w) == 1 { wn = NR }
END { print (an > 0 && wn > an) ? "yes" : "no" }
' "${CALLS}"
)"
# A broken optional webhook must leave the control plane booting.
for FAILING in enable start; do
run_case "${INIT}" server_${FAILING}_fail
printf 'SPINIFEX_K3S_ROLE=server\nEKS_CLUSTER_NAME=alpha\n' > "${EKS_NODE_ENVFILE}"
if [ "${FAILING}" = enable ]; then
STUB_FAIL="$(enable_cmd "${INIT}" eks-pod-identity-webhook)"
export STUB_FAIL
STUB_FAIL="${STUB_FAIL#rc-update add }"; STUB_FAIL="${STUB_FAIL#systemctl enable }"
else
STUB_FAIL="$(start_cmd "${INIT}" eks-pod-identity-webhook)"
export STUB_FAIL
STUB_FAIL="${STUB_FAIL#rc-service }"; STUB_FAIL="${STUB_FAIL#systemctl start --no-block }"
fi
invoke
unset STUB_FAIL
check "server(${INIT}): webhook ${FAILING} failure still exits 0" 0 "${rc}"
check "server(${INIT}): webhook ${FAILING} failure still starts mulga-eks-addon-sync" "yes" "$(present "${CALLS}" "$(start_cmd "${INIT}" mulga-eks-addon-sync)")"
check "server(${INIT}): webhook ${FAILING} failure still self-disables" "yes" "$(present "${CALLS}" "$(disable_cmd "${INIT}" eks-node-role)")"
done
# --- server-join ---
run_case "${INIT}" server_join
@@ -186,16 +153,12 @@ for INIT in openrc systemd; do
check "server-join(${INIT}): enables webhook" "yes" "$(present "${CALLS}" "$(enable_cmd "${INIT}" eks-token-webhook)")"
check "server-join(${INIT}): enables k3s" "yes" "$(present "${CALLS}" "$(enable_cmd "${INIT}" k3s)")"
check "server-join(${INIT}): enables state-report" "yes" "$(present "${CALLS}" "$(enable_cmd "${INIT}" mulga-eks-state-report)")"
check "server-join(${INIT}): enables node-gc" "yes" "$(present "${CALLS}" "$(enable_cmd "${INIT}" mulga-eks-node-gc)")"
check "server-join(${INIT}): starts node-gc" "yes" "$(present "${CALLS}" "$(start_cmd "${INIT}" mulga-eks-node-gc)")"
check "server-join(${INIT}): enables konnectivity-server" "yes" "$(present "${CALLS}" "$(enable_cmd "${INIT}" konnectivity-server)")"
check "server-join(${INIT}): enables pod-identity-webhook" "yes" "$(present "${CALLS}" "$(enable_cmd "${INIT}" eks-pod-identity-webhook)")"
check "server-join(${INIT}): enables k3s-recovery" "yes" "$(present "${CALLS}" "$(enable_cmd "${INIT}" mulga-eks-k3s-recovery)")"
check "server-join(${INIT}): starts k3s-recovery" "yes" "$(present "${CALLS}" "$(start_cmd "${INIT}" mulga-eks-k3s-recovery)")"
# Join servers must NOT re-publish bootstrap: the first server already
# published the cluster-identical artifacts, and a join re-publish would
# only race the bootstrap bus. This is deliberate, not an oversight.
check "server-join(${INIT}): pod-identity-webhook is the last call" "$(start_cmd "${INIT}" eks-pod-identity-webhook)" "$(grep -v "$(disable_cmd "${INIT}" eks-node-role)" "${CALLS}" | tail -n 1)"
check "server-join(${INIT}): no k3s-first-boot" "yes" "$(absent_all "${CALLS}" "k3s-first-boot")"
check "server-join(${INIT}): no k3s-agent" "yes" "$(absent_all "${CALLS}" "$(enable_cmd "${INIT}" k3s-agent)")"
check "server-join(${INIT}): self-disables" "yes" "$(present "${CALLS}" "$(disable_cmd "${INIT}" eks-node-role)")"
@@ -207,7 +170,7 @@ for INIT in openrc systemd; do
check "agent(${INIT}): exit 0" 0 "${rc}"
check "agent(${INIT}): role file" "agent" "$(cat "${ROLE_FILE}")"
check "agent(${INIT}): enables k3s-agent" "yes" "$(present "${CALLS}" "$(enable_cmd "${INIT}" k3s-agent)")"
check "agent(${INIT}): no server services" "yes" "$(absent_all "${CALLS}" "eks-token-webhook" "eks-pod-identity-webhook" "k3s-first-boot" "mulga-eks-node-gc" "$(enable_cmd "${INIT}" k3s)")"
check "agent(${INIT}): no server services" "yes" "$(absent_all "${CALLS}" "eks-token-webhook" "k3s-first-boot" "$(enable_cmd "${INIT}" k3s)")"
# --- agent (inferred from agent.env, no explicit role) ---
run_case "${INIT}" agent_inferred
@@ -1,26 +0,0 @@
#!/sbin/openrc-run
# eks-pod-identity-webhook — IRSA mutating admission webhook. Binds 127.0.0.1:8445
# only; the local kube-apiserver is the sole client. Supervised and respawned so
# it can wait out k3s writing its CAs without ever delaying k3s or the token
# webhook: it exits until the CAs exist and supervise-daemon retries.
description="EKS pod identity webhook (Mulga)"
command="/usr/local/bin/eks-pod-identity-webhook"
command_args="--addr=127.0.0.1:8445"
pidfile="/run/eks-pod-identity-webhook.pid"
supervisor="supervise-daemon"
respawn_delay=5
respawn_max=0
output_log="/dev/console"
error_log="/dev/console"
if [ -f /etc/spinifex-eks/first-boot.env ]; then
# shellcheck disable=SC1091
. /etc/spinifex-eks/first-boot.env
export EKS_REGION EKS_ADDON_GATEWAY_URL EKS_GATEWAY_CA
fi
depend() {
need net
after k3s
}
+2 -5
View File
@@ -22,9 +22,9 @@ APK_PACKAGES="ca-certificates curl jq iptables ip6tables conntrack-tools cgroup-
# at a pinned tag (its GitHub releases ship only container images, and its go.mod
# carries replace directives that block `go install pkg@version`). The agent half
# is the matching proxy-agent container image, pinned in k3s.initd.
BUILD_COMMANDS="CGO_ENABLED=0 GOFIPS140=v1.0.0 go build -o bin/eks-token-webhook ./cmd/eks-token-webhook && CGO_ENABLED=0 GOFIPS140=v1.0.0 go build -o bin/eks-gateway-publish ./cmd/eks-gateway-publish && CGO_ENABLED=0 GOFIPS140=v1.0.0 go build -o bin/eks-gateway-fetch ./cmd/eks-gateway-fetch && CGO_ENABLED=0 GOFIPS140=v1.0.0 go build -o bin/eks-webhook-cert ./cmd/eks-webhook-cert && CGO_ENABLED=0 GOFIPS140=v1.0.0 go build -o bin/eks-konnectivity-cert ./cmd/eks-konnectivity-cert && CGO_ENABLED=0 GOFIPS140=v1.0.0 go build -o bin/eks-etcd-member ./cmd/eks-etcd-member && CGO_ENABLED=0 GOFIPS140=v1.0.0 go build -o bin/eks-pod-identity-webhook ./cmd/eks-pod-identity-webhook && CGO_ENABLED=0 GOFIPS140=v1.0.0 go build -o bin/ecr-credential-provider ./cmd/ecr-credential-provider && rm -rf /tmp/anp-build && git clone --depth 1 --branch v0.30.3 https://github.com/kubernetes-sigs/apiserver-network-proxy /tmp/anp-build && ( cd /tmp/anp-build && CGO_ENABLED=0 GOFIPS140=v1.0.0 go build -o /tmp/anp-build/konnectivity-server ./cmd/server ) && cp /tmp/anp-build/konnectivity-server bin/konnectivity-server && rm -rf /tmp/anp-build"
BUILD_COMMANDS="CGO_ENABLED=0 GOFIPS140=v1.0.0 go build -o bin/eks-token-webhook ./cmd/eks-token-webhook && CGO_ENABLED=0 GOFIPS140=v1.0.0 go build -o bin/eks-gateway-publish ./cmd/eks-gateway-publish && CGO_ENABLED=0 GOFIPS140=v1.0.0 go build -o bin/eks-gateway-fetch ./cmd/eks-gateway-fetch && CGO_ENABLED=0 GOFIPS140=v1.0.0 go build -o bin/eks-webhook-cert ./cmd/eks-webhook-cert && CGO_ENABLED=0 GOFIPS140=v1.0.0 go build -o bin/eks-konnectivity-cert ./cmd/eks-konnectivity-cert && CGO_ENABLED=0 GOFIPS140=v1.0.0 go build -o bin/eks-etcd-member ./cmd/eks-etcd-member && CGO_ENABLED=0 GOFIPS140=v1.0.0 go build -o bin/ecr-credential-provider ./cmd/ecr-credential-provider && rm -rf /tmp/anp-build && git clone --depth 1 --branch v0.30.3 https://github.com/kubernetes-sigs/apiserver-network-proxy /tmp/anp-build && ( cd /tmp/anp-build && CGO_ENABLED=0 GOFIPS140=v1.0.0 go build -o /tmp/anp-build/konnectivity-server ./cmd/server ) && cp /tmp/anp-build/konnectivity-server bin/konnectivity-server && rm -rf /tmp/anp-build"
INSTALL_BINARIES="bin/eks-token-webhook:/usr/local/bin/eks-token-webhook bin/eks-gateway-publish:/usr/local/bin/eks-gateway-publish bin/eks-gateway-fetch:/usr/local/bin/eks-gateway-fetch bin/eks-webhook-cert:/usr/local/bin/eks-webhook-cert bin/eks-konnectivity-cert:/usr/local/bin/eks-konnectivity-cert bin/eks-etcd-member:/usr/local/bin/eks-etcd-member bin/eks-pod-identity-webhook:/usr/local/bin/eks-pod-identity-webhook bin/konnectivity-server:/usr/local/bin/konnectivity-server bin/ecr-credential-provider:/usr/local/bin/ecr-credential-provider"
INSTALL_BINARIES="bin/eks-token-webhook:/usr/local/bin/eks-token-webhook bin/eks-gateway-publish:/usr/local/bin/eks-gateway-publish bin/eks-gateway-fetch:/usr/local/bin/eks-gateway-fetch bin/eks-webhook-cert:/usr/local/bin/eks-webhook-cert bin/eks-konnectivity-cert:/usr/local/bin/eks-konnectivity-cert bin/eks-etcd-member:/usr/local/bin/eks-etcd-member bin/konnectivity-server:/usr/local/bin/konnectivity-server bin/ecr-credential-provider:/usr/local/bin/ecr-credential-provider"
# All role services ship; none but the selector are enabled at bake time.
# eks-node-role picks server vs agent at first boot and enables the rest.
@@ -36,15 +36,12 @@ scripts/images/eks-node/k3s-prestart.sh:/usr/local/sbin/k3s-prestart \
scripts/images/eks-node/k3s-agent.initd:/etc/init.d/k3s-agent \
scripts/images/eks-node/k3s-agent-prestart.sh:/usr/local/sbin/k3s-agent-prestart \
scripts/images/eks-node/eks-token-webhook.initd:/etc/init.d/eks-token-webhook \
scripts/images/eks-node/eks-pod-identity-webhook.initd:/etc/init.d/eks-pod-identity-webhook \
scripts/images/eks-node/k3s-first-boot.initd:/etc/init.d/k3s-first-boot \
scripts/images/eks-node/k3s-first-boot.sh:/usr/local/sbin/k3s-first-boot \
scripts/images/eks-node/mulga-eks-state-report.initd:/etc/init.d/mulga-eks-state-report \
scripts/images/eks-node/mulga-eks-state-report.sh:/usr/local/sbin/mulga-eks-state-report \
scripts/images/eks-node/mulga-eks-addon-sync.initd:/etc/init.d/mulga-eks-addon-sync \
scripts/images/eks-node/mulga-eks-addon-sync.sh:/usr/local/sbin/mulga-eks-addon-sync \
scripts/images/eks-node/mulga-eks-node-gc.initd:/etc/init.d/mulga-eks-node-gc \
scripts/images/eks-node/mulga-eks-node-gc.sh:/usr/local/sbin/mulga-eks-node-gc \
scripts/images/eks-node/konnectivity-server.initd:/etc/init.d/konnectivity-server \
scripts/images/eks-node/konnectivity-server-prestart.sh:/usr/local/sbin/konnectivity-server-prestart \
scripts/images/eks-node/konnectivity/konnectivity-agent.yaml:/usr/share/spinifex-eks/konnectivity/konnectivity-agent.yaml \
@@ -1,27 +0,0 @@
#!/sbin/openrc-run
# mulga-eks-node-gc — server-role service that deletes the Node of every
# nodegroup worker the nodegroup has terminated, every NODE_GC_INTERVAL
# seconds. Enabled on every server role by eks-node-role.sh.
description="Mulga EKS terminated-worker Node cleanup"
command="/usr/local/sbin/mulga-eks-node-gc"
command_background="yes"
pidfile="/run/mulga-eks-node-gc.pid"
output_log="/var/log/mulga-eks-node-gc.log"
error_log="/var/log/mulga-eks-node-gc.log"
# A Node goes NotReady about 40s after its worker stops, so 30s deletes it
# within a couple of minutes of the terminate.
export NODE_GC_INTERVAL="${NODE_GC_INTERVAL:-30}"
depend() {
need net
after k3s
}
start_pre() {
if [ ! -f /etc/spinifex-eks/first-boot.env ]; then
eerror "/etc/spinifex-eks/first-boot.env not present — cloud-init did not seed gateway creds"
return 1
fi
}
@@ -1,89 +0,0 @@
#!/bin/sh
set -eu
# mulga-eks-node-gc — deletes the Node of a nodegroup worker whose instance the
# nodegroup has terminated (DeleteNodegroup, scale-down, reclaim), as the AWS
# cloud controller deletes a Node whose instance no longer exists. K3s runs no
# AWS cloud provider: its own reports every instance as existing, so without
# this a terminated worker stays a NotReady Node forever, its pods stay bound to
# it, and once most Nodes are NotReady the node lifecycle controller stops
# evicting at all.
#
# Pull (host→VM): GET /clusters/{cluster}/internal-workers/{accountId}, the
# instance ID of every worker the cluster's nodegroups still own.
#
# A Node is deleted only when all of these hold, so a live worker is never
# touched: it carries the eks.amazonaws.com/nodegroup label, its providerID is
# aws:///<az>/<instance-id>, it is not Ready, and its instance is not in the
# fetched set. A failed fetch deletes nothing.
#
# Runs on every server; deletes are idempotent. Loops every NODE_GC_INTERVAL
# seconds in service mode, one-shot otherwise.
ENVFILE=${ENVFILE:-/etc/spinifex-eks/first-boot.env}
[ -f "${ENVFILE}" ] || { logger -t mulga-eks-node-gc "${ENVFILE} missing — exiting"; exit 0; }
# set -a so the sourced creds are exported to the eks-gateway-fetch child.
set -a
# shellcheck disable=SC1090
. "${ENVFILE}"
set +a
: "${EKS_GATEWAY_URL:?}"
: "${EKS_ACCOUNT_ID:?}"
: "${EKS_CLUSTER_NAME:?}"
KUBECONFIG=${KUBECONFIG:-/etc/rancher/k3s/k3s.yaml}
export KUBECONFIG
# One "name|providerID|nodegroup|Ready" row per Node.
NODE_ROWS='jsonpath={range .items[*]}{.metadata.name}{"|"}{.spec.providerID}{"|"}{.metadata.labels.eks\.amazonaws\.com/nodegroup}{"|"}{.status.conditions[?(@.type=="Ready")].status}{"\n"}{end}'
log() {
logger -t mulga-eks-node-gc "$*"
echo "[mulga-eks-node-gc] $*" > "${CONSOLE:-/dev/console}" 2>/dev/null || true
}
# stale_nodes LIVE_FILE — reads Node rows on stdin and prints the name of each
# Node to delete: a nodegroup worker, not Ready, whose instance is not listed
# in LIVE_FILE.
stale_nodes() {
awk -F'|' -v live="$1" '
BEGIN { while ((getline id < live) > 0) keep[id] = 1 }
$3 == "" || $4 == "True" { next }
$2 !~ /^aws:\/\/\/[^\/]+\/i-[0-9a-f]+$/ { next }
{ n = split($2, p, "/"); if (!(p[n] in keep)) print $1 }
'
}
sync_once() {
# Nodes are listed before the workers are fetched. A worker is recorded on
# its nodegroup before it boots, so every Node listed here is already in a
# set fetched after.
if ! rows=$(kubectl get nodes -o "${NODE_ROWS}" 2>/dev/null); then
log "kubectl get nodes failed; skipping this pass"
return 0
fi
live=$(mktemp)
if ! eks-gateway-fetch -resource workers > "${live}" 2>/dev/null; then
log "fetch of the cluster's nodegroup workers failed; deleting nothing"
rm -f "${live}"
return 0
fi
printf '%s\n' "${rows}" | stale_nodes "${live}" | while read -r node; do
if kubectl delete node "${node}" --ignore-not-found --wait=false >/dev/null 2>&1; then
log "deleted Node ${node}: its instance is no longer a nodegroup worker"
else
log "delete of Node ${node} failed; retrying next pass"
fi
done
rm -f "${live}"
}
if [ -n "${NODE_GC_INTERVAL:-}" ] && [ "${NODE_GC_INTERVAL}" -gt 0 ] 2>/dev/null; then
while true; do
sync_once
sleep "${NODE_GC_INTERVAL}"
done
else
sync_once
fi
@@ -1,107 +0,0 @@
#!/bin/sh
# Self-contained POSIX test for mulga-eks-node-gc.sh. kubectl, eks-gateway-fetch
# and logger are stubbed on PATH: kubectl serves canned Node rows and records
# every delete, and eks-gateway-fetch serves a canned worker set or fails.
#
# Run: sh scripts/images/eks-node/mulga-eks-node-gc_test.sh
set -eu
SCRIPT_DIR=$(cd "$(dirname "$0")" && pwd)
AGENT="${SCRIPT_DIR}/mulga-eks-node-gc.sh"
WORK=$(mktemp -d)
trap 'rm -rf "${WORK}"' EXIT
STUBBIN="${WORK}/bin"
mkdir -p "${STUBBIN}"
cat > "${STUBBIN}/kubectl" <<'EOF'
#!/bin/sh
case "$1 $2" in
"get nodes")
[ "${NODES_RC:-0}" -eq 0 ] || exit "${NODES_RC}"
printf '%s\n' "${NODES_BODY:-}" ;;
"delete node") echo "$3" >> "${DELETED}" ;;
esac
EOF
cat > "${STUBBIN}/eks-gateway-fetch" <<'EOF'
#!/bin/sh
echo "$*" >> "${FETCHED}"
[ "${FETCH_RC:-0}" -eq 0 ] || exit "${FETCH_RC}"
printf '%s' "${LIVE_BODY:-}"
EOF
cat > "${STUBBIN}/logger" <<'EOF'
#!/bin/sh
:
EOF
chmod +x "${STUBBIN}"/*
PATH="${STUBBIN}:${PATH}"
export PATH
ENVFILE="${WORK}/first-boot.env"
cat > "${ENVFILE}" <<'EOF'
EKS_GATEWAY_URL=https://gw.invalid:9999
EKS_ACCOUNT_ID=000000000001
EKS_CLUSTER_NAME=demo3
EOF
CONSOLE="${WORK}/console"
DELETED="${WORK}/deleted"
FETCHED="${WORK}/fetched"
export ENVFILE CONSOLE DELETED FETCHED
PASS=0
FAIL=0
check() {
if [ "$2" = "$3" ]; then
PASS=$((PASS + 1))
else
FAIL=$((FAIL + 1))
printf 'FAIL: %s\n want: %s\n got: %s\n' "$1" "$2" "$3"
fi
}
run_agent() {
: > "${DELETED}"
: > "${FETCHED}"
sh "${AGENT}"
}
deleted() { tr '\n' ' ' < "${DELETED}" | sed 's/ $//'; }
# Every way a Node can look: only a NotReady nodegroup worker whose instance the
# cluster's nodegroups no longer own is deleted.
NODES_BODY='live|aws:///z1/i-0aaa|ng1|True
gone|aws:///z1/i-0bbb|ng1|Unknown
gone-never-reported|aws:///z1/i-0ddd|ng2|
owned-notready|aws:///z1/i-0ccc|ng1|False
gone-but-ready|aws:///z1/i-0eee|ng1|True
self-managed|aws:///z1/i-0fff||Unknown
k3s-provider|k3s://k3s-node|ng1|Unknown'
LIVE_BODY='i-0aaa
i-0ccc
'
export NODES_BODY LIVE_BODY
run_agent
check "deletes only NotReady nodegroup Nodes whose instance is gone" "gone gone-never-reported" "$(deleted)"
check "fetches the workers resource" "-resource workers" "$(cat "${FETCHED}")"
# A cluster with no nodegroups left: every NotReady nodegroup Node goes.
LIVE_BODY='' run_agent
check "empty worker set deletes every NotReady nodegroup Node" "gone gone-never-reported owned-notready" "$(deleted)"
# A failed fetch is not an empty set: nothing is deleted.
FETCH_RC=1 run_agent
check "failed fetch deletes nothing" "" "$(deleted)"
# Without a Node listing the workers are not fetched, so a set fetched before a
# Node registered can never be compared against it.
NODES_RC=1 run_agent
check "failed node listing deletes nothing" "" "$(deleted)"
check "failed node listing does not fetch" "" "$(cat "${FETCHED}")"
echo "---"
echo "PASS=${PASS} FAIL=${FAIL}"
[ "${FAIL}" -eq 0 ]
+2 -4
View File
@@ -44,11 +44,9 @@ ln -sf /usr/local/bin/k3s /usr/local/bin/ctr
# role's services are baked; the selector enables the right ones at first boot.
chmod 0755 /etc/init.d/eks-node-role /etc/init.d/k3s /etc/init.d/k3s-agent \
/etc/init.d/eks-token-webhook /etc/init.d/k3s-first-boot /etc/init.d/mulga-eks-state-report \
/etc/init.d/mulga-eks-addon-sync /etc/init.d/mulga-eks-node-gc /etc/init.d/konnectivity-server \
/etc/init.d/eks-pod-identity-webhook
/etc/init.d/mulga-eks-addon-sync /etc/init.d/konnectivity-server
chmod 0755 /usr/local/sbin/eks-node-role /usr/local/sbin/k3s-first-boot \
/usr/local/sbin/mulga-eks-state-report /usr/local/sbin/mulga-eks-addon-sync \
/usr/local/sbin/mulga-eks-node-gc
/usr/local/sbin/mulga-eks-state-report /usr/local/sbin/mulga-eks-addon-sync
# start_pre helpers shared with the systemd wrappers that will eventually
# replace these runscripts.
chmod 0755 /usr/local/sbin/k3s-prestart /usr/local/sbin/k3s-agent-prestart \
@@ -1,25 +0,0 @@
#!/bin/sh
# Every /etc/init.d script that INSTALL_FILES ships at 0644 must be chmod'd
# 0755 by setup.sh, or OpenRC refuses to enable it ("is not executable") and
# eks-node-role, running under set -eu, aborts before k3s starts.
#
# Run: sh scripts/images/eks-node/setup_initd_test.sh
set -eu
SCRIPT_DIR=$(cd "$(dirname "$0")" && pwd)
MANIFEST="${SCRIPT_DIR}/manifest.conf"
SETUP="${SETUP_SH:-${SCRIPT_DIR}/setup.sh}"
fail=0
for dst in $(grep -o ':/etc/init\.d/[A-Za-z0-9._-]*' "${MANIFEST}" | cut -d: -f2); do
if ! grep -E '^[[:space:]]*(chmod 0755 )?.*'"${dst}"'([[:space:]\\]|$)' "${SETUP}" \
| grep -q . ; then
echo "FAIL: ${dst} is installed 0644 and never chmod'd 0755 in setup.sh"
fail=1
fi
done
if [ "${fail}" -ne 0 ]; then
exit 1
fi
echo "PASS: every shipped init script is chmod'd 0755"
+293
View File
@@ -0,0 +1,293 @@
#!/bin/sh
# sqs-store-guest_test.sh — the SQS store guest steps start the store only on
# a valid contract and one checked, mounted disk, never format, and stop the
# store before unmounting it.
#
# Drives build/microvm/sqs-store/sqs-store-lib.sh against a fake fw_cfg, sysfs
# and stub tools. The stop cases signal only test-owned children.
# The case settings below are read by the sourced library, not by this file.
# shellcheck disable=SC2034
set -eu
ROOT=$(CDPATH='' cd -- "$(dirname -- "$0")/../.." && pwd)
LIB=${SQS_GUEST_LIB:-"$ROOT/build/microvm/sqs-store/sqs-store-lib.sh"}
TMP=$(mktemp -d)
CHILDREN=""
cleanup() {
for p in $CHILDREN; do kill -KILL "$p" 2>/dev/null || true; done
rm -rf "$TMP"
}
trap cleanup EXIT
fails=0
pass() { echo " ok - $1"; }
fail() { echo " FAIL - $1"; fails=$((fails + 1)); }
check() { if eval "$2"; then pass "$1"; else fail "$1"; fi; }
echo "-- build/microvm/sqs-store/sqs-store-lib.sh"
SERIAL=vol0123456789abcdef0
NKEY=UAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAB
ADDR=10.11.12.13
# Stub tools record each call to calls.log; their exit codes come from files.
mkdir -p "$TMP/bin"
cat > "$TMP/bin/e2fsck" <<'EOF'
#!/bin/sh
echo "e2fsck $*" >> "$T/calls.log"
exit "$(cat "$T/fsck.rc")"
EOF
cat > "$TMP/bin/mount" <<'EOF'
#!/bin/sh
echo "mount $*" >> "$T/calls.log"
exit "$(cat "$T/mount.rc")"
EOF
cat > "$TMP/bin/umount" <<'EOF'
#!/bin/sh
echo "umount $*" >> "$T/calls.log"
EOF
for f in mkfs mkfs.ext4 mke2fs; do
printf '#!/bin/sh\necho "mkfs %s $*" >> "$T/calls.log"\n' "$f" > "$TMP/bin/$f"
done
cat > "$TMP/bin/ip" <<'EOF'
#!/bin/sh
cat "$T/ip.out"
EOF
cat > "$TMP/bin/nats-server" <<'EOF'
#!/bin/sh
echo "nats-server $*" >> "$T/calls.log"
[ "$1" = -t ] || echo "GOMEMLIMIT=${GOMEMLIMIT:-unset}" >> "$T/env.log"
[ "$1" = -t ] && exit "$(cat "$T/configtest.rc")"
exit 0
EOF
chmod +x "$TMP/bin"/*
# fresh <name>: a case directory with a valid contract and one matching disk
# among others. Callers then break one thing.
fresh() {
T="$TMP/$1"
export T
mkdir -p "$T/fwcfg" "$T/sys" "$T/dev" "$T/run" "$T/data"
for k in data-serial listen-addr client-nkey; do mkdir -p "$T/fwcfg/$k"; done
printf '%s\n' "$SERIAL" > "$T/fwcfg/data-serial/raw"
printf '%s\n' "$ADDR" > "$T/fwcfg/listen-addr/raw"
printf '%s\n' "$NKEY" > "$T/fwcfg/client-nkey/raw"
disk vda vol00000000000000000
disk vdb "$SERIAL"
echo 0 > "$T/fsck.rc"
echo 0 > "$T/mount.rc"
echo 0 > "$T/configtest.rc"
: > "$T/calls.log"
: > "$T/console"
printf '1: lo inet 127.0.0.1/8 scope host lo\n2: eth0 inet %s/24 brd 10.11.12.255 scope global eth0\n' "$ADDR" > "$T/ip.out"
}
disk() { mkdir -p "$T/sys/$1"; printf '%s\n' "$2" > "$T/sys/$1/serial"; : > "$T/dev/$1"; }
# prepare: run sqs_prepare in a subshell with every path pointed at the case.
prepare() {
(
SQS_FWCFG="$T/fwcfg" SQS_SYSBLOCK="$T/sys" SQS_DEVDIR="$T/dev" SQS_NODE_TEST=-e
SQS_RUN="$T/run" SQS_DATA="$T/data" SQS_CONSOLE="$T/console"
E2FSCK="$TMP/bin/e2fsck" MOUNT="$TMP/bin/mount" IP="$TMP/bin/ip" NATS_BIN="$TMP/bin/nats-server"
PATH="$TMP/bin:$PATH"
export PATH
# shellcheck source=../../build/microvm/sqs-store/sqs-store-lib.sh
. "$LIB"
sqs_prepare
) > /dev/null 2>&1
}
refused() { [ ! -e "$T/run/ready" ] && grep -q FATAL "$T/console"; }
never_mounted() { ! grep -q '^mount ' "$T/calls.log"; }
never_formatted() { ! grep -q '^mkfs ' "$T/calls.log"; }
never_checked() { ! grep -q '^e2fsck ' "$T/calls.log"; }
# --- A valid contract prepares the store ---
fresh valid
check "valid contract is prepared" 'prepare && [ -e "$T/run/ready" ]'
check "the matching disk is checked, then mounted at the data dir" \
'grep -qx "e2fsck -p $T/dev/vdb" "$T/calls.log" && grep -qx "mount -t ext4 -o nodev,nosuid,noexec $T/dev/vdb $T/data" "$T/calls.log"'
check "config listens only on the supplied address" \
'grep -c "listen" "$T/run/nats.conf" | grep -qx 1 && grep -qx "listen: \"$ADDR:4222\"" "$T/run/nats.conf"'
check "config admits only the supplied user NKey" 'grep -q "users = \[ { nkey: \"$NKEY\" } \]" "$T/run/nats.conf"'
check "config syncs JetStream on the data disk" \
'grep -q "store_dir: \"$T/data\"" "$T/run/nats.conf" && grep -q "sync_interval: always" "$T/run/nats.conf"'
check "config admits a full SQS message (2MiB max_payload)" \
'grep -c "max_payload" "$T/run/nats.conf" | grep -qx 1 && grep -qx "max_payload: 2MiB" "$T/run/nats.conf"'
check "config is tested by the server before ready" 'grep -qx "nats-server -t -c $T/run/nats.conf" "$T/calls.log"'
check "no auth material on the console" '! grep -q "$NKEY" "$T/console"'
check "prepare reports the disk and config, not a listener" 'grep -q "prepared: $T/dev/vdb mounted" "$T/console" && ! grep -qi "listening" "$T/console"'
fresh fsck-corrected; echo 1 > "$T/fsck.rc"
check "e2fsck exit 1 (corrected) proceeds" 'prepare && [ -e "$T/run/ready" ]'
# --- Disk refusals: nothing mounted, nothing formatted ---
fresh no-disk; rm -rf "$T/sys/vdb"
check "missing disk is refused before any check or mount" '! prepare && refused && never_checked && never_mounted'
fresh wrong-disk; printf 'vol0fffffffffffffff0\n' > "$T/fwcfg/data-serial/raw"
check "disk with another serial is refused" '! prepare && refused && never_checked && never_mounted'
fresh two-disks; disk vdc "$SERIAL"
check "two disks with the serial are refused" '! prepare && refused && never_checked && never_mounted'
fresh bad-serial; printf 'vol-0123456789abcdef\n' > "$T/fwcfg/data-serial/raw"
check "malformed serial is refused" '! prepare && refused && never_mounted'
fresh no-node; rm -f "$T/dev/vdb"
check "disk without a device node is refused" '! prepare && refused && never_mounted'
for rc in 2 4 8; do
fresh "fsck-$rc"; echo "$rc" > "$T/fsck.rc"
check "e2fsck exit $rc is refused without mount or format" '! prepare && refused && never_mounted && never_formatted'
done
fresh mount-fail; echo 32 > "$T/mount.rc"
check "failed mount is refused" '! prepare && refused'
# --- Auth and listener refusals happen before the disk is touched ---
fresh no-nkey; rm -rf "$T/fwcfg/client-nkey"
check "missing NKey is refused before the disk" '! prepare && refused && never_checked && never_mounted'
for bad in seed lower short two empty; do
fresh "nkey-$bad"
case $bad in
seed) v=SUAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA ;;
lower) v=uaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaab ;;
short) v=UAAAA ;;
two) v="$NKEY
$NKEY" ;;
empty) v="" ;;
esac
printf '%s\n' "$v" > "$T/fwcfg/client-nkey/raw"
check "NKey ($bad) is refused before the disk, without echoing it" \
'! prepare && refused && never_checked && { [ -z "$v" ] || ! grep -qF "$v" "$T/console"; }'
done
fresh nkey-checksum; echo 1 > "$T/configtest.rc"
check "NKey the server rejects is refused before the disk" '! prepare && refused && never_checked && never_mounted'
fresh no-addr; rm -rf "$T/fwcfg/listen-addr"
check "missing listen address is refused" '! prepare && refused && never_mounted'
fresh addr-absent; printf '1: lo inet 127.0.0.1/8 scope host lo\n2: eth0 inet 10.11.12.99/24 scope global eth0\n' > "$T/ip.out"
check "listen address not on a guest NIC is refused" '! prepare && refused && never_mounted'
fresh addr-loopback; printf '127.0.0.1\n' > "$T/fwcfg/listen-addr/raw"
check "loopback listen address is refused" '! prepare && refused && never_mounted'
for bad in 0.0.0.0x 1.2.3 1.2.3.256 01.2.3.4; do
fresh "addr-$bad"; printf '%s\n' "$bad" > "$T/fwcfg/listen-addr/raw"
check "malformed listen address $bad is refused" '! prepare && refused && never_mounted'
done
# --- Run: the store starts only when prepared and not stopping ---
run_step() {
(
SQS_RUN="$T/run" SQS_CONSOLE="$T/console" NATS_BIN="$TMP/bin/nats-server"
# shellcheck source=../../build/microvm/sqs-store/sqs-store-lib.sh
. "$LIB"
sqs_run
) > /dev/null 2>&1
}
fresh run-ready; prepare; : > "$T/calls.log"
check "a prepared store starts the server" 'run_step && grep -qx "nats-server -c $T/run/nats.conf" "$T/calls.log"'
fresh run-memlimit; prepare; : > "$T/env.log"
check "the server runs under the ADR-0011:S11 heap limit" 'run_step && grep -qx "GOMEMLIMIT=512MiB" "$T/env.log"'
fresh run-unready
check "an unprepared store never starts the server" '! run_step && ! grep -q "^nats-server" "$T/calls.log"'
fresh run-stopping; prepare; : > "$T/calls.log"; : > "$T/run/stopping"
check "a stopping store never starts the server" '! run_step && ! grep -q "^nats-server" "$T/calls.log"'
# --- Stop: SIGTERM the verified store, then unmount ---
STORE="$TMP/bin/fake-store"
cp "$(command -v sleep)" "$STORE"
stop_step() {
(
SQS_RUN="$T/run" SQS_DATA="$T/data" SQS_CONSOLE="$T/console" SQS_MOUNTS="$T/mounts"
UMOUNT="$TMP/bin/umount" NATS_BIN="$STORE" SQS_STOP_BUDGET_TENTHS=10
# shellcheck source=../../build/microvm/sqs-store/sqs-store-lib.sh
. "$LIB"
sqs_stop
) > /dev/null 2>&1
}
gone() { [ ! -r "/proc/$1/status" ] || grep -q '^State:[[:space:]]*Z' "/proc/$1/status"; }
# record <pid>: write the identity sqs_run would record for that process.
# shellcheck source=../../build/microvm/sqs-store/sqs-store-lib.sh
record() { echo "$1 $( . "$LIB"; sqs_starttime "$1" )" > "$T/run/pid"; }
fresh stop-orderly; echo "dev $T/data ext4 rw 0 0" > "$T/mounts"
"$STORE" 60 & pid=$!; CHILDREN="$CHILDREN $pid"; record "$pid"
stop_step
check "stop sends SIGTERM and the store exits" 'gone "$pid" && grep -q "SIGTERM sent" "$T/console" && ! grep -q SIGKILL "$T/console"'
check "stop blocks respawn and unmounts after the store exits" \
'[ -e "$T/run/stopping" ] && grep -qx "umount $T/data" "$T/calls.log"'
fresh stop-hung; echo "dev $T/data ext4 rw 0 0" > "$T/mounts"
(trap '' TERM; exec "$STORE" 60) & pid=$!; CHILDREN="$CHILDREN $pid"; record "$pid"
stop_step
check "store ignoring SIGTERM is killed after the budget, then unmounted" \
'gone "$pid" && grep -q SIGKILL "$T/console" && grep -qx "umount $T/data" "$T/calls.log"'
fresh stop-reused; echo "dev $T/data ext4 rw 0 0" > "$T/mounts"
"$STORE" 60 & pid=$!; CHILDREN="$CHILDREN $pid"; echo "$pid 1" > "$T/run/pid"
stop_step
check "a recorded PID with another start time is never signalled" '! gone "$pid" && grep -q "no running store" "$T/console"'
kill -KILL "$pid"
# A start held after its stopping check and before its exec: the run step
# blocks writing "run: starting" to a FIFO console until the test reads it.
fresh stop-pending-start; echo "dev $T/data ext4 rw 0 0" > "$T/mounts"
: > "$T/run/ready"
mkfifo "$T/run-console"
printf '#!/bin/sh\n: > "%s/started"\nexec "%s" 60\n' "$T" "$STORE" > "$TMP/bin/pending-nats"
chmod +x "$TMP/bin/pending-nats"
SQS_RUN="$T/run" SQS_CONSOLE="$T/run-console" NATS_BIN="$TMP/bin/pending-nats" \
sh -c '. "$1"; sqs_run' sh "$LIB" & pid=$!; CHILDREN="$CHILDREN $pid"
i=0
while [ "$i" -lt 100 ] && ! grep -q "^$pid " "$T/run/pid" 2>/dev/null; do i=$((i + 1)); sleep 0.05; done
check "pending start is held before its exec" 'grep -q "^$pid " "$T/run/pid" && ! gone "$pid" && [ ! -e "$T/started" ]'
stop_step
# Release the FIFO: a start that survived the stop would exec the server now.
cat "$T/run-console" > /dev/null 2>&1 &
reader=$!; CHILDREN="$CHILDREN $reader"
i=0
while [ "$i" -lt 20 ] && ! gone "$pid"; do i=$((i + 1)); sleep 0.05; done
check "stop ends a pending start before it can exec the server" 'gone "$pid" && [ ! -e "$T/started" ]'
check "stop unmounts only after the pending start is gone" \
'grep -q "store exited" "$T/console" && grep -qx "umount $T/data" "$T/calls.log"'
kill -KILL "$reader" 2>/dev/null || true
# --- Shutdown: the generic remount runs only once the store is gone ---
# shutdown_step [survivor-pid]: with a pid, that process is reported alive
# whatever happens to it, as a writer stuck past SIGKILL would be.
shutdown_step() {
(
SQS_RUN="$T/run" SQS_DATA="$T/data" SQS_CONSOLE="$T/console" SQS_MOUNTS="$T/mounts"
UMOUNT="$TMP/bin/umount" SQS_STOP_BUDGET_TENTHS=5
# shellcheck source=../../build/microvm/sqs-store/sqs-store-lib.sh
. "$LIB"
if [ -n "${1:-}" ]; then sqs_same() { [ "$1" = "$survivor" ]; }; survivor=$1; fi
sqs_shutdown
) > /dev/null 2>&1
}
fresh shutdown-orderly; echo "dev $T/data ext4 rw 0 0" > "$T/mounts"
"$STORE" 60 & pid=$!; CHILDREN="$CHILDREN $pid"; record "$pid"
check "orderly shutdown succeeds" 'shutdown_step'
check "orderly shutdown unmounts the data disk, then runs umount -a -r" \
'[ "$(grep "^umount" "$T/calls.log" | tr "\n" "|")" = "umount $T/data|umount -a -r|" ]'
fresh shutdown-survivor; echo "dev $T/data ext4 rw 0 0" > "$T/mounts"
"$STORE" 60 & pid=$!; CHILDREN="$CHILDREN $pid"; record "$pid"
check "shutdown with a surviving store reports failure" '! shutdown_step "$pid"'
check "a surviving store leaves the data disk and every other mount alone" \
'! grep -q "^umount" "$T/calls.log" && grep -q "STILL ALIVE" "$T/console" && grep -q "skipping umount -a -r" "$T/console"'
fresh stop-unmounted; : > "$T/mounts"
stop_step
check "stop with no data mount does not unmount" '! grep -q "^umount" "$T/calls.log"'
if [ "$fails" -ne 0 ]; then
echo " $fails failure(s)"
exit 1
fi
+11 -4
View File
@@ -11,11 +11,13 @@ STAGE_DIR="${1:?usage: sqs-store-microvm-publish.sh STAGE_DIR OUT_DIR}"
OUT_DIR="${2:?usage: sqs-store-microvm-publish.sh STAGE_DIR OUT_DIR}"
VMLINUZ="$STAGE_DIR/vmlinuz"
INITRAMFS="$STAGE_DIR/initramfs.cpio.gz"
PROFILE="${3:-worker}"
PROFILE="${3:-store}"
case "$PROFILE" in
store)
payload="usr/local/bin/nats-server usr/local/lib/sqs-store/sqs-store-lib.sh usr/local/lib/sqs-store/sqs-store-prepare usr/local/lib/sqs-store/sqs-store-run usr/local/lib/sqs-store/sqs-store-stop" ;;
worker)
payload="usr/local/bin/nats-server usr/local/bin/sqs-worker usr/local/lib/sqs-worker/sqs-worker-lib.sh usr/local/lib/sqs-worker/sqs-worker-prepare usr/local/lib/sqs-worker/sqs-worker-store usr/local/lib/sqs-worker/sqs-worker-ready usr/local/lib/sqs-worker/sqs-worker-stop data" ;;
*) echo "ERROR: profile must be worker, not $PROFILE" >&2; exit 1 ;;
payload="usr/local/bin/sqs-worker usr/local/lib/sqs-worker/sqs-worker-lib.sh usr/local/lib/sqs-worker/sqs-worker-prepare usr/local/lib/sqs-worker/sqs-worker-ready usr/local/lib/sqs-worker/sqs-worker-stop data" ;;
*) echo "ERROR: profile must be store or worker, not $PROFILE" >&2; exit 1 ;;
esac
tag="[build-sqs-$PROFILE-microvm-image]"
@@ -54,7 +56,12 @@ for want_ko in ext4 tiny_power_button; do
fi
done
if grep -qE '(^|/)(lb-agent|haproxy|nginx)$' <<< "$archive_list"; then
echo "ERROR: LB data-plane binaries found in the SQS image" >&2
echo "ERROR: LB data-plane binaries found in the SQS store image" >&2
exit 1
fi
# The worker holds the store client key; it must never carry the store server.
if [ "$PROFILE" = worker ] && grep -qE '(^|/)nats-server$' <<< "$archive_list"; then
echo "ERROR: nats-server found in the SQS worker image" >&2
exit 1
fi
echo "$tag content gate: PASS"
@@ -1,5 +1,5 @@
#!/bin/sh
# sqs-store-microvm-publish_test.sh — a staged SQS microVM pair reaches
# sqs-store-microvm-publish_test.sh — a staged SQS store microVM pair reaches
# the output directory only when every gate passes, and a failed build cannot
# leave a pair that make then takes as up to date.
#
@@ -28,10 +28,9 @@ done
stage() {
dir=$1 omit=${2:-}
root="$TMP/root.$$.$(date +%s%N)"
for p in init etc/inittab usr/local/bin/nats-server usr/local/bin/sqs-worker data sbin/mkfs.ext4 sbin/e2fsck \
usr/local/lib/sqs-worker/sqs-worker-lib.sh usr/local/lib/sqs-worker/sqs-worker-prepare \
usr/local/lib/sqs-worker/sqs-worker-store usr/local/lib/sqs-worker/sqs-worker-ready \
usr/local/lib/sqs-worker/sqs-worker-stop \
for p in init etc/inittab usr/local/bin/nats-server sbin/mkfs.ext4 sbin/e2fsck \
usr/local/lib/sqs-store/sqs-store-lib.sh usr/local/lib/sqs-store/sqs-store-prepare \
usr/local/lib/sqs-store/sqs-store-run usr/local/lib/sqs-store/sqs-store-stop \
dev/console dev/null lib/modules/6.12-virt/kernel/fs/ext4/ext4.ko \
lib/modules/6.12-virt/kernel/drivers/acpi/tiny-power-button.ko; do
[ "$p" = "$omit" ] && continue
@@ -79,7 +78,7 @@ else
fi
# --- Each failing gate keeps the previous valid pair exactly ---
for case in nats-server worker-store ext4 size; do
for case in nats-server store-stop ext4 size; do
d="$TMP/keep-$case"
stage "$d/prev"
bash "$PUBLISH" "$d/prev" "$d/out" >/dev/null 2>&1
@@ -87,7 +86,7 @@ for case in nats-server worker-store ext4 size; do
max=50
case $case in
nats-server) stage "$d/stage" usr/local/bin/nats-server ;;
worker-store) stage "$d/stage" usr/local/lib/sqs-worker/sqs-worker-store ;;
store-stop) stage "$d/stage" usr/local/lib/sqs-store/sqs-store-stop ;;
ext4) stage "$d/stage" lib/modules/6.12-virt/kernel/fs/ext4/ext4.ko ;;
size) stage "$d/stage"; max=0 ;;
esac
+28 -141
View File
@@ -1,8 +1,7 @@
#!/bin/sh
# sqs-worker-guest_test.sh — the SQS guest's store starts only on a config
# prepared after the receipt key settled, its readiness watcher never leaves a
# store-ready marker behind a dead watcher, and shutdown unmounts the data disk
# only once both the watcher and the store are gone.
# sqs-worker-guest_test.sh — the SQS worker guest's readiness watcher is
# supervised by init, never leaves a store-ready marker behind a dead watcher,
# and shutdown unmounts the worker disk only once the watcher is gone.
#
# Drives build/microvm/sqs-worker/sqs-worker-lib.sh with test-owned processes
# and a stub umount. Every signal goes only to this test's own children.
@@ -13,7 +12,6 @@ set -eu
ROOT=$(CDPATH='' cd -- "$(dirname -- "$0")/../.." && pwd)
LIB=${SQSW_GUEST_LIB:-"$ROOT/build/microvm/sqs-worker/sqs-worker-lib.sh"}
INITTAB="$ROOT/build/microvm/sqs-worker/inittab"
GUEST_INIT="$ROOT/build/microvm/sqs-worker/init.sh"
TMP=$(mktemp -d)
CHILDREN=""
cleanup() {
@@ -43,56 +41,26 @@ echo "$$" >> "$T/watchers"
echo "10.0.0.5 UPUB" > "$T/run/store-ready"
exec sleep 60
EOF
# The fake server checks its config, or notes how it was started and stays.
cat > "$TMP/bin/nats" <<'EOF'
#!/bin/sh
if [ "$1" = -t ]; then [ -z "${NATS_BAD:-}" ]; exit; fi
echo "$GOMEMLIMIT $*" >> "$T/servers"
exec sleep 60
EOF
# The fake boot takes its exit codes in order from $T/boot-rcs, and like the
# real one writes the receipt marker only on success; "hold" blocks like a slow call.
cat > "$TMP/bin/boot" <<'EOF'
#!/bin/sh
echo "$*" >> "$T/boots"
rc=$(head -n1 "$T/boot-rcs"); sed -i 1d "$T/boot-rcs"
if [ "$rc" = hold ]; then echo "$$" > "$T/boot-child"; exec sleep 60; fi
[ "$rc" != 0 ] || echo FP > "$T/run/receipt-ready"
exit "$rc"
EOF
chmod +x "$TMP/bin"/*
fresh() {
T="$TMP/$1"
export T
mkdir -p "$T/run" "$T/data"
echo FP > "$T/run/receipt-ready"
echo "listen: 127.0.0.1:4222" > "$T/run/store.conf"
echo UPUBLICKEY > "$T/run/public-key"
for f in calls.log console mounts; do : > "$T/$f"; done
}
gone() { [ ! -r "/proc/$1/status" ] || grep -q '^State:[[:space:]]*Z' "/proc/$1/status" 2>/dev/null; }
waitfor() { i=0; while [ "$i" -lt 100 ] && ! eval "$1"; do i=$((i + 1)); sleep 0.05; done; eval "$1"; }
# start_run: one init respawn of the readiness step, in the background.
start_run() {
SQSW_RUN="$T/run" SQSW_CONSOLE="$T/console" SQSW_BIN="$TMP/bin/watcher" SQSW_RESPAWN_GAP="${GAP:-0}" SQSW_PARK="sleep 60" \
SQSW_RUN="$T/run" SQSW_CONSOLE="$T/console" SQSW_BIN="$TMP/bin/watcher" SQSW_RESPAWN_GAP="${GAP:-0}" SQSW_PARK_INTERVAL="${PARK:-30}" \
sh -c '. "$1"; sqsw_run' sh "$LIB" & pid=$!
CHILDREN="$CHILDREN $pid"
}
# start_store: one init respawn of the store step, in the background.
start_store() {
SQSW_RUN="$T/run" SQSW_DATA="$T/data" SQSW_CONSOLE="$T/console" SQSW_NATS_BIN="$TMP/bin/nats" \
SQSW_BIN="$TMP/bin/boot" SQSW_BOOT_RETRY=0 SQSW_PARK="sleep 60" SQSW_LIB="$LIB" \
sh -c '. "$1"; sqsw_store' sh "$LIB" & spid=$!
CHILDREN="$CHILDREN $spid"
}
# prepare_store: the sysinit step's store preparation, after boot.
prepare_store() {
SQSW_RUN="$T/run" SQSW_DATA="$T/data" SQSW_CONSOLE="$T/console" SQSW_NATS_BIN="$TMP/bin/nats" \
sh -c '. "$1"; sqsw_prepare_store' sh "$LIB"
}
# record <pid>: write the identity sqs_run would record for that process.
# shellcheck source=../../build/microvm/sqs-worker/sqs-worker-lib.sh
record() { echo "$1 $( . "$LIB"; sqsw_starttime "$1" )" > "$T/run/${2:-ready}.pid"; }
record() { echo "$1 $( . "$LIB"; sqsw_starttime "$1" )" > "$T/run/ready.pid"; }
# shutdown_step [survivor-pid]: with a pid, that process is reported alive
# whatever happens to it, as a watcher stuck past SIGKILL would be.
shutdown_step() {
@@ -106,86 +74,8 @@ shutdown_step() {
) > /dev/null 2>&1
}
check "init respawns the store and readiness steps rather than running them once" \
'grep -qx "::respawn:/usr/local/lib/sqs-worker/sqs-worker-store" "$INITTAB" &&
grep -qx "::respawn:/usr/local/lib/sqs-worker/sqs-worker-ready" "$INITTAB" && ! grep -q "::once:" "$INITTAB"'
# The store binds 127.0.0.1, which a fresh guest kernel leaves down.
lo_at=$(grep -n -x "ip link set lo up" "$GUEST_INIT" | cut -d: -f1 | head -n 1)
init_at=$(grep -n -x "exec /sbin/init" "$GUEST_INIT" | cut -d: -f1 | head -n 1)
check "guest init brings loopback up before init starts the store" \
'[ -n "$lo_at" ] && [ -n "$init_at" ] && [ "$lo_at" -lt "$init_at" ]'
# --- The store is prepared only after boot settled the receipt key ---
fresh prepare-ok; rm -f "$T/run/store.conf"
check "a settled receipt key prepares the store" 'prepare_store && [ -s "$T/run/store.conf" ]'
check "the store listens only on loopback, with no auth, monitor, route or cluster listener" \
'grep -qx "listen: \"127.0.0.1:4222\"" "$T/run/store.conf" && grep -qx " store_dir: \"$T/data/jetstream\"" "$T/run/store.conf" &&
! grep -Eq "authorization|http|monitor|cluster|routes|leafnodes|websocket|mqtt" "$T/run/store.conf"'
fresh prepare-no-key; rm -f "$T/run/store.conf" "$T/run/receipt-ready"
check "a receipt key boot did not settle leaves the store unprepared" '! prepare_store && [ ! -e "$T/run/store.conf" ]'
fresh prepare-bad; rm -f "$T/run/store.conf"
check "a config the server rejects leaves the store unprepared" '! NATS_BAD=1 prepare_store && [ ! -e "$T/run/store.conf" ]'
# --- The store step starts the server only on a prepared config ---
fresh store-run
start_store
check "a prepared store starts the dedicated server with its heap bound" \
'waitfor "[ -s \"\$T/servers\" ]" && grep -qx "512MiB -c $T/run/store.conf" "$T/servers"'
kill -KILL "$spid" 2>/dev/null || true
fresh store-refused; : > "$T/run/boot-refused"
start_store; waitfor "grep -q 'store: boot refused' \"\$T/console\"" || true
check "a store whose boot was refused parks instead of starting or respawning" \
'! gone "$spid" && [ ! -e "$T/servers" ] && [ ! -e "$T/boots" ]'
kill -KILL "$spid" 2>/dev/null || true
# --- Boot is supervised: a transient failure is retried, a refusal is final ---
fresh boot-transient; rm -f "$T/run/store.conf" "$T/run/receipt-ready"; printf '75\n0\n' > "$T/boot-rcs"
start_store
check "a transient boot failure exits so init runs the step again, starting nothing" \
'waitfor "gone $spid" && [ ! -e "$T/servers" ] && [ ! -e "$T/run/boot-refused" ]'
start_store
check "the respawn after recovery boots again and starts the store" \
'waitfor "[ -s \"\$T/servers\" ]" && [ "$(wc -l < "$T/boots")" -eq 2 ] && [ -s "$T/run/store.conf" ]'
kill -KILL "$spid" 2>/dev/null || true
fresh boot-final; rm -f "$T/run/store.conf" "$T/run/receipt-ready"; printf '1\n0\n' > "$T/boot-rcs"
start_store; waitfor "grep -q 'boot refused (rc=1)' \"\$T/console\"" || true
check "a final boot refusal parks the store and records the refusal" \
'! gone "$spid" && [ -e "$T/run/boot-refused" ] && [ ! -e "$T/servers" ]'
kill -KILL "$spid" 2>/dev/null || true
start_store; waitfor "grep -q 'store: boot refused, not starting' \"\$T/console\"" || true
check "a later respawn never boots again after a final refusal" '[ "$(wc -l < "$T/boots")" -eq 1 ]'
kill -KILL "$spid" 2>/dev/null || true
fresh boot-settled; rm -f "$T/run/store.conf"; : > "$T/boot-rcs"
start_store
check "a respawn after the key settled starts the store without booting again" \
'waitfor "[ -s \"\$T/servers\" ]" && [ ! -e "$T/boots" ]'
kill -KILL "$spid" 2>/dev/null || true
fresh boot-held; rm -f "$T/run/store.conf" "$T/run/receipt-ready"; echo hold > "$T/boot-rcs"
echo "/dev/vdb $T/data ext4 rw 0 0" > "$T/mounts"
start_store; waitfor '[ -s "$T/boot-child" ]' || true
bchild=$(cat "$T/boot-child" 2>/dev/null); CHILDREN="$CHILDREN $bchild"
shutdown_step
check "shutdown stops a boot still in progress before it unmounts the disk" \
'waitfor "gone $bchild" && grep -q "umount $T/data" "$T/calls.log"'
kill -KILL "$spid" "$bchild" 2>/dev/null || true
fresh boot-held-survivor; rm -f "$T/run/store.conf" "$T/run/receipt-ready"; echo hold > "$T/boot-rcs"
echo "/dev/vdb $T/data ext4 rw 0 0" > "$T/mounts"
start_store; waitfor '[ -s "$T/boot-child" ]' || true
bchild=$(cat "$T/boot-child" 2>/dev/null); CHILDREN="$CHILDREN $bchild"
check "a boot that survives SIGKILL fails shutdown and keeps the disk mounted" \
'! shutdown_step "$bchild" && ! grep -q umount "$T/calls.log" && grep -q "boot pid=$bchild STILL ALIVE" "$T/console"'
kill -KILL "$spid" "$bchild" 2>/dev/null || true
# Shutdown sets stopping after the store's own check but before the boot child
# runs: the child must still record itself and never start sqs-worker boot.
fresh boot-window; rm -f "$T/run/store.conf" "$T/run/receipt-ready"; echo 0 > "$T/boot-rcs"
: > "$T/run/stopping"
SQSW_RUN="$T/run" SQSW_CONSOLE="$T/console" SQSW_BIN="$TMP/bin/boot" SQSW_BOOT_RETRY=0 SQSW_PARK="sleep 60" SQSW_LIB="$LIB" \
sh -c '. "$1"; sqsw_boot' sh "$LIB" & wpid=$!
CHILDREN="$CHILDREN $wpid"
waitfor "grep -q 'shutdown during boot' \"\$T/console\"" || true
check "a boot child started after shutdown began never runs boot" \
'grep -q "shutdown during boot" "$T/console" && [ ! -e "$T/boots" ] && [ ! -e "$T/run/boot-refused" ]'
kill -KILL "$wpid" 2>/dev/null || true
check "init respawns the readiness step rather than running it once" \
'grep -qx "::respawn:/usr/local/lib/sqs-worker/sqs-worker-ready" "$INITTAB" && ! grep -q "::once:" "$INITTAB"'
# --- A watcher killed after readiness: its marker must not outlive it ---
fresh killed-after-ready
@@ -213,7 +103,8 @@ kill -KILL "$pid" 2>/dev/null || true
# A run that cannot start parks rather than exiting, so init does not respawn
# it in a loop; a second is many park intervals, and it must log only once.
fresh run-stopping; : > "$T/run/stopping"; echo stale > "$T/run/store-ready"
start_run; waitfor "grep -q 'shutdown in progress' \"\$T/console\"" || true
PARK=0.05 start_run; waitfor "grep -q 'shutdown in progress' \"\$T/console\"" || true
sleep 1
check "a start during shutdown parks instead of exiting and respawning" '! gone "$pid"'
check "a parked start during shutdown starts no watcher and removes the marker" \
'[ ! -e "$T/watchers" ] && [ ! -e "$T/run/store-ready" ]'
@@ -221,24 +112,26 @@ check "a parked start during shutdown logs once" '[ "$(grep -c "shutdown in prog
shutdown_step
check "shutdown stops the parked start" 'waitfor "gone $pid"'
fresh run-unprepared; rm -f "$T/run/store.conf"
start_run
check "a watcher whose store is not prepared yet exits to be respawned and never reports ready" \
'waitfor "gone $pid" && [ ! -e "$T/watchers" ] && [ ! -e "$T/run/store-ready" ]'
fresh run-refused; rm -f "$T/run/store.conf"; : > "$T/run/boot-refused"
start_run; waitfor "grep -q 'ready: boot refused' \"\$T/console\"" || true
check "a guest whose boot was refused parks its watcher and never reports ready" \
'! gone "$pid" && [ ! -e "$T/watchers" ] && [ ! -e "$T/run/store-ready" ]'
shutdown_step
check "shutdown stops a watcher parked after a refused boot" 'waitfor "gone $pid" && [ ! -e "$T/watchers" ]'
fresh run-no-key; rm -f "$T/run/public-key"
PARK=0.05 start_run; waitfor "grep -q 'no worker key' \"\$T/console\"" || true
sleep 1
check "a guest without a worker key parks instead of exiting and respawning" '! gone "$pid" && [ ! -e "$T/watchers" ]'
check "a parked guest without a worker key logs once" '[ "$(grep -c "no worker key" "$T/console")" -eq 1 ]'
echo UPUBLICKEY > "$T/run/public-key"
check "a key that appears later starts the watcher" 'waitfor "[ -s \"\$T/watchers\" ]"'
kill -KILL "$pid" 2>/dev/null || true
# --- Shutdown: unmount only once the watcher and the store are gone ---
fresh run-no-key-stop; rm -f "$T/run/public-key"
PARK=0.05 start_run; waitfor "grep -q 'no worker key' \"\$T/console\"" || true
shutdown_step
check "shutdown stops a start parked for a missing key" 'waitfor "gone $pid" && [ ! -e "$T/watchers" ]'
# --- Shutdown: unmount only once the watcher is confirmed gone ---
fresh shutdown-orderly; echo "dev $T/data ext4 rw 0 0" > "$T/mounts"
start_store; start_run; waitfor "[ -s \"\$T/run/store-ready\" ]"; waitfor "[ -s \"\$T/servers\" ]"
start_run; waitfor "[ -s \"\$T/run/store-ready\" ]"
check "orderly shutdown succeeds" 'shutdown_step'
check "orderly shutdown stops the watcher, then the store, removes the marker, then unmounts" \
'gone "$pid" && gone "$spid" && [ ! -e "$T/run/store-ready" ] &&
[ "$(grep "exited" "$T/console" | sed "s/.*stop: \([a-z]*\) pid.*/\1/" | tr "\n" "|")" = "ready|store|" ] &&
check "orderly shutdown confirms the watcher exited, removes the marker, then unmounts" \
'gone "$pid" && grep -q "readiness watcher pid=$pid exited" "$T/console" && [ ! -e "$T/run/store-ready" ] &&
[ "$(grep "^umount" "$T/calls.log" | tr "\n" "|")" = "umount $T/data|umount -a -r|" ]'
check "shutdown blocks any later respawn" '[ -e "$T/run/stopping" ]'
@@ -258,16 +151,10 @@ check "a surviving watcher leaves every mount alone and is never reported stoppe
check "a surviving watcher still leaves no readiness marker" '[ ! -e "$T/run/store-ready" ]'
kill -KILL "$pid" 2>/dev/null || true
fresh shutdown-store-survivor; echo "dev $T/data ext4 rw 0 0" > "$T/mounts"
(trap '' TERM; exec sleep 60) & pid=$!; CHILDREN="$CHILDREN $pid"; record "$pid" store
check "shutdown with a surviving store reports failure and leaves every mount alone" \
'! shutdown_step "$pid" && ! grep -q "^umount" "$T/calls.log" && grep -q "store pid=$pid STILL ALIVE" "$T/console"'
kill -KILL "$pid" 2>/dev/null || true
fresh shutdown-reused; echo "dev $T/data ext4 rw 0 0" > "$T/mounts"
(exec sleep 60) & pid=$!; CHILDREN="$CHILDREN $pid"; echo "$pid 1" > "$T/run/ready.pid"
shutdown_step
check "a recorded PID with another start time is never signalled" '! gone "$pid" && grep -q "no running ready" "$T/console"'
check "a recorded PID with another start time is never signalled" '! gone "$pid" && grep -q "no running readiness watcher" "$T/console"'
kill -KILL "$pid" 2>/dev/null || true
if [ "$fails" -ne 0 ]; then
+4 -3
View File
@@ -1,10 +1,11 @@
#!/bin/sh
# stage-sqs-store-microvm.sh <image-dir> <dist-root> [sqs-worker]: copy the
# built SQS microVM image into a release tree as <dist-root>/microvm-sqs-worker.
# stage-sqs-store-microvm.sh <image-dir> <dist-root> [sqs-store|sqs-worker]:
# copy a built SQS microVM image into a release tree as <dist-root>/microvm-<name>.
# Without a complete image it stages nothing and says so, as the LB image does.
set -eu
src=$1 name=${3:-sqs-worker}
src=$1 name=${3:-sqs-store}
case "$name" in
sqs-store) label="SQS store" ;;
sqs-worker) label="SQS worker" ;;
*) echo "[distro] unknown SQS microVM image '$name'" >&2; exit 2 ;;
esac
+1 -2
View File
@@ -100,8 +100,7 @@ profile_binaries() {
./cmd/eks-gateway-fetch:usr/local/bin/eks-gateway-fetch \
./cmd/eks-webhook-cert:usr/local/bin/eks-webhook-cert \
./cmd/eks-konnectivity-cert:usr/local/bin/eks-konnectivity-cert \
./cmd/eks-etcd-member:usr/local/bin/eks-etcd-member \
./cmd/eks-pod-identity-webhook:usr/local/bin/eks-pod-identity-webhook"
./cmd/eks-etcd-member:usr/local/bin/eks-etcd-member"
;;
ecs) echo "./cmd/ecs-agent:usr/local/bin/ecs-agent" ;;
*) echo "" ;;
+1 -1
View File
@@ -7,7 +7,7 @@
# Debian is the build host regardless of what is being built — mkosi bootstraps
# the target distribution from its own archive, so an Ubuntu image builds fine
# from here (see ubuntu-keyring below).
FROM debian:trixie-slim@sha256:f6b08b09a05725f9431502d3b5714f5c4576e82c1a84fa1debe0c24a7e0c3ec4
FROM debian:trixie-slim@sha256:62bd423aeee912639ca8ee681c2b762cb3e25137e0a92b20be455898a9f034ad
# The runner user is uid 1000; matching it keeps bind-mounted build output
# owned by the invoking user instead of root.
+6 -60
View File
@@ -1014,9 +1014,6 @@ RESOLV_CONF="${SPINIFEX_RESOLV_CONF:-/etc/resolv.conf}"
RESOLVED_STUB=/run/systemd/resolve/stub-resolv.conf
# node-reset.sh keeps /var/lib/spinifex-reset, so the copy survives until uninstall.
RESOLV_CONF_SAVED="${SPINIFEX_RESOLV_CONF_SAVED:-/var/lib/spinifex-reset/resolv.conf.orig}"
# NetworkManager started while resolv.conf is a plain file rewrites it on its
# next DNS update, replacing the stub link; this drop-in stops that.
NM_DNS_DROPIN="${SPINIFEX_NM_DNS_DROPIN:-/etc/NetworkManager/conf.d/90-spinifex-dns.conf}"
# systemd-resolved ships resolvconf as a link to resolvectl, which only works
# while resolved runs, so that one is not a resolvconf backend.
@@ -1069,52 +1066,10 @@ dns_handover_failed() {
fatal "Host DNS through systemd-resolved does not work: $1.
To put host DNS back as it was before this installer:
sudo systemctl disable --now systemd-resolved
sudo rm -f $NM_DNS_DROPIN
sudo cp -a --remove-destination $RESOLV_CONF_SAVED $RESOLV_CONF
sudo systemctl restart NetworkManager"
}
# Prints the rc-manager NetworkManager would use if it is not unmanaged, which
# means a file read after the Spinifex drop-in overrides it.
nm_rc_manager_override() {
local config
config=$($SUDO NetworkManager --print-config) || return 1
printf '%s\n' "$config" | awk -F= '
/^\[/ { main = ($0 == "[main]"); next }
main && $1 == "rc-manager" { rc = $2 }
END { if (rc != "unmanaged") print "rc-manager=" (rc == "" ? "symlink" : rc) }'
}
# Writes the NetworkManager drop-in and checks nothing overrides it. Returns 1
# when it was already in place.
install_nm_dns_dropin() {
local want override changed=1
want=$(printf '[main]\ndns=systemd-resolved\nrc-manager=unmanaged')
if [ "$(cat "$NM_DNS_DROPIN" 2>/dev/null)" != "$want" ]; then
$SUDO mkdir -p "$(dirname "$NM_DNS_DROPIN")"
printf '%s\n' "$want" | $SUDO tee "$NM_DNS_DROPIN" > /dev/null || dns_handover_failed "cannot write $NM_DNS_DROPIN"
info " $NM_DNS_DROPIN: NetworkManager leaves $RESOLV_CONF alone"
changed=0
fi
override=$(nm_rc_manager_override) || dns_handover_failed "cannot read NetworkManager's configuration with 'NetworkManager --print-config'"
if [ -n "$override" ]; then
dns_handover_failed "NetworkManager reads $override from a file after $NM_DNS_DROPIN, so it would replace the $RESOLV_CONF link. Remove that setting ('NetworkManager --print-config' shows the files it reads) and re-run this installer"
fi
return "$changed"
}
# Links the stub and checks the link is still there a second later, relinking a
# bounded number of times in case a write NetworkManager had queued lands first.
link_resolved_stub() {
local i
for i in 1 2 3 4 5; do
$SUDO ln -sfn "$RESOLVED_STUB" "$RESOLV_CONF"
sleep 1
[ "$(readlink "$RESOLV_CONF")" = "$RESOLVED_STUB" ] && return 0
done
dns_handover_failed "$RESOLV_CONF is not a link to $RESOLVED_STUB 5 seconds after linking it, so something keeps rewriting it"
}
# Checks resolved has upstream servers and still resolves the package
# repository host, the one name this host is known to have resolved.
check_resolved_dns() {
@@ -1148,13 +1103,8 @@ hand_dns_to_resolved() {
[ "${ISO_BUILD:-0}" = "1" ] && return 0
if systemctl is-active --quiet systemd-resolved.service; then
# A handover made by an earlier run is checked again, and repaired, on
# every run: installs before the drop-in could lose the link.
# A handover made by an earlier run is checked again on every run.
if [ -e "$RESOLV_CONF_SAVED" ] || [ -L "$RESOLV_CONF_SAVED" ]; then
if install_nm_dns_dropin && systemctl is-active --quiet NetworkManager.service; then
$SUDO systemctl reload NetworkManager.service || dns_handover_failed "NetworkManager did not reload"
fi
[ "$(readlink "$RESOLV_CONF")" = "$RESOLVED_STUB" ] || link_resolved_stub
check_resolved_dns
fi
return 0
@@ -1181,15 +1131,10 @@ hand_dns_to_resolved() {
$SUDO cp -a "$RESOLV_CONF" "$RESOLV_CONF_SAVED" || fatal "Cannot save $RESOLV_CONF to $RESOLV_CONF_SAVED"
info " saved $RESOLV_CONF to $RESOLV_CONF_SAVED"
fi
install_nm_dns_dropin || true
$SUDO systemctl enable --now systemd-resolved.service > /dev/null 2>&1 \
|| dns_handover_failed "systemd-resolved did not start"
$SUDO systemctl restart NetworkManager.service || dns_handover_failed "NetworkManager did not restart"
# Best effort: a link with no DHCP server can hold startup past the timeout.
if command -v nm-online > /dev/null; then
nm-online -s -q --timeout=15 || true
fi
link_resolved_stub
$SUDO ln -sfn "$RESOLVED_STUB" "$RESOLV_CONF"
check_resolved_dns
info " $RESOLV_CONF -> $RESOLVED_STUB"
}
@@ -1679,12 +1624,13 @@ install_files() {
$SUDO cp "$EXTRACT_DIR/microvm/"* /usr/share/spinifex/microvm/
info " /usr/share/spinifex/microvm/*"
fi
install_sqs_store_image
install_sqs_worker_image
}
# The SQS image ships beside the load balancer's, not inside it: an older
# installer copies microvm/* without -r and would stop at a subdirectory. A
# store image left by an earlier install is not touched: nothing boots it.
# The SQS images ship beside the load balancer's, not inside it: an older
# installer copies microvm/* without -r and would stop at a subdirectory.
install_sqs_store_image() { install_sqs_microvm_image sqs-store "SQS store"; }
install_sqs_worker_image() { install_sqs_microvm_image sqs-worker "SQS worker"; }
# install_sqs_microvm_image <name> <label>
+5 -70
View File
@@ -20,10 +20,9 @@ fail() { echo " FAIL - $1"; fails=$((fails + 1)); }
echo "-- setup.sh host DNS, chrony, needrestart and nbdkit"
mkdir -p "$TMP/sys"
for tool in bash awk sed grep readlink basename dirname cp ln mkdir cat head tail wc tee rm chmod install mv; do
for tool in bash awk sed grep readlink basename dirname cp ln mkdir cat head tail wc tee rm chmod install; do
ln -s "$(command -v "$tool")" "$TMP/sys/$tool"
done
ln -s "$(command -v sleep)" "$TMP/sys/realsleep"
# stub <name> <body> — a stub command; $S is the fixture's state directory.
stub() {
@@ -31,17 +30,10 @@ stub() {
chmod +x "$TMP/bin/$1"
}
# nm_settle — waits for the fixture's NetworkManager to finish writing.
nm_settle() {
n=0
while [ -e "$TMP/state/nm-pending" ] && [ $n -lt 500 ]; do "$TMP/sys/realsleep" 0.01; n=$((n + 1)); done
}
# host — a fresh fixture: resolved installed and inactive, resolvconf the
# resolvectl link, NetworkManager running with no DNS settings, a dnf host
# whose first enabled repository is yum.oracle.com.
host() {
nm_settle
rm -rf "${TMP:?}/state" "${TMP:?}/root" "${TMP:?}/bin"
mkdir -p "$TMP/state/active" "$TMP/root/etc" "$TMP/root/usr/lib/systemd/system" "$TMP/bin"
: > "$TMP/root/usr/lib/systemd/system/systemd-resolved.service"
@@ -50,28 +42,11 @@ host() {
printf '[main]\n# dns=default\nconfigure-and-quit=no\n' > "$TMP/state/nm-config"
printf 'Global:\nLink 2 (br-wan): 192.168.1.1\n' > "$TMP/state/resolvectl-dns"
printf '[ol10_UEKR8]\nbaseurl = https://mirror.example/uek/\nenabled = 0\n[ol10_baseos_latest]\nbaseurl = https://yum.oracle.com/repo/OracleLinux/OL10/baseos/latest/x86_64/\nenabled = 1\n' > "$TMP/state/dnf-dump"
# A (re)started NetworkManager whose rc-manager is not unmanaged writes
# resolv.conf as a plain file on its first DNS update, which can land just
# after the installer links the stub.
stub systemctl '[ "$1" = is-active ] && { [ -e "$S/active/$3" ]; exit; }
echo "$*" >> "$S/calls"
[ "$1 $2" = "enable --now" ] && : > "$S/active/$3"
case "$1 $2" in
"restart NetworkManager.service" | "reload NetworkManager.service")
NetworkManager --print-config | grep -qx rc-manager=unmanaged && exit 0
: > "$S/nm-pending"
(
n=0
while [ ! -L "$S/../root/etc/resolv.conf" ] && [ $n -lt 300 ]; do realsleep 0.01; n=$((n + 1)); done
printf "nameserver 192.168.1.1\n" > "$S/nm-resolv.conf"
mv -f "$S/nm-resolv.conf" "$S/../root/etc/resolv.conf"
rm -f "$S/nm-pending"
) > /dev/null 2>&1 &
;;
esac
exit 0'
stub NetworkManager 'cat "$S/nm-config"; cat "$S/../root/nm/90-spinifex-dns.conf" "$S/nm-config-late" 2>/dev/null; :'
stub nm-online ':'
stub NetworkManager 'cat "$S/nm-config"'
stub resolvectl '[ "$1" = dns ] && cat "$S/resolvectl-dns"'
stub resolvectl-real ':'
ln -s resolvectl "$TMP/bin/resolvconf"
@@ -85,17 +60,10 @@ run() {
RC=0
OUT=$(PATH="$TMP/bin:$TMP/sys" DISTRO_ROOT="$TMP/root" \
SPINIFEX_RESOLV_CONF="$TMP/root/etc/resolv.conf" SPINIFEX_RESOLV_CONF_SAVED="$TMP/root/saved/resolv.conf.orig" \
SPINIFEX_NM_DNS_DROPIN="$TMP/root/nm/90-spinifex-dns.conf" \
INSTALL_SPINIFEX_LIB_ONLY=1 bash -c '. "$1"; shift; for f in "$@"; do $f; done' _ "$SETUP" "$@" 2>&1) || RC=$?
}
calls() { cat "$TMP/state/calls" 2>/dev/null || true; }
# linked — whether resolv.conf is still the stub link once NetworkManager settles.
linked() {
nm_settle
[ "$(readlink "$TMP/root/etc/resolv.conf")" = /run/systemd/resolve/stub-resolv.conf ]
}
dropin() { cat "$TMP/root/nm/90-spinifex-dns.conf" 2>/dev/null || echo "<none>"; }
saved() { cat "$TMP/root/saved/resolv.conf.orig" 2>/dev/null || echo "<none>"; }
expect_untouched() {
if [ "$RC" = 0 ] && [ -z "$(calls)" ] && [ ! -L "$TMP/root/etc/resolv.conf" ] && [ "$(saved)" = "<none>" ]; then
@@ -118,7 +86,7 @@ expect_fatal() { # expect_fatal <desc> <text>...
# --------------------------------------------------------------------------
host
run hand_dns_to_resolved
if [ "$RC" = 0 ] && linked \
if [ "$RC" = 0 ] && [ "$(readlink "$TMP/root/etc/resolv.conf")" = /run/systemd/resolve/stub-resolv.conf ] \
&& [ "$(saved)" = "$(printf 'nameserver 192.168.1.1\nsearch example.lan')" ] \
&& [ "$(calls)" = "$(printf 'enable --now systemd-resolved.service\nrestart NetworkManager.service')" ] \
&& [ "$(cat "$TMP/state/looked-up")" = yum.oracle.com ]; then
@@ -130,44 +98,12 @@ fi
: > "$TMP/state/calls"
rm -f "$TMP/state/looked-up"
run hand_dns_to_resolved
if [ "$RC" = 0 ] && [ -z "$(calls)" ] && linked && [ "$(cat "$TMP/state/looked-up")" = yum.oracle.com ]; then
if [ "$RC" = 0 ] && [ -z "$(calls)" ] && [ "$(cat "$TMP/state/looked-up")" = yum.oracle.com ]; then
pass "a rerun after a handover checks DNS again and changes nothing"
else
fail "rerun after handover: rc=$RC calls='$(calls)': $OUT"
fi
if [ "$(dropin)" = "$(printf '[main]\ndns=systemd-resolved\nrc-manager=unmanaged')" ]; then
pass "the handover tells NetworkManager to send DNS to resolved and leave resolv.conf alone"
else
fail "NetworkManager drop-in: '$(dropin)'"
fi
# An install made before the drop-in, whose NetworkManager replaced the link.
host
mkdir -p "$TMP/root/saved"
echo "the original" > "$TMP/root/saved/resolv.conf.orig"
: > "$TMP/state/active/systemd-resolved.service"
run hand_dns_to_resolved
if [ "$RC" = 0 ] && linked && [ "$(calls)" = "reload NetworkManager.service" ] \
&& [ "$(dropin)" = "$(printf '[main]\ndns=systemd-resolved\nrc-manager=unmanaged')" ]; then
pass "a rerun after a handover whose link NetworkManager replaced adds the drop-in, reloads NM and relinks the stub"
else
fail "rerun repair: rc=$RC calls='$(calls)' link='$(readlink "$TMP/root/etc/resolv.conf")' dropin='$(dropin)': $OUT"
fi
host
printf '[main]\nrc-manager=symlink\n' > "$TMP/state/nm-config-late"
run hand_dns_to_resolved
expect_fatal "a NetworkManager file read after the drop-in that sets rc-manager stops the install, naming it" \
"rc-manager=symlink" "rm -f $TMP/root/nm/90-spinifex-dns.conf"
case "$(calls)" in *"restart NetworkManager"*) fail "rc-manager override: restarted NetworkManager first: $(calls)" ;; esac
host
stub ln ':'
run hand_dns_to_resolved
expect_fatal "a resolv.conf that will not stay linked to the stub stops the install" \
"is not a link to /run/systemd/resolve/stub-resolv.conf"
host
mkdir -p "$TMP/root/saved"
echo "the original" > "$TMP/root/saved/resolv.conf.orig"
@@ -219,8 +155,7 @@ host
printf 'Global:\nLink 2 (br-wan):\n' > "$TMP/state/resolvectl-dns"
run hand_dns_to_resolved
expect_fatal "no upstream servers stops the install, naming the check and printing the restore" \
"'resolvectl dns' lists no DNS servers" "systemctl disable --now systemd-resolved" "cp -a --remove-destination $TMP/root/saved/resolv.conf.orig" \
"rm -f $TMP/root/nm/90-spinifex-dns.conf"
"'resolvectl dns' lists no DNS servers" "systemctl disable --now systemd-resolved" "cp -a --remove-destination $TMP/root/saved/resolv.conf.orig"
host
: > "$TMP/state/getent-fail"
+88 -48
View File
@@ -1,6 +1,6 @@
#!/bin/sh
# setup_sqs_worker_image_test.sh — tests for shipping the SQS microVM
# image: staging it into a release tree and installing with setup.sh.
# setup_sqs_store_image_test.sh — tests for shipping the SQS store and worker
# microVM images: staging them into a release tree and installing with setup.sh.
#
# setup.sh is sourced with INSTALL_SPINIFEX_LIB_ONLY=1 so main never runs, and
# installs into a temporary SPINIFEX_MICROVM_DIR with SUDO cleared.
@@ -16,7 +16,7 @@ fails=0
pass() { echo " ok - $1"; }
fail() { echo " FAIL - $1"; fails=$((fails + 1)); }
echo "-- SQS microVM image release and install"
echo "-- SQS store and worker microVM image release and install"
# image <dir> <tag>: a fake built image whose bytes name its tag.
image() {
@@ -35,11 +35,11 @@ release() {
}
# install_image <extract-dir> <microvm-dir> [path-prefix]: runs
# install_sqs_worker_image and sets RC and OUT.
# install_sqs_store_image and sets RC and OUT.
install_image() {
RC=0
OUT=$(PATH="${3:-}$PATH" EXTRACT_DIR="$1" SPINIFEX_MICROVM_DIR="$2" INSTALL_SPINIFEX_LIB_ONLY=1 \
bash -c '. "$1"; SUDO=""; install_sqs_worker_image' _ "$SETUP" 2>&1) || RC=$?
bash -c '. "$1"; SUDO=""; install_sqs_store_image' _ "$SETUP" 2>&1) || RC=$?
}
same() { cmp -s "$1" "$2"; }
@@ -48,13 +48,13 @@ same() { cmp -s "$1" "$2"; }
image "$TMP/built" v1
release "$TMP/rel"
sh "$STAGE" "$TMP/built" "$TMP/rel" > "$TMP/stage.out"
if same "$TMP/built/vmlinuz" "$TMP/rel/microvm-sqs-worker/vmlinuz" \
&& same "$TMP/built/initramfs.cpio.gz" "$TMP/rel/microvm-sqs-worker/initramfs.cpio.gz"; then
if same "$TMP/built/vmlinuz" "$TMP/rel/microvm-sqs-store/vmlinuz" \
&& same "$TMP/built/initramfs.cpio.gz" "$TMP/rel/microvm-sqs-store/initramfs.cpio.gz"; then
pass "stages the built image beside the load balancer's"
else
fail "staged image differs from the build"
fi
if [ ! -e "$TMP/rel/microvm/sqs-worker" ] && [ "$(ls "$TMP/rel/microvm" | wc -l)" -eq 3 ]; then
if [ ! -e "$TMP/rel/microvm/sqs-store" ] && [ "$(ls "$TMP/rel/microvm" | wc -l)" -eq 3 ]; then
pass "leaves the load balancer's microvm directory as it was"
else
fail "load balancer directory changed: $(ls "$TMP/rel/microvm")"
@@ -63,21 +63,21 @@ fi
release "$TMP/rel-none"
mkdir -p "$TMP/half"; printf 'k\n' > "$TMP/half/vmlinuz"
OUT=$(sh "$STAGE" "$TMP/half" "$TMP/rel-none")
if [ ! -e "$TMP/rel-none/microvm-sqs-worker" ] && echo "$OUT" | grep -q 'WARNING: SQS worker microVM image missing'; then
if [ ! -e "$TMP/rel-none/microvm-sqs-store" ] && echo "$OUT" | grep -q 'WARNING: SQS store microVM image missing'; then
pass "stages nothing from an incomplete build, and says so"
else
fail "incomplete build staged or unannounced: $OUT"
fi
# --- an older installer meets a release that carries the SQS image ---
# --- an older installer meets a release that carries the store image ---
# It copies microvm/* without -r; the sibling directory must not reach it.
mkdir -p "$TMP/old-dest"
if cp "$TMP/rel/microvm/"* "$TMP/old-dest/" 2> "$TMP/old.err" && [ ! -e "$TMP/old-dest/sqs-worker" ]; then
pass "an older installer's copy still succeeds and ignores the SQS image"
if cp "$TMP/rel/microvm/"* "$TMP/old-dest/" 2> "$TMP/old.err" && [ ! -e "$TMP/old-dest/sqs-store" ]; then
pass "an older installer's copy still succeeds and ignores the store image"
else
fail "older installer copy failed: $(cat "$TMP/old.err")"
fi
mkdir -p "$TMP/nested/microvm/sqs-worker" "$TMP/nested-dest"
mkdir -p "$TMP/nested/microvm/sqs-store" "$TMP/nested-dest"
printf 'x\n' > "$TMP/nested/microvm/vmlinuz"
if cp "$TMP/nested/microvm/"* "$TMP/nested-dest/" 2> /dev/null; then
fail "control: a nested directory did not break the older copy, so the test proves nothing"
@@ -87,58 +87,58 @@ fi
# --- install ---
install_image "$TMP/rel" "$TMP/mv"
if [ "$RC" = 0 ] && same "$TMP/built/vmlinuz" "$TMP/mv/sqs-worker/vmlinuz" \
&& same "$TMP/built/initramfs.cpio.gz" "$TMP/mv/sqs-worker/initramfs.cpio.gz"; then
if [ "$RC" = 0 ] && same "$TMP/built/vmlinuz" "$TMP/mv/sqs-store/vmlinuz" \
&& same "$TMP/built/initramfs.cpio.gz" "$TMP/mv/sqs-store/initramfs.cpio.gz"; then
pass "installs the staged image where the daemon looks"
else
fail "install rc=$RC: $OUT"
fi
mode=$(stat -c '%a' "$TMP/mv/sqs-worker/initramfs.cpio.gz" 2> /dev/null || echo none)
mode=$(stat -c '%a' "$TMP/mv/sqs-store/initramfs.cpio.gz" 2> /dev/null || echo none)
if [ "$mode" = 644 ]; then pass "installs the image 0644"; else fail "initramfs mode $mode"; fi
image "$TMP/built2" v2
release "$TMP/rel2"
sh "$STAGE" "$TMP/built2" "$TMP/rel2" > /dev/null
install_image "$TMP/rel2" "$TMP/mv"
if [ "$RC" = 0 ] && same "$TMP/built2/vmlinuz" "$TMP/mv/sqs-worker/vmlinuz" \
&& same "$TMP/built2/initramfs.cpio.gz" "$TMP/mv/sqs-worker/initramfs.cpio.gz"; then
if [ "$RC" = 0 ] && same "$TMP/built2/vmlinuz" "$TMP/mv/sqs-store/vmlinuz" \
&& same "$TMP/built2/initramfs.cpio.gz" "$TMP/mv/sqs-store/initramfs.cpio.gz"; then
pass "an upgrade replaces both files"
else
fail "upgrade rc=$RC: $OUT"
fi
# A release without the SQS image: nothing installed, and a clear warning.
# A release without the store image: nothing installed, and a clear warning.
install_image "$TMP/rel-none" "$TMP/mv-none"
if [ "$RC" = 0 ] && [ ! -e "$TMP/mv-none/sqs-worker" ] && echo "$OUT" | grep -q 'no SQS worker microVM image; SQS worker launches will be refused'; then
if [ "$RC" = 0 ] && [ ! -e "$TMP/mv-none/sqs-store" ] && echo "$OUT" | grep -q 'no SQS store microVM image; SQS store launches will be refused'; then
pass "a release without the image installs nothing and warns"
else
fail "release without image: rc=$RC exists=$([ -e "$TMP/mv-none/sqs-worker" ] && echo yes || echo no) $OUT"
fail "release without image: rc=$RC exists=$([ -e "$TMP/mv-none/sqs-store" ] && echo yes || echo no) $OUT"
fi
# installed <dir>: an SQS image from an earlier install, plus an unrelated file.
# installed <dir>: a store image from an earlier install, plus an unrelated file.
installed() {
image "$1/sqs-worker" old
printf 'keep\n' > "$1/sqs-worker/operator-note"
image "$1/sqs-store" old
printf 'keep\n' > "$1/sqs-store/operator-note"
}
gone() { [ ! -e "$1/sqs-worker/vmlinuz" ] && [ ! -e "$1/sqs-worker/initramfs.cpio.gz" ]; }
kept() { [ "$(cat "$1/sqs-worker/operator-note" 2> /dev/null)" = keep ]; }
gone() { [ ! -e "$1/sqs-store/vmlinuz" ] && [ ! -e "$1/sqs-store/initramfs.cpio.gz" ]; }
kept() { [ "$(cat "$1/sqs-store/operator-note" 2> /dev/null)" = keep ]; }
# Upgrading to a release without the image must not leave the old one usable.
installed "$TMP/mv-up"
install_image "$TMP/rel-none" "$TMP/mv-up"
if [ "$RC" = 0 ] && gone "$TMP/mv-up" && kept "$TMP/mv-up" \
&& echo "$OUT" | grep -q 'Removed the SQS worker microVM image left by an earlier install' \
&& echo "$OUT" | grep -q 'SQS worker launches will be refused'; then
&& echo "$OUT" | grep -q 'Removed the SQS store microVM image left by an earlier install' \
&& echo "$OUT" | grep -q 'SQS store launches will be refused'; then
pass "a release without the image removes the earlier one, keeps other files, and warns"
else
fail "release without image over an installed one: rc=$RC $OUT"
fi
# An incomplete SQS image refuses and removes the earlier pair, because the
# An incomplete store image refuses and removes the earlier pair, because the
# binaries this release installed before it would otherwise boot the old one.
installed "$TMP/mv-bad"
cp -r "$TMP/rel2" "$TMP/rel-bad"
rm "$TMP/rel-bad/microvm-sqs-worker/initramfs.cpio.gz"
rm "$TMP/rel-bad/microvm-sqs-store/initramfs.cpio.gz"
install_image "$TMP/rel-bad" "$TMP/mv-bad"
if [ "$RC" != 0 ] && echo "$OUT" | grep -q 'incomplete: initramfs.cpio.gz is missing' \
&& gone "$TMP/mv-bad" && kept "$TMP/mv-bad"; then
@@ -154,17 +154,17 @@ for case in missing incomplete; do
sh "$STAGE" "$TMP/built" "$TMP/rel-$case" > /dev/null
if [ "$case" = missing ]; then rm -r "$TMP/built-$case"; else rm "$TMP/built-$case/initramfs.cpio.gz"; fi
OUT=$(sh "$STAGE" "$TMP/built-$case" "$TMP/rel-$case")
if [ ! -e "$TMP/rel-$case/microvm-sqs-worker" ] && echo "$OUT" | grep -q 'tarball will not include it'; then
if [ ! -e "$TMP/rel-$case/microvm-sqs-store" ] && echo "$OUT" | grep -q 'tarball will not include it'; then
pass "restaging from a $case build ships no earlier image"
else
fail "restaging from a $case build left: $(ls -R "$TMP/rel-$case/microvm-sqs-worker" 2> /dev/null)"
fail "restaging from a $case build left: $(ls -R "$TMP/rel-$case/microvm-sqs-store" 2> /dev/null)"
fi
done
mkdir -p "$TMP/rel-other/microvm-sqs-worker"
image "$TMP/rel-other/microvm-sqs-worker" old
printf 'keep\n' > "$TMP/rel-other/microvm-sqs-worker/note"
mkdir -p "$TMP/rel-other/microvm-sqs-store"
image "$TMP/rel-other/microvm-sqs-store" old
printf 'keep\n' > "$TMP/rel-other/microvm-sqs-store/note"
sh "$STAGE" "$TMP/no-such-build" "$TMP/rel-other" > /dev/null
if [ ! -e "$TMP/rel-other/microvm-sqs-worker/vmlinuz" ] && [ "$(cat "$TMP/rel-other/microvm-sqs-worker/note")" = keep ]; then
if [ ! -e "$TMP/rel-other/microvm-sqs-store/vmlinuz" ] && [ "$(cat "$TMP/rel-other/microvm-sqs-store/note")" = keep ]; then
pass "restaging removes only the image files"
else
fail "restaging touched more than the image files"
@@ -180,25 +180,65 @@ exec /usr/bin/install "$@"
EOF
chmod +x "$TMP/bin/install"
install_image "$TMP/rel" "$TMP/mv" "$TMP/bin:"
if [ "$RC" != 0 ] && same "$TMP/built/vmlinuz" "$TMP/mv/sqs-worker/vmlinuz" && [ ! -e "$TMP/mv/sqs-worker/initramfs.cpio.gz" ]; then
if [ "$RC" != 0 ] && same "$TMP/built/vmlinuz" "$TMP/mv/sqs-store/vmlinuz" && [ ! -e "$TMP/mv/sqs-store/initramfs.cpio.gz" ]; then
pass "an interrupted install leaves the initramfs missing, not mismatched"
else
fail "interrupted install: rc=$RC initramfs=$([ -e "$TMP/mv/sqs-worker/initramfs.cpio.gz" ] && echo present || echo missing)"
fail "interrupted install: rc=$RC initramfs=$([ -e "$TMP/mv/sqs-store/initramfs.cpio.gz" ] && echo present || echo missing)"
fi
image "$TMP/keep/sqs-store" old-store
install_image "$TMP/rel" "$TMP/keep"
if [ "$RC" = 0 ] && [ "$(cat "$TMP/keep/sqs-store/vmlinuz")" = kernel-old-store ] \
&& [ "$(cat "$TMP/keep/sqs-store/initramfs.cpio.gz")" = initramfs-old-store ]; then
pass "an install leaves a separate store image from an earlier install untouched"
# --- SQS worker image: same staging and install path, its own directory ---
install_worker() {
RC=0
OUT=$(EXTRACT_DIR="$1" SPINIFEX_MICROVM_DIR="$2" INSTALL_SPINIFEX_LIB_ONLY=1 \
bash -c '. "$1"; SUDO=""; install_sqs_worker_image' _ "$SETUP" 2>&1) || RC=$?
}
image "$TMP/wbuilt" w1
release "$TMP/wrel"
sh "$STAGE" "$TMP/built" "$TMP/wrel" > /dev/null
sh "$STAGE" "$TMP/wbuilt" "$TMP/wrel" sqs-worker > /dev/null
if same "$TMP/wbuilt/vmlinuz" "$TMP/wrel/microvm-sqs-worker/vmlinuz" \
&& same "$TMP/wbuilt/initramfs.cpio.gz" "$TMP/wrel/microvm-sqs-worker/initramfs.cpio.gz" \
&& same "$TMP/built/vmlinuz" "$TMP/wrel/microvm-sqs-store/vmlinuz"; then
pass "the worker image stages beside the store image"
else
fail "earlier store image: rc=$RC $(ls -R "$TMP/keep")"
fail "worker staging: $(ls -R "$TMP/wrel")"
fi
if OUT=$(sh "$STAGE" "$TMP/built" "$TMP/rel" sqs-store 2>&1); then
fail "the separate store image was staged"
install_worker "$TMP/wrel" "$TMP/wmv"
if [ "$RC" = 0 ] && same "$TMP/wbuilt/initramfs.cpio.gz" "$TMP/wmv/sqs-worker/initramfs.cpio.gz" \
&& [ ! -e "$TMP/wmv/sqs-store" ]; then
pass "the worker image installs into its own directory only"
else
pass "the separate store image is no longer staged"
fail "worker install: rc=$RC $OUT"
fi
release "$TMP/wrel-none"
OUT=$(sh "$STAGE" "$TMP/no-such-build" "$TMP/wrel-none" sqs-worker)
if [ ! -e "$TMP/wrel-none/microvm-sqs-worker" ] && echo "$OUT" | grep -q 'SQS worker microVM image missing'; then
pass "a missing worker build stages nothing and says so"
else
fail "missing worker build: $OUT"
fi
install_worker "$TMP/wrel-none" "$TMP/wmv"
if [ "$RC" = 0 ] && [ ! -e "$TMP/wmv/sqs-worker/vmlinuz" ] && [ ! -e "$TMP/wmv/sqs-worker/initramfs.cpio.gz" ] \
&& echo "$OUT" | grep -q 'no SQS worker microVM image; SQS worker launches will be refused'; then
pass "a release without the worker image removes the earlier one and warns"
else
fail "release without worker image: rc=$RC $OUT"
fi
cp -r "$TMP/wrel" "$TMP/wrel-bad"
rm "$TMP/wrel-bad/microvm-sqs-worker/vmlinuz"
install_worker "$TMP/wrel" "$TMP/wmv"
install_worker "$TMP/wrel-bad" "$TMP/wmv"
if [ "$RC" != 0 ] && echo "$OUT" | grep -q 'SQS worker microVM image in this release is incomplete: vmlinuz is missing' \
&& [ ! -e "$TMP/wmv/sqs-worker/initramfs.cpio.gz" ]; then
pass "an incomplete worker image is refused and the earlier pair is removed"
else
fail "incomplete worker image: rc=$RC $OUT"
fi
if OUT=$(sh "$STAGE" "$TMP/wbuilt" "$TMP/wrel" lb 2>&1); then
fail "an unknown image name was staged"
else
pass "an unknown image name is refused"
fi
[ "$fails" = 0 ] || { echo "FAIL: $fails"; exit 1; }
echo " setup_sqs_worker_image_test ok"
echo " setup_sqs_store_image_test ok"
-1
View File
@@ -29,7 +29,6 @@ MODELS=(
rds/2014-10-31
route-53/2013-04-01
s3/2006-03-01
sqs/2012-11-05
sts/2011-06-15
)
+1 -1
View File
@@ -83,10 +83,10 @@ func (a *Agent) reconcile(ctx context.Context) map[string]bool {
statuses := make([]ecsv1.ContainerStatus, 0, len(t.running))
for _, c := range t.running {
name := c.Labels[labelContainerName]
go a.waitContainer(ctx, t.as, name, c.ID)
statuses = append(statuses, ecsv1.ContainerStatus{Name: name, Status: ecsv1.TaskStatusRunning, ContainerID: c.ID})
}
a.reportTaskState(t.as, ecsv1.TaskStatusRunning, "", statuses)
a.waitContainers(ctx, t.as, statuses)
adopted[taskID] = true
slog.Info("ecs-agent: re-adopted running task", "task", taskID, "containers", len(t.running))
}
+1 -12
View File
@@ -191,7 +191,6 @@ func (a *Agent) runTask(ctx context.Context, as *ecsv1.Assign) {
slog.Error("ecs-agent: pull failed", "task", as.TaskID, "image", c.Image, "err", err)
a.teardownTaskNetns(as)
a.reportTaskState(as, ecsv1.TaskStatusStopped, "image pull failed: "+err.Error(), statuses)
a.waitContainers(ctx, as, statuses)
return
}
@@ -224,28 +223,18 @@ func (a *Agent) runTask(ctx context.Context, as *ecsv1.Assign) {
slog.Error("ecs-agent: run failed", "task", as.TaskID, "container", c.Name, "err", err)
a.teardownTaskNetns(as)
a.reportTaskState(as, ecsv1.TaskStatusStopped, "container start failed: "+err.Error(), statuses)
a.waitContainers(ctx, as, statuses)
return
}
statuses = append(statuses, ecsv1.ContainerStatus{
Name: c.Name, Status: ecsv1.TaskStatusRunning, ContainerID: id, GPUIDs: gpuIDs,
})
go a.waitContainer(ctx, as, c.Name, id)
}
a.reportTaskState(as, ecsv1.TaskStatusRunning, "", statuses)
a.waitContainers(ctx, as, statuses)
slog.Info("ecs-agent: task running", "task", as.TaskID, "containers", len(statuses))
}
// waitContainers starts one exit-wait per started container. Callers start
// them only after the report that precedes STOPPED is sent, so a container
// that exits at once can never have its STOPPED overtaken by that report.
func (a *Agent) waitContainers(ctx context.Context, as *ecsv1.Assign, started []ecsv1.ContainerStatus) {
for _, c := range started {
go a.waitContainer(ctx, as, c.Name, c.ContainerID)
}
}
// waitContainer blocks until a container exits, then reports the task STOPPED
// with the exit code. v1 stops the whole task when its first container exits.
func (a *Agent) waitContainer(ctx context.Context, as *ecsv1.Assign, name, containerID string) {
-85
View File
@@ -4,7 +4,6 @@ import (
"context"
"errors"
"slices"
"sync/atomic"
"testing"
"time"
@@ -824,87 +823,3 @@ func TestPullResolver(t *testing.T) {
t.Fatalf("execution role + cred endpoint: want distinct exec-role resolver, got %v", got)
}
}
// orderingCP fails the run if a container exit-wait starts before the RUNNING
// report is sent: an instant exit's STOPPED could then be overtaken by RUNNING.
type orderingCP struct {
*fakeCP
waitStarted chan struct{}
early atomic.Bool
}
func newOrderingCP(rt *ctrruntime.FakePuller) *orderingCP {
cp := &orderingCP{fakeCP: &fakeCP{}, waitStarted: make(chan struct{}, 8)}
rt.OnWait = func(string) { cp.waitStarted <- struct{}{} }
return cp
}
func (c *orderingCP) SubmitTaskState(st ecsv1.TaskState) error {
if st.LastStatus == ecsv1.TaskStatusRunning {
// A wait launched before this report is scheduled well within the window.
select {
case <-c.waitStarted:
c.early.Store(true)
case <-time.After(100 * time.Millisecond):
}
}
return c.fakeCP.SubmitTaskState(st)
}
func waitStopped(t *testing.T, cp *orderingCP, taskID string) {
t.Helper()
deadline := time.After(5 * time.Second)
for countStatus(cp.taskStates(), taskID, ecsv1.TaskStatusStopped) == 0 {
select {
case <-deadline:
t.Fatalf("task %s never reported STOPPED; states %+v", taskID, cp.taskStates())
case <-time.After(time.Millisecond):
}
}
}
func assertRunningThenStopped(t *testing.T, cp *orderingCP, taskID string) {
t.Helper()
if cp.early.Load() {
t.Fatalf("exit-wait started before the RUNNING report; states %+v", cp.taskStates())
}
var seq []string
for _, st := range cp.taskStates() {
if st.TaskID == taskID {
seq = append(seq, st.LastStatus)
}
}
if !slices.Equal(seq, []string{ecsv1.TaskStatusRunning, ecsv1.TaskStatusStopped}) {
t.Fatalf("task %s reported %v, want [RUNNING STOPPED]", taskID, seq)
}
}
// A container that exits the instant it starts must not let RUNNING be
// reported after its STOPPED: the gateway would revive a dead task.
func TestRunTask_InstantExitStoppedNeverPrecedesRunning(t *testing.T) {
rt := &ctrruntime.FakePuller{WaitCode: 7}
cp := newOrderingCP(rt)
a := newRunAgent(cp, rt)
a.runTask(context.Background(), testAssign())
waitStopped(t, cp, "t-001")
assertRunningThenStopped(t, cp, "t-001")
}
// The re-adopt path has the same ordering rule for a container that exits
// right after the agent re-attaches to it.
func TestReconcile_InstantExitStoppedNeverPrecedesRunning(t *testing.T) {
rt := &ctrruntime.FakePuller{
Containers: []ctrruntime.Container{
adoptedContainer("t-live", "web", "default", "t-live", testTaskRole, "", true),
},
}
cp := newOrderingCP(rt)
a := newAgent(Config{}, testIdentity(), cp, rt, nil)
a.cred = newCredEndpoint(nil, "us-east-1", "https://gw", "", "127.0.0.1", 0, nil)
a.reconcile(context.Background())
waitStopped(t, cp, "t-live")
assertRunningThenStopped(t, cp, "t-live")
}
@@ -26,7 +26,6 @@ type FakePuller struct {
Waited []string
Removed []string
OnRun func(string, RunSpec) (string, error)
OnWait func(containerID string) // called, unlocked, before Wait returns
// Stop bookkeeping: StopTimeouts pairs the container ID with the timeout it
// was stopped with, in call order, so a test can assert both the target and
@@ -106,11 +105,7 @@ func (f *FakePuller) Run(_ context.Context, id string, spec RunSpec) (string, er
func (f *FakePuller) Wait(_ context.Context, containerID string) (RunStatus, error) {
f.mu.Lock()
f.Waited = append(f.Waited, containerID)
onWait := f.OnWait
f.mu.Unlock()
if onWait != nil {
onWait(containerID)
}
return RunStatus{ExitCode: f.WaitCode}, f.WaitErr
}
+3 -30
View File
@@ -16,9 +16,6 @@
//
// The recovery resource returns a single epoch\taction\tsnapshot line the on-VM
// k3s-recovery agent applies before k3s starts (etcd cluster-reset / wipe-rejoin).
//
// The workers resource returns one instance ID per line: every worker the
// cluster's nodegroups still own, which the on-VM node-gc agent keeps Nodes for.
package gatewayfetch
import (
@@ -49,7 +46,7 @@ type Config struct {
Region string
AccountID string
ClusterName string
// Resource is "addons", "recovery" or "workers".
// Resource is "addons" or "recovery".
Resource string
InstanceID string
// Out receives the rendered TSV.
@@ -64,8 +61,8 @@ func Run(ctx context.Context, cfg Config) error {
return errors.New("--account-id is required (or set EKS_ACCOUNT_ID)")
case cfg.ClusterName == "":
return errors.New("--cluster is required (or set EKS_CLUSTER_NAME)")
case cfg.Resource != "addons" && cfg.Resource != "recovery" && cfg.Resource != "workers":
return errors.New("--resource must be addons, recovery or workers")
case cfg.Resource != "addons" && cfg.Resource != "recovery":
return errors.New("--resource must be addons or recovery")
case cfg.Resource == "recovery" && cfg.InstanceID == "":
return errors.New("--instance-id is required for --resource recovery (or set EKS_INSTANCE_ID)")
}
@@ -83,9 +80,6 @@ func Run(ctx context.Context, cfg Config) error {
case "recovery":
path = "/clusters/" + cfg.ClusterName + "/internal-recovery/" + cfg.AccountID + "/" + cfg.InstanceID
emit = emitRecoveryTSV
case "workers":
path = "/clusters/" + cfg.ClusterName + "/internal-workers/" + cfg.AccountID
emit = emitWorkers
default:
path = "/clusters/" + cfg.ClusterName + "/internal-addons/" + cfg.AccountID
emit = emitAddonsTSV
@@ -169,24 +163,3 @@ func emitRecoveryTSV(out io.Writer, body []byte) error {
}
return nil
}
type internalWorkersResponse struct {
InstanceIDs []string `json:"instanceIds"`
}
// emitWorkers writes one managed-worker instance ID per line. An empty set
// writes nothing, which the node-gc agent tells apart from a failed fetch by
// this command's exit status, never by the output being empty.
func emitWorkers(out io.Writer, body []byte) error {
var resp internalWorkersResponse
if err := json.Unmarshal(body, &resp); err != nil {
return fmt.Errorf("unmarshal internal-workers response: %w", err)
}
w := bufio.NewWriter(out)
for _, id := range resp.InstanceIDs {
if _, err := fmt.Fprintln(w, id); err != nil {
return err
}
}
return w.Flush()
}
+1 -21
View File
@@ -49,7 +49,7 @@ func TestRun_RejectsBeforeContactingGateway(t *testing.T) {
}{
{"account", func(c *Config) { c.AccountID = "" }, "--account-id is required (or set EKS_ACCOUNT_ID)"},
{"cluster", func(c *Config) { c.ClusterName = "" }, "--cluster is required (or set EKS_CLUSTER_NAME)"},
{"resource", func(c *Config) { c.Resource = "secrets" }, "--resource must be addons, recovery or workers"},
{"resource", func(c *Config) { c.Resource = "secrets" }, "--resource must be addons or recovery"},
{"recovery instance", func(c *Config) { c.Resource = "recovery" }, "--instance-id is required for --resource recovery (or set EKS_INSTANCE_ID)"},
{"gateway client", func(c *Config) { c.GatewayURL = "" }, "build gateway client: eksgw: baseURL is required"},
}
@@ -92,26 +92,6 @@ func TestRun_RendersRecoveryDirective(t *testing.T) {
}
}
// The gateway's restjson marshaler names the field InstanceIDs; both that and
// the json-tag spelling decode, so the agent reads either.
func TestRun_RendersManagedWorkers(t *testing.T) {
for _, body := range []string{
`{"InstanceIDs":["i-0aaa","i-0bbb"]}`,
`{"instanceIds":["i-0aaa","i-0bbb"]}`,
} {
var hits atomic.Int32
cfg, out := gateway(t, "/clusters/demo/internal-workers/123456789012", http.StatusOK, body, &hits)
cfg.Resource = "workers"
if err := Run(context.Background(), cfg); err != nil {
t.Fatalf("Run: %v", err)
}
if got, want := out.String(), "i-0aaa\ni-0bbb\n"; got != want {
t.Fatalf("stdout = %q, want %q", got, want)
}
}
}
func TestRun_RenderFailureIsNotRetried(t *testing.T) {
var hits atomic.Int32
cfg, _ := gateway(t, "/clusters/demo/internal-addons/123456789012", http.StatusOK, `{not-json`, &hits)
+3 -3
View File
@@ -76,7 +76,7 @@ func ensureCert(dir, caCertPath, caKeyPath, cn string, dns []string, ips []net.I
return certPath, keyPath, nil
}
caCert, caKey, err := LoadCA(caCertPath, caKeyPath)
caCert, caKey, err := loadCA(caCertPath, caKeyPath)
if err != nil {
return "", "", err
}
@@ -135,8 +135,8 @@ func certCoversSANs(certPath, keyPath string, dns []string, ips []net.IP) bool {
return true
}
// LoadCA parses the PEM CA cert + EC/PKCS8 private key used to sign the leaf.
func LoadCA(certPath, keyPath string) (*x509.Certificate, *ecdsa.PrivateKey, error) {
// loadCA parses the PEM CA cert + EC/PKCS8 private key used to sign the leaf.
func loadCA(certPath, keyPath string) (*x509.Certificate, *ecdsa.PrivateKey, error) {
certPEM, err := os.ReadFile(certPath)
if err != nil {
return nil, nil, fmt.Errorf("read ca cert %s: %w", certPath, err)
-196
View File
@@ -1,196 +0,0 @@
package podidentity
import (
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"crypto/tls"
"crypto/x509"
"crypto/x509/pkix"
"encoding/pem"
"fmt"
"math/big"
"net"
"os"
"path/filepath"
"time"
"github.com/mulgadc/spinifex/spinifex/agents/eks/konnectivitycert"
)
const (
// ClientCN is the identity the webhook presents to the API server; the
// ClusterRoleBinding names this user.
ClientCN = "spinifex:eks-pod-identity-webhook"
servingCertFile = "serving.crt"
servingKeyFile = "serving.key"
clientCertFile = "client.crt"
clientKeyFile = "client.key"
certRenewWindow = 30 * 24 * time.Hour
certValidity = 365 * 24 * time.Hour
)
// CertConfig names the k3s CAs that sign the webhook's two certificates and
// the directory they are cached in.
type CertConfig struct {
Dir string
ServerCACert string
ServerCAKey string
ClientCACert string
ClientCAKey string
}
// Certs are the paths of the cached certificate and key pairs.
type Certs struct {
ServingCert, ServingKey string
ClientCert, ClientKey string
}
// EnsureCerts mints, or reuses while still valid and signed by the current CA,
// a serving certificate for 127.0.0.1 signed by the k3s server CA and a client
// certificate signed by the k3s client CA. Keys are written 0600.
func EnsureCerts(c CertConfig) (Certs, error) {
out := Certs{
ServingCert: filepath.Join(c.Dir, servingCertFile), ServingKey: filepath.Join(c.Dir, servingKeyFile),
ClientCert: filepath.Join(c.Dir, clientCertFile), ClientKey: filepath.Join(c.Dir, clientKeyFile),
}
if err := os.MkdirAll(c.Dir, 0o700); err != nil {
return Certs{}, fmt.Errorf("mkdir %s: %w", c.Dir, err)
}
serving := leafSpec{cn: "pod-identity-webhook", usage: x509.ExtKeyUsageServerAuth, ips: []net.IP{net.IPv4(127, 0, 0, 1)}}
if err := ensureLeaf(out.ServingCert, out.ServingKey, c.ServerCACert, c.ServerCAKey, serving); err != nil {
return Certs{}, fmt.Errorf("serving certificate: %w", err)
}
client := leafSpec{cn: ClientCN, usage: x509.ExtKeyUsageClientAuth}
if err := ensureLeaf(out.ClientCert, out.ClientKey, c.ClientCACert, c.ClientCAKey, client); err != nil {
return Certs{}, fmt.Errorf("client certificate: %w", err)
}
return out, nil
}
type leafSpec struct {
cn string
usage x509.ExtKeyUsage
ips []net.IP
}
func ensureLeaf(certPath, keyPath, caCertPath, caKeyPath string, spec leafSpec) error {
ca, caKey, err := konnectivitycert.LoadCA(caCertPath, caKeyPath)
if err != nil {
return err
}
if leafCurrent(certPath, keyPath, ca, spec) {
return nil
}
priv, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
return fmt.Errorf("generate key: %w", err)
}
serial, err := rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 128))
if err != nil {
return fmt.Errorf("serial: %w", err)
}
now := time.Now().UTC()
tmpl := x509.Certificate{
SerialNumber: serial,
Subject: pkix.Name{CommonName: spec.cn},
NotBefore: now.Add(-time.Hour),
NotAfter: now.Add(certValidity),
KeyUsage: x509.KeyUsageDigitalSignature,
ExtKeyUsage: []x509.ExtKeyUsage{spec.usage},
BasicConstraintsValid: true,
IPAddresses: spec.ips,
}
der, err := x509.CreateCertificate(rand.Reader, &tmpl, ca, &priv.PublicKey, caKey)
if err != nil {
return fmt.Errorf("create certificate: %w", err)
}
keyDER, err := x509.MarshalECPrivateKey(priv)
if err != nil {
return fmt.Errorf("marshal key: %w", err)
}
// Key first: a cert without its key is never published.
if err := writeAtomic(keyPath, pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: keyDER}), 0o600); err != nil {
return err
}
return writeAtomic(certPath, pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der}), 0o644)
}
// leafCurrent reports whether the cached pair loads, matches, was signed by ca,
// has the wanted usage and SANs, and is not near expiry.
func leafCurrent(certPath, keyPath string, ca *x509.Certificate, spec leafSpec) bool {
pair, err := tls.LoadX509KeyPair(certPath, keyPath)
if err != nil || len(pair.Certificate) == 0 {
return false
}
crt, err := x509.ParseCertificate(pair.Certificate[0])
if err != nil || crt.CheckSignatureFrom(ca) != nil {
return false
}
if inRenewWindow(crt, time.Now()) || crt.Subject.CommonName != spec.cn {
return false
}
if len(crt.ExtKeyUsage) != 1 || crt.ExtKeyUsage[0] != spec.usage {
return false
}
for _, ip := range spec.ips {
found := false
for _, have := range crt.IPAddresses {
found = found || have.Equal(ip)
}
if !found {
return false
}
}
return true
}
func writeAtomic(path string, data []byte, mode os.FileMode) error {
tmp, err := os.CreateTemp(filepath.Dir(path), ".tmp-*")
if err != nil {
return fmt.Errorf("create temp for %s: %w", path, err)
}
defer func() { _ = os.Remove(tmp.Name()) }()
if err := tmp.Chmod(mode); err != nil {
_ = tmp.Close()
return fmt.Errorf("chmod %s: %w", path, err)
}
if _, err := tmp.Write(data); err != nil {
_ = tmp.Close()
return fmt.Errorf("write %s: %w", path, err)
}
if err := tmp.Close(); err != nil {
return fmt.Errorf("close %s: %w", path, err)
}
if err := os.Rename(tmp.Name(), path); err != nil {
return fmt.Errorf("rename %s: %w", path, err)
}
return nil
}
// inRenewWindow reports whether crt expires within the renew window at now.
func inRenewWindow(crt *x509.Certificate, now time.Time) bool {
return crt.NotAfter.Sub(now) < certRenewWindow
}
// NeedsRenewal reports whether either cached certificate is inside its renew
// window at now. A running process exits when this is true so the supervisor
// restarts it and EnsureCerts reissues; an unreadable certificate also counts.
func NeedsRenewal(c Certs, now time.Time) bool {
for _, path := range []string{c.ServingCert, c.ClientCert} {
b, err := os.ReadFile(path)
if err != nil {
return true
}
blk, _ := pem.Decode(b)
if blk == nil {
return true
}
crt, err := x509.ParseCertificate(blk.Bytes)
if err != nil || inRenewWindow(crt, now) {
return true
}
}
return false
}
-263
View File
@@ -1,263 +0,0 @@
package podidentity
import (
"bytes"
"context"
"crypto/tls"
"crypto/x509"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"os"
"strings"
"time"
)
const (
kubeCallTimeout = 15 * time.Second
watchRetryDelay = 2 * time.Second
maxKubeBody = 4 << 20
)
// KubeClient reaches the Kubernetes API with plain REST. It implements
// ServiceAccountGetter, ConfigMapStore and NamespaceSource using only the
// calls the webhook's ClusterRole grants.
type KubeClient struct {
base string
http *http.Client
}
var (
_ ServiceAccountGetter = (*KubeClient)(nil)
_ ConfigMapStore = (*KubeClient)(nil)
_ NamespaceSource = (*KubeClient)(nil)
)
// NewKubeClient returns a client for apiServer that trusts caFile and presents
// the client certificate in certFile and keyFile. The pair is re-read on every
// handshake, so a re-issued certificate is used without a restart.
func NewKubeClient(apiServer, caFile, certFile, keyFile string) (*KubeClient, error) {
ca, err := os.ReadFile(caFile)
if err != nil {
return nil, fmt.Errorf("read api server ca: %w", err)
}
pool := x509.NewCertPool()
if !pool.AppendCertsFromPEM(ca) {
return nil, fmt.Errorf("api server ca %s holds no certificate", caFile)
}
if _, err := tls.LoadX509KeyPair(certFile, keyFile); err != nil {
return nil, fmt.Errorf("load client certificate: %w", err)
}
tr := &http.Transport{TLSClientConfig: &tls.Config{
RootCAs: pool,
MinVersion: tls.VersionTLS12,
GetClientCertificate: func(*tls.CertificateRequestInfo) (*tls.Certificate, error) {
c, err := tls.LoadX509KeyPair(certFile, keyFile)
return &c, err
},
}}
return &KubeClient{base: strings.TrimSuffix(apiServer, "/"), http: &http.Client{Transport: tr}}, nil
}
type kubeObject struct {
APIVersion string `json:"apiVersion,omitempty"`
Kind string `json:"kind,omitempty"`
Metadata struct {
Name string `json:"name,omitempty"`
Namespace string `json:"namespace,omitempty"`
ResourceVersion string `json:"resourceVersion,omitempty"`
Annotations map[string]string `json:"annotations,omitempty"`
} `json:"metadata"`
Data map[string]string `json:"data,omitempty"`
}
// do sends one request and returns the status. A non-2xx status is returned
// without an error so callers can map the codes they expect.
func (k *KubeClient) do(ctx context.Context, method, path string, body, out any) (int, error) {
ctx, cancel := context.WithTimeout(ctx, kubeCallTimeout)
defer cancel()
var rd io.Reader
if body != nil {
b, err := json.Marshal(body)
if err != nil {
return 0, err
}
rd = bytes.NewReader(b)
}
req, err := http.NewRequestWithContext(ctx, method, k.base+path, rd)
if err != nil {
return 0, err
}
req.Header.Set("Accept", "application/json")
if body != nil {
req.Header.Set("Content-Type", "application/json")
}
resp, err := k.http.Do(req)
if err != nil {
return 0, err
}
defer func() { _ = resp.Body.Close() }()
raw, err := io.ReadAll(io.LimitReader(resp.Body, maxKubeBody))
if err != nil {
return resp.StatusCode, err
}
if resp.StatusCode/100 == 2 && out != nil {
if err := json.Unmarshal(raw, out); err != nil {
return resp.StatusCode, fmt.Errorf("decode %s %s: %w", method, path, err)
}
}
return resp.StatusCode, nil
}
func statusErr(method, path string, code int) error {
return fmt.Errorf("%s %s: unexpected status %d", method, path, code)
}
// GetServiceAccount implements ServiceAccountGetter.
func (k *KubeClient) GetServiceAccount(ctx context.Context, namespace, name string) (*ServiceAccount, error) {
path := "/api/v1/namespaces/" + url.PathEscape(namespace) + "/serviceaccounts/" + url.PathEscape(name)
var o kubeObject
switch code, err := k.do(ctx, http.MethodGet, path, nil, &o); {
case err != nil:
return nil, err
case code == http.StatusNotFound:
return nil, ErrServiceAccountNotFound
case code != http.StatusOK:
return nil, statusErr(http.MethodGet, path, code)
}
return &ServiceAccount{Annotations: o.Metadata.Annotations}, nil
}
func cmPath(namespace, name string) string {
p := "/api/v1/namespaces/" + url.PathEscape(namespace) + "/configmaps"
if name != "" {
p += "/" + url.PathEscape(name)
}
return p
}
func cmBody(cm ConfigMap) kubeObject {
o := kubeObject{APIVersion: "v1", Kind: "ConfigMap", Data: cm.Data}
o.Metadata.Name, o.Metadata.Namespace, o.Metadata.ResourceVersion = cm.Name, cm.Namespace, cm.ResourceVersion
return o
}
// Get implements ConfigMapStore.
func (k *KubeClient) Get(ctx context.Context, namespace, name string) (*ConfigMap, error) {
path := cmPath(namespace, name)
var o kubeObject
switch code, err := k.do(ctx, http.MethodGet, path, nil, &o); {
case err != nil:
return nil, err
case code == http.StatusNotFound:
return nil, ErrNotFound
case code != http.StatusOK:
return nil, statusErr(http.MethodGet, path, code)
}
return &ConfigMap{Namespace: namespace, Name: name, Data: o.Data, ResourceVersion: o.Metadata.ResourceVersion}, nil
}
// Create implements ConfigMapStore.
func (k *KubeClient) Create(ctx context.Context, cm ConfigMap) error {
path := cmPath(cm.Namespace, "")
switch code, err := k.do(ctx, http.MethodPost, path, cmBody(cm), nil); {
case err != nil:
return err
case code == http.StatusConflict:
return ErrAlreadyExists
case code/100 != 2:
return statusErr(http.MethodPost, path, code)
}
return nil
}
// Update implements ConfigMapStore.
func (k *KubeClient) Update(ctx context.Context, cm ConfigMap) error {
path := cmPath(cm.Namespace, cm.Name)
switch code, err := k.do(ctx, http.MethodPut, path, cmBody(cm), nil); {
case err != nil:
return err
case code == http.StatusConflict:
return ErrConflict
case code == http.StatusNotFound:
return ErrNotFound
case code/100 != 2:
return statusErr(http.MethodPut, path, code)
}
return nil
}
// List implements NamespaceSource.
func (k *KubeClient) List(ctx context.Context) ([]string, error) {
const path = "/api/v1/namespaces"
var l struct {
Items []kubeObject `json:"items"`
}
if code, err := k.do(ctx, http.MethodGet, path, nil, &l); err != nil {
return nil, err
} else if code != http.StatusOK {
return nil, statusErr(http.MethodGet, path, code)
}
names := make([]string, 0, len(l.Items))
for _, it := range l.Items {
names = append(names, it.Metadata.Name)
}
return names, nil
}
// Watch implements NamespaceSource. The stream replays existing namespaces as
// ADDED first, which is harmless, and is re-opened when the server ends it.
// The channel closes only when ctx ends.
func (k *KubeClient) Watch(ctx context.Context) (<-chan string, error) {
out := make(chan string, 16)
go func() {
defer close(out)
for ctx.Err() == nil {
k.watchOnce(ctx, out)
select {
case <-ctx.Done():
case <-time.After(watchRetryDelay):
}
}
}()
return out, nil
}
func (k *KubeClient) watchOnce(ctx context.Context, out chan<- string) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, k.base+"/api/v1/namespaces?watch=true&timeoutSeconds=300", nil)
if err != nil {
return
}
resp, err := k.http.Do(req)
if err != nil {
return
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusOK {
return
}
dec := json.NewDecoder(resp.Body)
for {
var ev struct {
Type string `json:"type"`
Object kubeObject `json:"object"`
}
if err := dec.Decode(&ev); err != nil {
if !errors.Is(err, io.EOF) && ctx.Err() == nil {
return
}
return
}
if ev.Type != "ADDED" || ev.Object.Metadata.Name == "" {
continue
}
select {
case out <- ev.Object.Metadata.Name:
case <-ctx.Done():
return
}
}
}
@@ -1,191 +0,0 @@
package podidentity
import (
"context"
"crypto/tls"
"encoding/json"
"encoding/pem"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// fakeAPI is a TLS API server that requires the webhook's client certificate.
type fakeAPI struct {
srv *httptest.Server
handler http.HandlerFunc
seenCN string
}
func newFakeAPI(t *testing.T) (*fakeAPI, *KubeClient) {
t.Helper()
f := &fakeAPI{}
f.srv = httptest.NewUnstartedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
f.seenCN = r.TLS.PeerCertificates[0].Subject.CommonName
f.handler(w, r)
}))
f.srv.TLS = &tls.Config{ClientAuth: tls.RequireAnyClientCert, MinVersion: tls.VersionTLS12}
f.srv.StartTLS()
t.Cleanup(f.srv.Close)
cfg, _ := certTestConfig(t)
certs, err := EnsureCerts(cfg)
require.NoError(t, err)
ca := filepath.Join(t.TempDir(), "ca.crt")
require.NoError(t, os.WriteFile(ca, pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: f.srv.Certificate().Raw}), 0o644))
k, err := NewKubeClient(f.srv.URL+"/", ca, certs.ClientCert, certs.ClientKey)
require.NoError(t, err)
return f, k
}
func reply(w http.ResponseWriter, code int, body any) {
w.WriteHeader(code)
if body != nil {
_ = json.NewEncoder(w).Encode(body)
}
}
func TestKubeClient_PresentsTheClientCertificate(t *testing.T) {
f, k := newFakeAPI(t)
f.handler = func(w http.ResponseWriter, _ *http.Request) {
reply(w, 200, map[string]any{"metadata": map[string]any{}})
}
_, err := k.GetServiceAccount(context.Background(), "app", "sa")
require.NoError(t, err)
assert.Equal(t, ClientCN, f.seenCN)
}
func TestKubeClient_GetServiceAccount(t *testing.T) {
f, k := newFakeAPI(t)
f.handler = func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/api/v1/namespaces/app/serviceaccounts/sa":
reply(w, 200, map[string]any{"metadata": map[string]any{"annotations": map[string]string{"a": "b"}}})
case "/api/v1/namespaces/app/serviceaccounts/gone":
reply(w, 404, nil)
default:
reply(w, 500, nil)
}
}
sa, err := k.GetServiceAccount(context.Background(), "app", "sa")
require.NoError(t, err)
assert.Equal(t, map[string]string{"a": "b"}, sa.Annotations)
_, err = k.GetServiceAccount(context.Background(), "app", "gone")
assert.ErrorIs(t, err, ErrServiceAccountNotFound)
_, err = k.GetServiceAccount(context.Background(), "app", "boom")
assert.Error(t, err)
assert.NotErrorIs(t, err, ErrServiceAccountNotFound)
}
func TestKubeClient_ConfigMapCalls(t *testing.T) {
f, k := newFakeAPI(t)
var gotBody kubeObject
f.handler = func(w http.ResponseWriter, r *http.Request) {
switch {
case r.Method == http.MethodGet && r.URL.Path == "/api/v1/namespaces/app/configmaps/ca":
reply(w, 200, map[string]any{"metadata": map[string]any{"resourceVersion": "7"}, "data": map[string]string{"k": "v"}})
case r.Method == http.MethodGet:
reply(w, 404, nil)
case r.Method == http.MethodPost && r.URL.Path == "/api/v1/namespaces/dup/configmaps":
reply(w, 409, nil)
case r.Method == http.MethodPost:
_ = json.NewDecoder(r.Body).Decode(&gotBody)
reply(w, 201, nil)
case r.Method == http.MethodPut && r.URL.Path == "/api/v1/namespaces/stale/configmaps/ca":
reply(w, 409, nil)
case r.Method == http.MethodPut && r.URL.Path == "/api/v1/namespaces/gone/configmaps/ca":
reply(w, 404, nil)
case r.Method == http.MethodPut && r.URL.Path == "/api/v1/namespaces/bad/configmaps/ca":
reply(w, 500, nil)
default:
reply(w, 200, nil)
}
}
ctx := context.Background()
cm, err := k.Get(ctx, "app", "ca")
require.NoError(t, err)
assert.Equal(t, "7", cm.ResourceVersion)
assert.Equal(t, "v", cm.Data["k"])
_, err = k.Get(ctx, "app", "missing")
assert.ErrorIs(t, err, ErrNotFound)
require.NoError(t, k.Create(ctx, ConfigMap{Namespace: "app", Name: "ca", Data: map[string]string{"x": "y"}}))
assert.Equal(t, "ca", gotBody.Metadata.Name)
assert.Equal(t, "y", gotBody.Data["x"])
assert.ErrorIs(t, k.Create(ctx, ConfigMap{Namespace: "dup", Name: "ca"}), ErrAlreadyExists)
require.NoError(t, k.Update(ctx, ConfigMap{Namespace: "app", Name: "ca"}))
assert.ErrorIs(t, k.Update(ctx, ConfigMap{Namespace: "stale", Name: "ca"}), ErrConflict)
assert.ErrorIs(t, k.Update(ctx, ConfigMap{Namespace: "gone", Name: "ca"}), ErrNotFound)
err = k.Update(ctx, ConfigMap{Namespace: "bad", Name: "ca"})
assert.Error(t, err)
assert.NotErrorIs(t, err, ErrConflict)
}
func TestKubeClient_ListNamespaces(t *testing.T) {
f, k := newFakeAPI(t)
ok := true
f.handler = func(w http.ResponseWriter, _ *http.Request) {
if !ok {
reply(w, 500, nil)
return
}
reply(w, 200, map[string]any{"items": []map[string]any{{"metadata": map[string]string{"name": "a"}}, {"metadata": map[string]string{"name": "b"}}}})
}
names, err := k.List(context.Background())
require.NoError(t, err)
assert.Equal(t, []string{"a", "b"}, names)
ok = false
_, err = k.List(context.Background())
assert.Error(t, err)
}
func TestKubeClient_WatchEmitsAddedNamespacesOnly(t *testing.T) {
f, k := newFakeAPI(t)
f.handler = func(w http.ResponseWriter, _ *http.Request) {
enc := json.NewEncoder(w)
for _, ev := range []struct{ t, n string }{{"ADDED", "one"}, {"MODIFIED", "two"}, {"DELETED", "three"}, {"ADDED", "four"}} {
_ = enc.Encode(map[string]any{"type": ev.t, "object": map[string]any{"metadata": map[string]string{"name": ev.n}}})
}
}
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
ch, err := k.Watch(ctx)
require.NoError(t, err)
got := []string{<-ch, <-ch}
assert.Equal(t, []string{"one", "four"}, got)
cancel()
require.Eventually(t, func() bool {
select {
case _, open := <-ch:
return !open
default:
return false
}
}, 5*time.Second, 10*time.Millisecond, "the channel closes when the context ends")
}
func TestNewKubeClient_RejectsBadInputs(t *testing.T) {
cfg, _ := certTestConfig(t)
certs, err := EnsureCerts(cfg)
require.NoError(t, err)
dir := t.TempDir()
empty := filepath.Join(dir, "empty.crt")
require.NoError(t, os.WriteFile(empty, []byte("not pem"), 0o644))
_, err = NewKubeClient("https://x", filepath.Join(dir, "absent.crt"), certs.ClientCert, certs.ClientKey)
assert.Error(t, err)
_, err = NewKubeClient("https://x", empty, certs.ClientCert, certs.ClientKey)
assert.Error(t, err)
_, err = NewKubeClient("https://x", cfg.ServerCACert, filepath.Join(dir, "no.crt"), certs.ClientKey)
assert.Error(t, err)
}
@@ -1,52 +0,0 @@
package podidentity
import (
"bytes"
"encoding/json"
)
// ClusterRoleName names the ClusterRole and ClusterRoleBinding the webhook runs as.
const ClusterRoleName = "spinifex:eks-pod-identity-webhook"
// RenderManifests returns the cluster objects the webhook needs, as JSON
// documents separated by "---" (valid YAML for the k3s manifest deployer): its
// least-privilege ClusterRole, the binding to its client-certificate user, and
// the MutatingWebhookConfiguration. The output is deterministic so every server
// writes the same bytes.
func RenderManifests(webhook WebhookConfig) ([]byte, error) {
cfg, err := RenderWebhookConfiguration(webhook)
if err != nil {
return nil, err
}
role := map[string]any{
"apiVersion": "rbac.authorization.k8s.io/v1",
"kind": "ClusterRole",
"metadata": map[string]any{"name": ClusterRoleName},
"rules": []map[string]any{
{"apiGroups": []string{""}, "resources": []string{"serviceaccounts"}, "verbs": []string{"get"}},
{"apiGroups": []string{""}, "resources": []string{"namespaces"}, "verbs": []string{"list", "watch"}},
{"apiGroups": []string{""}, "resources": []string{"configmaps"}, "verbs": []string{"create"}},
{"apiGroups": []string{""}, "resources": []string{"configmaps"}, "resourceNames": []string{CAConfigMapName}, "verbs": []string{"get", "update"}},
},
}
binding := map[string]any{
"apiVersion": "rbac.authorization.k8s.io/v1",
"kind": "ClusterRoleBinding",
"metadata": map[string]any{"name": ClusterRoleName},
"roleRef": map[string]any{"apiGroup": "rbac.authorization.k8s.io", "kind": "ClusterRole", "name": ClusterRoleName},
"subjects": []map[string]any{{"apiGroup": "rbac.authorization.k8s.io", "kind": "User", "name": ClientCN}},
}
var out bytes.Buffer
for i, doc := range []any{role, binding, json.RawMessage(cfg)} {
b, err := json.MarshalIndent(doc, "", " ")
if err != nil {
return nil, err
}
if i > 0 {
out.WriteString("---\n")
}
out.Write(b)
out.WriteByte('\n')
}
return out.Bytes(), nil
}
-307
View File
@@ -1,307 +0,0 @@
package podidentity
import (
"cmp"
"context"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"io"
"log/slog"
"math/rand/v2"
"net/http"
"strconv"
"strings"
)
const (
envRoleARN = "AWS_ROLE_ARN"
envTokenFile = "AWS_WEB_IDENTITY_TOKEN_FILE" //nolint:gosec // variable name, not a credential
envRegion = "AWS_REGION"
envDefRegion = "AWS_DEFAULT_REGION"
envRegionalS = "AWS_STS_REGIONAL_ENDPOINTS"
envSTSURL = "AWS_ENDPOINT_URL_STS"
envCABundle = "AWS_CA_BUNDLE"
envSSLCert = "SSL_CERT_FILE"
maxReviewSize = 8 << 20
)
type podObject struct {
Metadata struct {
Name string `json:"name"`
GenerateName string `json:"generateName"`
Namespace string `json:"namespace"`
Annotations map[string]string `json:"annotations"`
} `json:"metadata"`
Spec struct {
ServiceAccountName string `json:"serviceAccountName"`
ServiceAccount string `json:"serviceAccount"`
Containers []containerObject `json:"containers"`
InitContainers []containerObject `json:"initContainers"`
Volumes []nameOnly `json:"volumes"`
} `json:"spec"`
}
type nameOnly struct {
Name string `json:"name"`
}
type containerObject struct {
Name string `json:"name"`
Env []nameOnly `json:"env"`
VolumeMounts []nameOnly `json:"volumeMounts"`
}
type envVar struct {
Name string `json:"name"`
Value string `json:"value"`
}
type volumeMount struct {
Name string `json:"name"`
MountPath string `json:"mountPath"`
ReadOnly bool `json:"readOnly"`
}
// injection is what one pod gets, computed from its ServiceAccount and annotations.
type injection struct {
roleARN string
regional bool
skip map[string]bool
}
// Mutate returns the patch for one pod creation request. It never denies a pod.
func (m *Mutator) Mutate(ctx context.Context, req AdmissionRequest) AdmissionResponse {
resp := AdmissionResponse{UID: req.UID, Allowed: true}
if req.Operation != "CREATE" {
return resp
}
var pod podObject
if err := json.Unmarshal(req.Object, &pod); err != nil {
m.note(ctx, slog.LevelWarn, req.Namespace, "", ReasonDecode, "error", err)
return resp
}
ns := cmp.Or(req.Namespace, pod.Metadata.Namespace)
name := cmp.Or(pod.Metadata.Name, pod.Metadata.GenerateName)
saName := cmp.Or(pod.Spec.ServiceAccountName, pod.Spec.ServiceAccount, "default")
sa, err := m.sas.GetServiceAccount(ctx, ns, saName)
if err != nil {
if errors.Is(err, ErrServiceAccountNotFound) {
m.note(ctx, slog.LevelInfo, ns, name, ReasonServiceAccountMissing, "serviceaccount", saName)
} else {
m.note(ctx, slog.LevelWarn, ns, name, ReasonServiceAccountLookup, "serviceaccount", saName, "error", err)
}
return resp
}
dom := cmp.Or(m.cfg.AnnotationDomain, DefaultAnnotationDomain)
roleARN := sa.Annotations[dom+"/role-arn"]
if roleARN == "" {
return resp
}
in := injection{
roleARN: roleARN,
regional: sa.Annotations[dom+"/sts-regional-endpoints"] == "true",
skip: map[string]bool{},
}
for c := range strings.SplitSeq(pod.Metadata.Annotations[dom+"/skip-containers"], ",") {
if c = strings.TrimSpace(c); c != "" {
in.skip[c] = true
}
}
var ops []PatchOp
patched := 0
for _, set := range []struct {
field string
list []containerObject
}{{"containers", pod.Spec.Containers}, {"initContainers", pod.Spec.InitContainers}} {
for i, c := range set.list {
if in.skip[c.Name] {
m.note(ctx, slog.LevelInfo, ns, name, ReasonSkipContainer, "container", c.Name)
continue
}
cops := m.containerOps(fmt.Sprintf("/spec/%s/%d", set.field, i), c, in)
if cops == nil {
m.note(ctx, slog.LevelInfo, ns, name, ReasonAlreadyConfigured, "container", c.Name)
continue
}
ops = append(ops, cops...)
patched++
}
}
if patched == 0 {
return resp
}
ops = append(ops, volumeOps(pod, tokenExpiry(sa, pod.Metadata.Annotations, dom), cmp.Or(sa.Annotations[dom+"/audience"], DefaultAudience))...)
resp.Patch = ops
m.log.InfoContext(ctx, "pod identity injected", "namespace", ns, "pod", name, "serviceaccount", saName, "containers", patched)
return resp
}
// note logs a skip or failure with every attribute inline, not on a derived logger.
func (m *Mutator) note(ctx context.Context, lvl slog.Level, ns, pod, reason string, kv ...any) {
attrs := append([]any{"namespace", ns, "pod", pod, "reason", reason}, kv...)
m.log.Log(ctx, lvl, "pod identity skipped", attrs...)
}
// containerOps patches one container, or returns nil when it already carries
// the role, region and regional STS settings.
func (m *Mutator) containerOps(path string, c containerObject, in injection) []PatchOp {
has := map[string]bool{}
for _, e := range c.Env {
has[e.Name] = true
}
hasRole := has[envRoleARN] || has[envTokenFile]
hasRegion := m.cfg.Region == "" || has[envRegion] || has[envDefRegion]
hasSTS := has[envRegionalS]
if hasRole && hasRegion && hasSTS {
return nil
}
var vars []envVar
add := func(name, value string) { vars = append(vars, envVar{name, value}) }
if !hasRole {
add(envRoleARN, in.roleARN)
add(envTokenFile, TokenMountPath+"/"+TokenPath)
}
if !hasSTS && in.regional {
add(envRegionalS, "regional")
}
if !hasRegion {
add(envRegion, m.cfg.Region)
add(envDefRegion, m.cfg.Region)
}
caPath := CAMountPath + "/" + CABundleKey
for _, x := range []struct{ name, value string }{
{envSTSURL, m.cfg.STSEndpoint}, {envCABundle, caPath}, {envSSLCert, caPath},
} {
if x.value != "" && !has[x.name] {
add(x.name, x.value)
}
}
mounted := map[string]bool{}
for _, v := range c.VolumeMounts {
mounted[v.Name] = true
}
var mounts []volumeMount
if !mounted[TokenVolumeName] {
mounts = append(mounts, volumeMount{TokenVolumeName, TokenMountPath, true})
}
if !mounted[CAVolumeName] {
mounts = append(mounts, volumeMount{CAVolumeName, CAMountPath, true})
}
ops := appendOps(nil, path+"/env", len(c.Env) > 0, vars)
ops = appendOps(ops, path+"/volumeMounts", len(c.VolumeMounts) > 0, mounts)
if ops == nil {
ops = []PatchOp{}
}
return ops
}
// appendOps adds items to an array, creating the array first when it is absent
// (an add to "/-" on a missing array is not a valid JSON patch).
func appendOps[T any](ops []PatchOp, path string, exists bool, items []T) []PatchOp {
switch {
case len(items) == 0:
return ops
case !exists:
return append(ops, PatchOp{Op: "add", Path: path, Value: items})
}
for _, it := range items {
ops = append(ops, PatchOp{Op: "add", Path: path + "/-", Value: it})
}
return ops
}
func volumeOps(pod podObject, expiry int64, audience string) []PatchOp {
have := map[string]bool{}
for _, v := range pod.Spec.Volumes {
have[v.Name] = true
}
var vols []map[string]any
if !have[TokenVolumeName] {
vols = append(vols, map[string]any{
"name": TokenVolumeName,
"projected": map[string]any{"sources": []map[string]any{{
"serviceAccountToken": map[string]any{"audience": audience, "expirationSeconds": expiry, "path": TokenPath},
}}},
})
}
if !have[CAVolumeName] {
vols = append(vols, map[string]any{"name": CAVolumeName, "configMap": map[string]any{"name": CAConfigMapName}})
}
return appendOps(nil, "/spec/volumes", len(pod.Spec.Volumes) > 0, vols)
}
// tokenExpiry takes the pod annotation over the ServiceAccount's, clamps it to
// the allowed range and jitters the default so tokens do not all expire together.
func tokenExpiry(sa *ServiceAccount, podAnn map[string]string, dom string) int64 {
key := dom + "/token-expiration"
for _, raw := range []string{podAnn[key], sa.Annotations[key]} {
if raw == "" {
continue
}
if n, err := strconv.ParseInt(raw, 10, 64); err == nil {
return min(max(n, MinTokenExpiration), MaxTokenExpiration)
}
}
return DefaultMinTokenExpiration + rand.Int64N(MaxTokenExpiration-DefaultMinTokenExpiration+1) //nolint:gosec // jitter, not security
}
type admissionReview struct {
APIVersion string `json:"apiVersion"`
Kind string `json:"kind"`
Request *AdmissionRequest `json:"request,omitempty"`
Response *admissionResponse `json:"response,omitempty"`
}
type admissionResponse struct {
UID string `json:"uid"`
Allowed bool `json:"allowed"`
PatchType string `json:"patchType,omitempty"`
Patch string `json:"patch,omitempty"`
}
// ServeHTTP serves an AdmissionReview v1 request.
func (m *Mutator) ServeHTTP(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "POST required", http.StatusMethodNotAllowed)
return
}
body, err := io.ReadAll(http.MaxBytesReader(w, r.Body, maxReviewSize))
if err != nil {
http.Error(w, "read body", http.StatusBadRequest)
return
}
var in admissionReview
if err := json.Unmarshal(body, &in); err != nil || in.Request == nil {
http.Error(w, "not an AdmissionReview", http.StatusBadRequest)
return
}
res := m.Mutate(r.Context(), *in.Request)
out := admissionReview{
APIVersion: "admission.k8s.io/v1",
Kind: "AdmissionReview",
Response: &admissionResponse{UID: res.UID, Allowed: res.Allowed},
}
if len(res.Patch) > 0 {
patch, err := json.Marshal(res.Patch)
if err != nil {
http.Error(w, "encode patch", http.StatusInternalServerError)
return
}
out.Response.PatchType = "JSONPatch"
out.Response.Patch = base64.StdEncoding.EncodeToString(patch)
}
w.Header().Set("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(out); err != nil {
m.log.Warn("pod identity response write failed", "error", err)
}
}
@@ -1,630 +0,0 @@
package podidentity
import (
"bytes"
"context"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"log/slog"
"maps"
"net/http"
"net/http/httptest"
"strconv"
"strings"
"sync"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
const (
testRoleARN = "arn:aws:iam::000000000001:role/app"
testRegion = "ap-southeast-2"
testSTS = "https://sts.cluster.example:9999"
annRole = "eks.amazonaws.com/role-arn"
annAudience = "eks.amazonaws.com/audience"
annExpiry = "eks.amazonaws.com/token-expiration"
annRegionalS = "eks.amazonaws.com/sts-regional-endpoints"
annSkip = "eks.amazonaws.com/skip-containers"
)
type fakeSAs struct {
sas map[string]*ServiceAccount
err error
}
func (f *fakeSAs) GetServiceAccount(_ context.Context, ns, name string) (*ServiceAccount, error) {
if f.err != nil {
return nil, f.err
}
if sa, ok := f.sas[ns+"/"+name]; ok {
return sa, nil
}
return nil, ErrServiceAccountNotFound
}
// logRecords captures slog records for assertions.
type logRecords struct {
mu sync.Mutex
recs []map[string]string
}
func (l *logRecords) Enabled(context.Context, slog.Level) bool { return true }
func (l *logRecords) WithAttrs([]slog.Attr) slog.Handler { return l }
func (l *logRecords) WithGroup(string) slog.Handler { return l }
func (l *logRecords) Handle(_ context.Context, r slog.Record) error {
m := map[string]string{"msg": r.Message, "level": r.Level.String()}
r.Attrs(func(a slog.Attr) bool {
m[a.Key] = fmt.Sprint(a.Value.Any())
return true
})
l.mu.Lock()
l.recs = append(l.recs, m)
l.mu.Unlock()
return nil
}
// withReason returns the records at warn level or above that carry reason.
func (l *logRecords) withReason(reason string) []map[string]string {
l.mu.Lock()
defer l.mu.Unlock()
var out []map[string]string
for _, r := range l.recs {
if r["reason"] == reason {
out = append(out, r)
}
}
return out
}
func newMutator(sas ...*ServiceAccount) (*Mutator, *logRecords, *fakeSAs) {
f := &fakeSAs{sas: map[string]*ServiceAccount{}}
for i, sa := range sas {
f.sas[fmt.Sprintf("app/sa%d", i)] = sa
}
rec := &logRecords{}
return NewMutator(Config{Region: testRegion, STSEndpoint: testSTS}, f, slog.New(rec)), rec, f
}
func roleSA(extra map[string]string) *ServiceAccount {
ann := map[string]string{annRole: testRoleARN}
maps.Copy(ann, extra)
return &ServiceAccount{Annotations: ann}
}
type env struct {
Name string `json:"name"`
Value string `json:"value"`
}
func ctr(name string, envs ...env) map[string]any {
c := map[string]any{"name": name, "image": "registry.example/app:1"}
if len(envs) > 0 {
var e []map[string]any
for _, v := range envs {
e = append(e, map[string]any{"name": v.Name, "value": v.Value})
}
c["env"] = e
}
return c
}
func podOf(sa string, ann map[string]string, containers ...map[string]any) map[string]any {
spec := map[string]any{"containers": containers}
if sa != "" {
spec["serviceAccountName"] = sa
}
meta := map[string]any{"name": "web-1", "namespace": "app"}
if ann != nil {
meta["annotations"] = ann
}
return map[string]any{"apiVersion": "v1", "kind": "Pod", "metadata": meta, "spec": spec}
}
// The pod after the patch, decoded for assertions.
type outPod struct {
Spec struct {
Containers []outCtr `json:"containers"`
InitContainers []outCtr `json:"initContainers"`
Volumes []struct {
Name string `json:"name"`
Projected *struct {
Sources []struct {
SAT *struct {
Audience string `json:"audience"`
ExpirationSeconds int64 `json:"expirationSeconds"`
Path string `json:"path"`
} `json:"serviceAccountToken"`
} `json:"sources"`
} `json:"projected"`
ConfigMap *struct {
Name string `json:"name"`
} `json:"configMap"`
} `json:"volumes"`
} `json:"spec"`
}
type outCtr struct {
Name string `json:"name"`
Env []env `json:"env"`
VM []struct {
Name string `json:"name"`
MountPath string `json:"mountPath"`
ReadOnly bool `json:"readOnly"`
} `json:"volumeMounts"`
}
func (c outCtr) envMap() map[string]string {
m := map[string]string{}
for _, e := range c.Env {
m[e.Name] = e.Value
}
return m
}
func (c outCtr) mounts(name string) int {
n := 0
for _, v := range c.VM {
if v.Name == name {
n++
}
}
return n
}
func (p outPod) volumes(name string) int {
n := 0
for _, v := range p.Spec.Volumes {
if v.Name == name {
n++
}
}
return n
}
func (p outPod) tokenExpiry(t *testing.T) (aud string, exp int64) {
t.Helper()
for _, v := range p.Spec.Volumes {
if v.Name == TokenVolumeName && v.Projected != nil && len(v.Projected.Sources) == 1 && v.Projected.Sources[0].SAT != nil {
s := v.Projected.Sources[0].SAT
assert.Equal(t, TokenPath, s.Path)
return s.Audience, s.ExpirationSeconds
}
}
require.Failf(t, "no projected token volume", "volumes: %+v", p.Spec.Volumes)
return "", 0
}
// run mutates pod in namespace app and returns the patched pod and the response.
func run(t *testing.T, m *Mutator, pod map[string]any) (outPod, AdmissionResponse) {
t.Helper()
raw, err := json.Marshal(pod)
require.NoError(t, err)
resp := m.Mutate(context.Background(), AdmissionRequest{UID: "u1", Operation: "CREATE", Namespace: "app", Object: raw})
require.True(t, resp.Allowed, "the mutator must never deny a pod")
assert.Equal(t, "u1", resp.UID)
patched := applyPatch(t, raw, resp.Patch)
var out outPod
require.NoError(t, json.Unmarshal(patched, &out))
return out, resp
}
func TestMutate_InjectsTheAWSSetAndTheSpinifexExtras(t *testing.T) {
m, _, _ := newMutator(roleSA(map[string]string{annRegionalS: "true"}))
out, _ := run(t, m, podOf("sa0", nil, ctr("app")))
aud, exp := out.tokenExpiry(t)
assert.Equal(t, "sts.amazonaws.com", aud)
assert.GreaterOrEqual(t, exp, DefaultMinTokenExpiration)
assert.LessOrEqual(t, exp, MaxTokenExpiration)
require.Len(t, out.Spec.Containers, 1)
c := out.Spec.Containers[0]
assert.Equal(t, map[string]string{
"AWS_ROLE_ARN": testRoleARN,
"AWS_WEB_IDENTITY_TOKEN_FILE": TokenMountPath + "/" + TokenPath,
"AWS_STS_REGIONAL_ENDPOINTS": "regional",
"AWS_REGION": testRegion,
"AWS_DEFAULT_REGION": testRegion,
"AWS_ENDPOINT_URL_STS": testSTS,
"AWS_CA_BUNDLE": CAMountPath + "/" + CABundleKey,
"SSL_CERT_FILE": CAMountPath + "/" + CABundleKey,
}, c.envMap())
assert.Equal(t, 1, c.mounts(TokenVolumeName))
assert.Equal(t, 1, c.mounts(CAVolumeName))
for _, v := range c.VM {
assert.True(t, v.ReadOnly, "mount %s must be read-only", v.Name)
switch v.Name {
case TokenVolumeName:
assert.Equal(t, TokenMountPath, v.MountPath)
case CAVolumeName:
assert.Equal(t, CAMountPath, v.MountPath)
}
}
assert.Equal(t, 1, out.volumes(CAVolumeName))
for _, v := range out.Spec.Volumes {
if v.Name == CAVolumeName {
require.NotNil(t, v.ConfigMap)
assert.Equal(t, CAConfigMapName, v.ConfigMap.Name)
}
}
}
func TestMutate_RegionalSTSOnlyWhenTheServiceAccountAsksForIt(t *testing.T) {
m, _, _ := newMutator(roleSA(nil), roleSA(map[string]string{annRegionalS: "false"}))
for _, sa := range []string{"sa0", "sa1"} {
out, _ := run(t, m, podOf(sa, nil, ctr("app")))
require.Len(t, out.Spec.Containers, 1, sa)
assert.NotContains(t, out.Spec.Containers[0].envMap(), "AWS_STS_REGIONAL_ENDPOINTS", sa)
assert.Equal(t, testRoleARN, out.Spec.Containers[0].envMap()["AWS_ROLE_ARN"], sa)
}
}
func TestMutate_KeepsACallersOwnTrustSettings(t *testing.T) {
m, _, _ := newMutator(roleSA(nil))
out, _ := run(t, m, podOf("sa0", nil,
ctr("a", env{"SSL_CERT_FILE", "/etc/ssl/own.pem"}),
ctr("b", env{"AWS_CA_BUNDLE", "/etc/ssl/own-aws.pem"}, env{"AWS_ENDPOINT_URL_STS", "https://own"})))
a := out.Spec.Containers[0].envMap()
assert.Equal(t, "/etc/ssl/own.pem", a["SSL_CERT_FILE"], "an existing SSL_CERT_FILE replaces the image trust store and must be left alone")
assert.Equal(t, CAMountPath+"/"+CABundleKey, a["AWS_CA_BUNDLE"])
b := out.Spec.Containers[1].envMap()
assert.Equal(t, "/etc/ssl/own-aws.pem", b["AWS_CA_BUNDLE"])
assert.Equal(t, "https://own", b["AWS_ENDPOINT_URL_STS"])
assert.Equal(t, testRoleARN, b["AWS_ROLE_ARN"])
}
func TestMutate_InjectsInitContainersToo(t *testing.T) {
m, _, _ := newMutator(roleSA(nil))
pod := podOf("sa0", nil, ctr("app"))
pod["spec"].(map[string]any)["initContainers"] = []map[string]any{ctr("setup")}
out, _ := run(t, m, pod)
require.Len(t, out.Spec.InitContainers, 1)
assert.Equal(t, testRoleARN, out.Spec.InitContainers[0].envMap()["AWS_ROLE_ARN"])
assert.Equal(t, 1, out.Spec.InitContainers[0].mounts(TokenVolumeName))
}
func TestMutate_LeavesAContainerWithAllThreeKeySetsUntouched(t *testing.T) {
m, rec, _ := newMutator(roleSA(nil))
complete := ctr("done",
env{"AWS_ROLE_ARN", "arn:aws:iam::000000000001:role/own"},
env{"AWS_WEB_IDENTITY_TOKEN_FILE", "/own/token"},
env{"AWS_REGION", "us-east-1"},
env{"AWS_STS_REGIONAL_ENDPOINTS", "legacy"})
out, _ := run(t, m, podOf("sa0", nil, complete, ctr("plain")))
got := out.Spec.Containers[0]
assert.Len(t, got.Env, 4, "a complete container must not gain variables")
assert.Zero(t, got.mounts(TokenVolumeName)+got.mounts(CAVolumeName))
assert.Equal(t, testRoleARN, out.Spec.Containers[1].envMap()["AWS_ROLE_ARN"], "its neighbour is still injected")
assert.NotEmpty(t, rec.withReason(ReasonAlreadyConfigured))
}
func TestMutate_AddsOnlyTheMissingKeysToAPartiallyConfiguredContainer(t *testing.T) {
m, _, _ := newMutator(roleSA(map[string]string{annRegionalS: "true"}))
partial := ctr("partial", env{"AWS_ROLE_ARN", "arn:aws:iam::000000000001:role/own"})
out, _ := run(t, m, podOf("sa0", nil, partial))
e := out.Spec.Containers[0].envMap()
assert.Equal(t, "arn:aws:iam::000000000001:role/own", e["AWS_ROLE_ARN"], "the caller's role is kept")
assert.NotContains(t, e, "AWS_WEB_IDENTITY_TOKEN_FILE", "role keys already defined are not completed")
assert.Equal(t, testRegion, e["AWS_REGION"])
assert.Equal(t, testRegion, e["AWS_DEFAULT_REGION"])
assert.Equal(t, "regional", e["AWS_STS_REGIONAL_ENDPOINTS"])
assert.Equal(t, 1, out.Spec.Containers[0].mounts(TokenVolumeName))
count := 0
for _, kv := range out.Spec.Containers[0].Env {
if kv.Name == "AWS_ROLE_ARN" {
count++
}
}
assert.Equal(t, 1, count)
}
func TestMutate_DoesNotDuplicateAnExistingTokenVolume(t *testing.T) {
m, _, _ := newMutator(roleSA(nil))
pod := podOf("sa0", nil, ctr("app"))
pod["spec"].(map[string]any)["volumes"] = []map[string]any{{
"name": TokenVolumeName,
"projected": map[string]any{"sources": []map[string]any{{
"serviceAccountToken": map[string]any{"audience": "own", "expirationSeconds": 7200, "path": "token"},
}}},
}}
out, _ := run(t, m, pod)
assert.Equal(t, 1, out.volumes(TokenVolumeName))
aud, exp := out.tokenExpiry(t)
assert.Equal(t, "own", aud, "the caller's volume is kept")
assert.Equal(t, int64(7200), exp)
assert.Equal(t, 1, out.Spec.Containers[0].mounts(TokenVolumeName))
}
func TestMutate_AudienceAndExpiry(t *testing.T) {
cases := []struct {
name string
saAnn map[string]string
podAnn map[string]string
wantAud string
wantExp int64
}{
{"service account audience", map[string]string{annAudience: "custom.example"}, nil, "custom.example", 0},
{"service account expiry", map[string]string{annExpiry: "7200"}, nil, "sts.amazonaws.com", 7200},
{"pod expiry wins over service account", map[string]string{annExpiry: "7200"}, map[string]string{annExpiry: "1200"}, "sts.amazonaws.com", 1200},
{"below the minimum is raised to 600", nil, map[string]string{annExpiry: "100"}, "sts.amazonaws.com", 600},
{"above the maximum is lowered to 86400", map[string]string{annExpiry: "999999"}, nil, "sts.amazonaws.com", 86400},
{"invalid expiry falls back to the default", map[string]string{annExpiry: "soon"}, nil, "sts.amazonaws.com", 0},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
m, _, _ := newMutator(roleSA(tc.saAnn))
out, _ := run(t, m, podOf("sa0", tc.podAnn, ctr("app")))
aud, exp := out.tokenExpiry(t)
assert.Equal(t, tc.wantAud, aud)
if tc.wantExp != 0 {
assert.Equal(t, tc.wantExp, exp)
return
}
assert.GreaterOrEqual(t, exp, MaxTokenExpiration-7200)
assert.LessOrEqual(t, exp, MaxTokenExpiration)
})
}
}
func TestMutate_DefaultExpiryIsJitteredWithinTwoHours(t *testing.T) {
m, _, _ := newMutator(roleSA(nil))
seen := map[int64]bool{}
for range 60 {
out, _ := run(t, m, podOf("sa0", nil, ctr("app")))
_, exp := out.tokenExpiry(t)
require.GreaterOrEqual(t, exp, DefaultMinTokenExpiration)
require.LessOrEqual(t, exp, MaxTokenExpiration)
seen[exp] = true
}
assert.Greater(t, len(seen), 1, "60 pods all got the same default expiry; it is not jittered")
}
func TestMutate_SkipContainersAnnotation(t *testing.T) {
m, rec, _ := newMutator(roleSA(nil))
out, _ := run(t, m, podOf("sa0", map[string]string{annSkip: "sidecar, mesh"},
ctr("app"), ctr("sidecar"), ctr("mesh")))
assert.Equal(t, testRoleARN, out.Spec.Containers[0].envMap()["AWS_ROLE_ARN"])
for _, i := range []int{1, 2} {
c := out.Spec.Containers[i]
assert.Empty(t, c.Env, c.Name)
assert.Zero(t, c.mounts(TokenVolumeName), c.Name)
}
assert.Len(t, rec.withReason(ReasonSkipContainer), 2)
}
func TestMutate_LeavesPodsOfUnannotatedServiceAccountsAlone(t *testing.T) {
m, _, f := newMutator(&ServiceAccount{Annotations: map[string]string{"other": "x"}}, &ServiceAccount{})
f.sas["app/default"] = &ServiceAccount{}
for _, sa := range []string{"sa0", "sa1", ""} {
_, resp := run(t, m, podOf(sa, nil, ctr("app")))
assert.Empty(t, resp.Patch, "service account %q", sa)
}
}
func TestMutate_UsesTheDefaultServiceAccountWhenThePodNamesNone(t *testing.T) {
m, _, f := newMutator()
f.sas["app/default"] = roleSA(nil)
out, _ := run(t, m, podOf("", nil, ctr("app")))
assert.Equal(t, testRoleARN, out.Spec.Containers[0].envMap()["AWS_ROLE_ARN"])
}
// A pod of a managed add-on as render_addon writes it: the web identity,
// region and regional sts settings, the token volume and the gateway CA.
func TestMutate_LeavesARenderedManagedAddonPodUnchanged(t *testing.T) {
m, _, _ := newMutator(roleSA(map[string]string{annRegionalS: "true"}))
c := ctr("controller",
env{"AWS_ROLE_ARN", testRoleARN},
env{"AWS_WEB_IDENTITY_TOKEN_FILE", TokenMountPath + "/token"},
env{"AWS_STS_REGIONAL_ENDPOINTS", "regional"},
env{"AWS_REGION", testRegion},
env{"AWS_DEFAULT_REGION", testRegion},
env{"AWS_ENDPOINT_URL_STS", testSTS},
env{"AWS_CA_BUNDLE", "/etc/spinifex/gateway-ca/ca.pem"},
env{"SSL_CERT_FILE", "/etc/spinifex/gateway-ca/ca.pem"})
c["volumeMounts"] = []map[string]any{
{"name": TokenVolumeName, "mountPath": TokenMountPath, "readOnly": true},
{"name": "spinifex-gateway-ca", "mountPath": "/etc/spinifex/gateway-ca", "readOnly": true},
}
pod := podOf("sa0", nil, c)
pod["spec"].(map[string]any)["volumes"] = []map[string]any{
{"name": TokenVolumeName, "projected": map[string]any{"sources": []map[string]any{{
"serviceAccountToken": map[string]any{"audience": "sts.amazonaws.com", "expirationSeconds": 86400, "path": "token"}}}}},
{"name": "spinifex-gateway-ca", "secret": map[string]any{"secretName": "spinifex-gateway-ca"}},
}
_, resp := run(t, m, pod)
assert.Empty(t, resp.Patch, "a managed add-on pod must come out byte for byte as it went in")
}
func TestMutate_OnlyActsOnPodCreation(t *testing.T) {
m, _, _ := newMutator(roleSA(nil))
raw, err := json.Marshal(podOf("sa0", nil, ctr("app")))
require.NoError(t, err)
resp := m.Mutate(context.Background(), AdmissionRequest{UID: "u", Operation: "UPDATE", Namespace: "app", Object: raw})
assert.True(t, resp.Allowed)
assert.Empty(t, resp.Patch)
}
func TestMutate_LogsEverySkipAndFailureWithNamespacePodAndReason(t *testing.T) {
t.Run("lookup failure fails open", func(t *testing.T) {
m, rec, f := newMutator(roleSA(nil))
f.err = errors.New("apiserver unavailable")
_, resp := run(t, m, podOf("sa0", nil, ctr("app")))
assert.Empty(t, resp.Patch)
got := rec.withReason(ReasonServiceAccountLookup)
require.Len(t, got, 1)
assert.Equal(t, "app", got[0]["namespace"])
assert.Equal(t, "web-1", got[0]["pod"])
assert.Contains(t, got[0]["error"], "apiserver unavailable")
})
t.Run("missing service account", func(t *testing.T) {
m, rec, _ := newMutator()
_, resp := run(t, m, podOf("ghost", nil, ctr("app")))
assert.Empty(t, resp.Patch)
require.Len(t, rec.withReason(ReasonServiceAccountMissing), 1)
})
t.Run("undecodable object", func(t *testing.T) {
m, rec, _ := newMutator()
resp := m.Mutate(context.Background(), AdmissionRequest{UID: "u", Operation: "CREATE", Namespace: "app", Object: json.RawMessage(`{"spec":`)})
assert.True(t, resp.Allowed)
assert.Empty(t, resp.Patch)
got := rec.withReason(ReasonDecode)
require.Len(t, got, 1)
assert.Equal(t, "app", got[0]["namespace"])
})
t.Run("a pod with only generateName is named by it", func(t *testing.T) {
m, rec, _ := newMutator(roleSA(nil))
pod := podOf("sa0", map[string]string{annSkip: "app"}, ctr("app"))
meta := pod["metadata"].(map[string]any)
delete(meta, "name")
meta["generateName"] = "web-"
run(t, m, pod)
got := rec.withReason(ReasonSkipContainer)
require.Len(t, got, 1)
assert.True(t, strings.HasPrefix(got[0]["pod"], "web-"), "pod attr %q", got[0]["pod"])
})
t.Run("an unannotated pod is not a skip worth logging", func(t *testing.T) {
m, rec, _ := newMutator(&ServiceAccount{})
run(t, m, podOf("sa0", nil, ctr("app")))
for _, r := range rec.recs {
assert.NotEqual(t, "WARN", r["level"], "%v", r)
assert.NotEqual(t, "ERROR", r["level"], "%v", r)
}
})
}
func TestServeHTTP_AnswersAnAdmissionReview(t *testing.T) {
m, _, _ := newMutator(roleSA(nil))
obj, err := json.Marshal(podOf("sa0", nil, ctr("app")))
require.NoError(t, err)
body, err := json.Marshal(map[string]any{
"apiVersion": "admission.k8s.io/v1", "kind": "AdmissionReview",
"request": map[string]any{"uid": "abc-123", "operation": "CREATE", "namespace": "app", "object": json.RawMessage(obj)},
})
require.NoError(t, err)
rr := httptest.NewRecorder()
m.ServeHTTP(rr, httptest.NewRequest(http.MethodPost, "/mutate", bytes.NewReader(body)))
require.Equal(t, http.StatusOK, rr.Code)
var review struct {
APIVersion string `json:"apiVersion"`
Kind string `json:"kind"`
Response struct {
UID string `json:"uid"`
Allowed bool `json:"allowed"`
PatchType string `json:"patchType"`
Patch string `json:"patch"`
} `json:"response"`
}
require.NoError(t, json.Unmarshal(rr.Body.Bytes(), &review))
assert.Equal(t, "admission.k8s.io/v1", review.APIVersion)
assert.Equal(t, "AdmissionReview", review.Kind)
assert.Equal(t, "abc-123", review.Response.UID)
assert.True(t, review.Response.Allowed)
assert.Equal(t, "JSONPatch", review.Response.PatchType)
patch, err := base64.StdEncoding.DecodeString(review.Response.Patch)
require.NoError(t, err)
assert.Contains(t, string(patch), "AWS_ROLE_ARN")
}
// applyPatch applies add, replace and remove operations to raw.
func applyPatch(t *testing.T, raw []byte, ops []PatchOp) []byte {
t.Helper()
var doc any
require.NoError(t, json.Unmarshal(raw, &doc))
for _, op := range ops {
parts := strings.Split(strings.TrimPrefix(op.Path, "/"), "/")
for i := range parts {
parts[i] = strings.ReplaceAll(strings.ReplaceAll(parts[i], "~1", "/"), "~0", "~")
}
var val any
if op.Value != nil {
b, err := json.Marshal(op.Value)
require.NoError(t, err)
require.NoError(t, json.Unmarshal(b, &val))
}
doc = patchAt(t, doc, parts, op.Op, val)
}
out, err := json.Marshal(doc)
require.NoError(t, err)
return out
}
func patchAt(t *testing.T, node any, parts []string, op string, val any) any {
t.Helper()
if len(parts) == 1 {
key := parts[0]
switch n := node.(type) {
case map[string]any:
switch op {
case "add", "replace":
n[key] = val
case "remove":
delete(n, key)
default:
require.Failf(t, "unsupported op", "%s", op)
}
return n
case []any:
if key == "-" {
return append(n, val)
}
i, err := strconv.Atoi(key)
require.NoError(t, err)
switch op {
case "add":
n = append(n, nil)
copy(n[i+1:], n[i:])
n[i] = val
case "replace":
n[i] = val
case "remove":
n = append(n[:i], n[i+1:]...)
}
return n
}
require.Fail(t, "patch parent is neither object nor array")
}
switch n := node.(type) {
case map[string]any:
child, ok := n[parts[0]]
require.True(t, ok, "patch path parent %q missing", parts[0])
n[parts[0]] = patchAt(t, child, parts[1:], op, val)
return n
case []any:
i, err := strconv.Atoi(parts[0])
require.NoError(t, err)
n[i] = patchAt(t, n[i], parts[1:], op, val)
return n
}
require.Fail(t, "patch path crosses a scalar")
return nil
}
// Upstream skips a container only when the regional STS key is present too: a
// container with role and region but no STS key, on a ServiceAccount that does
// not ask for regional endpoints, still gets the mounts but no new STS variable.
func TestMutate_RoleAndRegionWithoutSTSKeyStillGetsMountsNotSTSEnv(t *testing.T) {
m, _, _ := newMutator(roleSA(nil))
c := ctr("app",
env{"AWS_ROLE_ARN", "arn:aws:iam::000000000001:role/own"},
env{"AWS_WEB_IDENTITY_TOKEN_FILE", "/own/token"},
env{"AWS_REGION", "us-east-1"})
out, _ := run(t, m, podOf("sa0", nil, c))
got := out.Spec.Containers[0]
assert.Equal(t, 1, got.mounts(TokenVolumeName))
assert.NotContains(t, got.envMap(), "AWS_STS_REGIONAL_ENDPOINTS")
assert.Equal(t, "arn:aws:iam::000000000001:role/own", got.envMap()["AWS_ROLE_ARN"])
}
@@ -1,166 +0,0 @@
// Package podidentity injects IRSA credentials into pods of annotated
// ServiceAccounts like the AWS pod identity webhook, plus the Spinifex STS
// endpoint and gateway CA. It uses no Kubernetes client; the cluster is reached through interfaces.
package podidentity
import (
"context"
"encoding/json"
"errors"
"log/slog"
)
const (
// DefaultAnnotationDomain is the annotation domain AWS uses.
DefaultAnnotationDomain = "eks.amazonaws.com"
// TokenVolumeName and TokenMountPath are where AWS mounts the projected token.
TokenVolumeName = "aws-iam-token" //nolint:gosec // volume name, not a credential
TokenMountPath = "/var/run/secrets/eks.amazonaws.com/serviceaccount" //nolint:gosec // mount path, not a credential
TokenPath = "token"
// DefaultAudience is the token audience when the ServiceAccount sets none.
DefaultAudience = "sts.amazonaws.com"
// MinTokenExpiration and MaxTokenExpiration bound a requested expiry in seconds.
MinTokenExpiration int64 = 600
MaxTokenExpiration int64 = 86400
// DefaultMinTokenExpiration is the low end of the jittered default expiry.
DefaultMinTokenExpiration int64 = 79200
// CAVolumeName, CAMountPath and CABundleKey describe the CA bundle ConfigMap
// mount; CAConfigMapName is the ConfigMap published into every namespace.
CAConfigMapName = "spinifex-ca-bundle"
CAVolumeName = "spinifex-ca-bundle"
CAMountPath = "/etc/spinifex/ca-bundle"
CABundleKey = "ca.pem"
)
// Reasons logged, with the namespace and pod, when injection is skipped or fails.
const (
ReasonDecode = "admission object could not be decoded"
ReasonServiceAccountLookup = "serviceaccount lookup failed"
ReasonServiceAccountMissing = "serviceaccount not found"
ReasonAlreadyConfigured = "container already has web identity, region and regional sts settings"
ReasonSkipContainer = "container named in skip-containers annotation"
)
var (
// ErrServiceAccountNotFound is returned by a ServiceAccountGetter when the
// ServiceAccount does not exist.
ErrServiceAccountNotFound = errors.New("serviceaccount not found")
// ErrNotFound, ErrAlreadyExists and ErrConflict are the ConfigMapStore errors.
ErrNotFound = errors.New("not found")
ErrAlreadyExists = errors.New("already exists")
ErrConflict = errors.New("conflict")
)
// Config is the mutator's configuration.
type Config struct {
// Region is injected as AWS_REGION and AWS_DEFAULT_REGION when non-empty.
Region string
// STSEndpoint is injected as AWS_ENDPOINT_URL_STS when non-empty.
STSEndpoint string
// AnnotationDomain defaults to DefaultAnnotationDomain.
AnnotationDomain string
}
// ServiceAccount is the part of a ServiceAccount the mutator reads.
type ServiceAccount struct {
Annotations map[string]string
}
// ServiceAccountGetter looks up a ServiceAccount.
type ServiceAccountGetter interface {
GetServiceAccount(ctx context.Context, namespace, name string) (*ServiceAccount, error)
}
// AdmissionRequest is the part of an AdmissionReview request the mutator reads.
type AdmissionRequest struct {
UID string `json:"uid"`
Operation string `json:"operation"`
Namespace string `json:"namespace"`
Object json.RawMessage `json:"object"`
}
// PatchOp is one RFC 6902 operation.
type PatchOp struct {
Op string `json:"op"`
Path string `json:"path"`
Value any `json:"value,omitempty"`
}
// AdmissionResponse is the mutator's answer. Patch is empty when the pod is
// left unchanged. The mutator never denies a pod.
type AdmissionResponse struct {
UID string
Allowed bool
Patch []PatchOp
}
// Mutator injects IRSA credentials into pods.
type Mutator struct {
cfg Config
sas ServiceAccountGetter
log *slog.Logger
}
// NewMutator returns a Mutator. A nil log uses slog.Default.
func NewMutator(cfg Config, sas ServiceAccountGetter, log *slog.Logger) *Mutator {
if log == nil {
log = slog.Default()
}
return &Mutator{cfg: cfg, sas: sas, log: log}
}
// ConfigMap is the part of a ConfigMap the publisher manages.
type ConfigMap struct {
Namespace string
Name string
Data map[string]string
// ResourceVersion is opaque and set by the store.
ResourceVersion string
}
// ConfigMapStore reads and writes ConfigMaps. Get returns ErrNotFound, Create
// returns ErrAlreadyExists, and Update returns ErrConflict or ErrNotFound.
type ConfigMapStore interface {
Get(ctx context.Context, namespace, name string) (*ConfigMap, error)
Create(ctx context.Context, cm ConfigMap) error
Update(ctx context.Context, cm ConfigMap) error
}
// NamespaceSource lists namespaces and reports ones created later.
type NamespaceSource interface {
List(ctx context.Context) ([]string, error)
// Watch sends the name of every namespace created after the call until ctx ends.
Watch(ctx context.Context) (<-chan string, error)
}
// PublisherConfig configures a Publisher.
type PublisherConfig struct {
// Bundle is the CA bundle published to every namespace as CABundleKey.
Bundle []byte
}
// Publisher keeps the CA bundle ConfigMap present in every namespace.
type Publisher struct {
cfg PublisherConfig
ns NamespaceSource
cms ConfigMapStore
log *slog.Logger
}
// NewPublisher returns a Publisher. A nil log uses slog.Default.
func NewPublisher(cfg PublisherConfig, ns NamespaceSource, cms ConfigMapStore, log *slog.Logger) *Publisher {
if log == nil {
log = slog.Default()
}
return &Publisher{cfg: cfg, ns: ns, cms: cms, log: log}
}
// WebhookConfig describes the MutatingWebhookConfiguration to render.
type WebhookConfig struct {
Name string
// URL is the https URL the API server calls, on 127.0.0.1.
URL string
// CA is the PEM the API server trusts for URL.
CA []byte
}
@@ -1,107 +0,0 @@
package podidentity
import (
"context"
"errors"
"maps"
"time"
)
const (
defaultResync = 5 * time.Minute
reconcileAttempts = 3
)
// Reconcile makes the CA bundle ConfigMap in one namespace match the bundle.
// Losing a create or update race to another publisher is not an error.
func (p *Publisher) Reconcile(ctx context.Context, namespace string) error {
want := string(p.cfg.Bundle)
var err error
for range reconcileAttempts {
var cm *ConfigMap
cm, err = p.cms.Get(ctx, namespace, CAConfigMapName)
switch {
case errors.Is(err, ErrNotFound):
err = p.cms.Create(ctx, ConfigMap{Namespace: namespace, Name: CAConfigMapName, Data: map[string]string{CABundleKey: want}})
if err == nil {
return nil
}
if !errors.Is(err, ErrAlreadyExists) {
return err
}
continue
case err != nil:
return err
case cm.Data[CABundleKey] == want:
return nil
}
data := maps.Clone(cm.Data)
if data == nil {
data = map[string]string{}
}
data[CABundleKey] = want
err = p.cms.Update(ctx, ConfigMap{Namespace: namespace, Name: CAConfigMapName, Data: data, ResourceVersion: cm.ResourceVersion})
if err == nil {
return nil
}
if !errors.Is(err, ErrConflict) && !errors.Is(err, ErrNotFound) {
return err
}
}
return err
}
// Run publishes to every existing namespace, then to each new one, and
// re-checks every namespace each resync interval until ctx ends.
func (p *Publisher) Run(ctx context.Context, resync time.Duration) error {
if resync <= 0 {
resync = defaultResync
}
created, err := p.ns.Watch(ctx)
if err != nil {
return err
}
p.reconcileAll(ctx)
tick := time.NewTicker(resync)
defer tick.Stop()
for {
select {
case <-ctx.Done():
return nil
case ns, ok := <-created:
if !ok {
created = nil
continue
}
p.reconcileOne(ctx, ns)
case <-tick.C:
if created == nil {
if ch, err := p.ns.Watch(ctx); err != nil {
p.log.Warn("namespace watch restart failed", "error", err)
} else {
created = ch
}
}
p.reconcileAll(ctx)
}
}
}
func (p *Publisher) reconcileAll(ctx context.Context) {
names, err := p.ns.List(ctx)
if err != nil {
p.log.Warn("namespace list failed", "error", err)
return
}
for _, ns := range names {
p.reconcileOne(ctx, ns)
}
}
func (p *Publisher) reconcileOne(ctx context.Context, ns string) {
if err := p.Reconcile(ctx, ns); err != nil && ctx.Err() == nil {
p.log.Warn("ca bundle publish failed", "namespace", ns, "error", err)
}
}
@@ -1,321 +0,0 @@
package podidentity
import (
"context"
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"crypto/x509"
"crypto/x509/pkix"
"encoding/base64"
"encoding/json"
"encoding/pem"
"math/big"
"strconv"
"strings"
"sync"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// memStore is an in-memory ConfigMapStore with the API server's create and
// update semantics: Create fails if present, Update needs the current version.
type memStore struct {
mu sync.Mutex
cms map[string]ConfigMap
version int
creates int
updates int
}
func newMemStore() *memStore { return &memStore{cms: map[string]ConfigMap{}} }
func (s *memStore) Get(_ context.Context, ns, name string) (*ConfigMap, error) {
s.mu.Lock()
defer s.mu.Unlock()
cm, ok := s.cms[ns+"/"+name]
if !ok {
return nil, ErrNotFound
}
return &cm, nil
}
func (s *memStore) Create(_ context.Context, cm ConfigMap) error {
s.mu.Lock()
defer s.mu.Unlock()
if _, ok := s.cms[cm.Namespace+"/"+cm.Name]; ok {
return ErrAlreadyExists
}
s.version++
cm.ResourceVersion = strconv.Itoa(s.version)
s.cms[cm.Namespace+"/"+cm.Name] = cm
s.creates++
return nil
}
func (s *memStore) Update(_ context.Context, cm ConfigMap) error {
s.mu.Lock()
defer s.mu.Unlock()
cur, ok := s.cms[cm.Namespace+"/"+cm.Name]
if !ok {
return ErrNotFound
}
if cur.ResourceVersion != cm.ResourceVersion {
return ErrConflict
}
s.version++
cm.ResourceVersion = strconv.Itoa(s.version)
s.cms[cm.Namespace+"/"+cm.Name] = cm
s.updates++
return nil
}
func (s *memStore) remove(ns, name string) {
s.mu.Lock()
delete(s.cms, ns+"/"+name)
s.mu.Unlock()
}
func (s *memStore) get(ns string) (ConfigMap, bool) {
s.mu.Lock()
defer s.mu.Unlock()
cm, ok := s.cms[ns+"/"+CAConfigMapName]
return cm, ok
}
// fakeNamespaces lists a fixed set and replays created ones on Watch.
type fakeNamespaces struct {
mu sync.Mutex
names []string
watcher chan string
}
func (f *fakeNamespaces) List(context.Context) ([]string, error) {
f.mu.Lock()
defer f.mu.Unlock()
return append([]string(nil), f.names...), nil
}
func (f *fakeNamespaces) Watch(context.Context) (<-chan string, error) {
f.mu.Lock()
defer f.mu.Unlock()
f.watcher = make(chan string, 8)
return f.watcher, nil
}
func (f *fakeNamespaces) create(name string) {
f.mu.Lock()
f.names = append(f.names, name)
w := f.watcher
f.mu.Unlock()
if w != nil {
w <- name
}
}
var testBundle = []byte("-----BEGIN CERTIFICATE-----\nQUJD\n-----END CERTIFICATE-----\n")
func TestPublisher_ReconcileCreatesTheBundleConfigMap(t *testing.T) {
st := newMemStore()
p := NewPublisher(PublisherConfig{Bundle: testBundle}, &fakeNamespaces{}, st, nil)
require.NoError(t, p.Reconcile(context.Background(), "team-a"))
cm, ok := st.get("team-a")
require.True(t, ok, "no %s ConfigMap in team-a", CAConfigMapName)
assert.Equal(t, string(testBundle), cm.Data[CABundleKey])
}
func TestPublisher_ReconcileIsIdempotentAndRepairsDrift(t *testing.T) {
st := newMemStore()
p := NewPublisher(PublisherConfig{Bundle: testBundle}, &fakeNamespaces{}, st, nil)
ctx := context.Background()
require.NoError(t, p.Reconcile(ctx, "team-a"))
require.NoError(t, p.Reconcile(ctx, "team-a"))
assert.Equal(t, 1, st.creates)
assert.Zero(t, st.updates, "an up-to-date ConfigMap must not be rewritten")
require.NoError(t, st.Update(ctx, ConfigMap{Namespace: "team-a", Name: CAConfigMapName,
Data: map[string]string{CABundleKey: "tampered"}, ResourceVersion: "1"}))
require.NoError(t, p.Reconcile(ctx, "team-a"))
cm, _ := st.get("team-a")
assert.Equal(t, string(testBundle), cm.Data[CABundleKey])
}
func TestPublisher_RunPublishesToEveryNamespaceIncludingLaterOnes(t *testing.T) {
st := newMemStore()
nss := &fakeNamespaces{names: []string{"default", "kube-system", "team-a"}}
p := NewPublisher(PublisherConfig{Bundle: testBundle}, nss, st, nil)
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
done := make(chan error, 1)
go func() { done <- p.Run(ctx, time.Hour) }()
for _, ns := range []string{"default", "kube-system", "team-a"} {
require.Eventually(t, func() bool { _, ok := st.get(ns); return ok }, 3*time.Second, 10*time.Millisecond, "namespace %s", ns)
}
nss.create("created-later")
require.Eventually(t, func() bool { _, ok := st.get("created-later"); return ok }, 3*time.Second, 10*time.Millisecond)
cancel()
select {
case err := <-done:
assert.NoError(t, err)
case <-time.After(3 * time.Second):
t.Fatal("Run did not return after its context ended")
}
}
func TestPublisher_RunRecreatesADeletedConfigMap(t *testing.T) {
st := newMemStore()
nss := &fakeNamespaces{names: []string{"team-a"}}
p := NewPublisher(PublisherConfig{Bundle: testBundle}, nss, st, nil)
ctx := t.Context()
go func() { _ = p.Run(ctx, 20*time.Millisecond) }()
require.Eventually(t, func() bool { _, ok := st.get("team-a"); return ok }, 3*time.Second, 10*time.Millisecond)
st.remove("team-a", CAConfigMapName)
require.Eventually(t, func() bool { _, ok := st.get("team-a"); return ok }, 3*time.Second, 10*time.Millisecond,
"the ConfigMap stayed deleted; pods created now would hang on the missing volume")
}
// All three control-plane servers run a publisher, so racing is the normal case.
func TestPublisher_ConcurrentPublishersDoNotFight(t *testing.T) {
st := newMemStore()
var wg sync.WaitGroup
errs := make(chan error, 12)
for range 3 {
p := NewPublisher(PublisherConfig{Bundle: testBundle}, &fakeNamespaces{}, st, nil)
for range 4 {
wg.Go(func() {
errs <- p.Reconcile(context.Background(), "team-a")
})
}
}
wg.Wait()
close(errs)
for err := range errs {
assert.NoError(t, err, "losing a create race is not an error")
}
cm, ok := st.get("team-a")
require.True(t, ok)
assert.Equal(t, string(testBundle), cm.Data[CABundleKey])
assert.Equal(t, 1, st.creates)
assert.Zero(t, st.updates, "publishers with the same bundle must not rewrite each other's ConfigMap")
}
func certPEM(t *testing.T, cn string) []byte {
t.Helper()
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
require.NoError(t, err)
tmpl := &x509.Certificate{
SerialNumber: big.NewInt(time.Now().UnixNano()),
Subject: pkix.Name{CommonName: cn},
NotBefore: time.Now().Add(-time.Hour),
NotAfter: time.Now().Add(time.Hour),
IsCA: true,
BasicConstraintsValid: true,
KeyUsage: x509.KeyUsageCertSign,
}
der, err := x509.CreateCertificate(rand.Reader, tmpl, tmpl, &key.PublicKey, key)
require.NoError(t, err)
return pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der})
}
func subjects(t *testing.T, bundle []byte) []string {
t.Helper()
var out []string
for rest := bundle; ; {
var b *pem.Block
b, rest = pem.Decode(rest)
if b == nil {
return out
}
c, err := x509.ParseCertificate(b.Bytes)
require.NoError(t, err)
out = append(out, c.Subject.CommonName)
}
}
func TestBuildCABundle_HoldsTheSystemRootsAndTheGatewayCAOnce(t *testing.T) {
root1, root2, gw := certPEM(t, "root-1"), certPEM(t, "root-2"), certPEM(t, "gateway")
system := append(append([]byte{}, root1...), root2...)
got := subjects(t, BuildCABundle(system, gw))
assert.ElementsMatch(t, []string{"root-1", "root-2", "gateway"}, got)
again := subjects(t, BuildCABundle(append(append([]byte{}, system...), gw...), gw))
assert.ElementsMatch(t, []string{"root-1", "root-2", "gateway"}, again, "a certificate in both inputs is listed once")
}
func TestRenderWebhookConfiguration(t *testing.T) {
ca := certPEM(t, "k3s-server-ca")
raw, err := RenderWebhookConfiguration(WebhookConfig{
Name: "spinifex-pod-identity",
URL: "https://127.0.0.1:8445/mutate",
CA: ca,
})
require.NoError(t, err)
var cfg struct {
APIVersion string `json:"apiVersion"`
Kind string `json:"kind"`
Metadata struct {
Name string `json:"name"`
} `json:"metadata"`
Webhooks []struct {
Name string `json:"name"`
FailurePolicy string `json:"failurePolicy"`
SideEffects string `json:"sideEffects"`
AdmissionReviewVersions []string `json:"admissionReviewVersions"`
ClientConfig struct {
URL string `json:"url"`
Service any `json:"service"`
CABundle string `json:"caBundle"`
} `json:"clientConfig"`
Rules []struct {
Operations []string `json:"operations"`
Resources []string `json:"resources"`
} `json:"rules"`
} `json:"webhooks"`
}
require.NoError(t, json.Unmarshal(raw, &cfg))
assert.Equal(t, "admissionregistration.k8s.io/v1", cfg.APIVersion)
assert.Equal(t, "MutatingWebhookConfiguration", cfg.Kind)
assert.Equal(t, "spinifex-pod-identity", cfg.Metadata.Name)
require.Len(t, cfg.Webhooks, 1)
w := cfg.Webhooks[0]
assert.Equal(t, "Ignore", w.FailurePolicy, "an unreachable injector must never block pod creation")
assert.Equal(t, "None", w.SideEffects)
assert.Contains(t, w.AdmissionReviewVersions, "v1")
assert.Nil(t, w.ClientConfig.Service, "a Service reference cannot be routed from the control plane")
assert.True(t, strings.HasPrefix(w.ClientConfig.URL, "https://127.0.0.1:"), "url %q", w.ClientConfig.URL)
got, err := base64.StdEncoding.DecodeString(w.ClientConfig.CABundle)
require.NoError(t, err)
assert.Equal(t, string(ca), string(got), "caBundle must be the k3s server CA")
require.Len(t, w.Rules, 1)
assert.Equal(t, []string{"CREATE"}, w.Rules[0].Operations)
assert.Equal(t, []string{"pods"}, w.Rules[0].Resources)
}
func TestRenderWebhookConfiguration_RejectsAURLThatIsNotLoopbackHTTPS(t *testing.T) {
ca := certPEM(t, "k3s-server-ca")
for _, url := range []string{
"http://127.0.0.1:8445/mutate",
"https://10.0.0.5:8445/mutate",
"https://webhook.kube-system.svc/mutate",
"",
} {
_, err := RenderWebhookConfiguration(WebhookConfig{Name: "n", URL: url, CA: ca})
assert.Error(t, err, "url %q", url)
}
_, err := RenderWebhookConfiguration(WebhookConfig{Name: "n", URL: "https://127.0.0.1:8445/mutate"})
assert.Error(t, err, "a missing CA must not render a webhook the API server cannot verify")
}
-110
View File
@@ -1,110 +0,0 @@
package podidentity
import (
"context"
"crypto/tls"
"crypto/x509"
"encoding/pem"
"io"
"log/slog"
"net"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func freeAddr(t *testing.T) string {
t.Helper()
l, err := net.Listen("tcp", "127.0.0.1:0")
require.NoError(t, err)
defer func() { _ = l.Close() }()
return l.Addr().String()
}
// startAPI serves a fake kube-apiserver whose certificate is signed by the k3s
// server CA, as the real one is.
func startAPI(t *testing.T, cfg CertConfig) *httptest.Server {
t.Helper()
other := cfg
other.Dir = t.TempDir()
certs, err := EnsureCerts(other)
require.NoError(t, err)
pair, err := tls.LoadX509KeyPair(certs.ServingCert, certs.ServingKey)
require.NoError(t, err)
api := httptest.NewUnstartedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Query().Get("watch") != "" {
return
}
_, _ = io.WriteString(w, `{"items":[]}`)
}))
api.TLS = &tls.Config{Certificates: []tls.Certificate{pair}, ClientAuth: tls.RequireAnyClientCert, MinVersion: tls.VersionTLS12}
api.StartTLS()
t.Cleanup(api.Close)
return api
}
func TestRun_ServesTheWebhookStagesManifestsAndStopsOnCancel(t *testing.T) {
cfg, tlsDir := certTestConfig(t)
api := startAPI(t, cfg)
dir := t.TempDir()
roots := filepath.Join(dir, "roots.pem")
require.NoError(t, os.WriteFile(roots, pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: api.Certificate().Raw}), 0o644))
c := ServiceConfig{
Addr: freeAddr(t), Dir: cfg.Dir, TLSDir: tlsDir, APIServer: api.URL,
Region: "ap-southeast-2", STSEndpoint: "https://gw:9999",
SystemRoots: roots, ManifestDir: filepath.Join(dir, "manifests"), Resync: time.Hour,
}
require.NoError(t, os.MkdirAll(c.ManifestDir, 0o755))
ctx, cancel := context.WithCancel(context.Background())
done := make(chan error, 1)
go func() { done <- Run(ctx, c, slog.New(slog.DiscardHandler)) }()
// The API server calls the webhook over TLS, trusting only the server CA.
caPEM, err := os.ReadFile(filepath.Join(tlsDir, "server-ca.crt"))
require.NoError(t, err)
pool := x509.NewCertPool()
require.True(t, pool.AppendCertsFromPEM(caPEM))
client := &http.Client{Transport: &http.Transport{TLSClientConfig: &tls.Config{RootCAs: pool, MinVersion: tls.VersionTLS12}}}
require.Eventually(t, func() bool {
resp, err := client.Get("https://" + c.Addr + "/healthz")
if err != nil {
return false
}
_ = resp.Body.Close()
return resp.StatusCode == http.StatusOK
}, 10*time.Second, 50*time.Millisecond)
_, err = os.Stat(filepath.Join(c.ManifestDir, manifestFile))
assert.NoError(t, err, "the RBAC and webhook configuration are staged for k3s")
cancel()
select {
case err := <-done:
assert.NoError(t, err)
case <-time.After(10 * time.Second):
t.Fatal("Run did not stop after cancel")
}
}
func TestRun_FailsWhenTheServerCAIsMissing(t *testing.T) {
cfg, tlsDir := certTestConfig(t)
require.NoError(t, os.Remove(filepath.Join(tlsDir, "server-ca.key")))
c := ServiceConfig{
Addr: freeAddr(t), Dir: cfg.Dir, TLSDir: tlsDir, APIServer: "https://127.0.0.1:1",
Region: "r", STSEndpoint: "https://gw", SystemRoots: filepath.Join(t.TempDir(), "none"), ManifestDir: t.TempDir(),
}
err := Run(context.Background(), c, slog.New(slog.DiscardHandler))
assert.Error(t, err, "the process exits so the supervisor retries until k3s has written its CAs")
}
func TestRun_RejectsANonLoopbackAddress(t *testing.T) {
err := Run(context.Background(), ServiceConfig{Addr: "0.0.0.0:8445", Region: "r", STSEndpoint: "s"}, slog.New(slog.DiscardHandler))
assert.Error(t, err)
}
-212
View File
@@ -1,212 +0,0 @@
package podidentity
import (
"bytes"
"context"
"crypto/tls"
"errors"
"flag"
"fmt"
"io"
"log/slog"
"net"
"net/http"
"os"
"time"
)
const (
// DefaultAddr is the loopback listener; 8443 belongs to eks-token-webhook.
DefaultAddr = "127.0.0.1:8445"
// MutatePath is the URL path the API server calls.
MutatePath = "/mutate"
defaultStateDir = "/var/lib/spinifex-eks/pod-identity"
defaultTLSDir = "/var/lib/rancher/k3s/server/tls"
defaultAPIServer = "https://127.0.0.1:6443"
defaultSystemRoot = "/etc/ssl/certs/ca-certificates.crt"
defaultManifests = "/var/lib/rancher/k3s/server/manifests"
manifestFile = "eks-pod-identity-webhook.yaml"
certCheckEvery = 24 * time.Hour
)
// ErrCertRenewal is returned by Run when a certificate entered its renew window.
// The process exits non-zero so the supervisor restarts it and EnsureCerts reissues.
var ErrCertRenewal = errors.New("certificate inside renew window")
// ServiceConfig is everything the webhook process needs.
type ServiceConfig struct {
Addr string
Dir string
TLSDir string
APIServer string
Region string
STSEndpoint string
GatewayCAFile string
SystemRoots string
ManifestDir string
Resync time.Duration
}
// ParseServiceConfig reads flags, defaulting region, STS endpoint and gateway
// CA from the environment the node already has (the cloud-init first-boot env
// that mulga-eks-addon-sync renders the managed add-ons from).
func ParseServiceConfig(args []string, getenv func(string) string, errOut io.Writer) (ServiceConfig, error) {
var c ServiceConfig
fs := flag.NewFlagSet("eks-pod-identity-webhook", flag.ContinueOnError)
fs.SetOutput(errOut)
fs.StringVar(&c.Addr, "addr", DefaultAddr, "loopback listen address")
fs.StringVar(&c.Dir, "dir", defaultStateDir, "directory holding the webhook's certificates")
fs.StringVar(&c.TLSDir, "k3s-tls-dir", defaultTLSDir, "k3s server tls directory")
fs.StringVar(&c.APIServer, "apiserver", defaultAPIServer, "kube-apiserver URL")
fs.StringVar(&c.Region, "region", getenv("EKS_REGION"), "region injected as AWS_REGION")
fs.StringVar(&c.STSEndpoint, "sts-endpoint", getenv("EKS_ADDON_GATEWAY_URL"), "endpoint injected as AWS_ENDPOINT_URL_STS")
fs.StringVar(&c.GatewayCAFile, "gateway-ca", getenv("EKS_GATEWAY_CA"), "gateway CA PEM added to the published bundle")
fs.StringVar(&c.SystemRoots, "system-roots", defaultSystemRoot, "system root certificates PEM")
fs.StringVar(&c.ManifestDir, "manifest-dir", defaultManifests, "k3s auto-deploy manifests directory")
fs.DurationVar(&c.Resync, "resync", 5*time.Minute, "how often every namespace's CA ConfigMap is re-checked")
if err := fs.Parse(args); err != nil {
return ServiceConfig{}, err
}
return c, c.Validate()
}
// Validate rejects a configuration that would expose the webhook beyond the
// node or run it with nothing to inject.
func (c ServiceConfig) Validate() error {
host, _, err := net.SplitHostPort(c.Addr)
if err != nil {
return fmt.Errorf("addr %q: %w", c.Addr, err)
}
if ip := net.ParseIP(host); ip == nil || !ip.IsLoopback() {
return fmt.Errorf("addr %q must be a loopback IP: only the local kube-apiserver may call the webhook", c.Addr)
}
if c.Region == "" || c.STSEndpoint == "" {
return errors.New("region and sts endpoint are required (EKS_REGION, EKS_ADDON_GATEWAY_URL)")
}
return nil
}
// CertConfig returns the certificate settings that follow from the k3s tls directory.
func (c ServiceConfig) CertConfig() CertConfig {
return CertConfig{
Dir: c.Dir,
ServerCACert: c.TLSDir + "/server-ca.crt", ServerCAKey: c.TLSDir + "/server-ca.key",
ClientCACert: c.TLSDir + "/client-ca.crt", ClientCAKey: c.TLSDir + "/client-ca.key",
}
}
// Run serves the webhook on the loopback address and keeps the CA ConfigMaps
// published until ctx ends.
func Run(ctx context.Context, c ServiceConfig, log *slog.Logger) error {
if err := c.Validate(); err != nil {
return err
}
certs, err := EnsureCerts(c.CertConfig())
if err != nil {
return err
}
bundle, err := c.caBundle()
if err != nil {
return err
}
if err := c.stageManifests(); err != nil {
return err
}
kube, err := NewKubeClient(c.APIServer, c.TLSDir+"/server-ca.crt", certs.ClientCert, certs.ClientKey)
if err != nil {
return err
}
pair, err := tls.LoadX509KeyPair(certs.ServingCert, certs.ServingKey)
if err != nil {
return fmt.Errorf("load serving certificate: %w", err)
}
mux := http.NewServeMux()
mux.Handle(MutatePath, NewMutator(Config{Region: c.Region, STSEndpoint: c.STSEndpoint}, kube, log))
mux.HandleFunc("/healthz", func(w http.ResponseWriter, _ *http.Request) { _, _ = io.WriteString(w, "ok") })
srv := &http.Server{
Handler: mux,
TLSConfig: &tls.Config{Certificates: []tls.Certificate{pair}, MinVersion: tls.VersionTLS12},
ReadHeaderTimeout: 5 * time.Second,
ReadTimeout: 10 * time.Second,
WriteTimeout: 10 * time.Second,
}
ln, err := net.Listen("tcp", c.Addr)
if err != nil {
return err
}
errc := make(chan error, 1)
go func() { errc <- srv.ServeTLS(ln, "", "") }()
go func() {
if err := NewPublisher(PublisherConfig{Bundle: bundle}, kube, kube, log).Run(ctx, c.Resync); err != nil {
log.Error("ca bundle publisher stopped", "error", err)
}
}()
log.Info("pod identity webhook listening", "addr", c.Addr)
renew := time.NewTicker(certCheckEvery)
defer renew.Stop()
for {
select {
case <-renew.C:
if !NeedsRenewal(certs, time.Now()) {
continue
}
log.Info("certificate inside renew window; exiting so the supervisor restarts and reissues")
shutdown, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
_ = srv.Shutdown(shutdown)
return ErrCertRenewal
case <-ctx.Done():
shutdown, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
return srv.Shutdown(shutdown)
case err := <-errc:
return err
}
}
}
// caBundle is the system roots plus the gateway CA, so a client that trusts only
// this bundle still reaches public endpoints as well as the gateway.
func (c ServiceConfig) caBundle() ([]byte, error) {
roots, err := os.ReadFile(c.SystemRoots)
if err != nil {
return nil, fmt.Errorf("read system roots: %w", err)
}
var gw []byte
if c.GatewayCAFile != "" {
if gw, err = os.ReadFile(c.GatewayCAFile); err != nil {
return nil, fmt.Errorf("read gateway ca: %w", err)
}
}
b := BuildCABundle(roots, gw)
if len(b) == 0 {
return nil, errors.New("ca bundle is empty")
}
return b, nil
}
// stageManifests writes the RBAC and webhook configuration into the k3s
// auto-deploy directory. Every server renders the same bytes (the CA is shared),
// and the file is only replaced when its content changes, so k3s re-applies it
// only on a real change.
func (c ServiceConfig) stageManifests() error {
ca, err := os.ReadFile(c.TLSDir + "/server-ca.crt")
if err != nil {
return fmt.Errorf("read server ca: %w", err)
}
body, err := RenderManifests(WebhookConfig{Name: WebhookName, URL: "https://" + c.Addr + MutatePath, CA: ca})
if err != nil {
return err
}
path := c.ManifestDir + "/" + manifestFile
if cur, err := os.ReadFile(path); err == nil && bytes.Equal(cur, body) {
return nil
}
return writeAtomic(path, body, 0o600)
}
@@ -1,305 +0,0 @@
package podidentity
import (
"bytes"
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"crypto/x509"
"crypto/x509/pkix"
"encoding/base64"
"encoding/json"
"encoding/pem"
"math/big"
"net"
"os"
"path/filepath"
"strings"
"testing"
"time"
"github.com/mulgadc/spinifex/spinifex/agents/eks/konnectivitycert"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// writeCA writes a self-signed CA cert and key as <name>.crt and <name>.key.
func writeCA(t *testing.T, dir, name string) *x509.Certificate {
t.Helper()
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
require.NoError(t, err)
tmpl := x509.Certificate{
SerialNumber: big.NewInt(1), Subject: pkix.Name{CommonName: name},
NotBefore: time.Now().Add(-time.Hour), NotAfter: time.Now().AddDate(10, 0, 0),
IsCA: true, BasicConstraintsValid: true, KeyUsage: x509.KeyUsageCertSign,
}
der, err := x509.CreateCertificate(rand.Reader, &tmpl, &tmpl, &key.PublicKey, key)
require.NoError(t, err)
kd, err := x509.MarshalECPrivateKey(key)
require.NoError(t, err)
require.NoError(t, os.WriteFile(filepath.Join(dir, name+".crt"), pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der}), 0o644))
require.NoError(t, os.WriteFile(filepath.Join(dir, name+".key"), pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: kd}), 0o600))
ca, err := x509.ParseCertificate(der)
require.NoError(t, err)
return ca
}
func certTestConfig(t *testing.T) (CertConfig, string) {
t.Helper()
tls := t.TempDir()
writeCA(t, tls, "server-ca")
writeCA(t, tls, "client-ca")
return CertConfig{
Dir: filepath.Join(t.TempDir(), "state"),
ServerCACert: filepath.Join(tls, "server-ca.crt"), ServerCAKey: filepath.Join(tls, "server-ca.key"),
ClientCACert: filepath.Join(tls, "client-ca.crt"), ClientCAKey: filepath.Join(tls, "client-ca.key"),
}, tls
}
func parseCert(t *testing.T, path string) *x509.Certificate {
t.Helper()
b, err := os.ReadFile(path)
require.NoError(t, err)
blk, _ := pem.Decode(b)
require.NotNil(t, blk)
c, err := x509.ParseCertificate(blk.Bytes)
require.NoError(t, err)
return c
}
func TestEnsureCerts_ServingCertIsForLoopbackAndSignedByTheServerCA(t *testing.T) {
cfg, _ := certTestConfig(t)
out, err := EnsureCerts(cfg)
require.NoError(t, err)
serverCA := parseCert(t, cfg.ServerCACert)
serving := parseCert(t, out.ServingCert)
require.NoError(t, serving.CheckSignatureFrom(serverCA))
require.Len(t, serving.IPAddresses, 1)
assert.True(t, serving.IPAddresses[0].IsLoopback())
assert.Equal(t, []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}, serving.ExtKeyUsage)
roots := x509.NewCertPool()
roots.AddCert(serverCA)
_, err = serving.Verify(x509.VerifyOptions{Roots: roots, DNSName: "127.0.0.1"})
assert.NoError(t, err, "the caBundle (server CA) must validate the serving cert for 127.0.0.1")
}
func TestEnsureCerts_ClientCertIsSignedByTheClientCAWithTheWebhookIdentity(t *testing.T) {
cfg, _ := certTestConfig(t)
out, err := EnsureCerts(cfg)
require.NoError(t, err)
client := parseCert(t, out.ClientCert)
require.NoError(t, client.CheckSignatureFrom(parseCert(t, cfg.ClientCACert)))
assert.Equal(t, ClientCN, client.Subject.CommonName)
assert.Equal(t, []x509.ExtKeyUsage{x509.ExtKeyUsageClientAuth}, client.ExtKeyUsage)
}
func TestEnsureCerts_KeysAreOwnerOnly(t *testing.T) {
cfg, _ := certTestConfig(t)
out, err := EnsureCerts(cfg)
require.NoError(t, err)
for _, k := range []string{out.ServingKey, out.ClientKey} {
st, err := os.Stat(k)
require.NoError(t, err)
assert.Equal(t, os.FileMode(0o600), st.Mode().Perm(), k)
}
}
func TestEnsureCerts_ReusesValidCertsAndReissuesWhenTheCAChanges(t *testing.T) {
cfg, tls := certTestConfig(t)
first, err := EnsureCerts(cfg)
require.NoError(t, err)
before, err := os.ReadFile(first.ServingCert)
require.NoError(t, err)
_, err = EnsureCerts(cfg)
require.NoError(t, err)
same, err := os.ReadFile(first.ServingCert)
require.NoError(t, err)
assert.True(t, bytes.Equal(before, same), "a valid cert must be reused so restarts do not churn it")
writeCA(t, tls, "server-ca") // the cluster CA is replaced
_, err = EnsureCerts(cfg)
require.NoError(t, err)
after, err := os.ReadFile(first.ServingCert)
require.NoError(t, err)
assert.False(t, bytes.Equal(before, after), "a cert signed by a retired CA must be reissued")
require.NoError(t, parseCert(t, first.ServingCert).CheckSignatureFrom(parseCert(t, cfg.ServerCACert)))
}
func TestEnsureCerts_MissingCAFails(t *testing.T) {
cfg, _ := certTestConfig(t)
cfg.ServerCACert = filepath.Join(t.TempDir(), "absent.crt")
_, err := EnsureCerts(cfg)
assert.Error(t, err)
}
func TestRenderManifests_GrantsLeastPrivilegeAndEmbedsTheWebhook(t *testing.T) {
out, err := RenderManifests(WebhookConfig{Name: "n", URL: "https://127.0.0.1:8445/mutate", CA: []byte("ca")})
require.NoError(t, err)
docs := strings.Split(string(out), "---\n")
require.Len(t, docs, 3)
var role struct {
Rules []struct {
Resources []string `json:"resources"`
ResourceNames []string `json:"resourceNames"`
Verbs []string `json:"verbs"`
} `json:"rules"`
}
require.NoError(t, json.Unmarshal([]byte(docs[0]), &role))
got := map[string][]string{}
for _, r := range role.Rules {
for _, res := range r.Resources {
key := res
if len(r.ResourceNames) > 0 {
key += "/named"
}
got[key] = append(got[key], r.Verbs...)
}
assert.NotContains(t, r.Verbs, "*")
assert.NotContains(t, r.Verbs, "delete")
}
assert.Equal(t, map[string][]string{
"serviceaccounts": {"get"},
"namespaces": {"list", "watch"},
"configmaps": {"create"},
"configmaps/named": {"get", "update"},
}, got)
var binding struct {
Subjects []struct {
Name string `json:"name"`
} `json:"subjects"`
}
require.NoError(t, json.Unmarshal([]byte(docs[1]), &binding))
assert.Equal(t, ClientCN, binding.Subjects[0].Name)
assert.Contains(t, docs[2], `"failurePolicy": "Ignore"`)
}
func TestRenderManifests_IsDeterministic(t *testing.T) {
w := WebhookConfig{Name: "n", URL: "https://127.0.0.1:8445/mutate", CA: []byte("ca")}
a, err := RenderManifests(w)
require.NoError(t, err)
b, err := RenderManifests(w)
require.NoError(t, err)
assert.Equal(t, a, b)
}
func TestParseServiceConfig_DefaultsFromTheNodeEnvironment(t *testing.T) {
env := map[string]string{"EKS_REGION": "ap-southeast-2", "EKS_ADDON_GATEWAY_URL": "https://gw:9999", "EKS_GATEWAY_CA": "/etc/gw.pem"}
c, err := ParseServiceConfig(nil, func(k string) string { return env[k] }, &bytes.Buffer{})
require.NoError(t, err)
assert.Equal(t, "ap-southeast-2", c.Region)
assert.Equal(t, "https://gw:9999", c.STSEndpoint)
assert.Equal(t, "/etc/gw.pem", c.GatewayCAFile)
assert.Equal(t, DefaultAddr, c.Addr)
}
func TestParseServiceConfig_RejectsANonLoopbackListener(t *testing.T) {
vars := map[string]string{"EKS_REGION": "r", "EKS_ADDON_GATEWAY_URL": "https://gw"}
env := func(k string) string { return vars[k] }
for _, addr := range []string{"0.0.0.0:8445", ":8445", "10.0.0.5:8445", "somehost:8445"} {
_, err := ParseServiceConfig([]string{"--addr=" + addr}, env, &bytes.Buffer{})
assert.Error(t, err, addr)
}
_, err := ParseServiceConfig([]string{"--addr=127.0.0.1:9000"}, env, &bytes.Buffer{})
assert.NoError(t, err)
}
func TestParseServiceConfig_RequiresRegionAndSTSEndpoint(t *testing.T) {
_, err := ParseServiceConfig(nil, func(string) string { return "" }, &bytes.Buffer{})
assert.Error(t, err)
}
func TestStageManifests_WritesTheServerCAAndIsIdempotent(t *testing.T) {
cfg, tls := certTestConfig(t)
c := ServiceConfig{Addr: DefaultAddr, TLSDir: tls, ManifestDir: t.TempDir()}
require.NoError(t, c.stageManifests())
path := filepath.Join(c.ManifestDir, manifestFile)
first, err := os.ReadFile(path)
require.NoError(t, err)
ca, err := os.ReadFile(cfg.ServerCACert)
require.NoError(t, err)
assert.Contains(t, string(first), base64Of(ca), "caBundle must be the k3s server CA")
assert.Contains(t, string(first), "https://127.0.0.1:8445/mutate")
st1, err := os.Stat(path)
require.NoError(t, err)
require.NoError(t, c.stageManifests())
st2, err := os.Stat(path)
require.NoError(t, err)
assert.True(t, st1.ModTime().Equal(st2.ModTime()), "an unchanged manifest must not be rewritten")
}
func TestCABundle_IsSystemRootsPlusGatewayCA(t *testing.T) {
dir := t.TempDir()
writeCA(t, dir, "sys")
writeCA(t, dir, "gw")
c := ServiceConfig{SystemRoots: filepath.Join(dir, "sys.crt"), GatewayCAFile: filepath.Join(dir, "gw.crt")}
b, err := c.caBundle()
require.NoError(t, err)
pool := x509.NewCertPool()
require.True(t, pool.AppendCertsFromPEM(b))
assert.Len(t, pool.Subjects(), 2) //nolint:staticcheck // counting entries only
}
func base64Of(b []byte) string { return base64.StdEncoding.EncodeToString(b) }
func TestEnsureCerts_AreValidForOneYear(t *testing.T) {
cfg, _ := certTestConfig(t)
out, err := EnsureCerts(cfg)
require.NoError(t, err)
for _, p := range []string{out.ServingCert, out.ClientCert} {
c := parseCert(t, p)
assert.InDelta(t, 365*24, c.NotAfter.Sub(c.NotBefore).Hours()-1, 24, p)
assert.Less(t, time.Until(c.NotAfter), 366*24*time.Hour, p)
}
}
func TestNeedsRenewal(t *testing.T) {
cfg, _ := certTestConfig(t)
out, err := EnsureCerts(cfg)
require.NoError(t, err)
now := time.Now()
assert.False(t, NeedsRenewal(out, now), "fresh certs are outside the window")
assert.False(t, NeedsRenewal(out, now.Add(300*24*time.Hour)))
assert.True(t, NeedsRenewal(out, now.Add(340*24*time.Hour)), "inside 30 days of expiry")
assert.True(t, NeedsRenewal(out, now.Add(400*24*time.Hour)), "already expired")
require.NoError(t, os.Remove(out.ClientCert))
assert.True(t, NeedsRenewal(out, now), "an unreadable certificate needs renewal")
}
func TestEnsureCerts_ReissuesACertInsideTheRenewWindow(t *testing.T) {
cfg, _ := certTestConfig(t)
out, err := EnsureCerts(cfg)
require.NoError(t, err)
// Replace the serving cert with one that expires in 10 days.
ca, caKey, err := konnectivitycert.LoadCA(cfg.ServerCACert, cfg.ServerCAKey)
require.NoError(t, err)
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
require.NoError(t, err)
tmpl := x509.Certificate{
SerialNumber: big.NewInt(7), Subject: pkix.Name{CommonName: "pod-identity-webhook"},
NotBefore: time.Now().Add(-time.Hour), NotAfter: time.Now().Add(10 * 24 * time.Hour),
KeyUsage: x509.KeyUsageDigitalSignature, ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
IPAddresses: []net.IP{net.IPv4(127, 0, 0, 1)},
}
der, err := x509.CreateCertificate(rand.Reader, &tmpl, ca, &key.PublicKey, caKey)
require.NoError(t, err)
kd, err := x509.MarshalECPrivateKey(key)
require.NoError(t, err)
require.NoError(t, os.WriteFile(out.ServingKey, pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: kd}), 0o600))
require.NoError(t, os.WriteFile(out.ServingCert, pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der}), 0o644))
require.True(t, NeedsRenewal(out, time.Now()))
_, err = EnsureCerts(cfg)
require.NoError(t, err)
assert.False(t, NeedsRenewal(out, time.Now()), "the restart path must leave certs outside the window")
}
@@ -1,80 +0,0 @@
package podidentity
import (
"crypto/sha256"
"encoding/base64"
"encoding/json"
"encoding/pem"
"errors"
"fmt"
"net/url"
)
// WebhookName is the fully qualified name of the webhook entry.
const WebhookName = "pod-identity.spinifex.mulgadc.com"
// RenderWebhookConfiguration returns the MutatingWebhookConfiguration as JSON.
// The URL must be https on 127.0.0.1: each API server calls its own webhook, and
// failurePolicy Ignore keeps an unreachable injector from blocking pod creation.
func RenderWebhookConfiguration(c WebhookConfig) ([]byte, error) {
if c.Name == "" {
return nil, errors.New("webhook configuration needs a name")
}
u, err := url.Parse(c.URL)
if err != nil || u.Scheme != "https" || u.Hostname() != "127.0.0.1" {
return nil, fmt.Errorf("webhook url %q must be https on 127.0.0.1", c.URL)
}
if len(c.CA) == 0 {
return nil, errors.New("webhook configuration needs a CA bundle")
}
cfg := map[string]any{
"apiVersion": "admissionregistration.k8s.io/v1",
"kind": "MutatingWebhookConfiguration",
"metadata": map[string]any{"name": c.Name},
"webhooks": []map[string]any{{
"name": WebhookName,
"clientConfig": map[string]any{
"url": c.URL,
"caBundle": base64.StdEncoding.EncodeToString(c.CA),
},
"rules": []map[string]any{{
"operations": []string{"CREATE"},
"apiGroups": []string{""},
"apiVersions": []string{"v1"},
"resources": []string{"pods"},
"scope": "Namespaced",
}},
"failurePolicy": "Ignore",
"sideEffects": "None",
"timeoutSeconds": 5,
"admissionReviewVersions": []string{"v1"},
}},
}
return json.Marshal(cfg)
}
// BuildCABundle returns the system roots plus the gateway CA as one PEM bundle,
// without duplicate certificates.
func BuildCABundle(system, gateway []byte) []byte {
seen := map[[sha256.Size]byte]bool{}
var out []byte
for _, src := range [][]byte{system, gateway} {
for rest := src; ; {
var b *pem.Block
b, rest = pem.Decode(rest)
if b == nil {
break
}
if b.Type != "CERTIFICATE" {
continue
}
sum := sha256.Sum256(b.Bytes)
if seen[sum] {
continue
}
seen[sum] = true
out = append(out, pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: b.Bytes})...)
}
}
return out
}
-22
View File
@@ -1,22 +0,0 @@
package workerboot
import (
"context"
"github.com/mulgadc/spinifex/internal/sqsworkergw"
"github.com/mulgadc/spinifex/spinifex/agents/sqs/workerkey"
)
// GatewayClaimer adapts the signed gateway client to the receipt key decision.
type GatewayClaimer struct{ Client *sqsworkergw.Client }
var _ workerkey.Claimer = GatewayClaimer{}
func (c GatewayClaimer) Claim(ctx context.Context) (workerkey.Decision, error) {
k, err := c.Client.ClaimReceiptKey(ctx)
return workerkey.Decision{Pending: k.Pending, Fingerprint: k.Fingerprint, HostNodes: k.HostNodes}, err
}
func (c GatewayClaimer) RecordFingerprint(ctx context.Context, fingerprint string) error {
return c.Client.RecordReceiptKey(ctx, fingerprint)
}
+425 -36
View File
@@ -3,14 +3,22 @@ package workerboot
import (
"bytes"
"context"
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"crypto/sha256"
"crypto/x509"
"crypto/x509/pkix"
"encoding/hex"
"encoding/pem"
"errors"
"fmt"
"io"
"maps"
"math/big"
"net/http"
"net/http/httptest"
"os"
"slices"
"strings"
"sync"
@@ -25,16 +33,20 @@ import (
testutil "github.com/mulgadc/spinifex/internal/testkit"
"github.com/mulgadc/spinifex/spinifex/agents/sqs/workerkey"
sqsworkerapi "github.com/mulgadc/spinifex/spinifex/domains/sqs/awsapi/worker"
"github.com/mulgadc/spinifex/spinifex/domains/sqs/firststore"
"github.com/mulgadc/spinifex/spinifex/domains/sqs/storelaunch"
"github.com/mulgadc/spinifex/spinifex/domains/sqs/workerident"
"github.com/mulgadc/spinifex/spinifex/domains/sqs/workerrole"
awsidentifiers "github.com/mulgadc/spinifex/spinifex/foundation/aws/identifiers"
"github.com/mulgadc/spinifex/spinifex/foundation/state/clustersize"
"github.com/mulgadc/spinifex/spinifex/ingress/aws/dispatch"
"github.com/nats-io/nkeys"
)
const (
gwInstance = "i-0abc12345def67890"
gwVolume = "vol-0a1b2c3d4e5f60718"
gwInstance = "i-0abc12345def67890"
gwSuccessor = "i-0fedcba9876543210"
gwVolume = "vol-0a1b2c3d4e5f60718"
)
// session is one instance-role credential: its secret and the instance ID
@@ -218,55 +230,58 @@ func (g *localGateway) reply(code string) {
}
}
// gwCluster is the domain's real worker-identity record behind the gateway.
type gwCluster struct {
ids workerident.Store
gw *localGateway
func (g *localGateway) sentBodies() []string {
g.mu.Lock()
defer g.mu.Unlock()
return slices.Clone(g.bodies)
}
// newGWCluster hands the gateway's work actions to work and has it report
// hostNodes as its cluster size; nil hostNodes is the declared size.
func newGWCluster(t *testing.T, work sqsworkerapi.Work, hostNodes func() int) gwCluster {
// gwCluster is the domain's real worker-identity and first-store records.
type gwCluster struct {
ids workerident.Store
fence firststore.Store
stores storelaunch.Records
gw *localGateway
}
func newGWCluster(t *testing.T) gwCluster {
t.Helper()
clustersize.DeclareForTest(t, 1)
_, _, js := testutil.StartTestJetStream(t)
ids, err := workerident.Open(context.Background(), js)
ctx := context.Background()
ids, err := workerident.Open(ctx, js)
if err != nil {
t.Fatal(err)
}
deps := sqsworkerapi.JetStreamDeps(js)
deps.Work = work
deps.HostNodes = hostNodes
return gwCluster{ids: ids, gw: newLocalGateway(t, deps)}
fence, err := firststore.Open(ctx, js)
if err != nil {
t.Fatal(err)
}
stores, err := storelaunch.Open(ctx, js)
if err != nil {
t.Fatal(err)
}
return gwCluster{ids: ids, fence: fence, stores: stores, gw: newLocalGateway(t, sqsworkerapi.JetStreamDeps(js))}
}
// launch records a worker on a newly created volume, so its receipt key is
// pending, and binds it to inst.
func (c gwCluster) launch(t *testing.T, inst string) workerident.Intent {
func (c gwCluster) launch(t *testing.T, claim string, supersedes uint64, inst string) workerident.Intent {
t.Helper()
ctx := context.Background()
in, err := c.ids.BeginLaunch(ctx, testRegion, workerident.LaunchRequest{
InstanceType: "t3.small", ClaimID: "claim-0001", NodeID: "node-a", DataVolumeEncrypted: true, SizeGiB: 8,
in, err := c.ids.BeginLaunch(ctx, testRegion, workerident.LaunchRequest{InstanceType: "t3.small",
ClaimID: claim, Supersedes: supersedes, NodeID: "node-a", DataVolumeID: gwVolume, DataVolumeEncrypted: true,
})
if err != nil {
t.Fatal(err)
}
for _, step := range []func() error{
func() error { return c.ids.MarkVolumeAttempted(ctx, in) },
func() error { return c.ids.RecordVolume(ctx, in, gwVolume) },
func() error { return c.ids.MarkDispatched(ctx, in) },
func() error { return c.ids.BindInstance(ctx, in, inst) },
} {
if err := step(); err != nil {
t.Fatal(err)
}
if err := c.ids.BindInstance(ctx, in, inst); err != nil {
t.Fatal(err)
}
return in
}
// guestFor points the fake guest's boot at the local gateway, signing with
// the instance's own credential through the real client.
func (c gwCluster) guestFor(t *testing.T, g *guest, accessKey, inst string) *gwsign.Signer {
func (c gwCluster) guestFor(t *testing.T, g *guest, accessKey, inst string) {
t.Helper()
signer := c.gw.issue(accessKey, "secret-"+accessKey, inst)
g.fw("data-serial", testSerial)
@@ -274,14 +289,388 @@ func (c gwCluster) guestFor(t *testing.T, g *guest, accessKey, inst string) *gws
g.fw("gateway-url", c.gw.srv.URL)
g.cfg.Wait = 10 * time.Millisecond
g.cfg.NewClaimer = func(region, url string) (workerkey.Claimer, error) {
cl, err := sqsworkergw.New(url, region, c.gw.caPEM(), signer)
return GatewayClaimer{Client: cl}, err
return sqsworkergw.New(url, region, c.gw.caPEM(), signer)
}
return signer
}
func (g *localGateway) sentBodies() []string {
g.mu.Lock()
defer g.mu.Unlock()
return slices.Clone(g.bodies)
func (c gwCluster) assertNoSeedSent(t *testing.T, seed string) {
t.Helper()
seed = strings.TrimSpace(seed)
for _, b := range c.gw.sentBodies() {
if strings.Contains(b, seed) {
t.Fatal("a request carried seed material")
}
}
}
// A bound worker boots: the gateway authorizes creation, the guest creates
// its key on the mounted disk and records only the public key; reboots get
// that same key back from the domain.
func TestGateway_FirstBootCreatesKeyAndRebootsReuseIt(t *testing.T) {
c := newGWCluster(t)
c.launch(t, "claim-0001", 0, gwInstance)
g := newGuest(t)
c.guestFor(t, g, "ASIAWORKER1", gwInstance)
pub, err := g.boot()
if err != nil {
t.Fatal(err)
}
seed := g.seed()
for boot := 2; boot <= 3; boot++ {
g.resetMounts()
got, err := g.boot()
if err != nil || got != pub || g.seed() != seed {
t.Fatalf("reboot %d = %q, %v", boot, got, err)
}
}
records := 0
for _, b := range c.gw.sentBodies() {
if strings.Contains(b, "Action=RecordWorkerPublicKey") {
records++
if !strings.Contains(b, "PublicKey="+pub) {
t.Fatal("a record carried a different key")
}
}
}
if records != 1 {
t.Fatalf("records = %d, want 1", records)
}
c.assertNoSeedSent(t, seed)
g.assertNoSeedLeak(nil, seed)
}
// The record's reply is lost after it landed: the boot retries, the domain
// answers with the recorded key, and the same key is used.
func TestGateway_LostRecordReplyKeepsTheKey(t *testing.T) {
c := newGWCluster(t)
c.launch(t, "claim-0001", 0, gwInstance)
g := newGuest(t)
c.guestFor(t, g, "ASIAWORKER1", gwInstance)
var dropped bool
c.gw.dropReply = func(action string) bool {
if action == "RecordWorkerPublicKey" && !dropped {
dropped = true
return true
}
return false
}
pub, err := g.boot()
if err != nil || !dropped {
t.Fatalf("boot = %q, %v (dropped=%v)", pub, err, dropped)
}
g.resetMounts()
if again, err := g.boot(); err != nil || again != pub {
t.Fatalf("reboot = %q, %v", again, err)
}
c.assertNoSeedSent(t, g.seed())
}
// After the key is recorded, a lost guest copy fails closed: the domain
// answers with the recorded key and no replacement is created or sent.
func TestGateway_KeyLostAfterRecordingFailsClosed(t *testing.T) {
c := newGWCluster(t)
c.launch(t, "claim-0001", 0, gwInstance)
g := newGuest(t)
c.guestFor(t, g, "ASIAWORKER1", gwInstance)
if _, err := g.boot(); err != nil {
t.Fatal(err)
}
if err := os.Remove(g.keyFile()); err != nil {
t.Fatal(err)
}
before := len(c.gw.sentBodies())
g.resetMounts()
if _, err := g.boot(); !errors.Is(err, workerkey.ErrKeyMissing) {
t.Fatalf("err = %v, want ErrKeyMissing", err)
}
if _, err := os.Lstat(g.keyFile()); !errors.Is(err, os.ErrNotExist) {
t.Fatal("a replacement key was created")
}
for _, b := range c.gw.sentBodies()[before:] {
if strings.Contains(b, "RecordWorkerPublicKey") {
t.Fatal("a replacement key was offered")
}
}
}
// An instance the domain never bound, or one already replaced, is refused
// at the first call and creates no key; AccessDenied is not retried.
func TestGateway_UnboundOrReplacedInstanceIsRefused(t *testing.T) {
t.Run("unbound", func(t *testing.T) {
c := newGWCluster(t)
c.launch(t, "claim-0001", 0, gwInstance)
g := newGuest(t)
c.guestFor(t, g, "ASIAOTHER", gwSuccessor)
_, err := g.boot()
api, ok := errors.AsType[*sqsworkergw.APIError](err)
if !ok || api.Code != "AccessDenied" {
t.Fatalf("err = %v, want AccessDenied", err)
}
if n := len(c.gw.sentBodies()); n != g.cfg.Attempts {
t.Fatalf("calls = %d, want the %d attempts and no more", n, g.cfg.Attempts)
}
g.assertNoKey()
})
t.Run("replaced", func(t *testing.T) {
c := newGWCluster(t)
first := c.launch(t, "claim-0001", 0, gwInstance)
ctx := context.Background()
if err := c.ids.RecordStopped(ctx, first, gwInstance); err != nil {
t.Fatal(err)
}
if err := c.fence.Fence(ctx, testRegion, first.Generation+1); err != nil {
t.Fatal(err)
}
c.launch(t, "claim-0002", first.Generation, gwSuccessor)
g := newGuest(t)
c.guestFor(t, g, "ASIAOLD", gwInstance)
if _, err := g.boot(); err == nil {
t.Fatal("the replaced instance booted")
}
g.assertNoKey()
})
}
// A bad signature is refused before any decision, and creates no key.
func TestGateway_BadSignatureCreatesNoKey(t *testing.T) {
c := newGWCluster(t)
c.launch(t, "claim-0001", 0, gwInstance)
g := newGuest(t)
c.guestFor(t, g, "ASIAWORKER1", gwInstance)
forged := gwsign.NewStatic("ASIAWORKER1", "not-the-secret")
g.cfg.NewClaimer = func(region, url string) (workerkey.Claimer, error) {
return sqsworkergw.New(url, region, c.gw.caPEM(), forged)
}
if _, err := g.boot(); err == nil {
t.Fatal("a forged signature booted")
}
g.assertNoKey()
}
// The client trusts only the cluster CA it was given: a gateway whose
// certificate another CA signed is never sent a request.
func TestGateway_UntrustedServerIsNotContacted(t *testing.T) {
c := newGWCluster(t)
c.launch(t, "claim-0001", 0, gwInstance)
g := newGuest(t)
c.guestFor(t, g, "ASIAWORKER1", gwInstance)
g.cfg.Attempts = 1
signer := c.gw.issue("ASIAWORKER1", "secret-ASIAWORKER1", gwInstance)
g.cfg.NewClaimer = func(region, url string) (workerkey.Claimer, error) {
return sqsworkergw.New(url, region, otherCA(t), signer)
}
if _, err := g.boot(); err == nil || !strings.Contains(err.Error(), "certificate") {
t.Fatalf("err = %v, want a TLS refusal", err)
}
if len(c.gw.sentBodies()) != 0 {
t.Fatal("a request reached a server outside the trusted CA")
}
g.assertNoKey()
}
// otherCA is a fresh self-signed CA unrelated to the test server's.
func otherCA(t *testing.T) []byte {
t.Helper()
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
t.Fatal(err)
}
tmpl := &x509.Certificate{
SerialNumber: big.NewInt(1), Subject: pkix.Name{CommonName: "other"},
NotBefore: time.Now().Add(-time.Hour), NotAfter: time.Now().Add(time.Hour),
IsCA: true, BasicConstraintsValid: true, KeyUsage: x509.KeyUsageCertSign,
}
der, err := x509.CreateCertificate(rand.Reader, tmpl, tmpl, &key.PublicKey, key)
if err != nil {
t.Fatal(err)
}
return pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der})
}
// The worker VM starts before its launch binds the instance, so its first
// calls are denied. It creates nothing while denied, and boots once bound.
func TestGateway_BootBeforeBindSucceedsAfterBinding(t *testing.T) {
c := newGWCluster(t)
ctx := context.Background()
intent, err := c.ids.BeginLaunch(ctx, testRegion, workerident.LaunchRequest{InstanceType: "t3.small",
ClaimID: "claim-0001", NodeID: "node-a", DataVolumeID: gwVolume, DataVolumeEncrypted: true,
})
if err != nil {
t.Fatal(err)
}
g := newGuest(t)
c.guestFor(t, g, "ASIAWORKER1", gwInstance)
g.cfg.Attempts = 1000
c.gw.replied = make(chan string, 2000)
type result struct {
pub string
err error
}
done := make(chan result, 1)
go func() {
pub, err := g.boot()
done <- result{pub, err}
}()
if code := <-c.gw.replied; code != "AccessDenied" {
t.Fatalf("first reply = %q, want AccessDenied before binding", code)
}
g.assertNoKey()
if err := c.ids.BindInstance(ctx, intent, gwInstance); err != nil {
t.Fatal(err)
}
r := <-done
if r.err != nil || r.pub == "" {
t.Fatalf("boot after binding = %q, %v", r.pub, r.err)
}
if g.seed() == "" {
t.Fatal("booted without a key on the worker disk")
}
}
// A replaced worker is never bound again: it keeps being denied, creates
// nothing and gives up within its attempts.
func TestGateway_StaleWorkerGivesUpWithinTheBound(t *testing.T) {
c := newGWCluster(t)
ctx := context.Background()
first := c.launch(t, "claim-0001", 0, gwInstance)
if err := c.ids.RecordStopped(ctx, first, gwInstance); err != nil {
t.Fatal(err)
}
if err := c.fence.Fence(ctx, testRegion, first.Generation+1); err != nil {
t.Fatal(err)
}
c.launch(t, "claim-0002", first.Generation, gwSuccessor)
g := newGuest(t)
c.guestFor(t, g, "ASIAOLD", gwInstance)
g.cfg.Attempts = 5
_, err := g.boot()
if api, ok := errors.AsType[*sqsworkergw.APIError](err); !ok || !api.Pending() {
t.Fatalf("err = %v, want the denial", err)
}
if n := len(c.gw.sentBodies()); n != 5 {
t.Fatalf("calls = %d, want exactly the 5 attempts", n)
}
g.assertNoKey()
}
// bindStore runs the store launch record through to bound for pub at address,
// as the operator's EnsureSQSStore would.
func (c gwCluster) bindStore(t *testing.T, pub, address string) {
t.Helper()
ctx := context.Background()
const claim = "store-claim-0001"
steps := []func() error{
func() error { return c.stores.Begin(ctx, testRegion, claim, "node-a", pub, "t3.medium", 8) },
func() error { return c.stores.MarkVolumeAttempted(ctx, testRegion, claim) },
func() error { return c.stores.RecordVolume(ctx, testRegion, claim, "vol-0b1c2d3e4f5a60718") },
func() error { return c.stores.MarkNetworkAttempted(ctx, testRegion, claim) },
func() error { return c.stores.MarkDispatched(ctx, testRegion, claim) },
func() error { return c.stores.Bind(ctx, testRegion, claim, "i-0123456789abcdef0", address) },
}
for i, step := range steps {
if err := step(); err != nil {
t.Fatalf("store launch step %d: %v", i, err)
}
}
}
// readyFor points the guest's readiness at the local gateway and a store on port.
func (c gwCluster) readyFor(g *guest, accessKey, inst string, port int) ReadyConfig {
signer := c.gw.issue(accessKey, "secret-"+accessKey, inst)
return ReadyConfig{
Config: g.cfg,
NewDescriber: func(region, url string) (StoreDescriber, error) {
cl, err := sqsworkergw.New(url, region, c.gw.caPEM(), signer)
if err != nil {
return nil, err
}
return gwDescriber{cl}, nil
},
Checker: NATSChecker{Port: port, Dial: time.Second},
}
}
type gwDescriber struct{ c *sqsworkergw.Client }
func (d gwDescriber) DescribeStore(ctx context.Context) (Store, error) {
st, err := d.c.DescribeStore(ctx)
return Store(st), err
}
// The whole path with real components: the worker boots and records its key,
// waits while the operator has not launched the store, then reaches the real
// store's JetStream with its own key once the store is bound; after a reboot
// it is ready again with the same key. No request carries the seed.
func TestGateway_ReadyAfterADelayedStoreLaunch(t *testing.T) {
c := newGWCluster(t)
c.launch(t, "claim-0001", 0, gwInstance)
g := newGuest(t)
c.guestFor(t, g, "ASIAWORKER1", gwInstance)
pub, err := g.boot()
if err != nil {
t.Fatal(err)
}
port := storeServer(t, pub)
err = g.watch(c.readyFor(g, "ASIAWORKER1", gwInstance, port), func(call int, ready bool) bool {
if ready {
if call < 4 {
t.Fatalf("ready at call %d, before the store launch", call)
}
return true
}
if call == 3 {
c.bindStore(t, pub, "127.0.0.1")
}
return false
})
if !errors.Is(err, context.Canceled) {
t.Fatalf("watch = %v", err)
}
for boot := 1; boot <= 2; boot++ {
g.resetMounts()
if again, err := g.boot(); err != nil || again != pub {
t.Fatalf("reboot %d = %q %v", boot, again, err)
}
if err := g.watch(c.readyFor(g, "ASIAWORKER1", gwInstance, port), func(_ int, ready bool) bool { return ready }); !errors.Is(err, context.Canceled) {
t.Fatalf("ready after reboot %d: %v", boot, err)
}
}
c.assertNoSeedSent(t, g.seed())
g.assertNoSeedLeak(nil, g.seed())
}
// A store bound to a key other than this worker's is never checked, and a
// replaced worker cannot read the store at all; neither is ever ready.
func TestGateway_ReadyRefusesWrongKeyAndReplacedWorker(t *testing.T) {
c := newGWCluster(t)
first := c.launch(t, "claim-0001", 0, gwInstance)
g := newGuest(t)
c.guestFor(t, g, "ASIAWORKER1", gwInstance)
if _, err := g.boot(); err != nil {
t.Fatal(err)
}
other, _ := nkeys.CreateUser()
otherPub, _ := other.PublicKey()
c.bindStore(t, otherPub, "127.0.0.1")
port := storeServer(t, otherPub)
never := func(call int, ready bool) bool {
if ready {
t.Fatal("ready against a store for another key")
}
return call == 3
}
_ = g.watch(c.readyFor(g, "ASIAWORKER1", gwInstance, port), never)
if !slices.ContainsFunc(g.logs, func(l string) bool { return strings.Contains(l, ErrStoreKeyMismatch.Error()) }) {
t.Fatalf("no mismatch reported: %v", g.logs)
}
if err := c.ids.RecordStopped(context.Background(), first, gwInstance); err != nil {
t.Fatal(err)
}
_ = g.watch(c.readyFor(g, "ASIAWORKER1", gwInstance, port), never)
if !strings.Contains(g.logs[len(g.logs)-1], "AccessDenied") {
t.Fatalf("stopped worker: %v", g.logs[len(g.logs)-1])
}
}
+27 -14
View File
@@ -10,14 +10,15 @@ import (
"github.com/nats-io/nats.go"
"github.com/nats-io/nats.go/jetstream"
"github.com/nats-io/nkeys"
)
// StorePort is the port the guest's own SQS NATS server listens on, bound
// to loopback only; nothing outside the guest can reach it.
// StorePort is the port the store guest's NATS listens on.
const StorePort = 4222
// NATSChecker asks the guest's loopback JetStream for its account info. A
// pass says the local store is up and serving JetStream.
// NATSChecker connects with the worker key and asks JetStream for its account
// info. It authenticates the client only: there is no TLS, so a pass says
// something answering at the address accepted the key.
type NATSChecker struct {
// Port overrides StorePort, for tests against a local server.
Port int
@@ -34,22 +35,36 @@ func (e *StoreUnreachableError) Error() string { return "store not answering:
func (e *StoreUnreachableError) Unwrap() error { return e.Err }
func (e *StoreUnreachableError) Retryable() bool { return true }
func localURL(port int) string {
// ErrStoreRefusedKey is a store that answered but refused the worker key.
var ErrStoreRefusedKey = errors.New("the store refused the worker key")
// Check passes once JetStream answers for the worker key.
func (c NATSChecker) Check(ctx context.Context, address string, kp nkeys.KeyPair) error {
if ip := net.ParseIP(address); ip == nil || ip.To4() == nil {
return errors.New("store address is not an IPv4 address")
}
pub, err := kp.PublicKey()
if err != nil {
return fmt.Errorf("derive worker public key: %w", err)
}
port := c.Port
if port == 0 {
port = StorePort
}
return "nats://" + net.JoinHostPort("127.0.0.1", strconv.Itoa(port))
}
// Check passes once the local JetStream answers.
func (c NATSChecker) Check(ctx context.Context) error {
dial := c.Dial
if dial == 0 {
dial = 5 * time.Second
}
nc, err := nats.Connect(localURL(c.Port), nats.Timeout(dial), nats.NoReconnect(),
nats.Name("spinifex-sqs-worker-readiness"))
nc, err := nats.Connect("nats://"+net.JoinHostPort(address, strconv.Itoa(port)),
nats.Nkey(pub, kp.Sign),
nats.Timeout(dial),
nats.NoReconnect(),
nats.Name("spinifex-sqs-worker-readiness"),
)
if err != nil {
if errors.Is(err, nats.ErrAuthorization) || errors.Is(err, nats.ErrAuthExpired) {
return fmt.Errorf("%w: %w", ErrStoreRefusedKey, err)
}
return &StoreUnreachableError{Err: err}
}
defer nc.Close()
@@ -64,5 +79,3 @@ func (c NATSChecker) Check(ctx context.Context) error {
}
return nil
}
var errNoChecker = errors.New("no store checker is configured")

Some files were not shown because too many files have changed in this diff Show More