Files
spinifex/scripts/images/eks-node/mulga-eks-addon-sync.sh
T
Josh Siviour 2acb37342d feat(eks): aws-load-balancer-controller addon — real ALB Ingress (L7, IRSA) (#376)
* fix(ec2): stamp subnet AvailabilityZoneId for single-AZ ALB resolution

DescribeSubnets omitted AvailabilityZoneId, so the AWS Load Balancer
Controller's DescribeAvailabilityZones-by-ZoneId lookup resolved no AZ
info ("cannot resolve AZ info for AZs: []") and never built the ALB
model. Stamp the single zone-id on every subnet and share it with
DescribeAvailabilityZones through one SingleZoneID constant so the two
responses can never drift.

* feat(eks): add aws-load-balancer-controller v2.8.1 addon bundle

Baked manifests for the LBC managed addon (CRDs, controller Deployment/
RBAC/webhooks/Service, IngressClass alb, IRSA render markers). Two
wirings make it work against the spinifex gateway:

- --aws-api-endpoints carries sts= alongside elasticloadbalancing/ec2/
  acm. The aws-sdk-go v1 credential bootstrap resolves the STS client
  from the session endpoint resolver, not AWS_ENDPOINT_URL_STS, so the
  web-identity AssumeRoleWithWebIdentity must be pointed at the gateway
  here or it falls back to real AWS STS.
- --feature-gates=ALBSingleSubnet=true drops the ALB minimum subnet
  count from two to one so an ALB provisions in a single-AZ deployment.

* feat(eks): render IRSA into managed addons + node-minted webhook cert

Extend the addon-sync renderer to inject the projected-token / role-arn /
gateway STS-endpoint + CA block into managed-addon pod specs via opt-in
markers, and substitute cluster-name/region/vpc-id/gateway-endpoint/CA
placeholders. Add cmd/eks-webhook-cert: the eks-node AMI has no openssl,
so this mints (then reuses) a self-signed ECDSA-P256 serving cert for the
admission webhook, cached on disk so the render is byte-stable across
ticks. Wire both into the image build (manifest.conf) plus INSTALL_FILES
for the LBC bundle.

* feat(eks): surface cluster VPC id as EKS_VPC_ID for the LB controller

Thread the control-plane VPC id through K3sServerInput.VpcID into the
first-boot EKS_VPC_ID env so the in-cluster aws-load-balancer-controller
can pass --aws-vpc-id to the gateway elbv2/ec2 handlers.

* fix(eks): point the LB controller at the customer VPC + customer-facing gateway

The aws-load-balancer-controller ran with two control-plane-scoped values
that customer worker pods cannot use:

- --aws-vpc-id was set from the managed control-plane VPC (cpRefs.VpcID),
  which the gateway's DescribeVpcs does not return for customer calls, so
  backend-SG auto-create failed with InvalidVpcID.NotFound. Source it from
  meta.ResourcesVpcConfig.VpcId, the customer cluster VPC where the workers
  and their subnets live and where the ALB is provisioned.

- The addon render baked the mgmt gateway endpoint (EKS_GATEWAY_URL /
  SystemGatewayURL) into pod specs, unreachable from worker pods. Add a
  distinct EKS_ADDON_GATEWAY_URL (the customer-facing GatewayBaseURL, same
  host as the OIDC issuer) and substitute it for {{GATEWAY_ENDPOINT}} in
  managed addon manifests. EKS_GATEWAY_URL stays mgmt for the CP's own
  bootstrap, state-report and token-webhook paths.

* fix(elbv2): resolve forward target group from ForwardConfig

listenerActionFromSDK read only the legacy flat Action.TargetGroupArn, so a
forward action carrying its target group via ForwardConfig.TargetGroups (the
shape the aws-load-balancer-controller emits) left the action target empty and
CreateRule/CreateListener rejected it with MissingParameter. Flatten the first
target group from ForwardConfig when the flat field is absent.

* fix(ec2): mirror CreateTags/DeleteTags into subnet/vpc records

The generic S3 tag store and the per-resource KV records are disjoint:
DescribeSubnets/DescribeVpcs read tags from their KV record, while
CreateTags only wrote the generic store. Subnet tags applied after
create were therefore invisible to tag-filtered describes, breaking
aws-load-balancer-controller subnet auto-discovery (kubernetes.io/role/elb).

Write through CreateTags/DeleteTags to the owning subnet/vpc record so
tag-filtered describes observe them. Resource types the VPC service does
not own are skipped; the generic store stays their record of truth.

* test(daemon): pin synthetic capacity in host-dependent allocate tests

TestCanAllocate_CountEdgeCases/Mixed_instance_types and
TestHandleEC2DescribeInstanceTypes/NoFilterReturnsSupportedRegardlessOfAllocation
called rm.allocate against live-detected host capacity. allocate gates on
readMemAvailableGB (live /proc MemAvailable), so a box already running VMs
fails the allocation spuriously, breaking the preflight gate locally.

Pin hostVCPU/hostMemGB/reserved and nil readMemAvailableGB to a synthetic
host, matching the sibling Capacity_decreases_after_allocation subtest.

* fix(eks): route apiserver->pod traffic via agent tunnel (egress-selector cluster)

In the managed-CP topology the apiserver VM sits in the system CP VPC with
no route to the worker pod network in the unpeered customer VPC. k3s default
egress-selector-mode=agent tunnels only apiserver->kubelet, so kubectl
logs/exec work but apiserver->pod traffic (admission webhooks, aggregated
APIs) is attempted directly and times out -- the aws-load-balancer-controller
webhooks fail with context deadline exceeded, blocking Service/Ingress
creation cluster-wide.

Set egress-selector-mode=cluster so apiserver->pod/service traffic routes
through the agent's outbound tunnel, selected by the node hosting the
destination, without requiring inbound CP->worker reachability.

* feat: bump aws-load-balancer-controller addon bundle to v2.11.0

v2.8.1 predates the ALBSingleSubnet feature gate, so single-AZ spinifex
clusters fail Ingress reconcile (subnet resolver dedups to 1 < required 2)
and never provision an ALB. v2.11.0 honors --feature-gates=ALBSingleSubnet=true.

Replace the baked bundle: new CRDs (ingressclassparams + targetgroupbindings),
controller manifest, and image tag v2.11.0, re-applying the spinifex deltas
(IRSA SA annotation, node-minted webhook serving-cert secret, gateway-CA
secret, gateway aws-api-endpoints, caBundle on every webhook clientConfig,
alb IngressClass). Pick up the new ClusterRole configmaps rule. Drop v2.8.1.

* fix: point LBC at the gateway via AWS_ENDPOINT_URL (sdk-v2)

LBC v2.11 uses aws-sdk-go-v2, which keys --aws-api-endpoints on SDK
service IDs (e.g. 'Elastic Load Balancing v2'), not the legacy lowercase
names the bundle passed. Those overrides were silently ignored, so every
non-STS call (ec2/elbv2/acm) went to real AWS and failed TLS against the
gateway CA -- no ALB could provision.

Inject AWS_ENDPOINT_URL in the shared IRSA env block (sdk-v2 global
base-endpoint override; AWS_ENDPOINT_URL_STS still takes precedence for
STS) and drop the dead --aws-api-endpoints flag from the controller args.

* fix: synthesize taggable ARN for default listener rule

Gateway DescribeRules returned the synthetic IsDefault rule with a null
RuleArn. LBC read the arn-less default rule after creating a listener and
issued DescribeTags with an empty resource ARN, which the gateway rejected
with 400 MissingParameter, aborting reconcile.

Set a deterministic RuleArn on the default rule, resolve it back to its
parent listener in DescribeTags (empty tags) and DescribeRules by-arn.
Also disable shield/waf/wafv2 in the LBC bundle to drop unsupported calls.

* feat: inject ELB subnets into alb IngressClassParams for single-AZ ALB

LBC's tag auto-discovery never threads the ALBSingleSubnet feature gate, so a
single-AZ cluster (subnets sharing one AZ) dedups to 1<2 subnets and aborts
Ingress reconcile. Only the explicit-subnet path honors the gate.

Resolve the cluster's ELB-eligible subnets, dedup to one per AZ, and surface
them as EKS_ELB_SUBNET_IDS. The addon sync renders them into the alb
IngressClassParams .spec.subnets.ids so every Ingress takes the explicit path.
An empty set falls back to auto-discovery, preserving the native UX.

* fix: route LBC service clients to gateway via --aws-api-endpoints, link IngressClassParams

Live verification showed two gaps in the LBC addon bundle:

LBC installs a per-client BaseEndpoint resolver, so the global AWS_ENDPOINT_URL
is ignored for the ec2/elbv2/acm clients (calls hit real AWS, failing TLS).
Redirect them with --aws-api-endpoints keyed on the aws-sdk-go-v2 service IDs
(EC2, "Elastic Load Balancing v2", ACM, STS) rather than the legacy lowercase
names the flag never matched. Drop the misleading AWS_ENDPOINT_URL env; the
web-identity STS client still resolves via AWS_ENDPOINT_URL_STS.

The injected IngressClassParams was orphaned: the alb IngressClass had no
.spec.parameters, so LBC fell back to subnet auto-discovery (which fails on a
single-AZ cluster). Link the IngressClass to the IngressClassParams so the
explicit-subnet path is taken.

* fix: stamp EKS-parity providerID on worker nodes for LBC ip targets

k3s registers workers with providerID k3s://<name>, which the AWS Load
Balancer Controller rejects (providerID invalid for EC2 instances) when
resolving nodes for ip-mode target registration. Resolve the instance-id and
AZ from IMDSv2 at agent boot and set --kubelet-arg=provider-id=aws:///<az>/<id>
so LBC can map the node to its EC2 instance. On any IMDS miss the agent keeps
the default providerID and still starts.

* fix(elbv2): honor SubnetMappings in CreateLoadBalancer

LBC supplies ALB subnets via SubnetMappings, not the plain Subnets list.
createLoadBalancer only read input.Subnets, so ENI allocation was skipped,
VpcId/AZ stayed empty, and the HAProxy data-plane VM never launched — the
ALB went active but could not serve traffic. Add flattenSubnetIDs to read
both sources and reuse it in createLoadBalancer and desiredSubnetSet.

* fix(vm): capture GCInterval before spawning GC goroutine

TestStart_SweepsImmediatelyThenStopsOnCtxCancel mutates the package global
GCInterval and restores it in a defer, racing the Start goroutine's read at
time.NewTicker(GCInterval) under -race. Read GCInterval on the caller's
goroutine into a local before spawning so the global access is ordered with
the test's defer; the goroutine touches only the captured local.
2026-06-18 10:53:05 +10:00

321 lines
14 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 IRSA (web-identity) 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). The token (aud sts.amazonaws.com) + role ARN + gateway
# STS endpoint/CA are what AssumeRoleWithWebIdentity at the gateway needs.
# AWS_ENDPOINT_URL_STS points the web-identity credential STS client (built
# from the standard config, before per-service overrides) at the gateway.
# The LBC service clients (ec2/elbv2/acm) ignore the global AWS_ENDPOINT_URL
# because LBC installs its own per-client BaseEndpoint resolver; those are
# redirected via the bundle's --aws-api-endpoints flag, which keys on the
# SDK-v2 service IDs (EC2, "Elastic Load Balancing v2", ACM), not the legacy
# lowercase names. AWS_CA_BUNDLE makes every client trust the gateway CA.
_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'
_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'
# 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
awk -v env="${_irsa_env}" -v vol="${_irsa_vol}" -v mnt="${_irsa_mnt}" -v icp="${_icp_spec}" '
index($0, "{{IRSA_ENV}}") { print env; next }
index($0, "{{IRSA_VOLUME_MOUNT}}") { print mnt; next }
index($0, "{{IRSA_VOLUME}}") { print vol; next }
index($0, "{{ELB_INGRESS_PARAMS_SPEC}}") { 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