Files
spinifex/scripts/images/eks-node/mulga-eks-addon-sync.sh
T
Josh Siviour 5025a37006 feat: Containers — internal-ECR pulls, DNS-free registry, EKS hardening + docs (#409)
* docs: add Containers section with EKS and ECR service pages

New docs/containers/{eks,ecr} pages covering each service via AWS CLI, the Spinifex console, and Terraform, plus the requisite VPC/IGW/NAT/SG/IAM setup. EKS page embeds console screenshots. Cross-links the EKS Terraform workbooks.

* docs(eks): rework Terraform workbooks into a capability ladder

Rework the three EKS workbooks into a coherent ladder and publish them to
the Containers section. All tiers are single- or three-node capable via
node_desired_size and serve a shared Spinifex-themed demo app from ECR,
replacing nginxdemos/hello.

- demo-app: new std-lib Go web app (themed page reporting pod/node/cluster/
  region + hit counter; optional /data persistence), distroless image.
- eks-quickstart: node_desired_size var, ECR repo + themed app over NodePort.
- eks-https-ingress (was eks-addons-access): HTTPS via the aws-load-balancer-
  controller addon + ACM, public/private VPC with NAT, managed-ingress=false.
- eks-gitops-argocd (was eks-https-ingress): Argo CD addon syncs the app from
  a git repo, aws-ebs-csi-driver PVC (Viperblock-backed) for state, access
  entry RBAC, HTTPS Ingress carried over.

Pending live e2e verification (image build/push, apply, ALB + PVC bind).

* fix(ecr): implement ListTagsForResource (empty tags) for client parity

The AWS provider calls ecr:ListTagsForResource on every repository read, which
returned 501 NotImplementedException and broke 'tofu apply' of any workbook that
creates an aws_ecr_repository. Implement it to return an empty tag set (resource
tagging stays a deferred feature) so reads succeed. Drop the tags block from the
three EKS workbooks' ECR repos so the provider doesn't call the still-stubbed
TagResource on create.

* feat(eks): pull from internal ECR via kubelet credential provider

Worker nodes could not pull custom images from the internal Spinifex ECR
registry: the eks-node had no DNS for the registry host, no containerd CA
trust, no port rewrite to :9999, and no pull credentials. Only public
images worked.

Add cmd/ecr-credential-provider, a kubelet exec credential provider that
fetches the node IAM-role credentials from IMDS, calls GetAuthorizationToken
against the gateway ECR endpoint, and returns the minted AWS:<jwt> as Basic
auth for the registry host. Thread the gateway URL, account, region, and
internal suffix into the agent userdata, which now writes the gateway CA,
a registries.yaml (CA trust + :9999 endpoint), the kubelet
credential-provider config and config.yaml.d drop-in, and an /etc/hosts
entry mapping the registry host to the gateway IP. Ship the provider binary
in the eks-node AMI.

Also remove a stray paragraph from the admin-cli README.

* fix: dial EKS ECR registry mirror by gateway IP

cloud-init manage_etc_hosts rewrites /etc/hosts from the alpine template on
every boot, wiping the registry-host mapping appended via bootcmd, so worker
nodes could not resolve the internal ECR host and pulls failed with no such
host. Point the k3s registries.yaml mirror endpoint and tls config at the
gateway IP instead (the gateway cert carries an IP SAN), keeping the pod image
ref port-less and dropping the DNS leg until northstar lands. Fall back to the
hostname endpoint when the gateway is not an IP.

* fix: attach node-role instance profile to EKS workers for ECR

EKS workers launched with no IAM instance profile, so IMDS served no role and
the ECR credential provider could not mint a registry token, leaving internal
ECR pulls failing with 401. Real EKS implicitly creates an instance profile for
the node role; do the same here. Wire a KV-backed IAM service into the EKS deps
and, at worker launch, find-or-create an instance profile named for the node
role, attach the role, and pass it to RunInstances so IMDS exposes the role to
the credential provider.

* feat: use mulga logo in EKS demo app

* feat: add ACM certificate tag support (List/Add/RemoveTags)

Implements ListTagsForCertificate, AddTagsToCertificate and RemoveTagsFromCertificate across the store, service, NATS, daemon and gateway layers. Import now persists tags. Fixes terraform aws_acm_certificate InvalidActionException on ListTagsForCertificate.

* test: cover ACM tag gateway and NATS wrapper paths

* feat: support EKS nodegroup tags (Tag/Untag/ListTags)

Extends EKS tag ops to back nodegroup ARNs alongside cluster ARNs. Nodegroup create-time tags persist on the record and echo via DescribeNodegroup, giving the terraform-aws provider a clean default_tags round-trip. Fixes aws_eks_node_group TagResource 501 NotImplementedException.

* fix: provision EKS ALB ingress via node credentials

The aws-load-balancer-controller addon could not provision an ALB. Three fixes:

- TLS: the controller's per-service ec2/elbv2/acm SDK clients install their own
  BaseEndpoint resolver and build an HTTP client that bypasses AWS_CA_BUNDLE,
  falling back to the Go system trust pool and rejecting the gateway cert as
  'unknown authority'. Add SSL_CERT_FILE so the gateway CA joins the system pool.
- Credentials: an empty AWS_ROLE_ARN beside the web-identity token file
  half-arms the SDK web-identity provider, which then signs anonymously
  (MissingAuthenticationToken). Inject the IRSA env only when a service-account
  role ARN is staged; otherwise omit it so the default chain falls back to the
  node instance-profile credentials served over IMDS.
- Authorization: the node role lacked the ec2/elbv2 actions the controller
  needs. The eks-https-ingress workbook now attaches an AWSLoadBalancerController
  managed policy to the node role (CreatePolicy + AttachRolePolicy).

* fix: add force_delete to EKS workbook demo ECR repos

tofu destroy of an EKS workbook left the demo ECR repository behind: it
still holds the pushed demo image, so DeleteRepository (force=false)
returns RepositoryNotEmptyException. Add force_delete = true to each
workbook's aws_ecr_repository.demo so teardown removes the repo with its
images.

* fix: force-delete EKS control-plane ENIs on teardown

TerminateK3sServerVM deleted the CP ENI with a plain force=false
DeleteNetworkInterface. eniIsLiveAttachment reports InUse whenever the
record's Status/InstanceId/AttachmentId are set, so when the K3s VM is
gone but the ENI record's attachment fields were never cleared the delete
returned InvalidNetworkInterface.InUse permanently and EKSDeletingReaper
re-drove the same non-idempotent call every 90s, wedging the cluster in
DELETING.

Teardown owns its CP ENIs and has already terminated the VM, so the
attachment is authoritatively dead. Replace the InUse-retry budget loop
with detach-then-delete (detachAndDeleteServerENI): DetachENI clears the
stale attachment fields, then DeleteNetworkInterface passes the in-use
guard. Both calls tolerate an already-gone ENI (NotFound) so a race with
the async instance-terminate cascade resolves to idempotent success.
purgeClusterInfra's private-endpoint ENI path reuses the same helper.

* fix: detach+delete worker ENIs on EKS nodegroup teardown

A terminated nodegroup worker could leave its primary ENI in
spinifex-vpc-enis as in-use/attached, and a dangling ENI throws
DependencyViolation on subnet/VPC delete, blocking tofu destroy. Two
gaps:

TerminateWorkerInstances did a local-only vmMgr.Get and silently skipped
any worker owned by another node, stranding both the VM and its ENI on
multi-node clusters. Route the terminate per-instance via ec2.cmd.<id>
(mirroring the gateway TerminateInstances path) so the owning daemon runs
the full teardown — detach + force-delete the primary ENI and clear its
record. No responder / NotFound payload is treated as already-gone so a
retried DeleteNodegroup stays idempotent.

deleteInstanceENI (stopped-instance terminate) issued a force=false
DeleteNetworkInterface with no prior detach: a stale in-use attachment
made it fail InvalidNetworkInterface.InUse, leaving the ENI record behind
after the instance was already written to the terminated bucket. Detach
first, then delete, tolerating NotFound.

* fix: heal volume State drift on detach/terminate teardown

A detach/terminate could leave a volume's State empty instead of
'available'. DeleteVolume gated on State=='available' exactly, so it
returned VolumeInUse for an unattached volume nothing was using, leaving
it undeletable and blocking stack teardown. DescribeVolumes cosmetically
rendered an empty State as 'available', masking the drift.

- DeleteVolume now treats an empty State with no attachment as deletable
  (an attached volume still returns VolumeInUse).
- DescribeVolumes derives an empty State from the attachment (attached ->
  in-use, unattached -> available) instead of blindly rendering
  available, so an empty-but-attached volume is no longer hidden.
- UpdateVolumeState normalizes an unattached volume with an empty state to
  available, so a detach writeback that omits the state cannot strand the
  volume in drift.

* fix: break all SG cross-references on EKS teardown

DeleteClusterSGs broke the cp<->nodegroup cycle too narrowly. It revoked
only ingress (IpPermissions), never egress (IpPermissionsEgress), so a
cross-reference via an egress rule kept the SG pinned on
DependencyViolation. It also resolved only the two named cluster SGs, so
any OTHER SG in the VPC referencing them — the default SG, the LBC/ALB SG,
the private-endpoint SG, user/TF SGs — was never revoked and pinned the
referenced SG on delete, leaving the VPC stuck.

Enumerate every non-default SG in the VPC and revoke BOTH ingress and
egress on each before deleting, breaking any cycle order-independently,
then delete only the cluster-owned SGs (referrer SGs are deleted by their
own teardown). The default SG is left untouched. Since DeleteClusterSGs
runs against both the customer VPC and the managed control-plane VPC, the
broadened sweep covers both teardown paths.

* feat: support TargetGroup ProtocolVersion in elbv2

CreateTargetGroup now stores ProtocolVersion (defaulting HTTP/HTTPS target
groups to HTTP1) and DescribeTargetGroups/Create output return it. Without
this the aws-load-balancer-controller sees a perpetual immutable-field
mismatch (desired HTTP1 vs empty) and recreates the target group every
reconcile, looping on DuplicateTargetGroupName once a shared ALB IngressGroup
reconciles more than one target group. NLB protocols (TCP/UDP/TLS) keep it
omitted to match AWS.

* fix: omit IRSA env from ebs-csi addon when no role staged

The ebs-csi controller manifest hardcoded AWS_ROLE_ARN={{SERVICE_ACCOUNT_ROLE_ARN}}
plus the web-identity token volume. With no service-account role staged the
placeholder rendered empty, half-arming the SDK web-identity provider, which
panics 'role ARN is not set' and CrashLoops the controller, leaving PVCs
Pending. Convert to the conditional {{IRSA_ENV}}/{{IRSA_VOLUME_MOUNT}}/{{IRSA_VOLUME}}
markers like the LBC bundle so the IRSA env and token volume are omitted when
no role is staged, falling back to node instance-profile credentials over IMDS.
The EBS-specific AWS_EC2_ENDPOINT/AWS_ENDPOINT_URL_EC2 stay in both modes.
Requires an eks-node AMI rebake to take effect.

* docs: route gitops-argocd workbook through a shared ALB and expose ArgoCD UI

Fold the demo app and ArgoCD ingresses onto one ALB via an LBC IngressGroup
with host routing (app./argocd. on a wildcard cert) instead of one ALB per
ingress, add a NodePort + HTTPS Ingress for the ArgoCD UI, and document the
access steps. Add the node-role LBC and EBS-CSI IAM policies the controllers
need (Spinifex wires controller creds at the node level, not IRSA) and an
ignore_changes block for the cluster's access_config and
vpc_config security_group_ids, which Spinifex DescribeCluster does not echo
back, so the provider stops trying to replace the cluster on every plan.

* fix: stop addon-sync substituting IRSA markers named in comments

The ebs-csi controller manifest's header comment spelled out the literal
{{IRSA_ENV}} / {{IRSA_VOLUME_MOUNT}} / {{IRSA_VOLUME}} marker tokens. addon-sync
matched each marker by substring, so the comment lines were replaced with the
env/mount blocks, injecting a bare YAML sequence between two documents. K3s'
auto-deploy then rejected the whole file (ParseManifestFailed: cannot unmarshal
array into unstructured), so the ebs-csi Addon CR never applied and the EKS
addon hung in CREATING. Reword the comment to not contain the literal tokens,
and anchor the marker substitution to lines that are exactly the marker
(whitespace aside) so a future comment mentioning a marker can't retrigger it.

* fix: disable K3s local-storage on EKS clusters in parity mode

In AWS LB Controller parity mode (BuiltinIngress=false) the K3s server
disabled traefik and servicelb but left local-storage enabled. Its local-path
provisioner ships a StorageClass marked default, which races the EBS CSI
ebs-gp3 default StorageClass — two defaults, so dynamic PVCs bind
nondeterministically. EKS has no local-path, so disable local-storage too and
let ebs-gp3 be the sole default. The built-in-ingress path is unchanged.

* fix: re-encrypt HTTPS target groups in haproxy ALB backends

An ALB target group with backend-protocol HTTPS (e.g. argocd-server, which
serves TLS on its own port) rendered a plaintext haproxy server line. HAProxy's
own check then spoke HTTP to a TLS port, failed, and marked the server DOWN,
returning 503 — even though the lb-agent's separate HTTPS health probe reported
the target healthy, so describe-target-health masked the fault.

Emit check-ssl ssl verify none on the server line for HTTPS target groups so
both the check and proxied traffic re-encrypt to the backend.

* fix: reconnect wedged QMP client on next command

A timed-out QMP decode under host contention left the cached client's
shared json.Decoder mid-message, wedging every later command and breaking
EBS AttachVolume/DetachVolume for the VM's lifetime. Mark the client Dead
on encode/decode error and redial the retained socket path (re-running the
qmp_capabilities handshake in place) on the next send.

* fix: round-trip security-group rule descriptions

SG ingress/egress rules dropped the per-rule Description on store and
describe. The AWS Load Balancer Controller tags the worker-node SG rules it
manages with a UserIdGroupPair description and identifies them by that value
to revoke them on teardown. Without the round-trip the controller cannot
match its rules, never revokes them, and the backend security group stays
referenced — DeleteSecurityGroup returns DependencyViolation forever,
hanging the ingress finalizer and tofu destroy with orphaned security groups.

Persist Description on SGRule (excluded from sgRuleKey so it is metadata, not
identity) and render it back through describe.

* fix: reclaim customer-VPC nodegroup SGs on managed-CP-VPC teardown

In the managed control-plane VPC topology, launchNodegroupInfra creates the
cluster control-plane and nodegroup security groups in the customer VPC for
worker<->control-plane networking, but purgeClusterInfra only reclaimed the
security groups in the managed CP VPC. The customer-VPC SGs orphaned,
cross-referenced each other, and pinned the customer VPC with
DependencyViolation, hanging tofu destroy of the VPC. Reclaim them in the
managed-CP-VPC branch too, matching the legacy customer-VPC branch.

* fix: reclaim ALB EIP when system VM already gone on terminate

TerminateSystemInstance only released an internet-facing ALB's Elastic IP on
the local path, where the VM is still present in the manager. When the VM was
already gone (health-reaped, crashed, or raced by ENI teardown), it took the
remote/no-owner path and returned idempotent success without reconciling the
EIP, leaking the wan-pool address still associated to a deleted ENI.

Add EIPServiceImpl.ReleaseAddressByInstanceID, an idempotent account-agnostic
scan that disassociates then releases any EIP recorded against an instance, and
call it as a backstop in TerminateSystemInstance for both branches.

* fix: cascade-prune demo PVC via Argo CD finalizer in eks-gitops workbook

The demo Application carried no resources finalizer, so tofu destroy removed
the Application CR without pruning the synced PVC. The PVC survived, the cluster
was destroyed, and the Delete-reclaim PV's EBS volume orphaned. Add the
resources-finalizer.argocd.argoproj.io finalizer so deleting the Application
cascades to the PVC and ebs-csi reaps the volume before cluster teardown.

* feat: raw-IP access + documented Argo CD creds in eks-gitops workbook

The shared ALB host-routed everything, so the raw ALB IP 404'd in a browser
without an /etc/hosts entry. Make the app the hostless catch-all on :443 and add
a hostless Argo CD listener on :8443, so https://<alb-ip>/ serves the app and
https://<alb-ip>:8443/ serves the UI with no DNS. Surface the admin-password
command as a tofu output and document the credential + IP URLs in the README.

* fix: reclaim ALB EIP on the owning-node terminate path

The EIP backstop lived only in TerminateSystemInstance, but the ALB
delete path goes serveSystemTerminateInstance -> terminateSystemInstanceLocal
directly, bypassing it, and releaseSystemInstanceEIP relied on the VM's
cached allocation which is often empty. Extract reclaimSystemInstanceEIP
(authoritative KV reconcile via ReleaseAddressByInstanceID) and call it
from terminateSystemInstanceLocal as well as the no-owner backstop, so an
internet-facing ALB's EIP is released on every terminate route.

* chore(eks): drop coredns from the managed addon catalog

Cluster DNS ships as the baked node-local coredns-mulga DaemonSet, not as
a managed addon — there is no bundled coredns manifest, so CreateAddon
coredns would stage but fail VM-side render. Remove the phantom catalog
entry so DescribeAddonVersions and CreateAddon no longer advertise it;
repoint the addon tests that used coredns as their sample to argocd.

* feat(ui): group EKS and ECR under a Containers nav section

Fold the separate EKS and ECR sidebar groups into one Containers group
with EKS (clusters) and ECR (repositories) entries.

* fix(ui): satisfy strict null checks in ECR component tests

Index mock.calls via optional chaining and narrow the getAllByRole result
before clicking, clearing the TS2532/TS2345 errors that failed pnpm fix.

* refactor(eks): hide internal fixture addons from the public catalog

Add an AddonSpec.Hidden flag and mark spinifex-noop hidden: it stays
creatable (lookupAddon finds it, so the delivery-transport e2e still drives
it) but no longer surfaces in the unfiltered DescribeAddonVersions listing.

* refactor(eks): remove K3s built-in ingress; LBC-only

Drop the BuiltinIngress option and its managed-ingress tag opt-in. traefik,
servicelb, and local-storage are now always disabled on EKS nodes (AWS
parity — Service type=LoadBalancer and Ingress are the Load Balancer
Controller's job). Removes the EKS_DEFER_TRAEFIK userdata flag, the k3s.initd
traefik .skip staging, and the maybe_enable_traefik deferred-enable loop in
the state reporter.

* fix(ui): clear lint debt in VPC-wizard mutations and CIDR validation

Parallelize the independent subnet-create and route-table-association loops
in the create-VPC mutation with Promise.all (each task records its resource
in the rollback list before any further call), and switch the CIDR-validation
regex to named capture groups. Clears the no-await-in-loop and
prefer-named-capture-group lint errors.

* fix: carry gateway port in ECR registry endpoints; rename mulga.internal -> spinifex.internal

The ECR registry is served on the gateway port (:9999), but proxyEndpoint and
repositoryUri were emitted port-less, so docker login/tag/push dialed :443 and
failed. Derive the port from the awsgw listen host and append it to the registry
host (omitted for 443). Also rename the default internal DNS suffix.

* docs: drop verbose ECR :9999 host note

* docs: fix literal-asterisk emphasis (single-* not rendered)

* docs: brace-wrap ${REGISTRY}:latest (zsh :l modifier ate the tag); document Docker CA trust

* feat: default ECR registry URIs to the gateway host (no-DNS interim)

The registry authenticates by token, not hostname, so the per-account
<acct>.dkr.ecr name is cosmetic. Advertise the gateway's reachable bind host:port
(already a cert SAN) in repositoryUri/proxyEndpoint so docker login/tag/push need
no DNS; the parity host still works server-side where DNS exists.

* fix: serve ECR registry on a DNS-free gateway host end to end

Advertise the gateway's AdvertiseIP as the ECR registry host when the AWSGW
bind is unspecified (0.0.0.0/::), so repository_url resolves to a reachable
host:9999 carried in the server cert SANs rather than the unresolvable
per-account parity name. Docker push then dials it directly with no DNS.

Mirror the same host on EKS workers: the registries.yaml mirror keys and the
credential provider matchImages now cover the port-less parity name, the
port-ful parity name, and the gateway IP:9999, all redirecting to the dialed
gateway endpoint. A port-ful pull from the API-returned repository_url matches
the mirror instead of bypassing it into a failed DNS lookup.

Net: docker push host, pod image host, and the API's repository_url are the
same value, with no DNS or /etc/hosts.

* docs: resolve eks-https Ingress ALB DNS name to its public IP (no DNS)

* docs: resolve eks-gitops Ingress ALB DNS name to its public IP (no DNS)

* docs: remind to run aws eks update-kubeconfig before kubectl in EKS workbooks
2026-06-23 15:29:14 +10:00

352 lines
16 KiB
Bash

#!/bin/sh
set -eu
# mulga-eks-addon-sync — renders Spinifex managed-addon bundles into the K3s
# auto-deploy dir and reports per-addon delivery status back to the spinifex
# control plane. The host-side daemon stages each CreateAddon as a manifest
# descriptor in KV; the VM cannot read KV directly, so this agent pulls the
# staged set through the AWS gateway (eks-gateway-fetch, SigV4) and renders the
# baked bundle for each one. Status flows back via eks-gateway-publish on the
# "addon" channel, where the reconciler CASes the AddonRecord CREATING→ACTIVE.
#
# Pull (host→VM): GET /clusters/{cluster}/internal-addons/{accountId}
# Report (VM→host): eks.addon.{accountID}.{clusterName}.status
#
# Runs server-role only (it owns the single auto-deploy dir); enabled by
# eks-node-role.sh. Loops every ADDON_SYNC_INTERVAL seconds in service mode,
# one-shot otherwise.
ENVFILE=/etc/spinifex-eks/first-boot.env
[ -f "${ENVFILE}" ] || { logger -t mulga-eks-addon-sync "${ENVFILE} missing — exiting"; exit 0; }
# set -a so the sourced creds are exported to the eks-gateway-{fetch,publish}
# children, which read EKS_ACCOUNT_ID etc. from their environment.
set -a
# shellcheck disable=SC1090
. "${ENVFILE}"
set +a
: "${EKS_GATEWAY_URL:?}"
: "${EKS_ADDON_GATEWAY_URL:?}"
: "${EKS_ACCESS_KEY:?}"
: "${EKS_SECRET_KEY:?}"
: "${EKS_ACCOUNT_ID:?}"
: "${EKS_CLUSTER_NAME:?}"
KUBECONFIG=${KUBECONFIG:-/etc/rancher/k3s/k3s.yaml}
export KUBECONFIG
# Baked bundles live at BAKED_DIR/<addon>/<version>/*.yaml (air-gapped, shipped
# in the AMI). Rendered output lands in the K3s auto-deploy dir under a stable
# per-addon filename so GC can find orphans this agent owns.
BAKED_DIR=/usr/share/spinifex-eks/addons
DEPLOY_DIR=/var/lib/rancher/k3s/server/manifests
RENDER_PREFIX=spinifex-addon-
# Per-cluster admission-webhook serving certs are minted once and cached here so
# the rendered manifest is byte-stable across sync ticks (a churning cert would
# re-apply the addon every tick).
WEBHOOK_CERT_DIR=/var/lib/spinifex-eks/webhook-certs
# log mirrors to syslog (on-VM /var/log) and, best-effort, to the serial
# console — the host captures ttyS0 to a per-instance log, so this is the only
# delivery diagnostics channel reachable from the host (the VM has no
# host-routable address). Keep messages terse; one line per sync outcome.
log() {
logger -t mulga-eks-addon-sync "$*"
echo "[mulga-eks-addon-sync] $*" > /dev/console 2>/dev/null || true
}
# report PHASE ADDON VERSION [MESSAGE] — publish one addon status report.
report() {
_phase=$1; _addon=$2; _version=$3; _msg=${4:-}
printf '{"addon":"%s","version":"%s","phase":"%s","message":"%s","ts":%s}' \
"${_addon}" "${_version}" "${_phase}" "${_msg}" "$(date +%s)" \
| eks-gateway-publish -channel addon 2>&1 | logger -t mulga-eks-addon-sync || true
}
# render_addon ADDON VERSION ROLE_ARN — render the baked bundle into the
# auto-deploy dir, substituting the IRSA role ARN plus the cluster/gateway
# placeholders an in-cluster AWS-SDK addon (e.g. the LB controller) needs to
# reach the gateway. Returns non-zero (and reports failed) when the baked bundle
# for ADDON/VERSION is absent. Placeholders not present in a bundle are no-ops.
render_addon() {
_addon=$1; _version=$2; _role=$3
_src="${BAKED_DIR}/${_addon}/${_version}"
_dst="${DEPLOY_DIR}/${RENDER_PREFIX}${_addon}.yaml"
if [ ! -d "${_src}" ]; then
log "no baked bundle for ${_addon}/${_version} (looked in ${_src})"
report failed "${_addon}" "${_version}" "no baked bundle for version ${_version}"
return 1
fi
# Gateway CA as single-line base64 for a kube Secret .data entry the addon
# mounts as AWS_CA_BUNDLE. tr -d so busybox base64 (no -w0) stays one line.
_ca_b64=""
if [ -n "${EKS_GATEWAY_CA:-}" ] && [ -f "${EKS_GATEWAY_CA}" ]; then
_ca_b64=$(base64 < "${EKS_GATEWAY_CA}" | tr -d '\n')
fi
# Webhook serving cert (opt-in): a bundle ships webhook.conf with its service
# SANs when it runs an admission webhook. Mint/reuse a self-signed cert on the
# node (the AMI has no openssl) and embed it as the Secret + every caBundle.
_wh_ca=""; _wh_crt=""; _wh_key=""
if [ -f "${_src}/webhook.conf" ]; then
WEBHOOK_CN=""; WEBHOOK_DNS=""
# shellcheck disable=SC1090,SC1091
. "${_src}/webhook.conf"
if [ -n "${WEBHOOK_CN}" ] && [ -n "${WEBHOOK_DNS}" ]; then
_wh_err="${_dst}.whcert.$$"
if ! _wh_out=$(eks-webhook-cert -dir "${WEBHOOK_CERT_DIR}/${_addon}" \
-cn "${WEBHOOK_CN}" -dns "${WEBHOOK_DNS}" 2>"${_wh_err}"); then
log "addon ${_addon}: webhook cert gen failed: $(tr '\n' ' ' < "${_wh_err}")"
rm -f "${_wh_err}"
report failed "${_addon}" "${_version}" "webhook cert generation failed"
return 1
fi
rm -f "${_wh_err}"
_wh_ca=$(printf '%s' "${_wh_out}" | cut -f1)
_wh_crt=$(printf '%s' "${_wh_out}" | cut -f2)
_wh_key=$(printf '%s' "${_wh_out}" | cut -f3)
fi
fi
# Reusable AWS-credential injection. A bundle opts in by placing the
# {{IRSA_ENV}} / {{IRSA_VOLUME}} / {{IRSA_VOLUME_MOUNT}} markers on their own
# line; awk swaps each for the standard block, then the sed pass fills the
# nested role/region/gateway placeholders. Bundles without the markers are
# untouched. Blocks assume a Deployment pod spec (env 8sp, volumes 6sp,
# volumeMounts 8sp).
#
# Gateway-CA trust is injected in both modes: AWS_CA_BUNDLE covers the
# standard-config clients, and SSL_CERT_FILE adds the gateway CA to the Go
# system trust pool. The LBC ec2/elbv2/acm clients install their own
# per-client BaseEndpoint resolver and build a fresh HTTP client that
# ignores AWS_CA_BUNDLE, falling back to the system pool — so without
# SSL_CERT_FILE they reject the gateway cert as "unknown authority".
#
# IRSA (web-identity) env is injected ONLY when a service-account role ARN is
# staged. The token (aud sts.amazonaws.com) + role ARN + gateway STS endpoint
# are what AssumeRoleWithWebIdentity needs. Without a role ARN, those are
# omitted entirely: injecting an empty AWS_ROLE_ARN beside the token file
# half-arms the SDK web-identity provider, which then signs anonymously
# instead of falling through the default chain to the node instance-profile
# credentials served over IMDS. Omitting them restores that node-credential
# fallback.
if [ -n "${_role}" ]; then
_irsa_env=' - name: AWS_ROLE_ARN
value: "{{SERVICE_ACCOUNT_ROLE_ARN}}"
- name: AWS_WEB_IDENTITY_TOKEN_FILE
value: /var/run/secrets/eks.amazonaws.com/serviceaccount/token
- name: AWS_STS_REGIONAL_ENDPOINTS
value: regional
- name: AWS_REGION
value: "{{AWS_REGION}}"
- name: AWS_DEFAULT_REGION
value: "{{AWS_REGION}}"
- name: AWS_ENDPOINT_URL_STS
value: "{{GATEWAY_ENDPOINT}}"
- name: AWS_CA_BUNDLE
value: /etc/spinifex/gateway-ca/ca.pem
- name: SSL_CERT_FILE
value: /etc/spinifex/gateway-ca/ca.pem'
_irsa_vol=' - name: aws-iam-token
projected:
defaultMode: 420
sources:
- serviceAccountToken:
audience: sts.amazonaws.com
expirationSeconds: 86400
path: token
- name: spinifex-gateway-ca
secret:
defaultMode: 420
secretName: spinifex-gateway-ca'
_irsa_mnt=' - name: aws-iam-token
mountPath: /var/run/secrets/eks.amazonaws.com/serviceaccount
readOnly: true
- name: spinifex-gateway-ca
mountPath: /etc/spinifex/gateway-ca
readOnly: true'
else
_irsa_env=' - name: AWS_REGION
value: "{{AWS_REGION}}"
- name: AWS_DEFAULT_REGION
value: "{{AWS_REGION}}"
- name: AWS_CA_BUNDLE
value: /etc/spinifex/gateway-ca/ca.pem
- name: SSL_CERT_FILE
value: /etc/spinifex/gateway-ca/ca.pem'
_irsa_vol=' - name: spinifex-gateway-ca
secret:
defaultMode: 420
secretName: spinifex-gateway-ca'
_irsa_mnt=' - name: spinifex-gateway-ca
mountPath: /etc/spinifex/gateway-ca
readOnly: true'
fi
# IngressClassParams spec injection. EKS_ELB_SUBNET_IDS is the cluster's
# ELB-eligible subnets (CSV, deduped to one per AZ by the daemon). Inject them
# as explicit .spec.subnets.ids so every Ingress takes LBC's explicit-subnet
# path, the only one that honors ALBSingleSubnet; a single-AZ cluster otherwise
# fails reconcile. Empty value drops the marker line, leaving tag auto-discovery.
_icp_spec=''
if [ -n "${EKS_ELB_SUBNET_IDS:-}" ]; then
_icp_spec=' spec:
subnets:
ids:'
_oldifs=$IFS
IFS=','
for _sn in ${EKS_ELB_SUBNET_IDS}; do
[ -n "${_sn}" ] || continue
_icp_spec="${_icp_spec}
- ${_sn}"
done
IFS=$_oldifs
fi
_tmp="${_dst}.tmp.$$"
: > "${_tmp}"
for _f in "${_src}"/*.yaml; do
[ -e "${_f}" ] || continue
# Each block marker must be the ONLY token on its line (whitespace aside);
# matching a substring would also fire on a comment that merely names the
# marker, injecting the block mid-document and producing invalid YAML.
awk -v env="${_irsa_env}" -v vol="${_irsa_vol}" -v mnt="${_irsa_mnt}" -v icp="${_icp_spec}" '
$0 ~ /^[[:space:]]*[{][{]IRSA_ENV[}][}][[:space:]]*$/ { print env; next }
$0 ~ /^[[:space:]]*[{][{]IRSA_VOLUME_MOUNT[}][}][[:space:]]*$/ { print mnt; next }
$0 ~ /^[[:space:]]*[{][{]IRSA_VOLUME[}][}][[:space:]]*$/ { print vol; next }
$0 ~ /^[[:space:]]*[{][{]ELB_INGRESS_PARAMS_SPEC[}][}][[:space:]]*$/ { if (icp != "") print icp; next }
{ print }
' "${_f}" \
| sed -e "s|{{SERVICE_ACCOUNT_ROLE_ARN}}|${_role}|g" \
-e "s|{{CLUSTER_NAME}}|${EKS_CLUSTER_NAME}|g" \
-e "s|{{AWS_REGION}}|${EKS_REGION:-}|g" \
-e "s|{{AWS_VPC_ID}}|${EKS_VPC_ID:-}|g" \
-e "s|{{GATEWAY_ENDPOINT}}|${EKS_ADDON_GATEWAY_URL}|g" \
-e "s|{{GATEWAY_CA_PEM_B64}}|${_ca_b64}|g" \
-e "s|{{WEBHOOK_CA_B64}}|${_wh_ca}|g" \
-e "s|{{WEBHOOK_TLS_CRT_B64}}|${_wh_crt}|g" \
-e "s|{{WEBHOOK_TLS_KEY_B64}}|${_wh_key}|g" \
>> "${_tmp}"
printf '\n---\n' >> "${_tmp}"
done
# Idempotent: only replace the auto-deploy file when the rendered content
# actually changed. An unconditional mv bumps the file mtime every tick,
# which K3s' auto-deploy controller treats as a change and re-applies —
# clearing the Addon CR's .status.gvks each time, so the same-tick readiness
# check never observes a settled apply and the add-on stays CREATING forever.
if [ -e "${_dst}" ] && cmp -s "${_tmp}" "${_dst}"; then
rm -f "${_tmp}"
return 0
fi
mv -f "${_tmp}" "${_dst}"
return 0
}
# addon_gvks ADDON — echoes the GVKs K3s' auto-deploy controller applied for the
# rendered manifest, empty until it has applied the objects. A non-empty value
# means delivery succeeded. Addon-agnostic — confirms delivery, not deep per-pod
# health (a later hardening item).
#
# K3s (v1.32.x) records the applied GVKs in the addon.k3s.cattle.io/gvks
# *annotation* (e.g. "/v1, Kind=Namespace;/v1, Kind=ConfigMap"); the Addon CR
# carries no .status subresource. The CR is matched by .spec.source (the
# absolute path of the rendered file) rather than a guessed CR name, since K3s
# derives the name from the path with its own transform.
addon_gvks() {
_want="${DEPLOY_DIR}/${RENDER_PREFIX}$1.yaml"
kubectl -n kube-system get addon -o json 2>/dev/null \
| jq -r --arg src "${_want}" \
'.items[] | select(.spec.source==$src)
| .metadata.annotations["addon.k3s.cattle.io/gvks"] // ""' 2>/dev/null \
| grep -v '^$' | head -1 || true
}
# diag_addon ADDON — one-shot console dump of the live K3s state for a stuck
# add-on, so a delivery that never reaches ready is diagnosable from the
# host-captured serial console (the VM is otherwise unreachable). Fires once per
# process via a sentinel to avoid spamming every tick.
diag_addon() {
[ -f /tmp/.addon-diag-"$1" ] && return 0
: > /tmp/.addon-diag-"$1"
{
echo "----- addon-diag ${1} -----"
echo "[deploy dir]"; ls -l "${DEPLOY_DIR}" 2>&1
echo "[rendered file head]"; head -40 "${DEPLOY_DIR}/${RENDER_PREFIX}${1}.yaml" 2>&1
echo "[all addon CRs]"; kubectl get addon -A 2>&1
echo "[addon CR ${RENDER_PREFIX}${1} -o yaml]"; kubectl -n kube-system get addon "${RENDER_PREFIX}${1}" -o yaml 2>&1
echo "[namespace ${1}]"; kubectl get ns "${1}" 2>&1
echo "[configmaps in ${1}]"; kubectl -n "${1}" get cm 2>&1
echo "----- end addon-diag ${1} -----"
} > /dev/console 2>&1 || true
}
sync_once() {
_staged=$(mktemp)
_ferr=$(mktemp)
if ! eks-gateway-fetch -resource addons > "${_staged}" 2>"${_ferr}"; then
log "fetch staged addons failed — keeping current state: $(tr '\n' ' ' < "${_ferr}")"
rm -f "${_staged}" "${_ferr}"
return 0
fi
rm -f "${_ferr}"
# Names currently staged, for the GC pass below.
_names=$(cut -f1 "${_staged}")
_count=$(grep -c . "${_staged}" 2>/dev/null || echo 0)
log "fetch ok: ${_count} staged addon(s)"
# Render/refresh each staged addon and report its phase.
while IFS=$(printf '\t') read -r _addon _version _role _cfg_b64; do
[ -n "${_addon}" ] || continue
: "${_cfg_b64:-}" # config values reserved for per-addon templating (follow-up)
if render_addon "${_addon}" "${_version}" "${_role}"; then
_gvks=$(addon_gvks "${_addon}")
if [ -n "${_gvks}" ]; then
log "addon ${_addon}/${_version}: applied + ready (gvks=${_gvks})"
report ready "${_addon}" "${_version}"
else
log "addon ${_addon}/${_version}: applied, not ready yet (no K3s Addon CR for ${RENDER_PREFIX}${_addon}.yaml with populated .status.gvks)"
diag_addon "${_addon}"
report applied "${_addon}" "${_version}"
fi
fi
done < "${_staged}"
rm -f "${_staged}"
# GC: remove rendered manifests this agent owns whose addon is no longer
# staged (DeleteAddon removed its descriptor). K3s does NOT delete the applied
# objects when the manifest file disappears, so delete them explicitly.
for _file in "${DEPLOY_DIR}/${RENDER_PREFIX}"*.yaml; do
[ -e "${_file}" ] || continue
_base=$(basename "${_file}")
_name=${_base#"${RENDER_PREFIX}"}
_name=${_name%.yaml}
if ! printf '%s\n' "${_names}" | grep -qx "${_name}"; then
log "unstaging addon ${_name} (no longer staged)"
# Copy the manifest aside, drop the original so K3s stops tracking it
# (and never re-applies), then delete the objects it rendered.
# --wait=false so namespace finalizers don't stall the sync loop.
_gc=$(mktemp)
cp "${_file}" "${_gc}"
rm -f "${_file}"
kubectl delete -f "${_gc}" --ignore-not-found --wait=false 2>&1 \
| logger -t mulga-eks-addon-sync || true
rm -f "${_gc}"
fi
done
}
mkdir -p "${DEPLOY_DIR}"
if [ -n "${ADDON_SYNC_INTERVAL:-}" ] && [ "${ADDON_SYNC_INTERVAL}" -gt 0 ] 2>/dev/null; then
log "starting: cluster=${EKS_CLUSTER_NAME} account=${EKS_ACCOUNT_ID} interval=${ADDON_SYNC_INTERVAL}s gateway=${EKS_GATEWAY_URL}"
while true; do
sync_once
sleep "${ADDON_SYNC_INTERVAL}"
done
else
sync_once
fi