diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS
index 945ae3a08..0eb3d18c0 100644
--- a/.github/CODEOWNERS
+++ b/.github/CODEOWNERS
@@ -6,16 +6,10 @@
# Set the matching branch protection rule on `dev` and `main` to
# "Require review from Code Owners" or this file is advisory only.
-# Fall-through: any change needs a team review.
-* @mulgadc/mulgadc-engineers
-
# Network layer is governed by ADR-0006 invariant tests.
-# Changes here must respect those clauses — see
-# spinifex/network/invariants/ and
+# Changes here must respect those clauses
+# see spinifex/network/invariants/
# docs/development/proposals/az-local-architecture/0006-spinifex-network-layer-contract.md
/spinifex/network/ @joshsiv-mulga
/spinifex/vpcd/ @joshsiv-mulga
-
-# Invariants suite is the executable ADR-0006 fence — any widening or
-# narrowing of the scan roots needs an explicit owner review.
/spinifex/network/invariants/ @joshsiv-mulga
diff --git a/.github/workflows/e2e-baremetal.yml b/.github/workflows/e2e-baremetal.yml
index c4b8bfd79..fdfc56208 100644
--- a/.github/workflows/e2e-baremetal.yml
+++ b/.github/workflows/e2e-baremetal.yml
@@ -2,6 +2,13 @@ name: E2E Bare-Metal
on:
workflow_dispatch:
+ workflow_call:
+ inputs:
+ ref:
+ description: "spinifex ref to checkout/test (default: triggering ref)"
+ required: false
+ type: string
+ default: ""
permissions:
contents: read
@@ -12,7 +19,7 @@ env:
jobs:
e2e_baremetal:
- name: Bare-Metal Multi-Node E2E
+ name: Bare-Metal Single-Node E2E
runs-on: [ self-hosted, ci-baremetal ]
timeout-minutes: 120
concurrency:
@@ -20,11 +27,17 @@ jobs:
cancel-in-progress: false
env:
BM_ISO_CACHE: /var/cache/spinifex-iso-builder
+ ARTIFACT_DIR: /tmp/spinifex-e2e-baremetal-artifacts-${{ github.run_id }}
steps:
- name: Clean Workspace
run: sudo rm -rf "$GITHUB_WORKSPACE"/*
- uses: actions/checkout@v6
+ with:
+ # When called (nightly/release) the caller picks the ref (dev on
+ # schedule, the tag on release); standalone dispatch defaults to the
+ # triggering ref.
+ ref: ${{ inputs.ref || github.ref }}
- uses: actions/create-github-app-token@v3
id: app-token
@@ -92,6 +105,9 @@ jobs:
fi
+ - name: Install go-junit-report on runner
+ run: go install github.com/jstemmer/go-junit-report/v2@latest
+
- name: Run Bare-Metal E2E
env:
BM_CI_PASSWORD: ${{ secrets.BM_CI_PASSWORD }}
@@ -100,8 +116,38 @@ jobs:
BM_UBUNTU_IMAGE: ${{ env.BM_ISO_CACHE }}/ubuntu-26.04-cloudimg-amd64.img
SPX_EXTERNAL_POOL: ${{ env.SPINIFEX_CI_POOL }}
run: |
+ mkdir -p "${ARTIFACT_DIR}"
+ # Capture combined bootstrap + e2e output so failures produce a
+ # downloadable, analyzable artifact (the suites run go test -v on the
+ # init node and stream back over SSH). pipefail keeps the script's
+ # real exit code through the tee.
+ set -o pipefail
sudo -E mulga/scripts/baremetal/run-baremetal-e2e.sh \
--vmlinuz "${BM_ISO_CACHE}/vmlinuz" \
--initrd "${BM_ISO_CACHE}/initrd.img" \
--squashfs "${BM_ISO_CACHE}/spinifex.squashfs" \
- --tests-tar "${RUNNER_TEMP}/spinifex-tests.tar"
+ --tests-tar "${RUNNER_TEMP}/spinifex-tests.tar" \
+ 2>&1 | tee "${ARTIFACT_DIR}/test-baremetal.log"
+
+ - name: Generate JUnit report
+ if: always()
+ run: |
+ LOG="${ARTIFACT_DIR}/test-baremetal.log"
+ [ -f "$LOG" ] || exit 0
+ go-junit-report -in "$LOG" -out "${ARTIFACT_DIR}/junit-baremetal.xml" || true
+
+ - name: Analyze e2e failures
+ if: always()
+ uses: ./.github/actions/e2e-analyze
+ with:
+ junit-glob: ${{ env.ARTIFACT_DIR }}/junit-*.xml
+ log-dir: ${{ env.ARTIFACT_DIR }}
+ title: "Bare-Metal E2E failure analysis"
+
+ - uses: actions/upload-artifact@v7
+ if: always()
+ with:
+ name: e2e-baremetal-artifacts-${{ github.run_id }}
+ path: ${{ env.ARTIFACT_DIR }}
+ if-no-files-found: warn
+ retention-days: 14
diff --git a/.github/workflows/e2e-nightly.yml b/.github/workflows/e2e-nightly.yml
index 39282bbd0..ed9385c8b 100644
--- a/.github/workflows/e2e-nightly.yml
+++ b/.github/workflows/e2e-nightly.yml
@@ -216,3 +216,22 @@ jobs:
tofu workspace select -or-create "${SPINIFEX_CI_ENV}" >> "$TOFU_LOG" 2>&1
tofu destroy -var-file=envs/${SPINIFEX_CI_ENV}.tfvars ${TOFU_VAR_ARGS} -auto-approve -input=false >> "$TOFU_LOG" 2>&1
./scrub-env.sh "${SPINIFEX_CI_ENV}" >> "$TOFU_LOG" 2>&1 || true
+
+ # Bare-metal single-node suite (PXE-reimaged physical hardware). Doesn't fit
+ # the tofu matrix shape (different runner/provisioning/120m timeout), so it
+ # runs as a sibling job via the reusable e2e-baremetal workflow. Gated like a
+ # matrix cell ("cell 20"): every weeknight cron, or dispatch with 20 selected
+ # (blank cells = all). Tests `dev` on schedule to match GIT_BRANCH above.
+ baremetal:
+ name: cell-20 (baremetal/pxe/real-hw)
+ if: >-
+ github.event_name == 'schedule' ||
+ inputs.cells == '' ||
+ contains(format(',{0},', inputs.cells), ',20,')
+ permissions:
+ contents: read
+ statuses: write
+ uses: ./.github/workflows/e2e-baremetal.yml
+ with:
+ ref: ${{ github.event_name == 'schedule' && 'dev' || github.ref_name }}
+ secrets: inherit
diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml
index 9f745a773..02110e4b9 100644
--- a/.github/workflows/e2e.yml
+++ b/.github/workflows/e2e.yml
@@ -18,7 +18,7 @@ on:
description: "PR head SHA to report status on"
required: false
suites:
- description: "Space-separated Make targets to run (e.g. 'e2e-single e2e-multinode e2e-cert e2e-lb')"
+ description: "Space-separated Make targets to run (e.g. 'e2e-single e2e-multinode e2e-cert e2e-lb e2e-eks')"
required: false
default: "e2e-single e2e-multinode e2e-cert e2e-lb"
test_run:
@@ -30,13 +30,6 @@ permissions:
contents: read
statuses: write
-# Pushes to the same ref stack on the single `ci-single` self-hosted runner,
-# and a cancelled run can leave orphan `tofu`/`go test -c` procs behind that
-# wedge the runner host. Auto-cancel keeps at most one in-flight run per ref.
-concurrency:
- group: e2e-${{ github.ref }}
- cancel-in-progress: true
-
env:
GIT_BRANCH: ${{ github.ref_name }}
SUITES: ${{ inputs.suites || 'e2e-single e2e-multinode e2e-cert e2e-lb' }}
@@ -49,7 +42,7 @@ jobs:
# suites='e2e-reboot' only). Push triggers and the default SUITES list
# both include single/cert/lb so the inputs.suites null-check keeps the
# job running on plain PR pushes.
- if: github.event_name != 'workflow_dispatch' || contains(github.event.inputs.suites, 'e2e-single') || contains(github.event.inputs.suites, 'e2e-cert') || contains(github.event.inputs.suites, 'e2e-lb')
+ if: github.event_name != 'workflow_dispatch' || contains(github.event.inputs.suites, 'e2e-single') || contains(github.event.inputs.suites, 'e2e-cert') || contains(github.event.inputs.suites, 'e2e-lb') || contains(github.event.inputs.suites, 'e2e-eks')
runs-on: [ self-hosted, ci-single ]
timeout-minutes: 45
env:
@@ -77,7 +70,15 @@ jobs:
true
- name: Clean Workspace
- run: sudo rm -rf "$GITHUB_WORKSPACE"/*
+ run: |
+ # Detach any leaked mounts under the workspace before deleting. A
+ # killed image build can leave a `mount --bind /dev` under its build
+ # dir; if `rm -rf` traverses it as root it wipes the host's /dev
+ # (deletes /dev/null and wedges the box). umount deepest-first, then
+ # forbid rm from crossing any mount boundary as a hard backstop.
+ awk -v w="$GITHUB_WORKSPACE" '$2 ~ "^"w {print $2}' /proc/mounts \
+ | sort -r | xargs -r -I{} sudo umount -l -R {} || true
+ sudo rm -rf --one-file-system "$GITHUB_WORKSPACE"/*
- uses: actions/create-github-app-token@v3
id: app-token
@@ -153,6 +154,85 @@ jobs:
echo "wan_ip=${WAN_IP}" >> "$GITHUB_OUTPUT"
echo "ssh_key=${SSH_KEY}" >> "$GITHUB_OUTPUT"
+ # The eks suite's CreateCluster boots a control-plane VM from the
+ # eks-node AMI, so that AMI must exist in the VM's image store before the
+ # suite runs. build-system-image.sh customizes the image with libguestfs
+ # (an isolated userspace appliance) — it touches no host block device, so
+ # it is safe on the shared runner. The build needs the spinifex source +
+ # network (runner only); the import needs predastore/NATS (VM only), so
+ # build on the runner then ship the raw and import on the VM.
+ - name: Build eks-node AMI (libguestfs, runner)
+ if: contains(env.SUITES, 'e2e-eks')
+ working-directory: spinifex
+ run: |
+ set -euo pipefail
+ source "${GITHUB_WORKSPACE}/spinifex/.github/scripts/ci-fmt.sh"
+ banner "build eks-node AMI (libguestfs — no host devices)"
+ sudo apt-get update -qq
+ sudo apt-get install -y --no-install-recommends libguestfs-tools qemu-utils
+ # Debian/Ubuntu ship /boot/vmlinuz-* mode 0600; the libguestfs
+ # appliance needs to read a host kernel. direct backend = no libvirt.
+ export LIBGUESTFS_BACKEND=direct
+ sudo chmod 0644 /boot/vmlinuz-* || true
+ group_open "make build-eks-node-image"
+ make build-eks-node-image
+ group_close
+ ok "raw: $(du -h /tmp/eks-node-image-build/eks-node-alpine.raw | cut -f1)"
+
+ - name: Import eks-node AMI on VM
+ if: contains(env.SUITES, 'e2e-eks')
+ run: |
+ set -euo pipefail
+ source "${GITHUB_WORKSPACE}/spinifex/.github/scripts/ci-fmt.sh"
+ # Pull import args straight from the manifest so they never drift.
+ eval "$(grep -E '^(ALPINE_VERSION|AMI_NAME|SYSTEM_TAG)=' \
+ spinifex/scripts/images/eks-node/manifest.conf)"
+ SSH_OPTS=(-o StrictHostKeyChecking=no -i "${{ steps.vm.outputs.ssh_key }}")
+ VM=tf-user@${{ steps.vm.outputs.wan_ip }}
+ banner "import eks-node AMI to VM image store"
+ scp "${SSH_OPTS[@]}" \
+ /tmp/eks-node-image-build/eks-node-alpine.raw \
+ "$VM":/tmp/eks-node-alpine.raw
+ # Drop the import progress bar (pterm redraws to stdout — thousands of
+ # lines over the SSH pipe). Errors go to stderr, so they survive; the
+ # exit code still gates the step.
+ ssh "${SSH_OPTS[@]}" "$VM" \
+ "spx admin images import --file /tmp/eks-node-alpine.raw \
+ --distro alpine --version ${ALPINE_VERSION} --arch x86_64 \
+ --boot-mode bios --ami-name ${AMI_NAME} --tag ${SYSTEM_TAG}" >/dev/null
+ ok "eks-node AMI imported"
+
+ # The eks data-plane subtest (TestEKS/KubectlGetNodes) drives the full
+ # `aws eks get-token` → kubectl → apiserver → token-webhook → gateway
+ # broker → STS verify path. The e2e binaries run ON the VM, so kubectl
+ # must be on the VM's PATH or harness.NewKubectl t.Skips the subtest.
+ # Pin to the k3s server version (setup.sh K3S_VERSION) — kubectl is
+ # ±1-minor skew tolerant; matching is simplest. Checksum-verified.
+ - name: Install kubectl on VM
+ if: contains(env.SUITES, 'e2e-eks')
+ run: |
+ set -euo pipefail
+ source "${GITHUB_WORKSPACE}/spinifex/.github/scripts/ci-fmt.sh"
+ SSH_OPTS=(-o StrictHostKeyChecking=no -i "${{ steps.vm.outputs.ssh_key }}")
+ VM=tf-user@${{ steps.vm.outputs.wan_ip }}
+ banner "install kubectl on VM (eks data-plane subtest)"
+ ssh "${SSH_OPTS[@]}" "$VM" 'bash -s' <<'EOF'
+ set -euo pipefail
+ KVER=v1.32.5
+ case "$(uname -m)" in
+ x86_64) ARCH=amd64 ;;
+ aarch64) ARCH=arm64 ;;
+ *) echo "unsupported arch $(uname -m)" >&2; exit 1 ;;
+ esac
+ cd /tmp
+ curl -fsSLO "https://dl.k8s.io/release/${KVER}/bin/linux/${ARCH}/kubectl"
+ curl -fsSLO "https://dl.k8s.io/release/${KVER}/bin/linux/${ARCH}/kubectl.sha256"
+ echo "$(cat kubectl.sha256) kubectl" | sha256sum -c -
+ sudo install -m 0755 kubectl /usr/local/bin/kubectl
+ kubectl version --client
+ EOF
+ ok "kubectl installed on VM"
+
- name: Build E2E test binaries on runner
working-directory: spinifex/tests/e2e
run: |
@@ -277,6 +357,15 @@ jobs:
ssh "${SSH_OPTS[@]}" "$VM" 'sudo journalctl -u "spinifex-*" --no-pager --output=short-iso --since "60 min ago"' \
> "${ARTIFACT_DIR}/spinifex-journal.log" 2>/dev/null
ok "journal: $(wc -l < "${ARTIFACT_DIR}/spinifex-journal.log" 2>/dev/null || echo 0) lines"
+ # LB/system microVM serial consoles. The lb-agent logs its nginx (NLB)
+ # engine activation and any `nginx -t`/reload error + active-probe
+ # results to the guest serial console, which the host journal never
+ # captures. These files persist in /run/spinifex past VM teardown, so
+ # collecting them here makes an NLB "0/N healthy" timeout diagnosable.
+ mkdir -p "${ARTIFACT_DIR}/lb-console"
+ ssh "${SSH_OPTS[@]}" "$VM" 'sudo sh -c "cd /run/spinifex 2>/dev/null && tar -cf - console-*.log 2>/dev/null"' \
+ | tar -C "${ARTIFACT_DIR}/lb-console" -xf - 2>/dev/null
+ ok "lb-console: $(ls "${ARTIFACT_DIR}/lb-console" 2>/dev/null | wc -l) files"
- name: Convert test logs to JUnit XML
if: always()
@@ -383,7 +472,15 @@ jobs:
true
- name: Clean Workspace
- run: sudo rm -rf "$GITHUB_WORKSPACE"/*
+ run: |
+ # Detach any leaked mounts under the workspace before deleting. A
+ # killed image build can leave a `mount --bind /dev` under its build
+ # dir; if `rm -rf` traverses it as root it wipes the host's /dev
+ # (deletes /dev/null and wedges the box). umount deepest-first, then
+ # forbid rm from crossing any mount boundary as a hard backstop.
+ awk -v w="$GITHUB_WORKSPACE" '$2 ~ "^"w {print $2}' /proc/mounts \
+ | sort -r | xargs -r -I{} sudo umount -l -R {} || true
+ sudo rm -rf --one-file-system "$GITHUB_WORKSPACE"/*
- uses: actions/create-github-app-token@v3
id: app-token
@@ -471,7 +568,12 @@ jobs:
run: |
set -euo pipefail
source "${GITHUB_WORKSPACE}/spinifex/.github/scripts/ci-fmt.sh"
- SUITES_BUILD=$(echo "${SUITES}" | sed 's/e2e-//g' | tr ' ' '\n' | grep -vE '^(reboot|single)$' | tr '\n' ' ')
+ # eks is excluded on multinode: EKS KV is a clustered (Raft) JetStream
+ # stream and create-cluster needs an elected leader, but multinode
+ # JetStream clustering isn't solid yet (mulga-omq/z2z/5yg). EKS is
+ # validated single-node only for now; re-enable here once clustered
+ # JetStream + multinode EKS are a deliberate target.
+ SUITES_BUILD=$(echo "${SUITES}" | sed 's/e2e-//g' | tr ' ' '\n' | grep -vE '^(reboot|single|eks)$' | tr '\n' ' ')
if [ -z "$(echo "${SUITES_BUILD}" | tr -d ' ')" ]; then
echo "no multinode-eligible suites requested (SUITES=${SUITES}); nothing to build"
exit 0
@@ -554,7 +656,7 @@ jobs:
id: e2e
run: |
set +e
- SUITES_RUN=$(echo "${SUITES}" | sed 's/e2e-//g' | tr ' ' '\n' | grep -vE '^(reboot|single)$' | tr '\n' ' ')
+ SUITES_RUN=$(echo "${SUITES}" | sed 's/e2e-//g' | tr ' ' '\n' | grep -vE '^(reboot|single|eks)$' | tr '\n' ' ')
if [ -z "$(echo "${SUITES_RUN}" | tr -d ' ')" ]; then
echo "no multinode-eligible suites requested (SUITES=${SUITES}); skipping"
echo "rc=0" >> "$GITHUB_OUTPUT"
@@ -664,6 +766,18 @@ jobs:
ssh "${SSH_OPTS[@]}" tf-user@"$ip" 'sudo journalctl -u openvswitch-ipsec.service --no-pager --output=short-iso --since "60 min ago"' \
> "${ARTIFACT_DIR}/openvswitch-ipsec-node${idx}.log" 2>/dev/null
done
+ # LB/system microVM serial consoles, per node. The lb-agent logs its
+ # nginx (NLB) engine activation and any `nginx -t`/reload error +
+ # active-probe results to the guest serial console (never in the host
+ # journal). Files persist in /run/spinifex past VM teardown.
+ for i in "${!NODE_LIST[@]}"; do
+ idx=$((i + 1))
+ ip="${NODE_LIST[$i]}"
+ mkdir -p "${ARTIFACT_DIR}/lb-console/node${idx}"
+ ssh "${SSH_OPTS[@]}" tf-user@"$ip" 'sudo sh -c "cd /run/spinifex 2>/dev/null && tar -cf - console-*.log 2>/dev/null"' \
+ | tar -C "${ARTIFACT_DIR}/lb-console/node${idx}" -xf - 2>/dev/null
+ ok "lb-console node${idx}: $(ls "${ARTIFACT_DIR}/lb-console/node${idx}" 2>/dev/null | wc -l) files"
+ done
- name: Convert test logs to JUnit XML
if: always()
@@ -779,7 +893,15 @@ jobs:
true
- name: Clean Workspace
- run: sudo rm -rf "$GITHUB_WORKSPACE"/*
+ run: |
+ # Detach any leaked mounts under the workspace before deleting. A
+ # killed image build can leave a `mount --bind /dev` under its build
+ # dir; if `rm -rf` traverses it as root it wipes the host's /dev
+ # (deletes /dev/null and wedges the box). umount deepest-first, then
+ # forbid rm from crossing any mount boundary as a hard backstop.
+ awk -v w="$GITHUB_WORKSPACE" '$2 ~ "^"w {print $2}' /proc/mounts \
+ | sort -r | xargs -r -I{} sudo umount -l -R {} || true
+ sudo rm -rf --one-file-system "$GITHUB_WORKSPACE"/*
- uses: actions/create-github-app-token@v3
id: app-token
@@ -1004,7 +1126,7 @@ jobs:
name: Report E2E Status
if: always()
needs: [ e2e_single, e2e_real_multi, e2e_reboot ]
- runs-on: [ self-hosted, ci-single ]
+ runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- name: Report Status to PR
diff --git a/.github/workflows/publish-eks-ami.yml b/.github/workflows/publish-eks-ami.yml
new file mode 100644
index 000000000..89b0d6483
--- /dev/null
+++ b/.github/workflows/publish-eks-ami.yml
@@ -0,0 +1,92 @@
+name: Publish EKS node AMI
+
+# Builds the eks-node system AMI (libguestfs, on the runner) and publishes the
+# compressed qcow2 + .sha256 sidecar to the Cloudflare R2 bucket that backs
+# iso.mulgadc.com/system-ami/. Operators then install it on any cluster with:
+# spx admin images import --name spinifex-eks-node
+#
+# Manual-only: publishing overwrites the mirror artifact, so it is a deliberate
+# action, not something every push should trigger.
+
+on:
+ workflow_dispatch:
+ inputs:
+ dry_run:
+ description: "Build + convert only; skip the R2 upload"
+ type: boolean
+ default: false
+
+permissions:
+ contents: read
+
+concurrency:
+ group: publish-eks-ami
+ cancel-in-progress: false
+
+jobs:
+ publish:
+ name: Build + publish eks-node AMI
+ runs-on: ubuntu-24.04
+
+ steps:
+ - name: Checkout Spinifex
+ uses: actions/checkout@v6
+ with:
+ path: spinifex
+
+ - name: Checkout Viperblock
+ uses: actions/checkout@v6
+ with:
+ repository: mulgadc/viperblock
+ ref: dev
+ path: viperblock
+
+ - name: Checkout Predastore
+ uses: actions/checkout@v6
+ with:
+ repository: mulgadc/predastore
+ ref: dev
+ path: predastore
+
+ - name: Setup Go
+ uses: actions/setup-go@v6
+ with:
+ cache-dependency-path: "spinifex/go.sum"
+ go-version-file: "spinifex/go.mod"
+
+ - name: Setup Go Workspace
+ run: go work init ./spinifex ./viperblock ./predastore
+
+ - name: Install build + upload tooling
+ run: |
+ set -euo pipefail
+ sudo apt-get update -qq
+ sudo apt-get install -y --no-install-recommends libguestfs-tools qemu-utils
+ if ! command -v aws &>/dev/null; then
+ curl -fsSL "https://awscli.amazonaws.com/awscli-exe-linux-x86_64.zip" -o /tmp/awscliv2.zip
+ unzip -q /tmp/awscliv2.zip -d /tmp/aws-install
+ sudo /tmp/aws-install/aws/install
+ rm -rf /tmp/awscliv2.zip /tmp/aws-install
+ fi
+
+ - name: Build + publish to R2
+ working-directory: spinifex
+ env:
+ # Map the repo's R2 secrets onto the names publish-system-image.sh reads.
+ AWS_ACCESS_KEY_ID: ${{ secrets.CF_R2_ACCESS_KEY_ID }}
+ AWS_SECRET_ACCESS_KEY: ${{ secrets.CF_R2_SECRET_ACCESS_KEY }}
+ R2_ENDPOINT: ${{ secrets.CF_R2_ENDPOINT_URL }}
+ R2_BUCKET: spinifex-iso
+ DRY_RUN: ${{ inputs.dry_run }}
+ run: |
+ set -euo pipefail
+ # libguestfs needs to read a host kernel; Ubuntu ships it mode 0600.
+ # direct backend = no libvirt on the runner.
+ export LIBGUESTFS_BACKEND=direct
+ sudo chmod 0644 /boot/vmlinuz-* || true
+ FLAGS=(--build)
+ if [[ "${DRY_RUN}" == "true" ]]; then
+ FLAGS+=(--dry-run)
+ fi
+ ./scripts/publish-system-image.sh \
+ scripts/images/eks-node/manifest.conf "${FLAGS[@]}"
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 732a2656d..09d6c3c43 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -131,7 +131,16 @@ jobs:
REPO: ${{ github.repository }}
steps:
- name: Clean Workspace
- run: rm -rf "$GITHUB_WORKSPACE"/*
+ run: |
+ # This runner (ci-single) is shared with the iso job, which mounts a
+ # kernel-global devtmpfs at
2. Root credentials printed once
3. master.key is 32 bytes, mode 0600
4. bootstrap.json consumed on first start
5. `--force` re-initializes existing config
6. AWS CLI profile `spx` auto-configured
7. Multi-node init generates join token and writes to `
2. Missing host (error)
3. Missing node name (error)
4. Missing token (error)
5. Invalid token (401)
6. Expired token (401) | **DONE** |
+| Command | Flags | Description |
+|---------|-------|-------------|
+| `spx admin init` | `--nodes`, `--node`, `--bind`, `--port`, `--region`, `--az`, `--cluster-name`, `--cluster-bind`, `--cluster-routes`, `--predastore-nodes`, `--services`, `--formation-timeout`, `--token-ttl`, `--force` | Generates root IAM credentials (AKIA-prefixed access key + secret) → creates master.key (AES-256, 32 bytes, 0600) → writes bootstrap.json (consumed on first start) → generates CA + server TLS certificates → generates join token (written to `join-token` file, displayed in join command) → creates NATS config with auth token → writes spinifex.toml, awsgw.toml, predastore.toml → configures AWS CLI `spx` profile → creates directory structure under `~/spinifex/` |
+| `spx admin join` | `--host` (required), `--node` (required), `--token` (required), `--bind`, `--port`, `--region`, `--az`, `--cluster-bind`, `--cluster-routes`, `--data-dir`, `--services` | Connects to leader node with join token (Authorization: Bearer header) → retrieves cluster configuration → configures local node to join cluster and participate in distributed operations |
### Version
-| Command | Flags | Prerequisites | Basic Logic | Status |
-|---------|-------|---------------|-------------|--------|
-| `spx version` | — | None | Prints Spinifex version, commit hash, OS, and architecture (populated via build-time ldflags) | **DONE** |
+| Command | Flags | Description |
+|---------|-------|-------------|
+| `spx version` | — | Prints Spinifex version, commit hash, OS, and architecture (populated via build-time ldflags) |
### Cluster Operations
-| Command | Flags | Prerequisites | Basic Logic | Test Cases | Status |
-|---------|-------|---------------|-------------|------------|--------|
-| `spx admin cluster shutdown` | `--force` (shutdown even if nodes don't respond), `--timeout` (max wait per phase, default 120s), `--dry-run` (print phase plan without executing) | Cluster must be running | Performs coordinated, phased shutdown of entire cluster. Phases execute in order: GATE (stop API/UI) → DRAIN (stop VMs) → STORAGE (stop viperblock) → PERSIST (stop predastore) → INFRA (stop NATS/daemon). Each phase waits for all nodes to ACK before proceeding. Uses JetStream state tracking. | 1. Shutdown running cluster
2. All nodes stop cleanly
3. Force shutdown with unresponsive nodes
4. Dry-run prints plan | **DONE** |
+| Command | Flags | Description |
+|---------|-------|-------------|
+| `spx admin cluster shutdown` | `--force` (shutdown even if nodes don't respond), `--timeout` (max wait per phase, default 120s), `--dry-run` (print phase plan without executing) | Performs coordinated, phased shutdown of entire cluster. Phases execute in order: GATE (stop API/UI) → DRAIN (stop VMs) → STORAGE (stop viperblock) → PERSIST (stop predastore) → INFRA (stop NATS/daemon). Each phase waits for all nodes to ACK before proceeding. Uses JetStream state tracking. |
### Certificate Management
-| Command | Flags | Prerequisites | Basic Logic | Test Cases | Status |
-|---------|-------|---------------|-------------|------------|--------|
-| `spx admin cert renew` | `--extra-ip` (additional IPs for SANs), `--extra-dns` (additional DNS names for SANs) | Existing CA from `spx admin init` | Reads existing CA → regenerates server certificate with all current network interface IPs and machine hostname in SANs → writes new cert. Use after adding a new network interface or changing IP addresses. | 1. Renew with auto-detected IPs
2. Renew with extra IPs
3. Renew with extra DNS names | **DONE** |
+| Command | Flags | Description |
+|---------|-------|-------------|
+| `spx admin cert renew` | `--extra-ip` (additional IPs for SANs), `--extra-dns` (additional DNS names for SANs) | Reads existing CA → regenerates server certificate with all current network interface IPs and machine hostname in SANs → writes new cert. Use after adding a new network interface or changing IP addresses. |
### Upgrade Management
-| Command | Flags | Prerequisites | Basic Logic | Test Cases | Status |
-|---------|-------|---------------|-------------|------------|--------|
-| `spx admin upgrade` | `--yes` (apply without prompting), `--config-dir` (persistent, default: `~/spinifex/config`), `--spinifex-dir` (persistent, default: `~/spinifex`) | Existing installation (`spinifex.toml` must exist in config dir) | Reads current config versions from registry → computes pending config migrations (from→to per target) → prints version summary and pending list → prompts for confirmation unless `--yes` → runs `migrate.DefaultRegistry.RunAllConfig()` to apply migrations to config files. Intended for upgrades between Spinifex versions. Operators can skip migrations during install by setting `INSTALL_SPINIFEX_SKIP_MIGRATE=1`, then run `spx admin upgrade` manually to review and apply. After completion, services must be restarted with `sudo systemctl restart spinifex.target`. Invoked non-interactively by `scripts/setup.sh` with `--yes`. | 1. No installation present (error, suggests `spx admin init`)
2. No pending migrations (prints "No pending config migrations")
3. Pending migrations with interactive confirmation (y/yes applies, anything else aborts)
4. Pending migrations with `--yes` (applies without prompt)
5. Migration failure surfaces error and exits non-zero | **DONE** |
+| Command | Flags | Description |
+|---------|-------|-------------|
+| `spx admin upgrade` | `--yes` (apply without prompting), `--config-dir` (persistent, default: `~/spinifex/config`), `--spinifex-dir` (persistent, default: `~/spinifex`) | Reads current config versions from registry → computes pending config migrations (from→to per target) → prints version summary and pending list → prompts for confirmation unless `--yes` → runs `migrate.DefaultRegistry.RunAllConfig()` to apply migrations to config files. Intended for upgrades between Spinifex versions. Operators can skip migrations during install by setting `INSTALL_SPINIFEX_SKIP_MIGRATE=1`, then run `spx admin upgrade` manually to review and apply. After completion, services must be restarted with `sudo systemctl restart spinifex.target`. Invoked non-interactively by `scripts/setup.sh` with `--yes`. |
### Account Management
-| Command | Flags | Prerequisites | Basic Logic | Test Cases | Status |
-|---------|-------|---------------|-------------|------------|--------|
-| `spx admin account create` | `--name` | Cluster must be running (NATS), master key must exist | Connects to NATS → CAS loop on `spinifex-account-counter:next_id` for sequential 12-digit ID → creates Account record in `spinifex-accounts` KV → creates `admin` user in new account → creates access key for admin → creates AdministratorAccess policy (Action:*, Resource:*) → attaches policy → prints credentials | 1. Create first account (ID 000000000001)
2. Sequential IDs (000000000002, etc.)
3. Admin user has full access
4. Credentials printed once | **DONE** |
-| `spx admin account list` | — | Cluster must be running (NATS), master key must exist | Connects to NATS → IAMService.ListAccounts() → prints table with Account ID, Name, Status, Created | 1. List all accounts
2. Empty list (only global account) | **DONE** |
+| Command | Flags | Description |
+|---------|-------|-------------|
+| `spx admin account create` | `--name` | Connects to NATS → CAS loop on `spinifex-account-counter:next_id` for sequential 12-digit ID → creates Account record in `spinifex-accounts` KV → creates `admin` user in new account → creates access key for admin → creates AdministratorAccess policy (Action:*, Resource:*) → attaches policy → prints credentials |
+| `spx admin account list` | — | Connects to NATS → IAMService.ListAccounts() → prints table with Account ID, Name, Status, Created |
### Image Management
-| Command | Flags | Prerequisites | Basic Logic | Test Cases | Status |
-|---------|-------|---------------|-------------|------------|--------|
-| `spx admin images import` | `--name`, `--file`, `--distro`, `--version`, `--arch`, `--platform`, `--boot-mode` (bios/uefi/uefi-preferred), `--tag`, `--force`, `--skip-verify` | Cluster must be running; either `--name` (catalog download) or `--file` (operator-supplied media). `--file` mode additionally requires `--distro`, `--version`, `--arch`, `--boot-mode`. | Catalog imports (`--name`) download the image, fetch the catalog `Checksum` URL, verify the SHA-256/SHA-512 digest, and inherit `BootMode` from the catalog entry. `--boot-mode` overrides the catalog value when set. Mismatch fails closed; the cached file is left on disk and `--force` re-downloads. `--file` imports skip checksum verification (operator-supplied media is outside Spinifex's trust boundary, the skip is logged at INFO for audit) and require an explicit `--boot-mode` because there is no catalog metadata to inherit from. `--skip-verify` bypasses verification for catalog imports and emits a WARN slog + stderr notice; use only for debugging or when upstream mirrors are confirmed-broken. | 1. Import valid catalog image (verifies checksum)
2. Tampered cache hit fails with `ErrChecksumMismatch`
3. `--force` recovers after a mismatch
4. `--file` import skips verification
5. `--skip-verify` bypasses checksum with WARN
6. `--file` without `--boot-mode` rejected
7. `--boot-mode=invalid` rejected | **DONE** |
-| `spx admin images list` | — | None | Lists available OS images that can be imported or downloaded | 1. List available images | **DONE** |
-| `spx admin images remove` | `--image-id` (required), `--force`, `--yes` | Cluster must be running; predastore reachable. Targets AMIs imported via `spx admin images import` (non-account `ImageOwnerAlias`, e.g. `"system"`). | Loads `ami-
2. Account-owned → refused with AWS-flow hint
3. Missing/corrupt config → `InvalidAMIID.NotFound` (salvageable with `--force`)
4. Dependent volume (direct or via copied snap) → refused
5. Dependent account AMI → refused
6. `--force` overrides dependents
7. Idempotent re-run after partial delete | **DONE** |
-
-#### Image integrity verification (CMMC SI.L1-3.14.2)
-
-Catalog imports (`spx admin images import --name
2. Local node capable but passthrough disabled (shows "run enable")
3. Node without GPU hardware (shows "none detected")
4. `--node` targets a remote node
5. Node not found or not responding (error) | **DONE** |
-| `spx admin gpu enable` | — | Cluster must be running (NATS); node must be GPU-capable (IOMMU + vfio-pci configured) | Checks current passthrough state via NATS → errors if already enabled or prerequisites not met (directs to `setup`) → writes `gpu_passthrough = true` to `spinifex.toml` via `admin.SetGPUPassthrough` → sends SIGHUP to `spinifex-daemon` via `systemctl kill -s HUP` → polls node status for up to 30 s until daemon confirms new state → prints final `gpu status` output. Must be run directly on the target host. | 1. Enable on capable node
2. Already enabled (no-op)
3. Prerequisites not met (error, suggests `setup`)
4. Daemon does not confirm within 30 s (timeout error) | **DONE** |
-| `spx admin gpu disable` | — | Cluster must be running (NATS); no GPU instances may be running | Checks current passthrough state via NATS → errors if already disabled or if `AllocGPUs > 0` (must terminate GPU instances first) → writes `gpu_passthrough = false` → sends SIGHUP to `spinifex-daemon` → polls for up to 30 s → prints final `gpu status` output. Must be run directly on the target host. | 1. Disable with no GPU instances running
2. Already disabled (no-op)
3. GPU instances running (error with count)
4. Daemon does not confirm within 30 s (timeout error) | **DONE** |
-| `spx admin gpu setup` | — | Must be run as root; NVIDIA or AMD GPU must be present | Idempotent host configuration for GPU passthrough. Steps (skipped if already applied): detect GPUs via `gpu.Discover` → collect PCI IDs for all IOMMU-group siblings → check/enable IOMMU in GRUB (`intel_iommu=on iommu=pt` or `amd_iommu=on iommu=pt`) → write vfio udev rule (`/etc/udev/rules.d/99-spinifex-vfio.rules`) → blacklist nouveau (`/etc/modprobe.d/blacklist-nouveau.conf`) → blacklist amdgpu if AMD GPU present (`/etc/modprobe.d/blacklist-amdgpu.conf`) → write vfio-pci early binding config (`/etc/modprobe.d/vfio-pci.conf`) → add vfio modules to initramfs. If any change requires a reboot: runs `update-initramfs -u` and exits with reboot instructions. After reboot: verifies `vfio_pci` module is loaded → verifies each GPU is bound to `vfio-pci` (binds explicitly via `driver_override` if unbound) → calls `gpu enable` to activate passthrough. | 1. First run on unconfigured host (GRUB updated, reboot required)
2. Re-run after reboot (verifies bindings, enables passthrough)
3. Already fully configured (all steps skipped, enables passthrough)
4. AMD GPU present (blacklists amdgpu in addition to nouveau)
5. Not run as root (error)
6. No GPU hardware detected (error) | **DONE** |
-| `spx admin gpu mig status` | — (must be run on target host) | NVIDIA GPU with MIG support present | Runs `gpu.Discover()` locally → for each GPU prints: PCI address, model, MIG capability, MIG mode (enabled/disabled/N/A); for GPUs with MIG enabled lists active MIG slices with GI ID, profile name, and mdev path. | 1. Node with MIG enabled (shows slices)
2. MIG capable but not enabled (shows disabled)
3. No MIG-capable GPU (shows N/A for MIG fields) | **DONE** |
-| `spx admin gpu mig enable` | `--profile
2. Invalid profile name (error with available profile list)
3. No MIG-capable GPU (error)
4. GPU instances running (error)
5. `--gpu` targets specific GPU
6. Not run as root (error) | **DONE** |
-| `spx admin gpu mig disable` | `--gpu
2. GPU instances running (error)
3. MIG not enabled on GPU (skips that GPU)
4. `--gpu` targets specific GPU
5. Not run as root (error) | **DONE** |
+| Command | Flags | Description |
+|---------|-------|-------------|
+| `spx admin gpu status` | `--node` (default: local node) | Queries `spinifex.node.status` fan-out → finds the target node response → prints Node, GPU hardware (model list or "none detected"), IOMMU state, vfio-pci state, passthrough enabled/disabled, and GPU pool allocation (`allocated/total`). Also lists GPU-capable instance types when passthrough is active. |
+| `spx admin gpu enable` | — | Checks current passthrough state via NATS → errors if already enabled or prerequisites not met (directs to `setup`) → writes `gpu_passthrough = true` to `spinifex.toml` via `admin.SetGPUPassthrough` → sends SIGHUP to `spinifex-daemon` via `systemctl kill -s HUP` → polls node status for up to 30 s until daemon confirms new state → prints final `gpu status` output. Must be run directly on the target host. |
+| `spx admin gpu disable` | — | Checks current passthrough state via NATS → errors if already disabled or if `AllocGPUs > 0` (must terminate GPU instances first) → writes `gpu_passthrough = false` → sends SIGHUP to `spinifex-daemon` → polls for up to 30 s → prints final `gpu status` output. Must be run directly on the target host. |
+| `spx admin gpu setup` | — | Idempotent host configuration for GPU passthrough. Steps (skipped if already applied): detect GPUs via `gpu.Discover` → collect PCI IDs for all IOMMU-group siblings → check/enable IOMMU in GRUB (`intel_iommu=on iommu=pt` or `amd_iommu=on iommu=pt`) → write vfio udev rule (`/etc/udev/rules.d/99-spinifex-vfio.rules`) → blacklist nouveau (`/etc/modprobe.d/blacklist-nouveau.conf`) → blacklist amdgpu if AMD GPU present (`/etc/modprobe.d/blacklist-amdgpu.conf`) → write vfio-pci early binding config (`/etc/modprobe.d/vfio-pci.conf`) → add vfio modules to initramfs. If any change requires a reboot: runs `update-initramfs -u` and exits with reboot instructions. After reboot: verifies `vfio_pci` module is loaded → verifies each GPU is bound to `vfio-pci` (binds explicitly via `driver_override` if unbound) → calls `gpu enable` to activate passthrough. |
+| `spx admin gpu mig status` | — (must be run on target host) | Runs `gpu.Discover()` locally → for each GPU prints: PCI address, model, MIG capability, MIG mode (enabled/disabled/N/A); for GPUs with MIG enabled lists active MIG slices with GI ID, profile name, and mdev path. |
+| `spx admin gpu mig enable` | `--profile
Exception","message":...}, Content-Type
+ // application/x-amz-json-1.1). Query/XML services fall through below.
+ if svc == "eks" || svc == "acm" || svc == "tagging" {
body := GenerateEKSErrorResponse(err.Error(), errorMsg.Message, requestId)
- slog.Debug("Generated EKS error response", "error", err.Error(), "json", string(body), "requestId", requestId)
+ slog.Debug("Generated JSON error response", "service", svc, "error", err.Error(), "json", string(body), "requestId", requestId)
w.Header().Set("Content-Type", eksJSONContentType)
w.WriteHeader(errorMsg.HTTPCode)
if _, err := w.Write(body); err != nil {
diff --git a/spinifex/gateway/gateway_test.go b/spinifex/gateway/gateway_test.go
index 2bb22bc78..445e7979e 100644
--- a/spinifex/gateway/gateway_test.go
+++ b/spinifex/gateway/gateway_test.go
@@ -508,6 +508,11 @@ func TestGetService(t *testing.T) {
ctxVal: "account",
wantSvc: "account",
},
+ {
+ name: "tagging service",
+ ctxVal: "tagging",
+ wantSvc: "tagging",
+ },
}
for _, tc := range tests {
diff --git a/spinifex/gateway/iam.go b/spinifex/gateway/iam.go
index 5cb344834..444035d0f 100644
--- a/spinifex/gateway/iam.go
+++ b/spinifex/gateway/iam.go
@@ -154,6 +154,20 @@ var iamActions = map[string]IAMHandler{
"RemoveRoleFromInstanceProfile": iamHandler(func(accountID string, input *iam.RemoveRoleFromInstanceProfileInput, gw *GatewayConfig) (any, error) {
return gateway_iam.RemoveRoleFromInstanceProfile(accountID, input, gw.IAMService)
}),
+
+ // OIDC identity-provider registry (IRSA)
+ "CreateOpenIDConnectProvider": iamHandler(func(accountID string, input *iam.CreateOpenIDConnectProviderInput, gw *GatewayConfig) (any, error) {
+ return gateway_iam.CreateOpenIDConnectProvider(accountID, input, gw.IAMService)
+ }),
+ "GetOpenIDConnectProvider": iamHandler(func(accountID string, input *iam.GetOpenIDConnectProviderInput, gw *GatewayConfig) (any, error) {
+ return gateway_iam.GetOpenIDConnectProvider(accountID, input, gw.IAMService)
+ }),
+ "ListOpenIDConnectProviders": iamHandler(func(accountID string, input *iam.ListOpenIDConnectProvidersInput, gw *GatewayConfig) (any, error) {
+ return gateway_iam.ListOpenIDConnectProviders(accountID, input, gw.IAMService)
+ }),
+ "DeleteOpenIDConnectProvider": iamHandler(func(accountID string, input *iam.DeleteOpenIDConnectProviderInput, gw *GatewayConfig) (any, error) {
+ return gateway_iam.DeleteOpenIDConnectProvider(accountID, input, gw.IAMService)
+ }),
}
func (gw *GatewayConfig) IAM_Request(w http.ResponseWriter, r *http.Request) error {
diff --git a/spinifex/gateway/iam/oidc_provider.go b/spinifex/gateway/iam/oidc_provider.go
new file mode 100644
index 000000000..e57310c63
--- /dev/null
+++ b/spinifex/gateway/iam/oidc_provider.go
@@ -0,0 +1,34 @@
+package gateway_iam
+
+import (
+ "errors"
+
+ "github.com/aws/aws-sdk-go/service/iam"
+ "github.com/mulgadc/spinifex/spinifex/awserrors"
+ handlers_iam "github.com/mulgadc/spinifex/spinifex/handlers/iam"
+)
+
+func CreateOpenIDConnectProvider(accountID string, input *iam.CreateOpenIDConnectProviderInput, svc handlers_iam.IAMService) (*iam.CreateOpenIDConnectProviderOutput, error) {
+ if input.Url == nil || *input.Url == "" {
+ return nil, errors.New(awserrors.ErrorMissingParameter)
+ }
+ return svc.CreateOpenIDConnectProvider(accountID, input)
+}
+
+func GetOpenIDConnectProvider(accountID string, input *iam.GetOpenIDConnectProviderInput, svc handlers_iam.IAMService) (*iam.GetOpenIDConnectProviderOutput, error) {
+ if input.OpenIDConnectProviderArn == nil || *input.OpenIDConnectProviderArn == "" {
+ return nil, errors.New(awserrors.ErrorMissingParameter)
+ }
+ return svc.GetOpenIDConnectProvider(accountID, input)
+}
+
+func ListOpenIDConnectProviders(accountID string, input *iam.ListOpenIDConnectProvidersInput, svc handlers_iam.IAMService) (*iam.ListOpenIDConnectProvidersOutput, error) {
+ return svc.ListOpenIDConnectProviders(accountID, input)
+}
+
+func DeleteOpenIDConnectProvider(accountID string, input *iam.DeleteOpenIDConnectProviderInput, svc handlers_iam.IAMService) (*iam.DeleteOpenIDConnectProviderOutput, error) {
+ if input.OpenIDConnectProviderArn == nil || *input.OpenIDConnectProviderArn == "" {
+ return nil, errors.New(awserrors.ErrorMissingParameter)
+ }
+ return svc.DeleteOpenIDConnectProvider(accountID, input)
+}
diff --git a/spinifex/gateway/iam/oidc_provider_mock_test.go b/spinifex/gateway/iam/oidc_provider_mock_test.go
new file mode 100644
index 000000000..c08575a62
--- /dev/null
+++ b/spinifex/gateway/iam/oidc_provider_mock_test.go
@@ -0,0 +1,19 @@
+package gateway_iam
+
+import "github.com/aws/aws-sdk-go/service/iam"
+
+// OIDC identity-provider registry stubs so stubIAMService satisfies the
+// IAMService interface. Dedicated CRUD coverage lives in handlers/iam.
+
+func (s *stubIAMService) CreateOpenIDConnectProvider(string, *iam.CreateOpenIDConnectProviderInput) (*iam.CreateOpenIDConnectProviderOutput, error) {
+ return nil, nil
+}
+func (s *stubIAMService) GetOpenIDConnectProvider(string, *iam.GetOpenIDConnectProviderInput) (*iam.GetOpenIDConnectProviderOutput, error) {
+ return nil, nil
+}
+func (s *stubIAMService) ListOpenIDConnectProviders(string, *iam.ListOpenIDConnectProvidersInput) (*iam.ListOpenIDConnectProvidersOutput, error) {
+ return nil, nil
+}
+func (s *stubIAMService) DeleteOpenIDConnectProvider(string, *iam.DeleteOpenIDConnectProviderInput) (*iam.DeleteOpenIDConnectProviderOutput, error) {
+ return nil, nil
+}
diff --git a/spinifex/gateway/oidc_discovery.go b/spinifex/gateway/oidc_discovery.go
new file mode 100644
index 000000000..c7c5ec598
--- /dev/null
+++ b/spinifex/gateway/oidc_discovery.go
@@ -0,0 +1,116 @@
+package gateway
+
+import (
+ "encoding/json"
+ "errors"
+ "log/slog"
+ "net/http"
+
+ "github.com/go-chi/chi/v5"
+ handlers_eks "github.com/mulgadc/spinifex/spinifex/handlers/eks"
+ "github.com/nats-io/nats.go"
+)
+
+// oidcDiscoveryDocument is the subset of the OpenID Connect Discovery metadata
+// (RFC 8414) that AWS STS and the AWS SDKs consume to validate IRSA
+// ServiceAccount tokens. Served unauthenticated at the per-cluster issuer URL.
+type oidcDiscoveryDocument struct {
+ Issuer string `json:"issuer"`
+ JWKSURI string `json:"jwks_uri"`
+ ResponseTypesSupported []string `json:"response_types_supported"`
+ SubjectTypesSupported []string `json:"subject_types_supported"`
+ IDTokenSigningAlgValuesSupported []string `json:"id_token_signing_alg_values_supported"`
+ ClaimsSupported []string `json:"claims_supported"`
+}
+
+// eksAccountKV opens the read-only per-account EKS bucket. Returns
+// nats.ErrBucketNotFound (unwrapped) when the account has no clusters, which
+// the OIDC handlers translate to 404.
+func (gw *GatewayConfig) eksAccountKV(accountID string) (nats.KeyValue, error) {
+ if gw.NATSConn == nil {
+ return nil, errors.New("gateway NATS connection not initialised")
+ }
+ js, err := gw.NATSConn.JetStream()
+ if err != nil {
+ return nil, err
+ }
+ return js.KeyValue(handlers_eks.AccountBucketName(accountID))
+}
+
+// OIDCDiscoveryDocument serves the cluster's OpenID discovery document. The
+// `issuer` it returns is the cluster's stored IssuerURL — byte-identical to the
+// apiserver's `iss` claim — so an AWS SDK that fetches this document while
+// registering the provider sees a matching issuer.
+func (gw *GatewayConfig) OIDCDiscoveryDocument(w http.ResponseWriter, r *http.Request) {
+ accountID := chi.URLParam(r, "accountID")
+ clusterName := chi.URLParam(r, "clusterName")
+
+ kv, err := gw.eksAccountKV(accountID)
+ if err != nil {
+ if errors.Is(err, nats.ErrBucketNotFound) {
+ http.NotFound(w, r)
+ return
+ }
+ slog.Error("OIDC discovery: open account bucket", "accountID", accountID, "err", err)
+ http.Error(w, "internal error", http.StatusInternalServerError)
+ return
+ }
+
+ meta, err := handlers_eks.GetClusterMeta(kv, clusterName)
+ if err != nil || meta == nil || meta.OIDCIssuer == "" {
+ http.NotFound(w, r)
+ return
+ }
+
+ gw.writeOIDCJSON(w, oidcDiscoveryDocument{
+ Issuer: meta.OIDCIssuer,
+ JWKSURI: meta.OIDCIssuer + "/keys",
+ ResponseTypesSupported: []string{"id_token"},
+ SubjectTypesSupported: []string{"public"},
+ IDTokenSigningAlgValuesSupported: []string{"ES256"},
+ ClaimsSupported: []string{"sub", "iss", "aud", "exp", "iat"},
+ })
+}
+
+// OIDCJWKS serves the cluster's JWKS document (the public verification key) as
+// referenced by the discovery document's jwks_uri.
+func (gw *GatewayConfig) OIDCJWKS(w http.ResponseWriter, r *http.Request) {
+ accountID := chi.URLParam(r, "accountID")
+ clusterName := chi.URLParam(r, "clusterName")
+
+ kv, err := gw.eksAccountKV(accountID)
+ if err != nil {
+ if errors.Is(err, nats.ErrBucketNotFound) {
+ http.NotFound(w, r)
+ return
+ }
+ slog.Error("OIDC JWKS: open account bucket", "accountID", accountID, "err", err)
+ http.Error(w, "internal error", http.StatusInternalServerError)
+ return
+ }
+
+ entry, err := kv.Get(handlers_eks.OIDCJWKSKey(clusterName))
+ if err != nil {
+ http.NotFound(w, r)
+ return
+ }
+
+ w.Header().Set("Content-Type", "application/json")
+ w.Header().Set("Cache-Control", "public, max-age=300")
+ if _, err := w.Write(entry.Value()); err != nil {
+ slog.Debug("OIDC JWKS: write response", "err", err)
+ }
+}
+
+func (gw *GatewayConfig) writeOIDCJSON(w http.ResponseWriter, v any) {
+ body, err := json.Marshal(v)
+ if err != nil {
+ http.Error(w, "internal error", http.StatusInternalServerError)
+ return
+ }
+ w.Header().Set("Content-Type", "application/json")
+ w.Header().Set("Cache-Control", "public, max-age=300")
+ if _, err := w.Write(body); err != nil {
+ slog.Debug("OIDC discovery: write response", "err", err)
+ }
+}
diff --git a/spinifex/gateway/oidc_discovery_test.go b/spinifex/gateway/oidc_discovery_test.go
new file mode 100644
index 000000000..a8203ed81
--- /dev/null
+++ b/spinifex/gateway/oidc_discovery_test.go
@@ -0,0 +1,123 @@
+package gateway
+
+import (
+ "encoding/json"
+ "net/http"
+ "net/http/httptest"
+ "testing"
+
+ handlers_eks "github.com/mulgadc/spinifex/spinifex/handlers/eks"
+ "github.com/mulgadc/spinifex/spinifex/testutil"
+)
+
+const (
+ testDiscAccount = "000000000001"
+ testDiscCluster = "h1"
+ testDiscIssuer = "https://10.0.0.1:9999/oidc/eks/ap-southeast-2/000000000001/h1"
+)
+
+// seedDiscoveryCluster writes a ClusterMeta (with OIDCIssuer) and a JWKS doc
+// into the per-account EKS bucket, returning a router wired to the same NATS.
+func seedDiscoveryCluster(t *testing.T) http.Handler {
+ t.Helper()
+ _, nc, js := testutil.StartTestJetStream(t)
+
+ kv, err := handlers_eks.GetOrCreateAccountBucket(js, testDiscAccount)
+ if err != nil {
+ t.Fatalf("account bucket: %v", err)
+ }
+ if err := handlers_eks.PutClusterMeta(kv, &handlers_eks.ClusterMeta{
+ Name: testDiscCluster,
+ Status: handlers_eks.ClusterStatusActive,
+ OIDCIssuer: testDiscIssuer,
+ }); err != nil {
+ t.Fatalf("put cluster meta: %v", err)
+ }
+ if _, err := kv.Put(handlers_eks.OIDCJWKSKey(testDiscCluster), []byte(`{"keys":[{"kty":"EC","kid":"abc"}]}`)); err != nil {
+ t.Fatalf("put jwks: %v", err)
+ }
+
+ gw := &GatewayConfig{DisableLogging: true, NATSConn: nc}
+ return gw.SetupRoutes()
+}
+
+func TestOIDCDiscoveryDocument(t *testing.T) {
+ h := seedDiscoveryCluster(t)
+
+ req := httptest.NewRequest(http.MethodGet,
+ "/oidc/eks/ap-southeast-2/"+testDiscAccount+"/"+testDiscCluster+"/.well-known/openid-configuration", nil)
+ rec := httptest.NewRecorder()
+ h.ServeHTTP(rec, req)
+
+ if rec.Code != http.StatusOK {
+ t.Fatalf("status = %d, body=%s", rec.Code, rec.Body.String())
+ }
+ var doc oidcDiscoveryDocument
+ if err := json.Unmarshal(rec.Body.Bytes(), &doc); err != nil {
+ t.Fatalf("unmarshal: %v", err)
+ }
+ if doc.Issuer != testDiscIssuer {
+ t.Fatalf("issuer = %q, want %q", doc.Issuer, testDiscIssuer)
+ }
+ if doc.JWKSURI != testDiscIssuer+"/keys" {
+ t.Fatalf("jwks_uri = %q, want issuer+/keys", doc.JWKSURI)
+ }
+ if len(doc.IDTokenSigningAlgValuesSupported) != 1 || doc.IDTokenSigningAlgValuesSupported[0] != "ES256" {
+ t.Fatalf("alg = %v, want [ES256]", doc.IDTokenSigningAlgValuesSupported)
+ }
+}
+
+func TestOIDCJWKS(t *testing.T) {
+ h := seedDiscoveryCluster(t)
+
+ req := httptest.NewRequest(http.MethodGet,
+ "/oidc/eks/ap-southeast-2/"+testDiscAccount+"/"+testDiscCluster+"/keys", nil)
+ rec := httptest.NewRecorder()
+ h.ServeHTTP(rec, req)
+
+ if rec.Code != http.StatusOK {
+ t.Fatalf("status = %d", rec.Code)
+ }
+ if ct := rec.Header().Get("Content-Type"); ct != "application/json" {
+ t.Fatalf("content-type = %q", ct)
+ }
+ var jwks struct {
+ Keys []map[string]string `json:"keys"`
+ }
+ if err := json.Unmarshal(rec.Body.Bytes(), &jwks); err != nil {
+ t.Fatalf("unmarshal jwks: %v", err)
+ }
+ if len(jwks.Keys) != 1 || jwks.Keys[0]["kid"] != "abc" {
+ t.Fatalf("jwks = %s", rec.Body.String())
+ }
+}
+
+func TestOIDCDiscovery_UnknownCluster404(t *testing.T) {
+ h := seedDiscoveryCluster(t)
+
+ for _, path := range []string{
+ "/oidc/eks/ap-southeast-2/" + testDiscAccount + "/nope/.well-known/openid-configuration",
+ "/oidc/eks/ap-southeast-2/" + testDiscAccount + "/nope/keys",
+ "/oidc/eks/ap-southeast-2/999999999999/h1/keys",
+ } {
+ req := httptest.NewRequest(http.MethodGet, path, nil)
+ rec := httptest.NewRecorder()
+ h.ServeHTTP(rec, req)
+ if rec.Code != http.StatusNotFound {
+ t.Fatalf("path %s: status = %d, want 404", path, rec.Code)
+ }
+ }
+}
+
+// TestOIDCDiscovery_NoAuth confirms the discovery routes bypass SigV4 — they
+// return 200 with no Authorization header, unlike the authenticated surface.
+func TestOIDCDiscovery_NoAuth(t *testing.T) {
+ h := seedDiscoveryCluster(t)
+ req := httptest.NewRequest(http.MethodGet,
+ "/oidc/eks/ap-southeast-2/"+testDiscAccount+"/"+testDiscCluster+"/keys", nil)
+ rec := httptest.NewRecorder()
+ h.ServeHTTP(rec, req)
+ if rec.Code != http.StatusOK {
+ t.Fatalf("unauthenticated discovery status = %d, want 200", rec.Code)
+ }
+}
diff --git a/spinifex/gateway/oidc_provider_mock_test.go b/spinifex/gateway/oidc_provider_mock_test.go
new file mode 100644
index 000000000..404926706
--- /dev/null
+++ b/spinifex/gateway/oidc_provider_mock_test.go
@@ -0,0 +1,33 @@
+package gateway
+
+import "github.com/aws/aws-sdk-go/service/iam"
+
+// OIDC identity-provider registry methods — IRSA stubs so the mock IAM
+// services satisfy the IAMService interface. No gateway test exercises these
+// paths directly (covered by handlers/iam unit tests).
+
+func (m *mockIAMService) CreateOpenIDConnectProvider(string, *iam.CreateOpenIDConnectProviderInput) (*iam.CreateOpenIDConnectProviderOutput, error) {
+ return nil, nil
+}
+func (m *mockIAMService) GetOpenIDConnectProvider(string, *iam.GetOpenIDConnectProviderInput) (*iam.GetOpenIDConnectProviderOutput, error) {
+ return nil, nil
+}
+func (m *mockIAMService) ListOpenIDConnectProviders(string, *iam.ListOpenIDConnectProvidersInput) (*iam.ListOpenIDConnectProvidersOutput, error) {
+ return nil, nil
+}
+func (m *mockIAMService) DeleteOpenIDConnectProvider(string, *iam.DeleteOpenIDConnectProviderInput) (*iam.DeleteOpenIDConnectProviderOutput, error) {
+ return nil, nil
+}
+
+func (m *flexMockIAMService) CreateOpenIDConnectProvider(string, *iam.CreateOpenIDConnectProviderInput) (*iam.CreateOpenIDConnectProviderOutput, error) {
+ return nil, nil
+}
+func (m *flexMockIAMService) GetOpenIDConnectProvider(string, *iam.GetOpenIDConnectProviderInput) (*iam.GetOpenIDConnectProviderOutput, error) {
+ return nil, nil
+}
+func (m *flexMockIAMService) ListOpenIDConnectProviders(string, *iam.ListOpenIDConnectProvidersInput) (*iam.ListOpenIDConnectProvidersOutput, error) {
+ return nil, nil
+}
+func (m *flexMockIAMService) DeleteOpenIDConnectProvider(string, *iam.DeleteOpenIDConnectProviderInput) (*iam.DeleteOpenIDConnectProviderOutput, error) {
+ return nil, nil
+}
diff --git a/spinifex/gateway/ratelimit.go b/spinifex/gateway/ratelimit.go
index 42155715c..05b40aa50 100644
--- a/spinifex/gateway/ratelimit.go
+++ b/spinifex/gateway/ratelimit.go
@@ -2,8 +2,6 @@ package gateway
import (
"log/slog"
- "net"
- "net/http"
"sync"
"time"
@@ -187,17 +185,6 @@ func (rl *AuthRateLimiter) cleanup() {
}
}
-// extractClientIP returns the IP address from the request's RemoteAddr,
-// stripping the port. Handles both IPv4 and IPv6.
-func extractClientIP(r *http.Request) string {
- host, _, err := net.SplitHostPort(r.RemoteAddr)
- if err != nil {
- // RemoteAddr might already be bare IP (no port).
- return r.RemoteAddr
- }
- return host
-}
-
// pruneOldFailures returns only the failures within the sliding window.
func pruneOldFailures(failures []time.Time, now time.Time) []time.Time {
cutoff := now.Add(-failureWindow)
diff --git a/spinifex/gateway/ratelimit_test.go b/spinifex/gateway/ratelimit_test.go
index 9ce9e9ecf..4ae068e10 100644
--- a/spinifex/gateway/ratelimit_test.go
+++ b/spinifex/gateway/ratelimit_test.go
@@ -311,29 +311,6 @@ func TestConcurrentAccess(t *testing.T) {
wg.Wait()
}
-func TestExtractClientIP(t *testing.T) {
- tests := []struct {
- name string
- remoteAddr string
- expected string
- }{
- {"IPv4 with port", "192.168.1.1:12345", "192.168.1.1"},
- {"IPv6 with port", "[::1]:12345", "::1"},
- {"IPv4 bare", "192.168.1.1", "192.168.1.1"},
- {"IPv6 full with port", "[2001:db8::1]:443", "2001:db8::1"},
- }
-
- for _, tt := range tests {
- t.Run(tt.name, func(t *testing.T) {
- r := &http.Request{RemoteAddr: tt.remoteAddr}
- got := extractClientIP(r)
- if got != tt.expected {
- t.Errorf("extractClientIP(%q) = %q, want %q", tt.remoteAddr, got, tt.expected)
- }
- })
- }
-}
-
// setupTestAppWithRateLimiter creates a test HTTP handler with SigV4 auth and
// the given rate limiter attached. A real NATS test connection is attached so
// the SigV4 middleware's cluster-unavailable short-circuit (mulga-siv-23) does
diff --git a/spinifex/gateway/sts.go b/spinifex/gateway/sts.go
index a29e79c8a..5ec599f11 100644
--- a/spinifex/gateway/sts.go
+++ b/spinifex/gateway/sts.go
@@ -58,19 +58,20 @@ var stsActions = map[string]STSHandler{
"AssumeRole": stsHandler(func(c stsCaller, input *sts.AssumeRoleInput, gw *GatewayConfig) (any, error) {
return gateway_sts.AssumeRole(c.accountID, c.arn, c.identity, input, gw.STSService)
}),
- // TODO: AssumeRoleWithWebIdentity is anonymous on AWS — the caller is
- // authenticated by the JWT body, not by a SigV4 envelope. The current
- // dispatch path runs SigV4 first, then enters this handler with a synthetic
- // stsCaller. That works for in-cluster callers that already hold SigV4
- // credentials but rejects the true anonymous code path (no AWS creds, only
- // a projected ServiceAccount token). The AWS gateway mux needs a pre-auth
- // route for this action so the JWT can be the sole authenticator.
+ // AssumeRoleWithWebIdentity is anonymous on AWS — the caller is authenticated
+ // by the JWT body, not by a SigV4 envelope. anonymousSTSInterceptor routes it
+ // to this dispatcher ahead of the SigV4 middleware, so STS_Request enters with
+ // a zero stsCaller (see anonymousSTSActions); the handler ignores it and
+ // gates the request on the token + the role's web-identity trust policy.
"AssumeRoleWithWebIdentity": stsHandler(func(_ stsCaller, input *sts.AssumeRoleWithWebIdentityInput, gw *GatewayConfig) (any, error) {
return gateway_sts.AssumeRoleWithWebIdentity(input, gw.STSService)
}),
"GetCallerIdentity": stsHandler(func(c stsCaller, input *sts.GetCallerIdentityInput, gw *GatewayConfig) (any, error) {
return gateway_sts.GetCallerIdentity(c.accountID, c.arn, c.principalType, c.identity, c.assumedRoleID, input, gw.IAMService, gw.STSService)
}),
+ "GetSessionToken": stsHandler(func(c stsCaller, input *sts.GetSessionTokenInput, gw *GatewayConfig) (any, error) {
+ return gateway_sts.GetSessionToken(c.accountID, c.identity, c.principalType, c.accessKey, input, gw.STSService)
+ }),
}
// stsSkipPolicyCheck lists the actions whose authorization is NOT gated by an
@@ -78,11 +79,24 @@ var stsActions = map[string]STSHandler{
// trust policy (evaluated inside the handler); GetCallerIdentity is always
// allowed per AWS so SDK init flows do not break;
// AssumeRoleWithWebIdentity is anonymous — caller is identified by the JWT,
-// not by SigV4 — and trust-policy-gated inside the handler.
+// not by SigV4 — and trust-policy-gated inside the handler. GetSessionToken is
+// allowed for any authenticated caller (consistent with GetCallerIdentity so
+// SDK/console init flows are not blocked) and enforces its user-only
+// constraint inside the handler — assumed-role callers are denied by principal
+// type, session (ASIA) callers by their access-key prefix.
var stsSkipPolicyCheck = map[string]bool{
"AssumeRole": true,
"AssumeRoleWithWebIdentity": true,
"GetCallerIdentity": true,
+ "GetSessionToken": true,
+}
+
+// anonymousSTSActions lists STS actions AWS accepts without SigV4: the caller
+// holds no AWS credentials and is authenticated solely by a web-identity JWT in
+// the request body. anonymousSTSInterceptor routes these ahead of the SigV4
+// middleware, and STS_Request skips SigV4-caller resolution for them.
+var anonymousSTSActions = map[string]bool{
+ "AssumeRoleWithWebIdentity": true,
}
func (gw *GatewayConfig) STS_Request(w http.ResponseWriter, r *http.Request) error {
@@ -113,9 +127,14 @@ func (gw *GatewayConfig) STS_Request(w http.ResponseWriter, r *http.Request) err
}
}
- caller, err := gw.resolveSTSCaller(r)
- if err != nil {
- return err
+ // Anonymous actions (AssumeRoleWithWebIdentity) carry no SigV4 envelope, so
+ // there is no caller to resolve — the handler ignores it.
+ var caller stsCaller
+ if !anonymousSTSActions[action] {
+ caller, err = gw.resolveSTSCaller(r)
+ if err != nil {
+ return err
+ }
}
xmlOutput, err := handler(action, queryArgs, gw, caller)
diff --git a/spinifex/gateway/sts/GetSessionToken.go b/spinifex/gateway/sts/GetSessionToken.go
new file mode 100644
index 000000000..b3983d49f
--- /dev/null
+++ b/spinifex/gateway/sts/GetSessionToken.go
@@ -0,0 +1,14 @@
+package gateway_sts
+
+import (
+ "github.com/aws/aws-sdk-go/service/sts"
+ handlers_sts "github.com/mulgadc/spinifex/spinifex/handlers/sts"
+)
+
+// GetSessionToken delegates to the STSService. Like GetCallerIdentity it is not
+// gated by checkPolicy at the dispatcher (see stsSkipPolicyCheck); the handler
+// holds the authoritative user-only check. Caller fields come from the SigV4
+// context: callerUserName is c.identity, callerAccessKeyID is c.accessKey.
+func GetSessionToken(callerAccountID, callerUserName, callerPrincipalType, callerAccessKeyID string, input *sts.GetSessionTokenInput, svc handlers_sts.STSService) (*sts.GetSessionTokenOutput, error) {
+ return svc.GetSessionToken(callerAccountID, callerUserName, callerPrincipalType, callerAccessKeyID, input)
+}
diff --git a/spinifex/gateway/sts/handler_test.go b/spinifex/gateway/sts/handler_test.go
index 2eda64bab..c038d0c1f 100644
--- a/spinifex/gateway/sts/handler_test.go
+++ b/spinifex/gateway/sts/handler_test.go
@@ -20,6 +20,7 @@ import (
type stubSTSService struct {
assumeRoleFn func(callerAccountID, callerARN, callerIdentity string, input *sts.AssumeRoleInput) (*sts.AssumeRoleOutput, error)
getCallerIdentityFn func(callerAccountID, callerARN, callerUserID string, input *sts.GetCallerIdentityInput) (*sts.GetCallerIdentityOutput, error)
+ getSessionTokenFn func(callerAccountID, callerUserName, callerPrincipalType, callerAccessKeyID string, input *sts.GetSessionTokenInput) (*sts.GetSessionTokenOutput, error)
lookupSessionFn func(akid string) (*handlers_sts.SessionCredential, error)
}
@@ -32,6 +33,13 @@ func (s *stubSTSService) AssumeRole(callerAccountID, callerARN, callerIdentity s
return &sts.AssumeRoleOutput{}, nil
}
+// AssumeRoleForInstance is the in-process IMDS entry point and is never
+// dispatched over the HTTPS gateway; the stub exists only to satisfy the
+// interface.
+func (s *stubSTSService) AssumeRoleForInstance(_, _, _ string, _ int64) (*sts.AssumeRoleOutput, error) {
+ return &sts.AssumeRoleOutput{}, nil
+}
+
func (s *stubSTSService) GetCallerIdentity(callerAccountID, callerARN, callerUserID string, input *sts.GetCallerIdentityInput) (*sts.GetCallerIdentityOutput, error) {
if s.getCallerIdentityFn != nil {
return s.getCallerIdentityFn(callerAccountID, callerARN, callerUserID, input)
@@ -43,6 +51,13 @@ func (s *stubSTSService) GetCallerIdentity(callerAccountID, callerARN, callerUse
}, nil
}
+func (s *stubSTSService) GetSessionToken(callerAccountID, callerUserName, callerPrincipalType, callerAccessKeyID string, input *sts.GetSessionTokenInput) (*sts.GetSessionTokenOutput, error) {
+ if s.getSessionTokenFn != nil {
+ return s.getSessionTokenFn(callerAccountID, callerUserName, callerPrincipalType, callerAccessKeyID, input)
+ }
+ return &sts.GetSessionTokenOutput{}, nil
+}
+
func (s *stubSTSService) LookupSessionCredential(akid string) (*handlers_sts.SessionCredential, error) {
if s.lookupSessionFn != nil {
return s.lookupSessionFn(akid)
@@ -421,3 +436,45 @@ func TestGetCallerIdentity_UnknownPrincipalType(t *testing.T) {
require.Error(t, err)
assert.Equal(t, awserrors.ErrorInternalError, err.Error())
}
+
+func TestGetSessionToken_ForwardsCallerFields(t *testing.T) {
+ var got struct {
+ accountID string
+ userName string
+ principalType string
+ accessKeyID string
+ }
+ svc := &stubSTSService{
+ getSessionTokenFn: func(callerAccountID, callerUserName, callerPrincipalType, callerAccessKeyID string, _ *sts.GetSessionTokenInput) (*sts.GetSessionTokenOutput, error) {
+ got.accountID = callerAccountID
+ got.userName = callerUserName
+ got.principalType = callerPrincipalType
+ got.accessKeyID = callerAccessKeyID
+ return &sts.GetSessionTokenOutput{
+ Credentials: &sts.Credentials{AccessKeyId: aws.String("ASIAEXAMPLE")},
+ }, nil
+ },
+ }
+ // The wrapper's job is to forward c.identity as the user name, plus
+ // c.principalType and c.accessKey, through to the service so the handler can
+ // enforce its user-only / no-session rule; assert that mapping holds.
+ out, err := GetSessionToken("000000000000", "alice", PrincipalTypeUser, "AKIAEXAMPLE", &sts.GetSessionTokenInput{}, svc)
+ require.NoError(t, err)
+ require.NotNil(t, out)
+ assert.Equal(t, "ASIAEXAMPLE", aws.StringValue(out.Credentials.AccessKeyId))
+ assert.Equal(t, "000000000000", got.accountID)
+ assert.Equal(t, "alice", got.userName)
+ assert.Equal(t, PrincipalTypeUser, got.principalType)
+ assert.Equal(t, "AKIAEXAMPLE", got.accessKeyID)
+}
+
+func TestGetSessionToken_PropagatesServiceError(t *testing.T) {
+ svc := &stubSTSService{
+ getSessionTokenFn: func(string, string, string, string, *sts.GetSessionTokenInput) (*sts.GetSessionTokenOutput, error) {
+ return nil, errors.New(awserrors.ErrorAccessDenied)
+ },
+ }
+ _, err := GetSessionToken("000000000000", "s1", PrincipalTypeAssumedRole, "ASIAEXAMPLE", &sts.GetSessionTokenInput{}, svc)
+ require.Error(t, err)
+ assert.Equal(t, awserrors.ErrorAccessDenied, err.Error())
+}
diff --git a/spinifex/gateway/sts/oidc_provider_mock_test.go b/spinifex/gateway/sts/oidc_provider_mock_test.go
new file mode 100644
index 000000000..f42503d02
--- /dev/null
+++ b/spinifex/gateway/sts/oidc_provider_mock_test.go
@@ -0,0 +1,19 @@
+package gateway_sts
+
+import "github.com/aws/aws-sdk-go/service/iam"
+
+// OIDC identity-provider registry stubs so stubIAMService satisfies the
+// IAMService interface; STS gateway tests never call these.
+
+func (s *stubIAMService) CreateOpenIDConnectProvider(string, *iam.CreateOpenIDConnectProviderInput) (*iam.CreateOpenIDConnectProviderOutput, error) {
+ return nil, nil
+}
+func (s *stubIAMService) GetOpenIDConnectProvider(string, *iam.GetOpenIDConnectProviderInput) (*iam.GetOpenIDConnectProviderOutput, error) {
+ return nil, nil
+}
+func (s *stubIAMService) ListOpenIDConnectProviders(string, *iam.ListOpenIDConnectProvidersInput) (*iam.ListOpenIDConnectProvidersOutput, error) {
+ return nil, nil
+}
+func (s *stubIAMService) DeleteOpenIDConnectProvider(string, *iam.DeleteOpenIDConnectProviderInput) (*iam.DeleteOpenIDConnectProviderOutput, error) {
+ return nil, nil
+}
diff --git a/spinifex/gateway/sts_anonymous.go b/spinifex/gateway/sts_anonymous.go
new file mode 100644
index 000000000..c6a183215
--- /dev/null
+++ b/spinifex/gateway/sts_anonymous.go
@@ -0,0 +1,49 @@
+package gateway
+
+import (
+ "bytes"
+ "context"
+ "io"
+ "net/http"
+)
+
+// anonymousSTSInterceptor routes the credential-bootstrap STS actions
+// (AssumeRoleWithWebIdentity) to the STS dispatcher ahead of the SigV4
+// middleware. These calls carry no AWS credentials — the caller is
+// authenticated by the projected ServiceAccount JWT in the request body — so
+// the signed surface would reject them as unauthenticated. Requests bearing an
+// Authorization header skip this path untouched, keeping the signed hot path
+// free of body buffering.
+func (gw *GatewayConfig) anonymousSTSInterceptor(next http.Handler) http.Handler {
+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.Method == http.MethodPost && r.Header.Get("Authorization") == "" {
+ if args, ok := gw.anonymousSTSArgs(r); ok {
+ ctx := context.WithValue(r.Context(), ctxQueryArgs, args)
+ ctx = context.WithValue(ctx, ctxService, "sts")
+ r = r.WithContext(ctx)
+ if err := gw.STS_Request(w, r); err != nil {
+ gw.ErrorHandler(w, r, err)
+ }
+ return
+ }
+ }
+ next.ServeHTTP(w, r)
+ })
+}
+
+// anonymousSTSArgs buffers and restores the request body, parses the AWS query
+// args, and reports whether Action is an STS action permitted without SigV4. A
+// non-anonymous request flows on unchanged with its body intact.
+func (gw *GatewayConfig) anonymousSTSArgs(r *http.Request) (map[string]string, bool) {
+ body, err := io.ReadAll(r.Body)
+ if err != nil {
+ return nil, false
+ }
+ r.Body = io.NopCloser(bytes.NewReader(body))
+
+ args, err := ParseAWSQueryArgs(string(body))
+ if err != nil || !anonymousSTSActions[args["Action"]] {
+ return nil, false
+ }
+ return args, true
+}
diff --git a/spinifex/gateway/sts_anonymous_test.go b/spinifex/gateway/sts_anonymous_test.go
new file mode 100644
index 000000000..b7dd1ef32
--- /dev/null
+++ b/spinifex/gateway/sts_anonymous_test.go
@@ -0,0 +1,105 @@
+package gateway
+
+import (
+ "io"
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "testing"
+
+ "github.com/aws/aws-sdk-go/aws"
+ "github.com/aws/aws-sdk-go/service/sts"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+// TestAnonymousSTS_WebIdentityNoAuthReachesHandler proves the IRSA bootstrap
+// call — AssumeRoleWithWebIdentity carrying no Authorization header — is routed
+// to the STS dispatcher ahead of the SigV4 middleware, rather than rejected as
+// unauthenticated. The mock asserts the parsed token reached the handler.
+func TestAnonymousSTS_WebIdentityNoAuthReachesHandler(t *testing.T) {
+ var seenToken string
+ gw := &GatewayConfig{
+ DisableLogging: true,
+ IAMService: &flexMockIAMService{},
+ STSService: &flexMockSTSService{
+ assumeWebIdentityFn: func(in *sts.AssumeRoleWithWebIdentityInput) (*sts.AssumeRoleWithWebIdentityOutput, error) {
+ seenToken = aws.StringValue(in.WebIdentityToken)
+ return &sts.AssumeRoleWithWebIdentityOutput{
+ Credentials: &sts.Credentials{
+ AccessKeyId: aws.String("ASIAIRSA"),
+ SecretAccessKey: aws.String("secret"),
+ SessionToken: aws.String("token"),
+ },
+ }, nil
+ },
+ },
+ }
+ handler := gw.SetupRoutes()
+
+ body := "Action=AssumeRoleWithWebIdentity&Version=2011-06-15" +
+ "&RoleArn=arn:aws:iam::000000000001:role/demo&RoleSessionName=s1" +
+ "&WebIdentityToken=header.payload.sig"
+ req := httptest.NewRequest(http.MethodPost, "/", strings.NewReader(body))
+ req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+ // No Authorization header — this is the unsigned bootstrap path.
+
+ rec := httptest.NewRecorder()
+ handler.ServeHTTP(rec, req)
+
+ require.Equal(t, http.StatusOK, rec.Code, "body=%s", rec.Body.String())
+ b, _ := io.ReadAll(rec.Body)
+ assert.Contains(t, string(b), "AssumeRoleWithWebIdentityResult")
+ assert.Contains(t, string(b), "ASIAIRSA")
+ assert.Equal(t, "header.payload.sig", seenToken)
+}
+
+// TestAnonymousSTS_SignedRequestFallsThrough confirms a request bearing an
+// Authorization header is NOT intercepted — it flows to the signed surface, so
+// the anonymous dispatcher never runs and the body is left for the SigV4 path.
+func TestAnonymousSTS_SignedRequestFallsThrough(t *testing.T) {
+ gw := &GatewayConfig{
+ DisableLogging: true,
+ STSService: &flexMockSTSService{
+ assumeWebIdentityFn: func(*sts.AssumeRoleWithWebIdentityInput) (*sts.AssumeRoleWithWebIdentityOutput, error) {
+ t.Fatal("signed request must not reach the anonymous STS dispatcher")
+ return nil, nil
+ },
+ },
+ }
+
+ nextCalled := false
+ next := http.HandlerFunc(func(http.ResponseWriter, *http.Request) { nextCalled = true })
+
+ req := httptest.NewRequest(http.MethodPost, "/",
+ strings.NewReader("Action=AssumeRoleWithWebIdentity&WebIdentityToken=x"))
+ req.Header.Set("Authorization", "AWS4-HMAC-SHA256 Credential=...")
+
+ rec := httptest.NewRecorder()
+ gw.anonymousSTSInterceptor(next).ServeHTTP(rec, req)
+ assert.True(t, nextCalled, "signed request must pass through to next handler")
+}
+
+// TestAnonymousSTSArgs_Classification covers the body-peek helper: an anonymous
+// action is recognised and the body is restored for the downstream handler; a
+// non-anonymous action is rejected and the body left intact for SigV4.
+func TestAnonymousSTSArgs_Classification(t *testing.T) {
+ gw := &GatewayConfig{DisableLogging: true}
+
+ t.Run("anonymous action recognised, body restored", func(t *testing.T) {
+ const body = "Action=AssumeRoleWithWebIdentity&WebIdentityToken=tok"
+ req := httptest.NewRequest(http.MethodPost, "/", strings.NewReader(body))
+ args, ok := gw.anonymousSTSArgs(req)
+ require.True(t, ok)
+ assert.Equal(t, "AssumeRoleWithWebIdentity", args["Action"])
+
+ restored, _ := io.ReadAll(req.Body)
+ assert.Equal(t, body, string(restored), "body must be restored for downstream read")
+ })
+
+ t.Run("non-anonymous action rejected", func(t *testing.T) {
+ req := httptest.NewRequest(http.MethodPost, "/", strings.NewReader("Action=GetCallerIdentity"))
+ _, ok := gw.anonymousSTSArgs(req)
+ assert.False(t, ok)
+ })
+}
diff --git a/spinifex/gateway/sts_request_test.go b/spinifex/gateway/sts_request_test.go
index 0ffd9c64d..f23b084a7 100644
--- a/spinifex/gateway/sts_request_test.go
+++ b/spinifex/gateway/sts_request_test.go
@@ -25,7 +25,9 @@ import (
type flexMockSTSService struct {
assumeRoleFn func(string, string, string, *sts.AssumeRoleInput) (*sts.AssumeRoleOutput, error)
getCallerIdentityFn func(string, string, string, *sts.GetCallerIdentityInput) (*sts.GetCallerIdentityOutput, error)
+ getSessionTokenFn func(string, string, string, string, *sts.GetSessionTokenInput) (*sts.GetSessionTokenOutput, error)
lookupSessionFn func(string) (*handlers_sts.SessionCredential, error)
+ assumeWebIdentityFn func(*sts.AssumeRoleWithWebIdentityInput) (*sts.AssumeRoleWithWebIdentityOutput, error)
}
var _ handlers_sts.STSService = (*flexMockSTSService)(nil)
@@ -37,6 +39,12 @@ func (m *flexMockSTSService) AssumeRole(callerAccountID, callerARN, callerIdenti
return &sts.AssumeRoleOutput{}, nil
}
+// AssumeRoleForInstance is the in-process IMDS entry point, never dispatched
+// over the HTTPS gateway; the mock exists only to satisfy the interface.
+func (m *flexMockSTSService) AssumeRoleForInstance(_, _, _ string, _ int64) (*sts.AssumeRoleOutput, error) {
+ return &sts.AssumeRoleOutput{}, nil
+}
+
func (m *flexMockSTSService) GetCallerIdentity(callerAccountID, callerARN, callerUserID string, input *sts.GetCallerIdentityInput) (*sts.GetCallerIdentityOutput, error) {
if m.getCallerIdentityFn != nil {
return m.getCallerIdentityFn(callerAccountID, callerARN, callerUserID, input)
@@ -48,6 +56,13 @@ func (m *flexMockSTSService) GetCallerIdentity(callerAccountID, callerARN, calle
}, nil
}
+func (m *flexMockSTSService) GetSessionToken(callerAccountID, callerUserName, callerPrincipalType, callerAccessKeyID string, input *sts.GetSessionTokenInput) (*sts.GetSessionTokenOutput, error) {
+ if m.getSessionTokenFn != nil {
+ return m.getSessionTokenFn(callerAccountID, callerUserName, callerPrincipalType, callerAccessKeyID, input)
+ }
+ return &sts.GetSessionTokenOutput{}, nil
+}
+
func (m *flexMockSTSService) LookupSessionCredential(akid string) (*handlers_sts.SessionCredential, error) {
if m.lookupSessionFn != nil {
return m.lookupSessionFn(akid)
@@ -59,7 +74,10 @@ func (m *flexMockSTSService) VerifySessionToken(*handlers_sts.SessionCredential,
return true
}
-func (m *flexMockSTSService) AssumeRoleWithWebIdentity(*sts.AssumeRoleWithWebIdentityInput) (*sts.AssumeRoleWithWebIdentityOutput, error) {
+func (m *flexMockSTSService) AssumeRoleWithWebIdentity(input *sts.AssumeRoleWithWebIdentityInput) (*sts.AssumeRoleWithWebIdentityOutput, error) {
+ if m.assumeWebIdentityFn != nil {
+ return m.assumeWebIdentityFn(input)
+ }
return nil, errors.New(awserrors.ErrorNotImplemented)
}
@@ -206,6 +224,62 @@ func TestSTSRequest_GetCallerIdentity_RootShortcircuitsIAMLookup(t *testing.T) {
assert.Contains(t, xmlStr, "000000000000 ")
}
+func TestSTSRequest_GetSessionToken_Success(t *testing.T) {
+ var got struct {
+ accountID string
+ userName string
+ principalType string
+ accessKeyID string
+ duration int64
+ }
+ svc := &flexMockSTSService{
+ getSessionTokenFn: func(accountID, userName, principalType, accessKeyID string, input *sts.GetSessionTokenInput) (*sts.GetSessionTokenOutput, error) {
+ got.accountID = accountID
+ got.userName = userName
+ got.principalType = principalType
+ got.accessKeyID = accessKeyID
+ got.duration = aws.Int64Value(input.DurationSeconds)
+ return &sts.GetSessionTokenOutput{
+ Credentials: &sts.Credentials{
+ AccessKeyId: aws.String("ASIAEXAMPLE123"),
+ SecretAccessKey: aws.String("secret"),
+ SessionToken: aws.String("token"),
+ },
+ }, nil
+ },
+ }
+ handler := setupSTSRequestHandler(stsRequestParams{
+ accountID: utils.GlobalAccountID,
+ identity: "alice",
+ principalType: principalTypeUser,
+ accessKey: "AKIAEXAMPLE",
+ stsSvc: svc,
+ })
+
+ body := "Action=GetSessionToken&DurationSeconds=3600"
+ req := httptest.NewRequest(http.MethodPost, "/", strings.NewReader(body))
+ req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+
+ resp := doRequest(handler, req)
+ require.Equal(t, 200, resp.StatusCode)
+
+ b, _ := io.ReadAll(resp.Body)
+ xmlStr := string(b)
+ assert.Contains(t, xmlStr, "GetSessionTokenResult")
+ assert.Contains(t, xmlStr, "ASIAEXAMPLE123")
+
+ // The dispatcher must forward the SigV4 user name (c.identity), principal
+ // type, and access key (c.accessKey) so the handler can enforce its
+ // user-only / no-session constraint, plus the parsed DurationSeconds.
+ // GetSessionToken is in stsSkipPolicyCheck, so reaching the service at all
+ // also confirms it is not blocked by checkPolicy.
+ assert.Equal(t, utils.GlobalAccountID, got.accountID)
+ assert.Equal(t, "alice", got.userName)
+ assert.Equal(t, principalTypeUser, got.principalType)
+ assert.Equal(t, "AKIAEXAMPLE", got.accessKeyID)
+ assert.Equal(t, int64(3600), got.duration)
+}
+
func TestSTSRequest_UnknownAction(t *testing.T) {
handler := setupSTSRequestHandler(stsRequestParams{
accountID: utils.GlobalAccountID,
diff --git a/spinifex/gateway/tagging.go b/spinifex/gateway/tagging.go
new file mode 100644
index 000000000..f5a558585
--- /dev/null
+++ b/spinifex/gateway/tagging.go
@@ -0,0 +1,82 @@
+package gateway
+
+import (
+ "errors"
+ "io"
+ "log/slog"
+ "net/http"
+ "strings"
+
+ "github.com/mulgadc/spinifex/spinifex/awserrors"
+ gateway_tagging "github.com/mulgadc/spinifex/spinifex/gateway/tagging"
+)
+
+// taggingHandler invokes a per-action Resource Groups Tagging API function: it
+// receives the parent gateway config, the caller's accountID, and the raw JSON
+// request body, and returns a typed *resourcegroupstaggingapi.Output (as
+// any) or an error whose Error() is an awserrors code.
+type taggingHandler func(gw *GatewayConfig, accountID string, body []byte) (any, error)
+
+// taggingActions maps a tagging action (the suffix of the X-Amz-Target header)
+// to its handler. The Resource Groups Tagging API speaks AWS JSON 1.1: the
+// action is carried in "X-Amz-Target: ResourceGroupsTaggingAPI_20170126.".
+var taggingActions = map[string]taggingHandler{
+ "GetResources": func(gw *GatewayConfig, acct string, b []byte) (any, error) {
+ return gateway_tagging.GetResources(gw.NATSConn, gw.Region, acct, b)
+ },
+}
+
+// taggingActionFromTarget extracts the bare action name from an X-Amz-Target
+// header value of the form "ResourceGroupsTaggingAPI_20170126.".
+func taggingActionFromTarget(target string) string {
+ if i := strings.LastIndex(target, "."); i >= 0 {
+ return target[i+1:]
+ }
+ return target
+}
+
+// Tagging_Request dispatches AWS JSON 1.1 Resource Groups Tagging API requests.
+// The action is read from the X-Amz-Target header; the body is the JSON-encoded
+// operation input. Errors are returned as plain awserrors codes; the caller
+// (gateway.Request) routes them through ErrorHandler, which emits the JSON
+// error envelope.
+func (gw *GatewayConfig) Tagging_Request(w http.ResponseWriter, r *http.Request) error {
+ action := taggingActionFromTarget(r.Header.Get("X-Amz-Target"))
+ if action == "" {
+ return errors.New(awserrors.ErrorMissingAction)
+ }
+
+ handler, ok := taggingActions[action]
+ if !ok {
+ slog.Debug("tagging: unknown action", "action", action)
+ return errors.New(awserrors.ErrorInvalidAction)
+ }
+
+ if err := gw.checkPolicy(r, "tagging", action); err != nil {
+ return err
+ }
+
+ if gw.NATSConn == nil {
+ return errors.New(awserrors.ErrorServerInternal)
+ }
+
+ accountID, _ := r.Context().Value(ctxAccountID).(string)
+ if accountID == "" {
+ slog.Error("Tagging_Request: no account ID in auth context")
+ return errors.New(awserrors.ErrorServerInternal)
+ }
+
+ body, err := io.ReadAll(r.Body)
+ if err != nil {
+ slog.Error("Tagging_Request: failed to read body", "err", err)
+ return errors.New(awserrors.ErrorInvalidParameterValue)
+ }
+
+ output, err := handler(gw, accountID, body)
+ if err != nil {
+ return err
+ }
+
+ gateway_tagging.WriteJSONResponse(w, output)
+ return nil
+}
diff --git a/spinifex/gateway/tagging/GetResources.go b/spinifex/gateway/tagging/GetResources.go
new file mode 100644
index 000000000..0f7196f85
--- /dev/null
+++ b/spinifex/gateway/tagging/GetResources.go
@@ -0,0 +1,334 @@
+package gateway_tagging
+
+import (
+ "errors"
+ "fmt"
+ "sort"
+ "strconv"
+ "strings"
+
+ "github.com/aws/aws-sdk-go/aws"
+ "github.com/aws/aws-sdk-go/service/ec2"
+ "github.com/aws/aws-sdk-go/service/elbv2"
+ rgt "github.com/aws/aws-sdk-go/service/resourcegroupstaggingapi"
+ "github.com/mulgadc/spinifex/spinifex/awserrors"
+ handlers_ec2_tags "github.com/mulgadc/spinifex/spinifex/handlers/ec2/tags"
+ handlers_elbv2 "github.com/mulgadc/spinifex/spinifex/handlers/elbv2"
+ "github.com/nats-io/nats.go"
+)
+
+// describeTagsBatchSize is the AWS ceiling on ARNs per ELBv2 DescribeTags call.
+const describeTagsBatchSize = 20
+
+// defaultResourcesPerPage caps a page when the caller omits ResourcesPerPage.
+// AWS allows up to 100; matching that keeps the controller's reconcile fast.
+const defaultResourcesPerPage = 100
+
+// resourceLister fetches an account's tagged resources by family, already
+// shaped as RGT mappings. Splitting the fetch out of getResources keeps the
+// filter/sort/paginate orchestration testable without a live NATS backend.
+type resourceLister interface {
+ listELBv2(typeFilters map[string]bool) ([]*rgt.ResourceTagMapping, error)
+ listEC2(typeFilters map[string]bool) ([]*rgt.ResourceTagMapping, error)
+}
+
+// GetResources implements the Resource Groups Tagging API GetResources call by
+// aggregating tagged ELBv2 (load balancers + target groups) and EC2 resources
+// for the account, applying the request's resource-type and tag filters. The
+// aws-load-balancer-controller uses this to discover and adopt the resources it
+// manages (filtering on its cluster/stack tags). Pagination is an opaque
+// integer offset into a deterministically ARN-sorted result set.
+func GetResources(natsConn *nats.Conn, region, accountID string, body []byte) (any, error) {
+ return getResources(&natsLister{natsConn: natsConn, region: region, accountID: accountID}, body)
+}
+
+// getResources is the protocol-independent core: it parses the request, asks
+// the lister for the in-scope resource families, then applies tag filters,
+// stable ordering, and pagination.
+func getResources(lister resourceLister, body []byte) (any, error) {
+ var input rgt.GetResourcesInput
+ if err := unmarshalIfBody(body, &input); err != nil {
+ return nil, errors.New(awserrors.ErrorInvalidParameterValue)
+ }
+
+ typeFilters := lowerStringSlice(input.ResourceTypeFilters)
+
+ var mappings []*rgt.ResourceTagMapping
+
+ elbMappings, err := lister.listELBv2(typeFilters)
+ if err != nil {
+ return nil, err
+ }
+ mappings = append(mappings, elbMappings...)
+
+ ec2Mappings, err := lister.listEC2(typeFilters)
+ if err != nil {
+ return nil, err
+ }
+ mappings = append(mappings, ec2Mappings...)
+
+ mappings = filterByTags(mappings, input.TagFilters)
+
+ // Deterministic order so pagination tokens are stable across pages.
+ sort.Slice(mappings, func(i, j int) bool {
+ return aws.StringValue(mappings[i].ResourceARN) < aws.StringValue(mappings[j].ResourceARN)
+ })
+
+ page, next, err := paginate(mappings, input.PaginationToken, input.ResourcesPerPage)
+ if err != nil {
+ return nil, err
+ }
+
+ out := &rgt.GetResourcesOutput{ResourceTagMappingList: page}
+ // AWS returns an empty (not absent) token on the last page.
+ out.PaginationToken = aws.String(next)
+ return out, nil
+}
+
+// natsLister fetches tagged resources from the elbv2 and ec2 tag stores over
+// NATS.
+type natsLister struct {
+ natsConn *nats.Conn
+ region string
+ accountID string
+}
+
+func (l *natsLister) listELBv2(typeFilters map[string]bool) ([]*rgt.ResourceTagMapping, error) {
+ return elbv2Resources(l.natsConn, l.accountID, typeFilters)
+}
+
+func (l *natsLister) listEC2(typeFilters map[string]bool) ([]*rgt.ResourceTagMapping, error) {
+ svc := handlers_ec2_tags.NewNATSTagsService(l.natsConn)
+ tagsOut, err := svc.DescribeTags(&ec2.DescribeTagsInput{}, l.accountID)
+ if err != nil {
+ return nil, err
+ }
+ return buildEC2Mappings(tagsOut.Tags, l.region, l.accountID, typeFilters), nil
+}
+
+// elbv2Resources lists the account's load balancers and target groups (when the
+// type filters admit them) and attaches their tags via batched DescribeTags.
+func elbv2Resources(natsConn *nats.Conn, accountID string, typeFilters map[string]bool) ([]*rgt.ResourceTagMapping, error) {
+ wantLB := matchesType(typeFilters, "elasticloadbalancing:loadbalancer")
+ wantTG := matchesType(typeFilters, "elasticloadbalancing:targetgroup")
+ if !wantLB && !wantTG {
+ return nil, nil
+ }
+
+ svc := handlers_elbv2.NewNATSELBv2Service(natsConn)
+ var arns []string
+
+ if wantLB {
+ lbs, err := svc.DescribeLoadBalancers(&elbv2.DescribeLoadBalancersInput{}, accountID)
+ if err != nil {
+ return nil, err
+ }
+ for _, lb := range lbs.LoadBalancers {
+ if lb.LoadBalancerArn != nil {
+ arns = append(arns, *lb.LoadBalancerArn)
+ }
+ }
+ }
+ if wantTG {
+ tgs, err := svc.DescribeTargetGroups(&elbv2.DescribeTargetGroupsInput{}, accountID)
+ if err != nil {
+ return nil, err
+ }
+ for _, tg := range tgs.TargetGroups {
+ if tg.TargetGroupArn != nil {
+ arns = append(arns, *tg.TargetGroupArn)
+ }
+ }
+ }
+ if len(arns) == 0 {
+ return nil, nil
+ }
+
+ mappings := make([]*rgt.ResourceTagMapping, 0, len(arns))
+ for start := 0; start < len(arns); start += describeTagsBatchSize {
+ end := min(start+describeTagsBatchSize, len(arns))
+ tagsOut, err := svc.DescribeTags(&elbv2.DescribeTagsInput{
+ ResourceArns: aws.StringSlice(arns[start:end]),
+ }, accountID)
+ if err != nil {
+ return nil, err
+ }
+ for _, td := range tagsOut.TagDescriptions {
+ mappings = append(mappings, &rgt.ResourceTagMapping{
+ ResourceARN: td.ResourceArn,
+ Tags: elbv2TagsToRGT(td.Tags),
+ })
+ }
+ }
+ return mappings, nil
+}
+
+// buildEC2Mappings groups EC2 tag descriptions by resource, synthesises an ARN
+// per resource, and keeps those that pass the type filters. EC2 tags carry a
+// bare resource ID and a type word (e.g. "subnet"); the RGT type string is
+// "ec2:". Pure (no NATS) so the grouping/ARN logic is unit-tested.
+func buildEC2Mappings(tagDescriptions []*ec2.TagDescription, region, accountID string, typeFilters map[string]bool) []*rgt.ResourceTagMapping {
+ type resourceTags struct {
+ fullType string
+ tags map[string]string
+ }
+ byResource := make(map[string]*resourceTags)
+ for _, td := range tagDescriptions {
+ id := aws.StringValue(td.ResourceId)
+ if id == "" {
+ continue
+ }
+ fullType := "ec2:" + aws.StringValue(td.ResourceType)
+ rt := byResource[id]
+ if rt == nil {
+ rt = &resourceTags{fullType: fullType, tags: map[string]string{}}
+ byResource[id] = rt
+ }
+ rt.tags[aws.StringValue(td.Key)] = aws.StringValue(td.Value)
+ }
+
+ var mappings []*rgt.ResourceTagMapping
+ for id, rt := range byResource {
+ if !matchesType(typeFilters, rt.fullType) {
+ continue
+ }
+ mappings = append(mappings, &rgt.ResourceTagMapping{
+ ResourceARN: aws.String(ec2ARN(region, accountID, rt.fullType, id)),
+ Tags: tagMapToRGT(rt.tags),
+ })
+ }
+ return mappings
+}
+
+// ec2ARN builds an arn:aws:ec2 ARN for a resource. fullType is "ec2:".
+func ec2ARN(region, accountID, fullType, id string) string {
+ resourceType := strings.TrimPrefix(fullType, "ec2:")
+ return fmt.Sprintf("arn:aws:ec2:%s:%s:%s/%s", region, accountID, resourceType, id)
+}
+
+// matchesType reports whether a resource of fullType (e.g.
+// "elasticloadbalancing:loadbalancer") passes the request's type filters. An
+// empty filter set admits everything. A service-only filter ("ec2") admits all
+// of that service's types; a fully-qualified filter must match exactly.
+func matchesType(typeFilters map[string]bool, fullType string) bool {
+ if len(typeFilters) == 0 {
+ return true
+ }
+ if typeFilters[fullType] {
+ return true
+ }
+ if svc, _, ok := strings.Cut(fullType, ":"); ok {
+ if typeFilters[svc] {
+ return true
+ }
+ }
+ return false
+}
+
+// filterByTags keeps only resources matching every TagFilter (AND across
+// filters). Within a filter, a non-empty Values list is an OR over values; an
+// empty Values list matches any value for that key.
+func filterByTags(mappings []*rgt.ResourceTagMapping, filters []*rgt.TagFilter) []*rgt.ResourceTagMapping {
+ if len(filters) == 0 {
+ return mappings
+ }
+ out := mappings[:0]
+ for _, m := range mappings {
+ if matchesAllTagFilters(m.Tags, filters) {
+ out = append(out, m)
+ }
+ }
+ return out
+}
+
+func matchesAllTagFilters(tags []*rgt.Tag, filters []*rgt.TagFilter) bool {
+ tagMap := make(map[string]string, len(tags))
+ for _, t := range tags {
+ tagMap[aws.StringValue(t.Key)] = aws.StringValue(t.Value)
+ }
+ for _, f := range filters {
+ key := aws.StringValue(f.Key)
+ if key == "" {
+ continue
+ }
+ val, ok := tagMap[key]
+ if !ok {
+ return false
+ }
+ if len(f.Values) == 0 {
+ continue
+ }
+ matched := false
+ for _, v := range f.Values {
+ if aws.StringValue(v) == val {
+ matched = true
+ break
+ }
+ }
+ if !matched {
+ return false
+ }
+ }
+ return true
+}
+
+// paginate slices an offset-token page out of the sorted mappings and returns
+// the page plus the next token ("" when exhausted).
+func paginate(mappings []*rgt.ResourceTagMapping, token *string, perPage *int64) ([]*rgt.ResourceTagMapping, string, error) {
+ start := 0
+ if token != nil && *token != "" {
+ n, err := strconv.Atoi(*token)
+ if err != nil || n < 0 {
+ return nil, "", errors.New(awserrors.ErrorInvalidParameterValue)
+ }
+ start = n
+ }
+ if start > len(mappings) {
+ start = len(mappings)
+ }
+
+ size := defaultResourcesPerPage
+ if perPage != nil && *perPage > 0 {
+ size = int(*perPage)
+ }
+
+ end := start + size
+ if end >= len(mappings) {
+ return mappings[start:], "", nil
+ }
+ return mappings[start:end], strconv.Itoa(end), nil
+}
+
+func elbv2TagsToRGT(tags []*elbv2.Tag) []*rgt.Tag {
+ out := make([]*rgt.Tag, 0, len(tags))
+ for _, t := range tags {
+ out = append(out, &rgt.Tag{Key: t.Key, Value: t.Value})
+ }
+ return out
+}
+
+func tagMapToRGT(tags map[string]string) []*rgt.Tag {
+ keys := make([]string, 0, len(tags))
+ for k := range tags {
+ keys = append(keys, k)
+ }
+ sort.Strings(keys)
+ out := make([]*rgt.Tag, 0, len(keys))
+ for _, k := range keys {
+ out = append(out, &rgt.Tag{Key: aws.String(k), Value: aws.String(tags[k])})
+ }
+ return out
+}
+
+func lowerStringSlice(in []*string) map[string]bool {
+ if len(in) == 0 {
+ return nil
+ }
+ out := make(map[string]bool, len(in))
+ for _, s := range in {
+ if s != nil {
+ out[strings.ToLower(*s)] = true
+ }
+ }
+ return out
+}
diff --git a/spinifex/gateway/tagging/GetResources_test.go b/spinifex/gateway/tagging/GetResources_test.go
new file mode 100644
index 000000000..fd7b445ac
--- /dev/null
+++ b/spinifex/gateway/tagging/GetResources_test.go
@@ -0,0 +1,231 @@
+package gateway_tagging
+
+import (
+ "encoding/json"
+ "testing"
+
+ "github.com/aws/aws-sdk-go/aws"
+ "github.com/aws/aws-sdk-go/service/ec2"
+ rgt "github.com/aws/aws-sdk-go/service/resourcegroupstaggingapi"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+// fakeLister returns canned mappings so getResources can be exercised without a
+// NATS backend.
+type fakeLister struct {
+ elbv2 []*rgt.ResourceTagMapping
+ ec2 []*rgt.ResourceTagMapping
+ elbv2Err error
+ ec2Err error
+ gotFilter map[string]bool
+}
+
+func (f *fakeLister) listELBv2(typeFilters map[string]bool) ([]*rgt.ResourceTagMapping, error) {
+ f.gotFilter = typeFilters
+ return f.elbv2, f.elbv2Err
+}
+
+func (f *fakeLister) listEC2(typeFilters map[string]bool) ([]*rgt.ResourceTagMapping, error) {
+ return f.ec2, f.ec2Err
+}
+
+func mustJSON(t *testing.T, v any) []byte {
+ t.Helper()
+ b, err := json.Marshal(v)
+ require.NoError(t, err)
+ return b
+}
+
+func TestMatchesType(t *testing.T) {
+ // Empty filter set admits everything.
+ assert.True(t, matchesType(nil, "elasticloadbalancing:loadbalancer"))
+
+ exact := map[string]bool{"elasticloadbalancing:loadbalancer": true}
+ assert.True(t, matchesType(exact, "elasticloadbalancing:loadbalancer"))
+ assert.False(t, matchesType(exact, "elasticloadbalancing:targetgroup"))
+ assert.False(t, matchesType(exact, "ec2:subnet"))
+
+ // Service-only filter admits all of that service's types.
+ svcOnly := map[string]bool{"ec2": true}
+ assert.True(t, matchesType(svcOnly, "ec2:subnet"))
+ assert.True(t, matchesType(svcOnly, "ec2:security-group"))
+ assert.False(t, matchesType(svcOnly, "elasticloadbalancing:loadbalancer"))
+}
+
+func TestEC2ARN(t *testing.T) {
+ assert.Equal(t,
+ "arn:aws:ec2:us-east-1:123456789012:subnet/subnet-abc",
+ ec2ARN("us-east-1", "123456789012", "ec2:subnet", "subnet-abc"))
+}
+
+func mapping(arn string, kv ...string) *rgt.ResourceTagMapping {
+ var tags []*rgt.Tag
+ for i := 0; i+1 < len(kv); i += 2 {
+ tags = append(tags, &rgt.Tag{Key: aws.String(kv[i]), Value: aws.String(kv[i+1])})
+ }
+ return &rgt.ResourceTagMapping{ResourceARN: aws.String(arn), Tags: tags}
+}
+
+func tagFilter(key string, values ...string) *rgt.TagFilter {
+ return &rgt.TagFilter{Key: aws.String(key), Values: aws.StringSlice(values)}
+}
+
+func TestFilterByTags(t *testing.T) {
+ in := []*rgt.ResourceTagMapping{
+ mapping("arn:a", "elbv2.k8s.aws/cluster", "prod", "env", "live"),
+ mapping("arn:b", "elbv2.k8s.aws/cluster", "dev"),
+ mapping("arn:c", "env", "live"),
+ }
+
+ // No filters: passthrough.
+ assert.Len(t, filterByTags(in, nil), 3)
+
+ // Key-only filter: any value for the key.
+ got := filterByTags(in, []*rgt.TagFilter{tagFilter("elbv2.k8s.aws/cluster")})
+ require.Len(t, got, 2)
+
+ // Key+value filter: exact value match (OR over values).
+ got = filterByTags(in, []*rgt.TagFilter{tagFilter("elbv2.k8s.aws/cluster", "prod", "staging")})
+ require.Len(t, got, 1)
+ assert.Equal(t, "arn:a", aws.StringValue(got[0].ResourceARN))
+
+ // AND across filters.
+ got = filterByTags(in, []*rgt.TagFilter{
+ tagFilter("elbv2.k8s.aws/cluster", "prod"),
+ tagFilter("env", "live"),
+ })
+ require.Len(t, got, 1)
+ assert.Equal(t, "arn:a", aws.StringValue(got[0].ResourceARN))
+
+ // Filter on a key no resource has: empty result.
+ assert.Empty(t, filterByTags(in, []*rgt.TagFilter{tagFilter("missing")}))
+}
+
+func TestPaginate(t *testing.T) {
+ all := []*rgt.ResourceTagMapping{
+ mapping("arn:0"), mapping("arn:1"), mapping("arn:2"), mapping("arn:3"), mapping("arn:4"),
+ }
+
+ // First page of 2, expect a next token.
+ page, next, err := paginate(all, nil, aws.Int64(2))
+ require.NoError(t, err)
+ assert.Len(t, page, 2)
+ assert.Equal(t, "2", next)
+
+ // Follow the token to the middle page.
+ page, next, err = paginate(all, aws.String("2"), aws.Int64(2))
+ require.NoError(t, err)
+ assert.Len(t, page, 2)
+ assert.Equal(t, "4", next)
+
+ // Last partial page: no next token.
+ page, next, err = paginate(all, aws.String("4"), aws.Int64(2))
+ require.NoError(t, err)
+ assert.Len(t, page, 1)
+ assert.Equal(t, "", next)
+
+ // Default page size returns everything in one page.
+ page, next, err = paginate(all, nil, nil)
+ require.NoError(t, err)
+ assert.Len(t, page, 5)
+ assert.Equal(t, "", next)
+
+ // Bad token is rejected.
+ _, _, err = paginate(all, aws.String("not-a-number"), nil)
+ require.Error(t, err)
+}
+
+func TestGetResources_MergesFiltersSortsPaginates(t *testing.T) {
+ lister := &fakeLister{
+ elbv2: []*rgt.ResourceTagMapping{
+ mapping("arn:elb:c", "elbv2.k8s.aws/cluster", "prod"),
+ mapping("arn:elb:a", "elbv2.k8s.aws/cluster", "dev"),
+ },
+ ec2: []*rgt.ResourceTagMapping{
+ mapping("arn:ec2:b", "elbv2.k8s.aws/cluster", "prod"),
+ },
+ }
+
+ // Tag-filter to prod + page size 1: two matches (arn:ec2:b, arn:elb:c),
+ // ARN-sorted, first page returns arn:ec2:b with a next token.
+ body := mustJSON(t, rgt.GetResourcesInput{
+ TagFilters: []*rgt.TagFilter{tagFilter("elbv2.k8s.aws/cluster", "prod")},
+ ResourcesPerPage: aws.Int64(1),
+ })
+ out, err := getResources(lister, body)
+ require.NoError(t, err)
+ res, ok := out.(*rgt.GetResourcesOutput)
+ require.True(t, ok)
+ require.Len(t, res.ResourceTagMappingList, 1)
+ assert.Equal(t, "arn:ec2:b", aws.StringValue(res.ResourceTagMappingList[0].ResourceARN))
+ assert.Equal(t, "1", aws.StringValue(res.PaginationToken))
+
+ // Follow the token: last page, arn:elb:c, empty token.
+ body = mustJSON(t, rgt.GetResourcesInput{
+ TagFilters: []*rgt.TagFilter{tagFilter("elbv2.k8s.aws/cluster", "prod")},
+ ResourcesPerPage: aws.Int64(1),
+ PaginationToken: aws.String("1"),
+ })
+ out, err = getResources(lister, body)
+ require.NoError(t, err)
+ res, ok = out.(*rgt.GetResourcesOutput)
+ require.True(t, ok)
+ require.Len(t, res.ResourceTagMappingList, 1)
+ assert.Equal(t, "arn:elb:c", aws.StringValue(res.ResourceTagMappingList[0].ResourceARN))
+ assert.Equal(t, "", aws.StringValue(res.PaginationToken))
+}
+
+func TestGetResources_ResourceTypeFiltersLowercasedAndPassed(t *testing.T) {
+ lister := &fakeLister{}
+ body := mustJSON(t, rgt.GetResourcesInput{
+ ResourceTypeFilters: aws.StringSlice([]string{"ElasticLoadBalancing:LoadBalancer"}),
+ })
+ _, err := getResources(lister, body)
+ require.NoError(t, err)
+ assert.True(t, lister.gotFilter["elasticloadbalancing:loadbalancer"])
+}
+
+func TestGetResources_ListerErrorPropagates(t *testing.T) {
+ lister := &fakeLister{elbv2Err: assert.AnError}
+ _, err := getResources(lister, []byte("{}"))
+ require.ErrorIs(t, err, assert.AnError)
+}
+
+func TestGetResources_InvalidBody(t *testing.T) {
+ _, err := getResources(&fakeLister{}, []byte("not-json"))
+ require.Error(t, err)
+}
+
+func TestBuildEC2Mappings(t *testing.T) {
+ tags := []*ec2.TagDescription{
+ {ResourceId: aws.String("subnet-1"), ResourceType: aws.String("subnet"), Key: aws.String("Name"), Value: aws.String("a")},
+ {ResourceId: aws.String("subnet-1"), ResourceType: aws.String("subnet"), Key: aws.String("env"), Value: aws.String("prod")},
+ {ResourceId: aws.String("sg-1"), ResourceType: aws.String("security-group"), Key: aws.String("Name"), Value: aws.String("b")},
+ {ResourceId: aws.String(""), ResourceType: aws.String("subnet"), Key: aws.String("skip"), Value: aws.String("me")},
+ }
+
+ // No type filter: both resources, tags merged per resource.
+ all := buildEC2Mappings(tags, "us-east-1", "123456789012", nil)
+ require.Len(t, all, 2)
+ bySubnet := map[string]*rgt.ResourceTagMapping{}
+ for _, m := range all {
+ bySubnet[aws.StringValue(m.ResourceARN)] = m
+ }
+ subnet := bySubnet["arn:aws:ec2:us-east-1:123456789012:subnet/subnet-1"]
+ require.NotNil(t, subnet)
+ assert.Len(t, subnet.Tags, 2)
+
+ // Type filter narrows to subnets only.
+ onlySubnets := buildEC2Mappings(tags, "us-east-1", "123456789012", map[string]bool{"ec2:subnet": true})
+ require.Len(t, onlySubnets, 1)
+ assert.Equal(t, "arn:aws:ec2:us-east-1:123456789012:subnet/subnet-1", aws.StringValue(onlySubnets[0].ResourceARN))
+}
+
+func TestTagMapToRGT_SortedDeterministic(t *testing.T) {
+ out := tagMapToRGT(map[string]string{"b": "2", "a": "1", "c": "3"})
+ require.Len(t, out, 3)
+ assert.Equal(t, "a", aws.StringValue(out[0].Key))
+ assert.Equal(t, "b", aws.StringValue(out[1].Key))
+ assert.Equal(t, "c", aws.StringValue(out[2].Key))
+}
diff --git a/spinifex/gateway/tagging/handler.go b/spinifex/gateway/tagging/handler.go
new file mode 100644
index 000000000..0616318b5
--- /dev/null
+++ b/spinifex/gateway/tagging/handler.go
@@ -0,0 +1,43 @@
+// Package gateway_tagging is the HTTP-side glue between awsgw and the Resource
+// Groups Tagging API. Like ACM it speaks AWS JSON 1.1 (X-Amz-Target dispatch,
+// Content-Type application/x-amz-json-1.1). GetResources is answered by
+// aggregating the elbv2 and ec2 tag stores over NATS — there is no dedicated
+// tagging store. Error envelopes are emitted centrally by the shared gateway
+// ErrorHandler.
+package gateway_tagging
+
+import (
+ "encoding/json"
+ "log/slog"
+ "net/http"
+
+ "github.com/aws/aws-sdk-go/private/protocol/json/jsonutil"
+ "github.com/mulgadc/spinifex/spinifex/awserrors"
+)
+
+// JSONContentType is the AWS JSON 1.1 content type tagging clients expect.
+const JSONContentType = "application/x-amz-json-1.1"
+
+// WriteJSONResponse serialises obj as a 200 AWS JSON 1.1 response using the
+// SDK's own marshaler (epoch-seconds timestamps), the same path ACM uses.
+func WriteJSONResponse(w http.ResponseWriter, obj any) {
+ body, err := jsonutil.BuildJSON(obj)
+ if err != nil {
+ slog.Error("tagging: failed to marshal response JSON", "err", err)
+ http.Error(w, awserrors.ErrorInternalError, http.StatusInternalServerError)
+ return
+ }
+ w.Header().Set("Content-Type", JSONContentType)
+ w.WriteHeader(http.StatusOK)
+ if _, err := w.Write(body); err != nil {
+ slog.Error("tagging: failed to write response", "err", err)
+ }
+}
+
+// unmarshalIfBody decodes body into out only when body is non-empty.
+func unmarshalIfBody(body []byte, out any) error {
+ if len(body) == 0 {
+ return nil
+ }
+ return json.Unmarshal(body, out)
+}
diff --git a/spinifex/gateway/tagging_test.go b/spinifex/gateway/tagging_test.go
new file mode 100644
index 000000000..76425cb7f
--- /dev/null
+++ b/spinifex/gateway/tagging_test.go
@@ -0,0 +1,66 @@
+package gateway
+
+import (
+ "context"
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "testing"
+
+ "github.com/mulgadc/spinifex/spinifex/awserrors"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func setupTaggingRequest(target, body string) *http.Request {
+ req := httptest.NewRequest(http.MethodPost, "/", strings.NewReader(body))
+ if target != "" {
+ req.Header.Set("X-Amz-Target", target)
+ }
+ ctx := context.WithValue(req.Context(), ctxService, "tagging")
+ ctx = context.WithValue(ctx, ctxAccountID, "123456789012")
+ return req.WithContext(ctx)
+}
+
+func TestTaggingActionFromTarget(t *testing.T) {
+ assert.Equal(t, "GetResources", taggingActionFromTarget("ResourceGroupsTaggingAPI_20170126.GetResources"))
+ assert.Equal(t, "GetResources", taggingActionFromTarget("GetResources"))
+ assert.Equal(t, "", taggingActionFromTarget(""))
+}
+
+func TestTaggingActionsMap_AllActionsRegistered(t *testing.T) {
+ expected := []string{
+ "GetResources",
+ }
+ for _, action := range expected {
+ _, ok := taggingActions[action]
+ assert.True(t, ok, "action %q should be registered in taggingActions", action)
+ }
+ assert.Len(t, taggingActions, len(expected))
+}
+
+func TestTaggingRequest_MissingTarget(t *testing.T) {
+ gw := &GatewayConfig{DisableLogging: true}
+ w := httptest.NewRecorder()
+ err := gw.Tagging_Request(w, setupTaggingRequest("", ""))
+ require.Error(t, err)
+ assert.Equal(t, awserrors.ErrorMissingAction, err.Error())
+}
+
+func TestTaggingRequest_UnknownAction(t *testing.T) {
+ gw := &GatewayConfig{DisableLogging: true}
+ w := httptest.NewRecorder()
+ err := gw.Tagging_Request(w, setupTaggingRequest("ResourceGroupsTaggingAPI_20170126.TagResources", "{}"))
+ require.Error(t, err)
+ assert.Equal(t, awserrors.ErrorInvalidAction, err.Error())
+}
+
+// A known action with no NATS connection passes routing + policy and fails at
+// the NATS-availability guard.
+func TestTaggingRequest_KnownActionNoNATS(t *testing.T) {
+ gw := &GatewayConfig{DisableLogging: true}
+ w := httptest.NewRecorder()
+ err := gw.Tagging_Request(w, setupTaggingRequest("ResourceGroupsTaggingAPI_20170126.GetResources", "{}"))
+ require.Error(t, err)
+ assert.Equal(t, awserrors.ErrorServerInternal, err.Error())
+}
diff --git a/spinifex/gpu/mig_parsers_test.go b/spinifex/gpu/mig_parsers_test.go
index a96974b50..926132fcc 100644
--- a/spinifex/gpu/mig_parsers_test.go
+++ b/spinifex/gpu/mig_parsers_test.go
@@ -650,9 +650,10 @@ func TestIsMIGCapable(t *testing.T) {
{"10de", "20f1"}, // A100 PCIe 40GB
{"10de", "20f3"}, // A100 PCIe 80GB
{"10de", "2233"}, // A30
- {"10de", "2331"}, // H100 SXM
- {"10de", "2330"}, // H100 PCIe
+ {"10de", "2330"}, // H100 SXM
+ {"10de", "2331"}, // H100 PCIe
{"10de", "233a"}, // H100 NVL
+ {"10de", "2335"}, // H200 SXM
{"10de", "2bb5"}, // RTX Pro 6000 Blackwell Server Edition
}
for _, ids := range capable {
diff --git a/spinifex/gpu/models.go b/spinifex/gpu/models.go
index 9bf441cba..5352f6796 100644
--- a/spinifex/gpu/models.go
+++ b/spinifex/gpu/models.go
@@ -14,9 +14,10 @@ var knownModels = map[string]modelInfo{
"10de:20b5": {"NVIDIA A100 SXM 80GB", 81920},
"10de:20f1": {"NVIDIA A100 PCIe 40GB", 40960},
"10de:20f3": {"NVIDIA A100 PCIe 80GB", 81920},
- "10de:2331": {"NVIDIA H100 SXM", 81920},
- "10de:2330": {"NVIDIA H100 PCIe", 81920},
+ "10de:2330": {"NVIDIA H100 SXM", 81920},
+ "10de:2331": {"NVIDIA H100 PCIe", 81920},
"10de:233a": {"NVIDIA H100 NVL", 94208},
+ "10de:2335": {"NVIDIA H200 SXM", 144384},
"10de:2233": {"NVIDIA A30", 24576},
"10de:26b5": {"NVIDIA L40", 46068},
"10de:26b9": {"NVIDIA L40S", 46068},
@@ -44,9 +45,10 @@ var computeModels = map[string]bool{
"10de:20b5": true, // A100 SXM 80GB
"10de:20f1": true, // A100 PCIe 40GB
"10de:20f3": true, // A100 PCIe 80GB
- "10de:2331": true, // H100 SXM
- "10de:2330": true, // H100 PCIe
+ "10de:2330": true, // H100 SXM
+ "10de:2331": true, // H100 PCIe
"10de:233a": true, // H100 NVL
+ "10de:2335": true, // H200 SXM
"10de:2233": true, // A30
"10de:26b5": true, // L40
"10de:26b9": true, // L40S
@@ -65,9 +67,10 @@ var migCapableModels = map[string]bool{
"10de:20b5": true, // A100 SXM 80GB
"10de:20f1": true, // A100 PCIe 40GB
"10de:20f3": true, // A100 PCIe 80GB
- "10de:2331": true, // H100 SXM
- "10de:2330": true, // H100 PCIe
+ "10de:2330": true, // H100 SXM
+ "10de:2331": true, // H100 PCIe
"10de:233a": true, // H100 NVL
+ "10de:2335": true, // H200 SXM
"10de:2233": true, // A30
"10de:2bb5": true, // RTX Pro 6000 Blackwell Server Edition
}
diff --git a/spinifex/handlers/acm/service.go b/spinifex/handlers/acm/service.go
new file mode 100644
index 000000000..c4cd937d1
--- /dev/null
+++ b/spinifex/handlers/acm/service.go
@@ -0,0 +1,15 @@
+package handlers_acm
+
+import "github.com/aws/aws-sdk-go/service/acm"
+
+// ACMService is the minimal AWS Certificate Manager-alike control plane: BYO
+// certificate import and lifecycle. It covers only what ELBv2 HTTPS listeners
+// need to resolve a Certificates[].CertificateArn to PEM material. Request
+// flows (RequestCertificate + DNS/email validation), renewal, export, and
+// tagging are intentionally out of scope for now.
+type ACMService interface {
+ ImportCertificate(input *acm.ImportCertificateInput, accountID string) (*acm.ImportCertificateOutput, error)
+ DescribeCertificate(input *acm.DescribeCertificateInput, accountID string) (*acm.DescribeCertificateOutput, error)
+ ListCertificates(input *acm.ListCertificatesInput, accountID string) (*acm.ListCertificatesOutput, error)
+ DeleteCertificate(input *acm.DeleteCertificateInput, accountID string) (*acm.DeleteCertificateOutput, error)
+}
diff --git a/spinifex/handlers/acm/service_impl.go b/spinifex/handlers/acm/service_impl.go
new file mode 100644
index 000000000..6ca51de2f
--- /dev/null
+++ b/spinifex/handlers/acm/service_impl.go
@@ -0,0 +1,236 @@
+package handlers_acm
+
+import (
+ "crypto/ecdsa"
+ "crypto/rsa"
+ "crypto/tls"
+ "crypto/x509"
+ "encoding/pem"
+ "errors"
+ "fmt"
+ "log/slog"
+ "strings"
+ "time"
+
+ "github.com/aws/aws-sdk-go/aws"
+ "github.com/aws/aws-sdk-go/service/acm"
+ "github.com/google/uuid"
+ "github.com/mulgadc/spinifex/spinifex/awserrors"
+ "github.com/mulgadc/spinifex/spinifex/config"
+ "github.com/nats-io/nats.go"
+)
+
+const (
+ defaultRegion = "ap-southeast-2"
+
+ certStatusIssued = "ISSUED"
+ certTypeImported = "IMPORTED"
+)
+
+// ACMServiceImpl is the local, KV-backed implementation of ACMService.
+type ACMServiceImpl struct {
+ store *Store
+ region string
+}
+
+var _ ACMService = (*ACMServiceImpl)(nil)
+
+// NewACMServiceImplWithNATS builds an ACM service backed by a JetStream KV
+// store. cfg may be nil (tests); region then falls back to the default.
+func NewACMServiceImplWithNATS(cfg *config.Config, nc *nats.Conn) (*ACMServiceImpl, error) {
+ store, err := NewStore(nc)
+ if err != nil {
+ return nil, err
+ }
+ region := defaultRegion
+ if cfg != nil && cfg.Region != "" {
+ region = cfg.Region
+ }
+ return &ACMServiceImpl{store: store, region: region}, nil
+}
+
+// mintCertificateArn generates an ACM-style certificate ARN for accountID.
+func (s *ACMServiceImpl) mintCertificateArn(accountID string) string {
+ return fmt.Sprintf("arn:aws:acm:%s:%s:certificate/%s", s.region, accountID, uuid.NewString())
+}
+
+// ImportCertificate validates the PEM material, parses the leaf for metadata,
+// and stores it under a new (or, on re-import, the supplied) ACM ARN.
+func (s *ACMServiceImpl) ImportCertificate(input *acm.ImportCertificateInput, accountID string) (*acm.ImportCertificateOutput, error) {
+ if input == nil || len(input.Certificate) == 0 || len(input.PrivateKey) == 0 {
+ return nil, errors.New(awserrors.ErrorInvalidParameter)
+ }
+
+ // The leaf cert and private key must form a valid keypair.
+ if _, err := tls.X509KeyPair(input.Certificate, input.PrivateKey); err != nil {
+ slog.Debug("ImportCertificate: keypair validation failed", "err", err)
+ return nil, errors.New(awserrors.ErrorInvalidParameter)
+ }
+
+ leaf, err := parseLeaf(input.Certificate)
+ if err != nil {
+ return nil, errors.New(awserrors.ErrorInvalidParameter)
+ }
+
+ certArn := aws.StringValue(input.CertificateArn)
+ if certArn == "" {
+ certArn = s.mintCertificateArn(accountID)
+ } else {
+ // Re-import: the ARN must already exist and belong to the caller.
+ existing, gErr := s.store.GetCert(certArn)
+ if gErr != nil {
+ return nil, errors.New(awserrors.ErrorInternalError)
+ }
+ if existing == nil || existing.AccountID != accountID {
+ return nil, errors.New(awserrors.ErrorResourceNotFound)
+ }
+ }
+
+ rec := &CertRecord{
+ CertificateArn: certArn,
+ AccountID: accountID,
+ Certificate: string(input.Certificate),
+ CertificateChain: string(input.CertificateChain),
+ PrivateKey: string(input.PrivateKey),
+ DomainName: leafDomain(leaf),
+ SubjectAltNames: leaf.DNSNames,
+ Serial: leaf.SerialNumber.Text(16),
+ Subject: leaf.Subject.String(),
+ Issuer: leaf.Issuer.String(),
+ KeyAlgorithm: keyAlgorithm(leaf),
+ NotBefore: leaf.NotBefore,
+ NotAfter: leaf.NotAfter,
+ ImportedAt: time.Now().UTC(),
+ }
+ if err := s.store.PutCert(rec); err != nil {
+ slog.Error("ImportCertificate: store failed", "err", err)
+ return nil, errors.New(awserrors.ErrorInternalError)
+ }
+
+ slog.Info("ImportCertificate: stored", "arn", certArn, "domain", rec.DomainName, "account", accountID)
+ return &acm.ImportCertificateOutput{CertificateArn: aws.String(certArn)}, nil
+}
+
+// DescribeCertificate returns the CertificateDetail for an owned ARN.
+func (s *ACMServiceImpl) DescribeCertificate(input *acm.DescribeCertificateInput, accountID string) (*acm.DescribeCertificateOutput, error) {
+ if input == nil || aws.StringValue(input.CertificateArn) == "" {
+ return nil, errors.New(awserrors.ErrorACMInvalidArn)
+ }
+ rec, err := s.lookupOwned(aws.StringValue(input.CertificateArn), accountID)
+ if err != nil {
+ return nil, err
+ }
+ return &acm.DescribeCertificateOutput{Certificate: recordToDetail(rec)}, nil
+}
+
+// ListCertificates returns summaries for every cert owned by accountID.
+func (s *ACMServiceImpl) ListCertificates(input *acm.ListCertificatesInput, accountID string) (*acm.ListCertificatesOutput, error) {
+ recs, err := s.store.ListCerts(accountID)
+ if err != nil {
+ return nil, errors.New(awserrors.ErrorInternalError)
+ }
+ out := &acm.ListCertificatesOutput{}
+ for _, rec := range recs {
+ out.CertificateSummaryList = append(out.CertificateSummaryList, &acm.CertificateSummary{
+ CertificateArn: aws.String(rec.CertificateArn),
+ DomainName: aws.String(rec.DomainName),
+ Status: aws.String(certStatusIssued),
+ Type: aws.String(certTypeImported),
+ KeyAlgorithm: aws.String(rec.KeyAlgorithm),
+ NotBefore: timePtr(rec.NotBefore),
+ NotAfter: timePtr(rec.NotAfter),
+ ImportedAt: timePtr(rec.ImportedAt),
+ InUse: aws.Bool(false),
+ })
+ }
+ return out, nil
+}
+
+// DeleteCertificate removes an owned cert; unknown ARN → ResourceNotFound.
+func (s *ACMServiceImpl) DeleteCertificate(input *acm.DeleteCertificateInput, accountID string) (*acm.DeleteCertificateOutput, error) {
+ if input == nil || aws.StringValue(input.CertificateArn) == "" {
+ return nil, errors.New(awserrors.ErrorACMInvalidArn)
+ }
+ if _, err := s.lookupOwned(aws.StringValue(input.CertificateArn), accountID); err != nil {
+ return nil, err
+ }
+ if _, err := s.store.DeleteCert(aws.StringValue(input.CertificateArn)); err != nil {
+ return nil, errors.New(awserrors.ErrorInternalError)
+ }
+ return &acm.DeleteCertificateOutput{}, nil
+}
+
+// lookupOwned fetches a cert by ARN, returning ResourceNotFound when absent or
+// owned by a different account (no cross-account disclosure).
+func (s *ACMServiceImpl) lookupOwned(certArn, accountID string) (*CertRecord, error) {
+ rec, err := s.store.GetCert(certArn)
+ if err != nil {
+ return nil, errors.New(awserrors.ErrorInternalError)
+ }
+ if rec == nil || rec.AccountID != accountID {
+ return nil, errors.New(awserrors.ErrorResourceNotFound)
+ }
+ return rec, nil
+}
+
+func recordToDetail(rec *CertRecord) *acm.CertificateDetail {
+ detail := &acm.CertificateDetail{
+ CertificateArn: aws.String(rec.CertificateArn),
+ DomainName: aws.String(rec.DomainName),
+ Serial: aws.String(rec.Serial),
+ Subject: aws.String(rec.Subject),
+ Issuer: aws.String(rec.Issuer),
+ KeyAlgorithm: aws.String(rec.KeyAlgorithm),
+ Status: aws.String(certStatusIssued),
+ Type: aws.String(certTypeImported),
+ NotBefore: timePtr(rec.NotBefore),
+ NotAfter: timePtr(rec.NotAfter),
+ ImportedAt: timePtr(rec.ImportedAt),
+ InUseBy: []*string{},
+ }
+ if len(rec.SubjectAltNames) > 0 {
+ detail.SubjectAlternativeNames = aws.StringSlice(rec.SubjectAltNames)
+ }
+ return detail
+}
+
+func timePtr(t time.Time) *time.Time {
+ if t.IsZero() {
+ return nil
+ }
+ return &t
+}
+
+// parseLeaf decodes the first CERTIFICATE block from certPEM and parses it.
+func parseLeaf(certPEM []byte) (*x509.Certificate, error) {
+ block, _ := pem.Decode(certPEM)
+ if block == nil || block.Type != "CERTIFICATE" {
+ return nil, fmt.Errorf("no PEM CERTIFICATE block")
+ }
+ return x509.ParseCertificate(block.Bytes)
+}
+
+// leafDomain returns the certificate's primary domain: CommonName, else the
+// first SAN, else empty.
+func leafDomain(leaf *x509.Certificate) string {
+ if cn := strings.TrimSpace(leaf.Subject.CommonName); cn != "" {
+ return cn
+ }
+ if len(leaf.DNSNames) > 0 {
+ return leaf.DNSNames[0]
+ }
+ return ""
+}
+
+// keyAlgorithm maps the leaf public key to an ACM-style algorithm string
+// (RSA_2048, EC_prime256v1, ...).
+func keyAlgorithm(leaf *x509.Certificate) string {
+ switch pub := leaf.PublicKey.(type) {
+ case *rsa.PublicKey:
+ return fmt.Sprintf("RSA_%d", pub.N.BitLen())
+ case *ecdsa.PublicKey:
+ return "EC_" + pub.Curve.Params().Name
+ default:
+ return "UNKNOWN"
+ }
+}
diff --git a/spinifex/handlers/acm/service_impl_test.go b/spinifex/handlers/acm/service_impl_test.go
new file mode 100644
index 000000000..7c8729176
--- /dev/null
+++ b/spinifex/handlers/acm/service_impl_test.go
@@ -0,0 +1,166 @@
+package handlers_acm
+
+import (
+ "crypto/ecdsa"
+ "crypto/elliptic"
+ "crypto/rand"
+ "crypto/x509"
+ "crypto/x509/pkix"
+ "encoding/pem"
+ "math/big"
+ "testing"
+ "time"
+
+ "github.com/aws/aws-sdk-go/aws"
+ "github.com/aws/aws-sdk-go/service/acm"
+ "github.com/mulgadc/spinifex/spinifex/awserrors"
+ "github.com/mulgadc/spinifex/spinifex/testutil"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+const testAccountID = "000000000001"
+
+func setupACMService(t *testing.T) *ACMServiceImpl {
+ t.Helper()
+ _, nc, _ := testutil.StartTestJetStream(t)
+ svc, err := NewACMServiceImplWithNATS(nil, nc)
+ require.NoError(t, err)
+ return svc
+}
+
+// genCert returns a self-signed leaf certificate + private key as PEM for the
+// given common name.
+func genCert(t *testing.T, cn string, dnsNames ...string) (certPEM, keyPEM []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},
+ DNSNames: dnsNames,
+ NotBefore: time.Now().Add(-time.Hour),
+ NotAfter: time.Now().Add(24 * time.Hour),
+ KeyUsage: x509.KeyUsageDigitalSignature,
+ ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
+ }
+ der, err := x509.CreateCertificate(rand.Reader, tmpl, tmpl, &key.PublicKey, key)
+ require.NoError(t, err)
+ keyDER, err := x509.MarshalECPrivateKey(key)
+ require.NoError(t, err)
+
+ certPEM = pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der})
+ keyPEM = pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: keyDER})
+ return certPEM, keyPEM
+}
+
+func TestImportCertificate_MintsArnAndParsesLeaf(t *testing.T) {
+ svc := setupACMService(t)
+ certPEM, keyPEM := genCert(t, "example.com", "example.com", "www.example.com")
+
+ out, err := svc.ImportCertificate(&acm.ImportCertificateInput{
+ Certificate: certPEM,
+ PrivateKey: keyPEM,
+ }, testAccountID)
+ require.NoError(t, err)
+ require.NotNil(t, out.CertificateArn)
+ assert.Contains(t, aws.StringValue(out.CertificateArn), "arn:aws:acm:")
+ assert.Contains(t, aws.StringValue(out.CertificateArn), ":"+testAccountID+":certificate/")
+
+ desc, err := svc.DescribeCertificate(&acm.DescribeCertificateInput{
+ CertificateArn: out.CertificateArn,
+ }, testAccountID)
+ require.NoError(t, err)
+ d := desc.Certificate
+ assert.Equal(t, "example.com", aws.StringValue(d.DomainName))
+ assert.Equal(t, certStatusIssued, aws.StringValue(d.Status))
+ assert.Equal(t, certTypeImported, aws.StringValue(d.Type))
+ assert.Equal(t, "EC_P-256", aws.StringValue(d.KeyAlgorithm))
+ assert.ElementsMatch(t, []string{"example.com", "www.example.com"}, aws.StringValueSlice(d.SubjectAlternativeNames))
+ require.NotNil(t, d.NotAfter)
+}
+
+func TestImportCertificate_MismatchedKeyRejected(t *testing.T) {
+ svc := setupACMService(t)
+ certPEM, _ := genCert(t, "a.example.com")
+ _, otherKey := genCert(t, "b.example.com")
+
+ _, err := svc.ImportCertificate(&acm.ImportCertificateInput{
+ Certificate: certPEM,
+ PrivateKey: otherKey,
+ }, testAccountID)
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), awserrors.ErrorInvalidParameter)
+}
+
+func TestImportCertificate_GarbagePEMRejected(t *testing.T) {
+ svc := setupACMService(t)
+ _, err := svc.ImportCertificate(&acm.ImportCertificateInput{
+ Certificate: []byte("not a pem"),
+ PrivateKey: []byte("nope"),
+ }, testAccountID)
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), awserrors.ErrorInvalidParameter)
+}
+
+func TestListCertificates_ScopedToAccount(t *testing.T) {
+ svc := setupACMService(t)
+ c1, k1 := genCert(t, "one.example.com")
+ c2, k2 := genCert(t, "two.example.com")
+
+ _, err := svc.ImportCertificate(&acm.ImportCertificateInput{Certificate: c1, PrivateKey: k1}, testAccountID)
+ require.NoError(t, err)
+ _, err = svc.ImportCertificate(&acm.ImportCertificateInput{Certificate: c2, PrivateKey: k2}, "000000000002")
+ require.NoError(t, err)
+
+ list, err := svc.ListCertificates(&acm.ListCertificatesInput{}, testAccountID)
+ require.NoError(t, err)
+ require.Len(t, list.CertificateSummaryList, 1)
+ assert.Equal(t, "one.example.com", aws.StringValue(list.CertificateSummaryList[0].DomainName))
+}
+
+func TestDescribeCertificate_CrossAccountHidden(t *testing.T) {
+ svc := setupACMService(t)
+ c1, k1 := genCert(t, "secret.example.com")
+ out, err := svc.ImportCertificate(&acm.ImportCertificateInput{Certificate: c1, PrivateKey: k1}, testAccountID)
+ require.NoError(t, err)
+
+ _, err = svc.DescribeCertificate(&acm.DescribeCertificateInput{
+ CertificateArn: out.CertificateArn,
+ }, "000000000002")
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), awserrors.ErrorResourceNotFound)
+}
+
+func TestDeleteCertificate(t *testing.T) {
+ svc := setupACMService(t)
+ c1, k1 := genCert(t, "del.example.com")
+ out, err := svc.ImportCertificate(&acm.ImportCertificateInput{Certificate: c1, PrivateKey: k1}, testAccountID)
+ require.NoError(t, err)
+
+ _, err = svc.DeleteCertificate(&acm.DeleteCertificateInput{CertificateArn: out.CertificateArn}, testAccountID)
+ require.NoError(t, err)
+
+ // Gone now.
+ _, err = svc.DescribeCertificate(&acm.DescribeCertificateInput{CertificateArn: out.CertificateArn}, testAccountID)
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), awserrors.ErrorResourceNotFound)
+
+ // Deleting again → ResourceNotFound.
+ _, err = svc.DeleteCertificate(&acm.DeleteCertificateInput{CertificateArn: out.CertificateArn}, testAccountID)
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), awserrors.ErrorResourceNotFound)
+}
+
+func TestImportCertificate_ReimportUnknownArnRejected(t *testing.T) {
+ svc := setupACMService(t)
+ c1, k1 := genCert(t, "re.example.com")
+ _, err := svc.ImportCertificate(&acm.ImportCertificateInput{
+ Certificate: c1,
+ PrivateKey: k1,
+ CertificateArn: aws.String("arn:aws:acm:ap-southeast-2:000000000001:certificate/does-not-exist"),
+ }, testAccountID)
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), awserrors.ErrorResourceNotFound)
+}
diff --git a/spinifex/handlers/acm/service_nats.go b/spinifex/handlers/acm/service_nats.go
new file mode 100644
index 000000000..da5ad276f
--- /dev/null
+++ b/spinifex/handlers/acm/service_nats.go
@@ -0,0 +1,40 @@
+package handlers_acm
+
+import (
+ "time"
+
+ "github.com/aws/aws-sdk-go/service/acm"
+ "github.com/mulgadc/spinifex/spinifex/utils"
+ "github.com/nats-io/nats.go"
+)
+
+const defaultTimeout = 30 * time.Second
+
+// NATSACMService is the gateway-side client that forwards ACM operations to
+// the daemon over NATS request/response.
+type NATSACMService struct {
+ natsConn *nats.Conn
+}
+
+var _ ACMService = (*NATSACMService)(nil)
+
+// NewNATSACMService creates a NATS-backed ACM service client.
+func NewNATSACMService(conn *nats.Conn) ACMService {
+ return &NATSACMService{natsConn: conn}
+}
+
+func (s *NATSACMService) ImportCertificate(input *acm.ImportCertificateInput, accountID string) (*acm.ImportCertificateOutput, error) {
+ return utils.NATSRequest[acm.ImportCertificateOutput](s.natsConn, "acm.ImportCertificate", input, defaultTimeout, accountID)
+}
+
+func (s *NATSACMService) DescribeCertificate(input *acm.DescribeCertificateInput, accountID string) (*acm.DescribeCertificateOutput, error) {
+ return utils.NATSRequest[acm.DescribeCertificateOutput](s.natsConn, "acm.DescribeCertificate", input, defaultTimeout, accountID)
+}
+
+func (s *NATSACMService) ListCertificates(input *acm.ListCertificatesInput, accountID string) (*acm.ListCertificatesOutput, error) {
+ return utils.NATSRequest[acm.ListCertificatesOutput](s.natsConn, "acm.ListCertificates", input, defaultTimeout, accountID)
+}
+
+func (s *NATSACMService) DeleteCertificate(input *acm.DeleteCertificateInput, accountID string) (*acm.DeleteCertificateOutput, error) {
+ return utils.NATSRequest[acm.DeleteCertificateOutput](s.natsConn, "acm.DeleteCertificate", input, defaultTimeout, accountID)
+}
diff --git a/spinifex/handlers/acm/service_nats_test.go b/spinifex/handlers/acm/service_nats_test.go
new file mode 100644
index 000000000..b2e0c8e64
--- /dev/null
+++ b/spinifex/handlers/acm/service_nats_test.go
@@ -0,0 +1,25 @@
+package handlers_acm
+
+import (
+ "testing"
+
+ "github.com/aws/aws-sdk-go/service/acm"
+ "github.com/mulgadc/spinifex/spinifex/testutil"
+ "github.com/stretchr/testify/require"
+)
+
+// With no daemon subscribed to acm.*, each NATS request fast-fails with
+// no-responders. The point is to exercise the client delegation paths.
+func TestNATSACMService_NoResponder(t *testing.T) {
+ _, nc, _ := testutil.StartTestJetStream(t)
+ svc := NewNATSACMService(nc)
+
+ _, err := svc.ImportCertificate(&acm.ImportCertificateInput{}, testAccountID)
+ require.Error(t, err)
+ _, err = svc.DescribeCertificate(&acm.DescribeCertificateInput{}, testAccountID)
+ require.Error(t, err)
+ _, err = svc.ListCertificates(&acm.ListCertificatesInput{}, testAccountID)
+ require.Error(t, err)
+ _, err = svc.DeleteCertificate(&acm.DeleteCertificateInput{}, testAccountID)
+ require.Error(t, err)
+}
diff --git a/spinifex/handlers/acm/store.go b/spinifex/handlers/acm/store.go
new file mode 100644
index 000000000..7764f045f
--- /dev/null
+++ b/spinifex/handlers/acm/store.go
@@ -0,0 +1,143 @@
+package handlers_acm
+
+import (
+ "encoding/json"
+ "errors"
+ "fmt"
+ "log/slog"
+ "strings"
+ "time"
+
+ "github.com/mulgadc/spinifex/spinifex/utils"
+ "github.com/nats-io/nats.go"
+)
+
+const (
+ KVBucketACM = "spinifex-acm"
+ KVBucketACMVersion = 1
+
+ // KeyPrefixCert namespaces certificate records within the bucket.
+ KeyPrefixCert = "cert."
+)
+
+// CertRecord is the stored representation of an imported certificate. PEM
+// material (leaf, chain, private key) is held alongside the parsed metadata
+// surfaced by DescribeCertificate. AccountID scopes ownership so list/describe
+// never cross account boundaries.
+type CertRecord struct {
+ CertificateArn string `json:"certificate_arn"`
+ AccountID string `json:"account_id"`
+ Certificate string `json:"certificate"`
+ CertificateChain string `json:"certificate_chain,omitempty"`
+ PrivateKey string `json:"private_key"`
+ DomainName string `json:"domain_name"`
+ SubjectAltNames []string `json:"subject_alt_names,omitempty"`
+ Serial string `json:"serial"`
+ Subject string `json:"subject"`
+ Issuer string `json:"issuer"`
+ KeyAlgorithm string `json:"key_algorithm"`
+ NotBefore time.Time `json:"not_before"`
+ NotAfter time.Time `json:"not_after"`
+ ImportedAt time.Time `json:"imported_at"`
+}
+
+// Store provides CRUD for ACM certificate records backed by JetStream KV.
+type Store struct {
+ kv nats.KeyValue
+}
+
+// NewStore creates an ACM store using the provided NATS connection.
+func NewStore(nc *nats.Conn) (*Store, error) {
+ js, err := nc.JetStream()
+ if err != nil {
+ return nil, fmt.Errorf("failed to get JetStream context: %w", err)
+ }
+
+ kv, err := utils.GetOrCreateKVBucket(js, KVBucketACM, KVBucketACMVersion)
+ if err != nil {
+ return nil, fmt.Errorf("failed to create KV bucket %s: %w", KVBucketACM, err)
+ }
+
+ slog.Info("ACM store initialized", "bucket", KVBucketACM)
+ return &Store{kv: kv}, nil
+}
+
+// certKey derives the KV key from a certificate ARN. The ARN's certificate ID
+// (the UUID after "certificate/") is the stable per-record key.
+func certKey(certArn string) string {
+ id := certArn
+ if i := strings.LastIndex(certArn, "/"); i >= 0 {
+ id = certArn[i+1:]
+ }
+ return KeyPrefixCert + id
+}
+
+// PutCert stores (or replaces) a certificate record.
+func (s *Store) PutCert(rec *CertRecord) error {
+ data, err := json.Marshal(rec)
+ if err != nil {
+ return fmt.Errorf("marshal cert: %w", err)
+ }
+ _, err = s.kv.Put(certKey(rec.CertificateArn), data)
+ return err
+}
+
+// GetCert retrieves a certificate by ARN, returning (nil, nil) when absent.
+func (s *Store) GetCert(certArn string) (*CertRecord, error) {
+ entry, err := s.kv.Get(certKey(certArn))
+ if err != nil {
+ if errors.Is(err, nats.ErrKeyNotFound) {
+ return nil, nil
+ }
+ return nil, err
+ }
+ var rec CertRecord
+ if err := json.Unmarshal(entry.Value(), &rec); err != nil {
+ return nil, fmt.Errorf("unmarshal cert: %w", err)
+ }
+ return &rec, nil
+}
+
+// DeleteCert removes a certificate by ARN. Returns (false, nil) when absent.
+func (s *Store) DeleteCert(certArn string) (bool, error) {
+ if _, err := s.kv.Get(certKey(certArn)); err != nil {
+ if errors.Is(err, nats.ErrKeyNotFound) {
+ return false, nil
+ }
+ return false, err
+ }
+ if err := s.kv.Delete(certKey(certArn)); err != nil {
+ return false, err
+ }
+ return true, nil
+}
+
+// ListCerts returns all certificate records owned by accountID.
+func (s *Store) ListCerts(accountID string) ([]*CertRecord, error) {
+ keys, err := s.kv.Keys()
+ if err != nil {
+ if errors.Is(err, nats.ErrNoKeysFound) {
+ return nil, nil
+ }
+ return nil, err
+ }
+ var out []*CertRecord
+ for _, key := range keys {
+ if !strings.HasPrefix(key, KeyPrefixCert) {
+ continue
+ }
+ entry, err := s.kv.Get(key)
+ if err != nil {
+ continue
+ }
+ var rec CertRecord
+ if err := json.Unmarshal(entry.Value(), &rec); err != nil {
+ continue
+ }
+ if rec.AccountID != accountID {
+ continue
+ }
+ out = append(out, &rec)
+ }
+ return out, nil
+}
diff --git a/spinifex/handlers/ec2/eip/service_impl_test.go b/spinifex/handlers/ec2/eip/service_impl_test.go
index f0c35df94..44df84e15 100644
--- a/spinifex/handlers/ec2/eip/service_impl_test.go
+++ b/spinifex/handlers/ec2/eip/service_impl_test.go
@@ -82,22 +82,23 @@ func TestEIP_Release(t *testing.T) {
}, testAccountID)
require.NoError(t, err)
- // Verify IP returned to pool by allocating again — should get same IP
+ // Round-robin: re-allocating does NOT hand back the just-released IP
+ // (siv-246); the cursor advances, so the new EIP differs.
out2, err := svc.AllocateAddress(&ec2.AllocateAddressInput{}, testAccountID)
require.NoError(t, err)
- assert.Equal(t, allocatedIP, *out2.PublicIp)
+ assert.NotEqual(t, allocatedIP, *out2.PublicIp, "released EIP must not be reused immediately")
- // Verify the pool shows the IP was released and re-allocated
+ // The released IP stays free (not re-allocated) until the cursor cycles.
record, err := ipam.GetPoolRecord("test-pool")
require.NoError(t, err)
- _, allocated := record.Allocated[allocatedIP]
- // After re-allocation, IP should be allocated again by the EIP service
- // but let's just verify the release worked by checking describe returns nothing for old alloc
+ _, stillAllocated := record.Allocated[allocatedIP]
+ assert.False(t, stillAllocated, "released IP stays free until the cursor cycles the range")
+
+ // The released allocation is gone; describing it by its old ID errors.
_, descErr := svc.DescribeAddresses(&ec2.DescribeAddressesInput{
AllocationIds: []*string{out.AllocationId},
}, testAccountID)
assert.Error(t, descErr)
- _ = allocated // suppress unused
}
func TestEIP_ReleaseWhileAssociated(t *testing.T) {
diff --git a/spinifex/handlers/ec2/instance/helpers_test.go b/spinifex/handlers/ec2/instance/helpers_test.go
index 5f690b912..11687ee04 100644
--- a/spinifex/handlers/ec2/instance/helpers_test.go
+++ b/spinifex/handlers/ec2/instance/helpers_test.go
@@ -1,6 +1,7 @@
package handlers_ec2_instance
import (
+ "strings"
"testing"
"github.com/stretchr/testify/assert"
@@ -9,21 +10,24 @@ import (
// --- generateNetworkConfig ---
func TestGenerateNetworkConfig_BothEmpty(t *testing.T) {
- cfg := generateNetworkConfig("", "", "", "", nil)
+ cfg := generateNetworkConfig("", "", "", "", nil, true)
assert.Equal(t, cloudInitNetworkConfigWildcard, cfg)
}
func TestGenerateNetworkConfig_OneEmpty(t *testing.T) {
- cfg := generateNetworkConfig("02:00:00:aa:bb:cc", "", "", "", nil)
+ cfg := generateNetworkConfig("02:00:00:aa:bb:cc", "", "", "", nil, true)
assert.Contains(t, cfg, "vpc0:", "eniMAC alone should produce per-interface config")
assert.NotContains(t, cfg, "dev0:", "no dev NIC without devMAC")
+ // The IMDS on-link route rides vpc0 even with no dev NIC / extra ENIs — the
+ // common production shape — so guard it here too, not only on the multi-NIC path.
+ assert.Contains(t, cfg, "to: 169.254.169.254/32", "vpc0 must carry the IMDS on-link route")
- cfg = generateNetworkConfig("", "02:00:00:dd:ee:ff", "", "", nil)
+ cfg = generateNetworkConfig("", "02:00:00:dd:ee:ff", "", "", nil, true)
assert.Equal(t, cloudInitNetworkConfigWildcard, cfg, "should fall back to wildcard if eniMAC empty")
}
func TestGenerateNetworkConfig_DualNIC(t *testing.T) {
- cfg := generateNetworkConfig("02:00:00:aa:bb:cc", "02:00:00:dd:ee:ff", "", "", nil)
+ cfg := generateNetworkConfig("02:00:00:aa:bb:cc", "02:00:00:dd:ee:ff", "", "", nil, true)
assert.Contains(t, cfg, "version: 2")
assert.Contains(t, cfg, `macaddress: "02:00:00:aa:bb:cc"`)
assert.Contains(t, cfg, `macaddress: "02:00:00:dd:ee:ff"`)
@@ -35,7 +39,7 @@ func TestGenerateNetworkConfig_DualNIC(t *testing.T) {
}
func TestGenerateNetworkConfig_TripleNIC(t *testing.T) {
- cfg := generateNetworkConfig("02:00:00:aa:bb:cc", "02:de:00:dd:ee:ff", "02:a0:00:11:22:33", "10.15.8.101", nil)
+ cfg := generateNetworkConfig("02:00:00:aa:bb:cc", "02:de:00:dd:ee:ff", "02:a0:00:11:22:33", "10.15.8.101", nil, true)
assert.Contains(t, cfg, "version: 2")
assert.Contains(t, cfg, `macaddress: "02:00:00:aa:bb:cc"`)
assert.Contains(t, cfg, `macaddress: "02:de:00:dd:ee:ff"`)
@@ -49,7 +53,7 @@ func TestGenerateNetworkConfig_TripleNIC(t *testing.T) {
func TestGenerateNetworkConfig_MgmtWithoutDev(t *testing.T) {
// System instances: eniMAC + mgmtMAC, no devMAC — should get per-interface config with mgmt NIC
- cfg := generateNetworkConfig("02:00:00:aa:bb:cc", "", "02:a0:00:11:22:33", "10.15.8.101", nil)
+ cfg := generateNetworkConfig("02:00:00:aa:bb:cc", "", "02:a0:00:11:22:33", "10.15.8.101", nil, true)
assert.Contains(t, cfg, "vpc0:")
assert.NotContains(t, cfg, "dev0:", "no dev NIC without devMAC")
assert.Contains(t, cfg, "mgmt0:")
@@ -58,14 +62,62 @@ func TestGenerateNetworkConfig_MgmtWithoutDev(t *testing.T) {
}
func TestGenerateNetworkConfig_MgmtMACWithoutIP(t *testing.T) {
- cfg := generateNetworkConfig("02:00:00:aa:bb:cc", "02:de:00:dd:ee:ff", "02:a0:00:11:22:33", "", nil)
+ cfg := generateNetworkConfig("02:00:00:aa:bb:cc", "02:de:00:dd:ee:ff", "02:a0:00:11:22:33", "", nil, true)
assert.NotContains(t, cfg, "mgmt0:", "mgmt NIC should not appear without IP")
}
func TestGenerateNetworkConfig_MgmtIPWithoutMAC(t *testing.T) {
- cfg := generateNetworkConfig("02:00:00:aa:bb:cc", "02:de:00:dd:ee:ff", "", "10.15.8.101", nil)
+ cfg := generateNetworkConfig("02:00:00:aa:bb:cc", "02:de:00:dd:ee:ff", "", "10.15.8.101", nil, true)
assert.NotContains(t, cfg, "mgmt0:", "mgmt NIC should not appear without MAC")
}
+func TestGenerateNetworkConfig_IMDSOnLinkRoute(t *testing.T) {
+ // The primary VPC NIC carries an on-link route to the IMDS metadata IP so the
+ // guest ARPs 169.254.169.254 directly; the per-subnet localport answers.
+ cfg := generateNetworkConfig("02:00:00:aa:bb:cc", "02:00:00:dd:ee:ff", "", "",
+ []string{"02:00:00:bb:bb:bb"}, true)
+ assert.Contains(t, cfg, "to: 169.254.169.254/32")
+ assert.Contains(t, cfg, "scope: link")
+ // Only the primary NIC (vpc0) gets it — not extra VPC NICs or the dev NIC,
+ // matching AWS (IMDS reached via the primary ENI).
+ assert.Equal(t, 1, strings.Count(cfg, "169.254.169.254/32"))
+}
+
+func TestGenerateNetworkConfig_IMDSRouteOmittedWhenDisabled(t *testing.T) {
+ // Alpine's eni renderer crashes on a gateway-less route, so the route is
+ // omitted from network-config and delivered out-of-band (see
+ // buildAlpineCloudInit). emitIMDSRoute=false must drop it entirely.
+ cfg := generateNetworkConfig("02:00:00:aa:bb:cc", "02:00:00:dd:ee:ff", "", "", nil, false)
+ assert.Contains(t, cfg, "vpc0:")
+ assert.NotContains(t, cfg, "169.254.169.254/32", "route must not render when emitIMDSRoute=false")
+ assert.NotContains(t, cfg, " routes:", "no vpc0 routes block when the only route is the IMDS one")
+}
+
+// selectNetworkConfigForFamily must omit the IMDS route for Alpine (eni renderer
+// can't handle the gateway-less route) but keep it for everyone else.
+func TestSelectNetworkConfigForFamily_AlpineOmitsIMDSRoute(t *testing.T) {
+ alpine := selectNetworkConfigForFamily("alpine", "02:00:00:aa:bb:cc", "02:00:00:dd:ee:ff", "", "", nil)
+ assert.NotContains(t, alpine, "169.254.169.254/32", "Alpine network-config must not carry the IMDS route")
+
+ deb := selectNetworkConfigForFamily("debian", "02:00:00:aa:bb:cc", "02:00:00:dd:ee:ff", "", "", nil)
+ assert.Contains(t, deb, "169.254.169.254/32", "non-Alpine families keep the netplan IMDS route")
+}
+
+// buildAlpineCloudInit delivers the IMDS on-link route via a persistent
+// /etc/local.d script + OpenRC local service, since the eni renderer can't.
+func TestBuildAlpineCloudInit(t *testing.T) {
+ wf, rc := buildAlpineCloudInit("02:00:00:aa:bb:cc")
+ assert.Contains(t, wf, "/etc/local.d/imds-onlink-route.start")
+ assert.Contains(t, wf, "ip route show default")
+ assert.Contains(t, wf, `ip route replace 169.254.169.254/32 dev "$dev" scope link`)
+ assert.Contains(t, rc, "rc-update")
+ assert.Contains(t, rc, "local")
+
+ // No VPC NIC → nothing to add.
+ wf, rc = buildAlpineCloudInit("")
+ assert.Empty(t, wf)
+ assert.Empty(t, rc)
+}
+
// Route for multi-node LB mgmt traffic is handled via the fw_cfg netcfg
// blob delivered to the LB microVM, not in the network-config.
diff --git a/spinifex/handlers/ec2/instance/service.go b/spinifex/handlers/ec2/instance/service.go
index c552157c3..8c7e40d42 100644
--- a/spinifex/handlers/ec2/instance/service.go
+++ b/spinifex/handlers/ec2/instance/service.go
@@ -129,15 +129,32 @@ type SubnetInfo struct {
MapPublicIpOnLaunch bool
}
+// ENIInfo carries the subset of ENI metadata RunInstances needs to attach a
+// pre-created ENI as the primary interface. Translated from
+// handlers/ec2/vpc.ENIRecord by the daemon adapter to avoid a cyclic
+// instance↔vpc import.
+type ENIInfo struct {
+ NetworkInterfaceID string
+ SubnetID string
+ VpcID string
+ PrivateIpAddress string
+ MacAddress string
+ Status string
+ SecurityGroupIDs []string
+}
+
// ENICreator covers the VPC/ENI operations RunInstances performs while
// auto-attaching a primary interface. Implemented via an adapter over
// handlers/ec2/vpc.VPCServiceImpl on the daemon. DetachENI lives here (not on
// ENIDeleter) because the launch-time NAT-rollback path needs to flip the ENI
// to "available" before ENIDeleter.DeleteNetworkInterface, which rejects
-// in-use ENIs.
+// in-use ENIs. GetENI supports the pre-created-ENI launch path where the
+// caller passes NetworkInterfaces[0].NetworkInterfaceId and RunInstances
+// attaches the existing ENI instead of creating a new one.
type ENICreator interface {
GetDefaultSubnet(accountID string) (*SubnetInfo, error)
GetSubnet(accountID, subnetID string) (*SubnetInfo, error)
+ GetENI(accountID, eniID string) (*ENIInfo, error)
CreateNetworkInterface(input *ec2.CreateNetworkInterfaceInput, accountID string) (*ec2.CreateNetworkInterfaceOutput, error)
AttachENI(accountID, eniID, instanceID string, deviceIndex int64) (string, error)
DetachENI(accountID, eniID string) error
diff --git a/spinifex/handlers/ec2/instance/service_impl.go b/spinifex/handlers/ec2/instance/service_impl.go
index 5e0c17b53..80e881d10 100644
--- a/spinifex/handlers/ec2/instance/service_impl.go
+++ b/spinifex/handlers/ec2/instance/service_impl.go
@@ -24,6 +24,7 @@ import (
"github.com/mulgadc/spinifex/spinifex/filterutil"
"github.com/mulgadc/spinifex/spinifex/gpu"
handlers_ec2_vpc "github.com/mulgadc/spinifex/spinifex/handlers/ec2/vpc"
+ handlers_imds "github.com/mulgadc/spinifex/spinifex/handlers/imds"
"github.com/mulgadc/spinifex/spinifex/instancetypes"
"github.com/mulgadc/spinifex/spinifex/network/topology"
"github.com/mulgadc/spinifex/spinifex/objectstore"
@@ -63,19 +64,21 @@ ca_certs:
{{.UserDataCloudConfig}}
{{end}}
-{{if or .RHELWriteFiles .UserDataScript}}
+{{if or .RHELWriteFiles .AlpineWriteFiles .UserDataScript}}
write_files:
{{- if .RHELWriteFiles}}
-{{.RHELWriteFiles}}{{end}}{{if .UserDataScript}}
+{{.RHELWriteFiles}}{{end}}{{if .AlpineWriteFiles}}
+{{.AlpineWriteFiles}}{{end}}{{if .UserDataScript}}
- path: /tmp/cloud-init-startup.sh
permissions: '0755'
content: |
{{.UserDataScript}}{{end}}
{{end}}
-{{if or .RHELRunCmd .UserDataScript}}
+{{if or .RHELRunCmd .AlpineRunCmd .UserDataScript}}
runcmd:
{{- if .RHELRunCmd}}
-{{.RHELRunCmd}}{{end}}{{if .UserDataScript}}
+{{.RHELRunCmd}}{{end}}{{if .AlpineRunCmd}}
+{{.AlpineRunCmd}}{{end}}{{if .UserDataScript}}
- [ "/bin/bash", "/tmp/cloud-init-startup.sh" ]
{{end}}
{{end}}
@@ -117,7 +120,11 @@ func selectNetworkConfigForFamily(distroFamily, eniMAC, devMAC, mgmtMAC, mgmtIP
if distroFamily == "rhel" {
return cloudInitNetworkConfigDisabled
}
- return generateNetworkConfig(eniMAC, devMAC, mgmtMAC, mgmtIP, extraENIMACs)
+ // Alpine renders via cloud-init's eni backend, which cannot emit a
+ // gateway-less route; the IMDS on-link route is delivered via
+ // buildAlpineCloudInit instead. Debian/Ubuntu render via netplan, which
+ // handles the on-link route natively.
+ return generateNetworkConfig(eniMAC, devMAC, mgmtMAC, mgmtIP, extraENIMACs, distroFamily != "alpine")
}
// generateNetworkConfig produces the cloud-init network-config for the instance.
@@ -133,7 +140,15 @@ func selectNetworkConfigForFamily(distroFamily, eniMAC, devMAC, mgmtMAC, mgmtIP
//
// The dev NIC still gets an IP via DHCP (needed for hostfwd port forwarding)
// but dhcp4-overrides prevents it from installing routes or DNS.
-func generateNetworkConfig(eniMAC, devMAC, mgmtMAC, mgmtIP string, extraENIMACs []string) string {
+//
+// The primary VPC NIC (vpc0) also carries an on-link route to the IMDS metadata
+// IP (169.254.169.254): under the L2 datapath the metadata localport lives on
+// the guest's subnet switch and answers ARP directly, so the guest must treat
+// the address as on-link rather than steering it via the default gateway. The
+// route is reached via the primary ENI, matching AWS; it is not delivered via
+// DHCP option 121 (which would force the default route into option 121 per
+// RFC 3442) and the default gateway stays option 3.
+func generateNetworkConfig(eniMAC, devMAC, mgmtMAC, mgmtIP string, extraENIMACs []string, emitIMDSRoute bool) string {
if eniMAC == "" {
return cloudInitNetworkConfigWildcard
}
@@ -146,6 +161,16 @@ func generateNetworkConfig(eniMAC, devMAC, mgmtMAC, mgmtIP string, extraENIMACs
dhcp4: true
dhcp-identifier: mac
`, eniMAC)
+ // The gateway-less IMDS on-link route renders only under netplan. cloud-init's
+ // eni renderer (Alpine) crashes on a route with no gateway, taking the whole
+ // network-config down with it, so eni-rendered guests get this route via
+ // buildAlpineCloudInit instead (emitIMDSRoute=false).
+ if emitIMDSRoute {
+ cfg += fmt.Sprintf(` routes:
+ - to: %s/32
+ scope: link
+`, handlers_imds.MetaDataServerIP)
+ }
for i, mac := range extraENIMACs {
if mac == "" {
@@ -208,6 +233,12 @@ type CloudInitData struct {
// + nmcli reload/up) needed to load and bring up the keyfiles. Empty on
// non-RHEL guests.
RHELRunCmd string
+ // AlpineWriteFiles / AlpineRunCmd carry the IMDS on-link route delivery for
+ // Alpine guests (a persistent /etc/local.d script + OpenRC `local` enable),
+ // since the eni renderer cannot emit the gateway-less route in the
+ // network-config. Empty on non-Alpine guests and on non-VPC instances.
+ AlpineWriteFiles string
+ AlpineRunCmd string
}
// buildRHELCloudInit produces the cloud-init write_files (NM keyfiles) and
@@ -215,9 +246,9 @@ type CloudInitData struct {
// RHEL-family guest. Returns empty strings when eniMAC is empty (wildcard /
// non-VPC path) so NM's default autoconnect handles the interface.
//
-// Mirrors generateNetworkConfig's per-interface structure: vpc0 + optional
-// vpc1..N for extra ENIs, dev0 (DHCP with route/DNS suppression), mgmt0
-// (static). Owning the keyfile bytes directly avoids relying on cloud-init's
+// Mirrors generateNetworkConfig's per-interface structure: vpc0 (DHCP + on-link
+// IMDS route) + optional vpc1..N for extra ENIs, dev0 (DHCP with route/DNS
+// suppression), mgmt0 (static). Owning the keyfile bytes directly avoids relying on cloud-init's
// v2-to-NM renderer round-tripping dhcp-identifier: mac → dhcp-client-id=mac,
// which OVN's MAC-keyed DHCP requires.
//
@@ -236,7 +267,7 @@ func buildRHELCloudInit(eniMAC, devMAC, mgmtMAC, mgmtIP string, extraENIMACs []s
rc.WriteString(" - [ restorecon, -R, /etc/NetworkManager/system-connections/ ]\n")
rc.WriteString(" - [ nmcli, connection, reload ]\n")
- appendRHELDHCPKeyfile(&wf, "vpc0", eniMAC, false)
+ appendRHELDHCPKeyfile(&wf, "vpc0", eniMAC, false, true)
rc.WriteString(" - [ nmcli, connection, up, vpc0 ]\n")
for i, mac := range extraENIMACs {
@@ -244,12 +275,12 @@ func buildRHELCloudInit(eniMAC, devMAC, mgmtMAC, mgmtIP string, extraENIMACs []s
continue
}
name := fmt.Sprintf("vpc%d", i+1)
- appendRHELDHCPKeyfile(&wf, name, mac, false)
+ appendRHELDHCPKeyfile(&wf, name, mac, false, false)
fmt.Fprintf(&rc, " - [ nmcli, connection, up, %s ]\n", name)
}
if devMAC != "" {
- appendRHELDHCPKeyfile(&wf, "dev0", devMAC, true)
+ appendRHELDHCPKeyfile(&wf, "dev0", devMAC, true, false)
rc.WriteString(" - [ nmcli, connection, up, dev0 ]\n")
}
@@ -264,7 +295,7 @@ func buildRHELCloudInit(eniMAC, devMAC, mgmtMAC, mgmtIP string, extraENIMACs []s
return strings.TrimRight(wf.String(), "\n"), strings.TrimRight(rc.String(), "\n")
}
-func appendRHELDHCPKeyfile(b *strings.Builder, name, mac string, suppressDefaults bool) {
+func appendRHELDHCPKeyfile(b *strings.Builder, name, mac string, suppressDefaults, imdsRoute bool) {
fmt.Fprintf(b, " - path: /etc/NetworkManager/system-connections/%s.nmconnection\n", name)
b.WriteString(" owner: root:root\n")
b.WriteString(" permissions: '0600'\n")
@@ -283,6 +314,12 @@ func appendRHELDHCPKeyfile(b *strings.Builder, name, mac string, suppressDefault
b.WriteString(" never-default=true\n")
b.WriteString(" ignore-auto-dns=true\n")
}
+ if imdsRoute {
+ // On-link route to the IMDS metadata IP so the guest ARPs 169.254.169.254
+ // directly on this NIC; the per-subnet localport answers. No next hop =
+ // link scope. Matches the netplan path in generateNetworkConfig.
+ fmt.Fprintf(b, " route1=%s/32\n", handlers_imds.MetaDataServerIP)
+ }
b.WriteString(" [ipv6]\n")
b.WriteString(" method=disabled\n")
}
@@ -304,6 +341,43 @@ func appendRHELStaticKeyfile(b *strings.Builder, name, mac, ip string) {
b.WriteString(" method=disabled\n")
}
+// buildAlpineCloudInit produces the cloud-init write_files + runcmd blocks that
+// deliver the IMDS on-link route on Alpine guests. Returns empty strings when
+// eniMAC is empty (wildcard / non-VPC path).
+//
+// Alpine renders networking via cloud-init's eni backend, which crashes on a
+// gateway-less route (it assumes every route has a next hop), so the route is
+// omitted from the netplan network-config (see generateNetworkConfig) and added
+// here instead. A persistent /etc/local.d script keeps the on-link route present
+// across reboots — runcmd runs once per instance, so the OpenRC `local` service
+// re-applies it on every boot. cloud-init's eni renderer renames the primary
+// VPC NIC to vpc0 via a 70-persistent-net.rules udev rule, so the device name is
+// stable. `ip route replace` is idempotent.
+func buildAlpineCloudInit(eniMAC string) (writeFiles, runCmd string) {
+ if eniMAC == "" {
+ return "", ""
+ }
+
+ var wf strings.Builder
+ wf.WriteString(" - path: /etc/local.d/imds-onlink-route.start\n")
+ wf.WriteString(" owner: root:root\n")
+ wf.WriteString(" permissions: '0755'\n")
+ wf.WriteString(" content: |\n")
+ wf.WriteString(" #!/bin/sh\n")
+ // Resolve the primary VPC NIC via its default route, not by name: the
+ // persistent-net udev rename to vpc0 doesn't apply on the Alpine AMI, so the
+ // kernel name is eth0 at first boot. The mgmt NIC (eth1) carries no default
+ // route, so this always picks the VPC egress NIC — the one IMDS rides.
+ wf.WriteString(" dev=$(ip route show default | awk '{print $5; exit}')\n")
+ fmt.Fprintf(&wf, " [ -n \"$dev\" ] && ip route replace %s/32 dev \"$dev\" scope link\n", handlers_imds.MetaDataServerIP)
+
+ var rc strings.Builder
+ rc.WriteString(" - [ rc-update, add, local, default ]\n")
+ rc.WriteString(" - [ rc-service, local, start ]\n")
+
+ return strings.TrimRight(wf.String(), "\n"), strings.TrimRight(rc.String(), "\n")
+}
+
type CloudInitMetaData struct {
InstanceID string
Hostname string
@@ -631,6 +705,35 @@ func (s *InstanceServiceImpl) PrepareRunInstances(input *ec2.RunInstancesInput,
}
}
+ // Pre-created ENI path: when the caller passes
+ // NetworkInterfaces[0].NetworkInterfaceId, attach that existing ENI
+ // instead of auto-creating one. AWS-parity behaviour; consumed by the
+ // EKS cluster launcher which needs the ENI to live under the customer
+ // account before the K3s server VM is created.
+ preExistingENIID := ""
+ if len(input.NetworkInterfaces) > 0 && input.NetworkInterfaces[0] != nil {
+ preExistingENIID = aws.StringValue(input.NetworkInterfaces[0].NetworkInterfaceId)
+ }
+ if preExistingENIID != "" {
+ if s.eniCreator == nil {
+ slog.Error("PrepareRunInstances: pre-created ENI specified but ENI service unavailable",
+ "instanceId", instance.ID, "eniId", preExistingENIID)
+ lastRunErr = errors.New(awserrors.ErrorServerInternal)
+ s.resourceMgr.Deallocate(instanceType)
+ continue
+ }
+ if err := s.attachPreCreatedENI(accountID, preExistingENIID, instance, ec2Instance); err != nil {
+ slog.Error("PrepareRunInstances: pre-created ENI attach failed",
+ "instanceId", instance.ID, "eniId", preExistingENIID, "err", err)
+ lastRunErr = err
+ s.resourceMgr.Deallocate(instanceType)
+ continue
+ }
+ instances = append(instances, instance)
+ allEC2Instances = append(allEC2Instances, ec2Instance)
+ continue
+ }
+
if (input.SubnetId == nil || *input.SubnetId == "") && s.eniCreator != nil {
defaultSubnet, dsErr := s.eniCreator.GetDefaultSubnet(accountID)
if dsErr == nil && defaultSubnet != nil {
@@ -774,6 +877,62 @@ func (s *InstanceServiceImpl) PrepareRunInstances(input *ec2.RunInstancesInput,
return reservation, instances, instanceType, nil
}
+// attachPreCreatedENI looks up an existing ENI in accountID, verifies it is
+// not already in-use, attaches it as device-0 to instance, and populates the
+// VM + ec2.Instance with the ENI's network identity (private IP, MAC, subnet,
+// VPC, security groups). Returns an error without mutating any state if the
+// lookup or attach fails. Public-IP auto-assignment is skipped on the
+// pre-created path because the caller is expected to manage that out-of-band.
+func (s *InstanceServiceImpl) attachPreCreatedENI(accountID, eniID string, instance *vm.VM, ec2Instance *ec2.Instance) error {
+ eniInfo, err := s.eniCreator.GetENI(accountID, eniID)
+ if err != nil {
+ return err
+ }
+ if eniInfo == nil {
+ return errors.New(awserrors.ErrorInvalidNetworkInterfaceIDNotFound)
+ }
+ if eniInfo.Status == "in-use" {
+ return errors.New(awserrors.ErrorInvalidNetworkInterfaceInUse)
+ }
+ if _, err := s.eniCreator.AttachENI(accountID, eniID, instance.ID, 0); err != nil {
+ return err
+ }
+
+ instance.ENIId = eniID
+ instance.ENIMac = eniInfo.MacAddress
+
+ ec2Instance.SetPrivateIpAddress(eniInfo.PrivateIpAddress)
+ ec2Instance.SetSubnetId(eniInfo.SubnetID)
+ ec2Instance.SetVpcId(eniInfo.VpcID)
+ if len(eniInfo.SecurityGroupIDs) > 0 {
+ groups := make([]*ec2.GroupIdentifier, 0, len(eniInfo.SecurityGroupIDs))
+ for _, id := range eniInfo.SecurityGroupIDs {
+ groups = append(groups, &ec2.GroupIdentifier{GroupId: aws.String(id)})
+ }
+ ec2Instance.SecurityGroups = groups
+ }
+ ec2Instance.NetworkInterfaces = []*ec2.InstanceNetworkInterface{{
+ NetworkInterfaceId: aws.String(eniID),
+ PrivateIpAddress: aws.String(eniInfo.PrivateIpAddress),
+ MacAddress: aws.String(eniInfo.MacAddress),
+ SubnetId: aws.String(eniInfo.SubnetID),
+ VpcId: aws.String(eniInfo.VpcID),
+ Status: aws.String("in-use"),
+ Attachment: &ec2.InstanceNetworkInterfaceAttachment{
+ DeviceIndex: aws.Int64(0),
+ Status: aws.String("attached"),
+ },
+ }}
+
+ slog.Info("PrepareRunInstances: attached pre-created ENI",
+ "instanceId", instance.ID,
+ "eniId", eniID,
+ "privateIp", eniInfo.PrivateIpAddress,
+ "mac", eniInfo.MacAddress,
+ )
+ return nil
+}
+
// LaunchRunInstances takes the VMs created by PrepareRunInstances (already
// inserted into vmMgr by the caller) and runs the heavyweight launch loop:
// volume preparation, GPU claim, vmMgr.Run. Failures mark the VM as failed
@@ -1768,7 +1927,8 @@ func (s *InstanceServiceImpl) createCloudInitISO(input *ec2.RunInstancesInput, i
sudoGroup := "sudo"
var rhelWriteFiles, rhelRunCmd string
- if distroFamily == "rhel" {
+ switch distroFamily {
+ case "rhel":
sudoGroup = "wheel"
rhelWriteFiles, rhelRunCmd = buildRHELCloudInit(instance.ENIMac, instance.DevMAC, instance.MgmtMAC, instance.MgmtIP, extraMACs)
}
@@ -1806,6 +1966,15 @@ func (s *InstanceServiceImpl) createCloudInitISO(input *ec2.RunInstancesInput, i
}
}
+ // Alpine's eni renderer can't emit the gateway-less IMDS route, so deliver it
+ // out-of-band via a local.d script. Only when the caller didn't supply its own
+ // #cloud-config: that payload owns the write_files/runcmd keys (e.g. the EKS
+ // k3s server VM, which bakes the same route into its own block), and a second
+ // write_files key here would collide — last key wins, silently dropping a block.
+ if distroFamily == "alpine" && userData.UserDataCloudConfig == "" {
+ userData.AlpineWriteFiles, userData.AlpineRunCmd = buildAlpineCloudInit(instance.ENIMac)
+ }
+
var buf bytes.Buffer
t := template.Must(template.New("cloud-init").Parse(cloudInitUserDataTemplate))
@@ -2052,6 +2221,16 @@ func (s *InstanceServiceImpl) DescribeInstances(input *ec2.DescribeInstancesInpu
if !IsInstanceVisible(accountID, instance.AccountID) {
continue
}
+ // Platform-managed system VMs (LB HAProxy, EKS control plane) carry a
+ // ManagedBy tag and must stay out of customer EC2 listings, the same
+ // way they're filtered from the UI Nodes page. LB VMs are owned by the
+ // system account so IsInstanceVisible already hides them, but the EKS
+ // control-plane VM is owned by the customer account (its ENI lives in
+ // the customer VPC), so guard on ManagedBy here. Root/operator callers
+ // still see system instances.
+ if instance.ManagedBy != "" && accountID != utils.GlobalAccountID {
+ continue
+ }
if len(instanceIDFilter) > 0 && !instanceIDFilter[instance.ID] {
continue
}
@@ -2341,10 +2520,10 @@ func (s *InstanceServiceImpl) ModifyInstanceAttribute(input *ec2.ModifyInstanceA
if input.UserData != nil && input.UserData.Value != nil {
slog.Info("ModifyInstanceAttribute: changing user data", "instanceId", instanceID)
- instance.UserData = string(input.UserData.Value)
- if instance.RunInstancesInput != nil {
- instance.RunInstancesInput.UserData = aws.String(base64.StdEncoding.EncodeToString(input.UserData.Value))
+ if instance.RunInstancesInput == nil {
+ instance.RunInstancesInput = &ec2.RunInstancesInput{}
}
+ instance.RunInstancesInput.UserData = aws.String(base64.StdEncoding.EncodeToString(input.UserData.Value))
}
if input.DisableApiTermination != nil && input.DisableApiTermination.Value != nil {
@@ -2718,7 +2897,12 @@ func (s *InstanceServiceImpl) DescribeInstanceAttribute(input *ec2.DescribeInsta
output.InstanceType = &ec2.AttributeValue{Value: &val}
case ec2.InstanceAttributeNameUserData:
- val := instance.UserData
+ // AWS returns user-data base64-encoded; RunInstancesInput.UserData is the
+ // canonical base64 store, set at launch and kept in sync by Modify.
+ var val string
+ if instance.RunInstancesInput != nil && instance.RunInstancesInput.UserData != nil {
+ val = *instance.RunInstancesInput.UserData
+ }
output.UserData = &ec2.AttributeValue{Value: &val}
case ec2.InstanceAttributeNameDisableApiTermination:
diff --git a/spinifex/handlers/ec2/instance/service_impl_test.go b/spinifex/handlers/ec2/instance/service_impl_test.go
index 9d8e81b4f..fe26bbe19 100644
--- a/spinifex/handlers/ec2/instance/service_impl_test.go
+++ b/spinifex/handlers/ec2/instance/service_impl_test.go
@@ -15,6 +15,7 @@ import (
"github.com/mulgadc/spinifex/spinifex/awserrors"
"github.com/mulgadc/spinifex/spinifex/config"
"github.com/mulgadc/spinifex/spinifex/gpu"
+ "github.com/mulgadc/spinifex/spinifex/tags"
spxtypes "github.com/mulgadc/spinifex/spinifex/types"
"github.com/mulgadc/spinifex/spinifex/utils"
"github.com/mulgadc/spinifex/spinifex/vm"
@@ -383,6 +384,8 @@ func TestBuildRHELCloudInit(t *testing.T) {
assert.Contains(t, wf, "dhcp-client-id=mac")
assert.Contains(t, wf, "method=auto")
assert.NotContains(t, wf, "never-default")
+ // The IMDS on-link route rides vpc0 even with no dev NIC / extra ENIs.
+ assert.Contains(t, wf, "route1=169.254.169.254/32", "vpc0 must carry the IMDS on-link route")
assert.Contains(t, rc, " - [ restorecon, -R, /etc/NetworkManager/system-connections/ ]")
assert.Contains(t, rc, " - [ nmcli, connection, reload ]")
assert.Contains(t, rc, " - [ nmcli, connection, up, vpc0 ]")
@@ -416,6 +419,18 @@ func TestBuildRHELCloudInit(t *testing.T) {
assert.NotContains(t, wf, "vpc1.nmconnection")
assert.Contains(t, wf, "vpc2.nmconnection")
})
+
+ t.Run("vpc0 carries on-link IMDS route, others do not", func(t *testing.T) {
+ wf, _ := buildRHELCloudInit(
+ "02:00:00:00:00:01",
+ "02:00:00:00:00:99",
+ "", "",
+ []string{"02:00:00:00:00:02"},
+ )
+ assert.Contains(t, wf, "route1=169.254.169.254/32")
+ // Primary NIC only — not extra VPC NICs or the dev NIC.
+ assert.Equal(t, 1, strings.Count(wf, "route1=169.254.169.254/32"))
+ })
}
func TestCloudInitTemplate_FamilyBranching(t *testing.T) {
@@ -648,7 +663,7 @@ func TestParseVolumeParams_PartialEbs(t *testing.T) {
func TestCloudInitNetworkConfigWildcard(t *testing.T) {
// No MACs → wildcard config (non-VPC or VPC without DEV_NETWORKING)
- cfg := generateNetworkConfig("", "", "", "", nil)
+ cfg := generateNetworkConfig("", "", "", "", nil, true)
assert.Contains(t, cfg, "version: 2")
assert.Contains(t, cfg, "dhcp4: true")
assert.Contains(t, cfg, "dhcp-identifier: mac")
@@ -660,7 +675,7 @@ func TestCloudInitNetworkConfigDualNIC(t *testing.T) {
eniMAC := "02:00:00:61:ef:c2"
devMAC := "02:de:00:60:83:0d"
- cfg := generateNetworkConfig(eniMAC, devMAC, "", "", nil)
+ cfg := generateNetworkConfig(eniMAC, devMAC, "", "", nil, true)
// Both MACs present in config
assert.Contains(t, cfg, eniMAC)
@@ -680,12 +695,12 @@ func TestCloudInitNetworkConfigDualNIC(t *testing.T) {
func TestCloudInitNetworkConfigPartialMAC(t *testing.T) {
// Only ENI MAC (VPC without dev) → per-interface config with VPC NIC only
- cfg := generateNetworkConfig("02:00:00:61:ef:c2", "", "", "", nil)
+ cfg := generateNetworkConfig("02:00:00:61:ef:c2", "", "", "", nil, true)
assert.Contains(t, cfg, "vpc0:")
assert.NotContains(t, cfg, "dev0:")
// Only dev MAC (shouldn't happen, but defensive) → wildcard
- cfg = generateNetworkConfig("", "02:de:00:60:83:0d", "", "", nil)
+ cfg = generateNetworkConfig("", "02:de:00:60:83:0d", "", "", nil, true)
assert.Contains(t, cfg, `name: "e*"`)
assert.NotContains(t, cfg, "use-routes")
}
@@ -696,7 +711,7 @@ func TestCloudInitNetworkConfigMultiVPCNICs(t *testing.T) {
"02:00:00:bb:bb:bb",
"02:00:00:cc:cc:cc",
}
- cfg := generateNetworkConfig("02:00:00:aa:aa:aa", "", "", "", extras)
+ cfg := generateNetworkConfig("02:00:00:aa:aa:aa", "", "", "", extras, true)
assert.Contains(t, cfg, "vpc0:")
assert.Contains(t, cfg, "vpc1:")
@@ -715,7 +730,7 @@ func TestCloudInitNetworkConfigMultiVPCNICs(t *testing.T) {
func TestCloudInitNetworkConfigEmptyExtraMACSkipped(t *testing.T) {
// Empty strings inside the extras slice are ignored rather than producing
// a malformed ethernets block.
- cfg := generateNetworkConfig("02:00:00:aa:aa:aa", "", "", "", []string{""})
+ cfg := generateNetworkConfig("02:00:00:aa:aa:aa", "", "", "", []string{""}, true)
assert.Contains(t, cfg, "vpc0:")
assert.NotContains(t, cfg, "vpc1:")
}
@@ -1067,6 +1082,70 @@ func TestDescribeInstances_AccountFilteringHidesOtherTenant(t *testing.T) {
assert.Empty(t, out.Reservations)
}
+// EKS control-plane VMs are owned by the customer account (their ENI lives in
+// the customer VPC) but are platform-managed system instances, so they must be
+// hidden from the owning customer's DescribeInstances the same way LB VMs are.
+func TestDescribeInstances_HidesManagedSystemVMFromCustomer(t *testing.T) {
+ owner := "111122223333"
+ v := &vm.VM{
+ ID: "i-ekscp",
+ AccountID: owner,
+ ManagedBy: tags.ManagedByEKS,
+ Reservation: &ec2.Reservation{
+ ReservationId: aws.String("r-ekscp"),
+ OwnerId: aws.String(owner),
+ },
+ Instance: &ec2.Instance{InstanceId: aws.String("i-ekscp")},
+ }
+ svc := &InstanceServiceImpl{vmMgr: mgrWith(map[string]*vm.VM{v.ID: v})}
+
+ out, err := svc.DescribeInstances(&ec2.DescribeInstancesInput{}, owner)
+ require.NoError(t, err)
+ assert.Empty(t, out.Reservations, "managed system VM must not appear in customer listing")
+}
+
+// Root/operator callers still see managed system VMs.
+func TestDescribeInstances_RootSeesManagedSystemVM(t *testing.T) {
+ v := &vm.VM{
+ ID: "i-lb",
+ AccountID: utils.GlobalAccountID,
+ ManagedBy: tags.ManagedByELBv2,
+ Reservation: &ec2.Reservation{
+ ReservationId: aws.String("r-lb"),
+ OwnerId: aws.String(utils.GlobalAccountID),
+ },
+ Instance: &ec2.Instance{InstanceId: aws.String("i-lb")},
+ }
+ svc := &InstanceServiceImpl{vmMgr: mgrWith(map[string]*vm.VM{v.ID: v})}
+
+ out, err := svc.DescribeInstances(&ec2.DescribeInstancesInput{}, utils.GlobalAccountID)
+ require.NoError(t, err)
+ require.Len(t, out.Reservations, 1)
+ assert.Equal(t, "i-lb", *out.Reservations[0].Instances[0].InstanceId)
+}
+
+// A customer's own unmanaged instance (no ManagedBy) stays visible — confirms
+// the exclusion keys on ManagedBy, not on account scope. Future EKS worker
+// nodes (customer-owned, no ManagedBy tag) follow this path.
+func TestDescribeInstances_CustomerWorkloadStaysVisible(t *testing.T) {
+ owner := "111122223333"
+ v := &vm.VM{
+ ID: "i-worker",
+ AccountID: owner,
+ Reservation: &ec2.Reservation{
+ ReservationId: aws.String("r-worker"),
+ OwnerId: aws.String(owner),
+ },
+ Instance: &ec2.Instance{InstanceId: aws.String("i-worker")},
+ }
+ svc := &InstanceServiceImpl{vmMgr: mgrWith(map[string]*vm.VM{v.ID: v})}
+
+ out, err := svc.DescribeInstances(&ec2.DescribeInstancesInput{}, owner)
+ require.NoError(t, err)
+ require.Len(t, out.Reservations, 1)
+ assert.Equal(t, "i-worker", *out.Reservations[0].Instances[0].InstanceId)
+}
+
func TestDescribeInstances_MalformedID(t *testing.T) {
svc := &InstanceServiceImpl{vmMgr: mgrWith(map[string]*vm.VM{})}
input := &ec2.DescribeInstancesInput{
@@ -1129,7 +1208,10 @@ func TestDescribeInstanceAttribute_RunningInstance(t *testing.T) {
ID: id,
InstanceType: "t3.large",
AccountID: owner,
- UserData: "raw-user-data",
+ RunInstancesInput: &ec2.RunInstancesInput{
+ // base64("raw-user-data") — DescribeInstanceAttribute returns user-data base64-encoded.
+ UserData: aws.String("cmF3LXVzZXItZGF0YQ=="),
+ },
}
svc := &InstanceServiceImpl{vmMgr: mgrWith(map[string]*vm.VM{id: v})}
@@ -1151,7 +1233,7 @@ func TestDescribeInstanceAttribute_RunningInstance(t *testing.T) {
attribute: ec2.InstanceAttributeNameUserData,
assertions: func(t *testing.T, out *ec2.DescribeInstanceAttributeOutput) {
require.NotNil(t, out.UserData)
- assert.Equal(t, "raw-user-data", *out.UserData.Value)
+ assert.Equal(t, "cmF3LXVzZXItZGF0YQ==", *out.UserData.Value)
},
},
{
@@ -1530,7 +1612,6 @@ func TestModifyInstanceAttribute_ChangeUserData(t *testing.T) {
ID: id,
Status: vm.StateStopped,
AccountID: "acc",
- UserData: "old",
RunInstancesInput: &ec2.RunInstancesInput{
UserData: aws.String("b2xk"),
},
@@ -1548,7 +1629,6 @@ func TestModifyInstanceAttribute_ChangeUserData(t *testing.T) {
updated := store.wroteStopped[id]
require.NotNil(t, updated)
- assert.Equal(t, newContent, updated.UserData)
assert.Equal(t, "IyEvYmluL2Jhc2g=", *updated.RunInstancesInput.UserData)
}
@@ -2451,7 +2531,10 @@ func TestStopOrTerminateInstance_TerminationProtection(t *testing.T) {
type fakeENICreator struct {
defaultSubnet *SubnetInfo
subnet *SubnetInfo
+ getENIByID map[string]*ENIInfo
+ getENIErr error
createOut *ec2.CreateNetworkInterfaceOutput
+ createCalls int
createErr error
attachErr error
attachCalls int
@@ -2474,7 +2557,22 @@ func (f *fakeENICreator) GetSubnet(_, _ string) (*SubnetInfo, error) {
return f.subnet, nil
}
+func (f *fakeENICreator) GetENI(_, eniID string) (*ENIInfo, error) {
+ if f.getENIErr != nil {
+ return nil, f.getENIErr
+ }
+ if f.getENIByID == nil {
+ return nil, errors.New("no ENI configured")
+ }
+ info, ok := f.getENIByID[eniID]
+ if !ok {
+ return nil, errors.New("eni not found")
+ }
+ return info, nil
+}
+
func (f *fakeENICreator) CreateNetworkInterface(_ *ec2.CreateNetworkInterfaceInput, _ string) (*ec2.CreateNetworkInterfaceOutput, error) {
+ f.createCalls++
if f.createErr != nil {
return nil, f.createErr
}
@@ -3188,3 +3286,115 @@ func TestInstanceArchitecture(t *testing.T) {
})
}
}
+
+func TestPrepareRunInstances_PreCreatedENIAttachedSkipsAutoCreate(t *testing.T) {
+ eni := &fakeENICreator{
+ getENIByID: map[string]*ENIInfo{
+ "eni-pre": {
+ NetworkInterfaceID: "eni-pre",
+ SubnetID: "subnet-pre",
+ VpcID: "vpc-pre",
+ PrivateIpAddress: "10.0.5.42",
+ MacAddress: "aa:bb:cc:dd:ee:01",
+ Status: "available",
+ SecurityGroupIDs: []string{"sg-pre"},
+ },
+ },
+ }
+ svc, _ := prepareSvcWithENI(t, eni, nil)
+
+ _, instances, _, err := svc.PrepareRunInstances(&ec2.RunInstancesInput{
+ InstanceType: aws.String("t3.micro"),
+ ImageId: aws.String("ami-1"),
+ MinCount: aws.Int64(1),
+ MaxCount: aws.Int64(1),
+ NetworkInterfaces: []*ec2.InstanceNetworkInterfaceSpecification{{
+ NetworkInterfaceId: aws.String("eni-pre"),
+ DeviceIndex: aws.Int64(0),
+ }},
+ }, "acc")
+ require.NoError(t, err)
+ require.Len(t, instances, 1)
+ assert.Equal(t, "eni-pre", instances[0].ENIId)
+ assert.Equal(t, "aa:bb:cc:dd:ee:01", instances[0].ENIMac)
+ assert.Equal(t, 1, eni.attachCalls)
+ assert.Equal(t, 0, eni.createCalls, "auto-create must not run when NetworkInterfaceId is specified")
+}
+
+func TestPrepareRunInstances_PreCreatedENIInUseRejected(t *testing.T) {
+ eni := &fakeENICreator{
+ getENIByID: map[string]*ENIInfo{
+ "eni-busy": {
+ NetworkInterfaceID: "eni-busy",
+ Status: "in-use",
+ },
+ },
+ }
+ svc, _ := prepareSvcWithENI(t, eni, nil)
+
+ _, _, _, err := svc.PrepareRunInstances(&ec2.RunInstancesInput{
+ InstanceType: aws.String("t3.micro"),
+ ImageId: aws.String("ami-1"),
+ MinCount: aws.Int64(1),
+ MaxCount: aws.Int64(1),
+ NetworkInterfaces: []*ec2.InstanceNetworkInterfaceSpecification{{
+ NetworkInterfaceId: aws.String("eni-busy"),
+ }},
+ }, "acc")
+ require.Error(t, err)
+ assert.Equal(t, awserrors.ErrorInvalidNetworkInterfaceInUse, err.Error())
+ assert.Equal(t, 0, eni.attachCalls, "in-use ENI must not be attached")
+ assert.Equal(t, 0, eni.createCalls, "auto-create must not run when NetworkInterfaceId is specified")
+}
+
+func TestPrepareRunInstances_PreCreatedENILookupErrorSurfaced(t *testing.T) {
+ eni := &fakeENICreator{
+ getENIErr: errors.New(awserrors.ErrorInvalidNetworkInterfaceIDNotFound),
+ }
+ svc, _ := prepareSvcWithENI(t, eni, nil)
+
+ _, _, _, err := svc.PrepareRunInstances(&ec2.RunInstancesInput{
+ InstanceType: aws.String("t3.micro"),
+ ImageId: aws.String("ami-1"),
+ MinCount: aws.Int64(1),
+ MaxCount: aws.Int64(1),
+ NetworkInterfaces: []*ec2.InstanceNetworkInterfaceSpecification{{
+ NetworkInterfaceId: aws.String("eni-ghost"),
+ }},
+ }, "acc")
+ require.Error(t, err)
+ assert.Equal(t, awserrors.ErrorInvalidNetworkInterfaceIDNotFound, err.Error())
+ assert.Equal(t, 0, eni.attachCalls)
+ assert.Equal(t, 0, eni.createCalls)
+}
+
+func TestPrepareRunInstances_PreCreatedENIAttachErrorSurfaced(t *testing.T) {
+ eni := &fakeENICreator{
+ getENIByID: map[string]*ENIInfo{
+ "eni-pre": {
+ NetworkInterfaceID: "eni-pre",
+ Status: "available",
+ PrivateIpAddress: "10.0.5.42",
+ MacAddress: "aa:bb:cc:dd:ee:01",
+ SubnetID: "subnet-pre",
+ VpcID: "vpc-pre",
+ },
+ },
+ attachErr: errors.New(awserrors.ErrorServerInternal),
+ }
+ svc, _ := prepareSvcWithENI(t, eni, nil)
+
+ _, _, _, err := svc.PrepareRunInstances(&ec2.RunInstancesInput{
+ InstanceType: aws.String("t3.micro"),
+ ImageId: aws.String("ami-1"),
+ MinCount: aws.Int64(1),
+ MaxCount: aws.Int64(1),
+ NetworkInterfaces: []*ec2.InstanceNetworkInterfaceSpecification{{
+ NetworkInterfaceId: aws.String("eni-pre"),
+ }},
+ }, "acc")
+ require.Error(t, err)
+ assert.Equal(t, awserrors.ErrorServerInternal, err.Error())
+ assert.Equal(t, 1, eni.attachCalls)
+ assert.Equal(t, 0, eni.createCalls)
+}
diff --git a/spinifex/handlers/ec2/snapshot/service_impl.go b/spinifex/handlers/ec2/snapshot/service_impl.go
index 48faa14c6..dad9bddad 100644
--- a/spinifex/handlers/ec2/snapshot/service_impl.go
+++ b/spinifex/handlers/ec2/snapshot/service_impl.go
@@ -181,16 +181,7 @@ func snapshotConfigToEC2(cfg *SnapshotConfig) *ec2.Snapshot {
OwnerId: aws.String(cfg.OwnerID),
}
- if len(cfg.Tags) > 0 {
- tags := make([]*ec2.Tag, 0, len(cfg.Tags))
- for key, value := range cfg.Tags {
- tags = append(tags, &ec2.Tag{
- Key: aws.String(key),
- Value: aws.String(value),
- })
- }
- snapshot.Tags = tags
- }
+ snapshot.Tags = utils.MapToEC2Tags(cfg.Tags)
return snapshot
}
diff --git a/spinifex/handlers/ec2/vpc/eni.go b/spinifex/handlers/ec2/vpc/eni.go
index a90494b10..e28749bcf 100644
--- a/spinifex/handlers/ec2/vpc/eni.go
+++ b/spinifex/handlers/ec2/vpc/eni.go
@@ -144,6 +144,15 @@ func (s *VPCServiceImpl) CreateNetworkInterface(input *ec2.CreateNetworkInterfac
return nil, errors.New(awserrors.ErrorServerInternal)
}
+ // Maintain the eni-by-vpc-ip reverse index after the source-of-truth ENI
+ // write. A failure here leaves IMDS returning safe 404s for this IP rather
+ // than phantom permissions, so it is logged, not fatal.
+ if s.eniIndex != nil {
+ if err := s.eniIndex.Put(subnet.VpcId, privateIP, eniId, accountID); err != nil {
+ slog.Warn("CreateNetworkInterface: eni-by-ip index write failed", "eniId", eniId, "vpcId", subnet.VpcId, "ip", privateIP, "err", err)
+ }
+ }
+
slog.Info("CreateNetworkInterface completed", "eniId", eniId, "subnetId", subnetId, "ip", privateIP, "accountID", accountID)
// Send vpc.create-port synchronously so vpcd OVSDB errors surface to the
@@ -213,6 +222,15 @@ func (s *VPCServiceImpl) DeleteNetworkInterface(input *ec2.DeleteNetworkInterfac
return nil, errors.New(awserrors.ErrorServerInternal)
}
+ // Drop the eni-by-vpc-ip reverse index entry. Idempotent and non-fatal:
+ // the source-of-truth ENI is already gone, so a stale index entry would
+ // only resolve to a now-missing ENI and surface as a safe 404.
+ if s.eniIndex != nil {
+ if err := s.eniIndex.Delete(record.VpcId, record.PrivateIpAddress); err != nil {
+ slog.Warn("DeleteNetworkInterface: eni-by-ip index delete failed", "eniId", eniId, "vpcId", record.VpcId, "ip", record.PrivateIpAddress, "err", err)
+ }
+ }
+
slog.Info("DeleteNetworkInterface completed", "eniId", eniId, "accountID", accountID)
// Publish vpc.delete-port event for vpcd topology cleanup. SG IDs are
diff --git a/spinifex/handlers/ec2/vpc/eni_hotplug_helpers_test.go b/spinifex/handlers/ec2/vpc/eni_hotplug_helpers_test.go
new file mode 100644
index 000000000..8099151e4
--- /dev/null
+++ b/spinifex/handlers/ec2/vpc/eni_hotplug_helpers_test.go
@@ -0,0 +1,163 @@
+package handlers_ec2_vpc
+
+import (
+ "testing"
+
+ "github.com/aws/aws-sdk-go/aws"
+ "github.com/aws/aws-sdk-go/service/ec2"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestGetENIRecord(t *testing.T) {
+ svc := setupTestVPCService(t)
+ vpcId := createTestVPC(t, svc, "10.0.0.0/16")
+ subnetId := createTestSubnet(t, svc, vpcId, "10.0.1.0/24")
+ eniId := createTestENI(t, svc, subnetId)
+
+ rec, err := svc.GetENIRecord(testAccountID, eniId)
+ require.NoError(t, err)
+ assert.Equal(t, eniId, rec.NetworkInterfaceId)
+ assert.Equal(t, subnetId, rec.SubnetId)
+ assert.Equal(t, "available", rec.Status)
+ assert.NotEmpty(t, rec.MacAddress)
+}
+
+func TestGetENIRecord_NotFound(t *testing.T) {
+ svc := setupTestVPCService(t)
+ _, err := svc.GetENIRecord(testAccountID, "eni-nonexistent")
+ assert.ErrorContains(t, err, "InvalidNetworkInterfaceID.NotFound")
+}
+
+func TestUpdateENI_PatchesFields(t *testing.T) {
+ svc := setupTestVPCService(t)
+ vpcId := createTestVPC(t, svc, "10.0.0.0/16")
+ subnetId := createTestSubnet(t, svc, vpcId, "10.0.1.0/24")
+ eniId := createTestENI(t, svc, subnetId)
+
+ err := svc.UpdateENI(testAccountID, eniId, func(r *ENIRecord) {
+ r.AttachmentStatus = "attaching"
+ r.HotPlugSlot = 3
+ r.LastAttachError = "prior failure"
+ })
+ require.NoError(t, err)
+
+ rec, err := svc.GetENIRecord(testAccountID, eniId)
+ require.NoError(t, err)
+ assert.Equal(t, "attaching", rec.AttachmentStatus)
+ assert.Equal(t, 3, rec.HotPlugSlot)
+ assert.Equal(t, "prior failure", rec.LastAttachError)
+}
+
+func TestUpdateENI_NotFound(t *testing.T) {
+ svc := setupTestVPCService(t)
+ err := svc.UpdateENI(testAccountID, "eni-nonexistent", func(r *ENIRecord) {
+ r.AttachmentStatus = "attached"
+ })
+ assert.ErrorContains(t, err, "InvalidNetworkInterfaceID.NotFound")
+}
+
+func TestUpdateENI_NoopPatchPersists(t *testing.T) {
+ svc := setupTestVPCService(t)
+ vpcId := createTestVPC(t, svc, "10.0.0.0/16")
+ subnetId := createTestSubnet(t, svc, vpcId, "10.0.1.0/24")
+ eniId := createTestENI(t, svc, subnetId)
+
+ err := svc.UpdateENI(testAccountID, eniId, func(*ENIRecord) {})
+ require.NoError(t, err)
+
+ rec, err := svc.GetENIRecord(testAccountID, eniId)
+ require.NoError(t, err)
+ assert.Equal(t, eniId, rec.NetworkInterfaceId)
+}
+
+func TestFindENIByAttachment(t *testing.T) {
+ svc := setupTestVPCService(t)
+ vpcId := createTestVPC(t, svc, "10.0.0.0/16")
+ subnetId := createTestSubnet(t, svc, vpcId, "10.0.1.0/24")
+ eniId := createTestENI(t, svc, subnetId)
+
+ attachId, err := svc.AttachENI(testAccountID, eniId, "i-find-test", 2)
+ require.NoError(t, err)
+
+ rec, err := svc.FindENIByAttachment(testAccountID, attachId)
+ require.NoError(t, err)
+ assert.Equal(t, eniId, rec.NetworkInterfaceId)
+ assert.Equal(t, "i-find-test", rec.InstanceId)
+ assert.Equal(t, int64(2), rec.DeviceIndex)
+}
+
+func TestFindENIByAttachment_NotFound(t *testing.T) {
+ svc := setupTestVPCService(t)
+ _, err := svc.FindENIByAttachment(testAccountID, "eni-attach-missing")
+ assert.ErrorContains(t, err, "InvalidAttachmentID.NotFound")
+}
+
+func TestFindENIByAttachment_EmptyBucket(t *testing.T) {
+ svc := setupTestVPCService(t)
+ _, err := svc.FindENIByAttachment(testAccountID, "eni-attach-anything")
+ assert.ErrorContains(t, err, "InvalidAttachmentID.NotFound")
+}
+
+func TestFindENIByAttachment_AccountScoped(t *testing.T) {
+ svc := setupTestVPCService(t)
+ vpcId := createTestVPC(t, svc, "10.0.0.0/16")
+ subnetId := createTestSubnet(t, svc, vpcId, "10.0.1.0/24")
+ eniId := createTestENI(t, svc, subnetId)
+
+ attachId, err := svc.AttachENI(testAccountID, eniId, "i-scoped", 0)
+ require.NoError(t, err)
+
+ _, err = svc.FindENIByAttachment("999988887777", attachId)
+ assert.ErrorContains(t, err, "InvalidAttachmentID.NotFound")
+}
+
+// TestUpdateENI_RoundTripsAllNewFields exercises every Sprint 3b field on
+// ENIRecord so a struct-field rename would surface as a test failure rather
+// than a silent JSON-tag drift.
+func TestUpdateENI_RoundTripsAllNewFields(t *testing.T) {
+ svc := setupTestVPCService(t)
+ vpcId := createTestVPC(t, svc, "10.0.0.0/16")
+ subnetId := createTestSubnet(t, svc, vpcId, "10.0.1.0/24")
+ eniId := createTestENI(t, svc, subnetId)
+
+ require.NoError(t, svc.UpdateENI(testAccountID, eniId, func(r *ENIRecord) {
+ r.AttachmentStatus = "detaching"
+ r.HotPlugSlot = 7
+ r.LastAttachError = "boom"
+ r.DetachInFlight = true
+ r.DetachForce = true
+ }))
+
+ rec, err := svc.GetENIRecord(testAccountID, eniId)
+ require.NoError(t, err)
+ assert.Equal(t, "detaching", rec.AttachmentStatus)
+ assert.Equal(t, 7, rec.HotPlugSlot)
+ assert.Equal(t, "boom", rec.LastAttachError)
+ assert.True(t, rec.DetachInFlight)
+ assert.True(t, rec.DetachForce)
+}
+
+// TestUpdateENI_PreservesUnrelatedFields ensures a partial patch does not
+// clobber pre-existing record state.
+func TestUpdateENI_PreservesUnrelatedFields(t *testing.T) {
+ svc := setupTestVPCService(t)
+ vpcId := createTestVPC(t, svc, "10.0.0.0/16")
+ subnetId := createTestSubnet(t, svc, vpcId, "10.0.1.0/24")
+ out, err := svc.CreateNetworkInterface(&ec2.CreateNetworkInterfaceInput{
+ SubnetId: aws.String(subnetId),
+ Description: aws.String("preserved-desc"),
+ }, testAccountID)
+ require.NoError(t, err)
+ eniId := *out.NetworkInterface.NetworkInterfaceId
+
+ require.NoError(t, svc.UpdateENI(testAccountID, eniId, func(r *ENIRecord) {
+ r.AttachmentStatus = "attaching"
+ }))
+
+ rec, err := svc.GetENIRecord(testAccountID, eniId)
+ require.NoError(t, err)
+ assert.Equal(t, "preserved-desc", rec.Description)
+ assert.Equal(t, subnetId, rec.SubnetId)
+ assert.NotEmpty(t, rec.MacAddress)
+}
diff --git a/spinifex/handlers/ec2/vpc/eni_index.go b/spinifex/handlers/ec2/vpc/eni_index.go
new file mode 100644
index 000000000..226555a55
--- /dev/null
+++ b/spinifex/handlers/ec2/vpc/eni_index.go
@@ -0,0 +1,52 @@
+package handlers_ec2_vpc
+
+import (
+ "encoding/json"
+ "errors"
+ "fmt"
+
+ "github.com/nats-io/nats.go"
+)
+
+// eniByIPValue is the stored shape of a spinifex-network-eni-by-vpc-ip row: the ENI
+// ID plus its owning account (the ENI bucket is keyed "{accountID}.{eniID}"). Both are
+// immutable identity, letting the handler resolve the ENI's account in a single Get.
+type eniByIPValue struct {
+ ENIId string `json:"eni_id"`
+ AccountID string `json:"account_id"`
+}
+
+// ENIByIPIndex maintains the spinifex-network-eni-by-vpc-ip reverse index
+// (vpcID/ip → eniID), so the IMDS handler resolves a source IP to an ENI in one
+// KV read. Written on CreateNetworkInterface, deleted on DeleteNetworkInterface.
+type ENIByIPIndex struct {
+ kv nats.KeyValue
+}
+
+// NewENIByIPIndex wraps an already-opened eni-by-vpc-ip KV bucket.
+func NewENIByIPIndex(kv nats.KeyValue) *ENIByIPIndex { return &ENIByIPIndex{kv: kv} }
+
+// eniByVPCIPKey builds the composite vpcID/ip key.
+func eniByVPCIPKey(vpcID, ip string) string { return vpcID + "/" + ip }
+
+// Put writes the vpcID/ip → {eniID, accountID} mapping, overwriting any prior
+// entry. Called after the source-of-truth ENI record is persisted so that a
+// failure here leaves IMDS returning safe 404s rather than phantom permissions.
+func (i *ENIByIPIndex) Put(vpcID, ip, eniID, accountID string) error {
+ data, err := json.Marshal(eniByIPValue{ENIId: eniID, AccountID: accountID})
+ if err != nil {
+ return fmt.Errorf("marshal eni-by-ip value: %w", err)
+ }
+ if _, err := i.kv.Put(eniByVPCIPKey(vpcID, ip), data); err != nil {
+ return fmt.Errorf("put eni-by-ip index %s/%s: %w", vpcID, ip, err)
+ }
+ return nil
+}
+
+// Delete removes the vpcID/ip mapping. Idempotent: a missing key returns nil.
+func (i *ENIByIPIndex) Delete(vpcID, ip string) error {
+ if err := i.kv.Delete(eniByVPCIPKey(vpcID, ip)); err != nil && !errors.Is(err, nats.ErrKeyNotFound) {
+ return fmt.Errorf("delete eni-by-ip index %s/%s: %w", vpcID, ip, err)
+ }
+ return nil
+}
diff --git a/spinifex/handlers/ec2/vpc/eni_index_test.go b/spinifex/handlers/ec2/vpc/eni_index_test.go
new file mode 100644
index 000000000..58a792add
--- /dev/null
+++ b/spinifex/handlers/ec2/vpc/eni_index_test.go
@@ -0,0 +1,147 @@
+package handlers_ec2_vpc
+
+import (
+ "encoding/json"
+ "testing"
+
+ "github.com/aws/aws-sdk-go/aws"
+ "github.com/aws/aws-sdk-go/service/ec2"
+ handlers_imds "github.com/mulgadc/spinifex/spinifex/handlers/imds"
+ "github.com/nats-io/nats.go"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+// openTestENIByIPBucket opens the eni-by-vpc-ip reverse-index bucket on the
+// test JetStream, exercising the same init path the daemon uses.
+func openTestENIByIPBucket(t *testing.T, nc *nats.Conn) nats.KeyValue {
+ t.Helper()
+ js, err := nc.JetStream()
+ require.NoError(t, err)
+ kv, err := handlers_imds.InitENIByIPBucket(js, 1)
+ require.NoError(t, err)
+ return kv
+}
+
+// readIndexEntry reads the raw vpcID/ip entry and returns its eni_id and
+// account_id, or ("", "") when the key is absent.
+func readIndexEntry(t *testing.T, kv nats.KeyValue, vpcID, ip string) (eniID, accountID string) {
+ t.Helper()
+ entry, err := kv.Get(vpcID + "/" + ip)
+ if err != nil {
+ require.ErrorIs(t, err, nats.ErrKeyNotFound)
+ return "", ""
+ }
+ var v struct {
+ ENIId string `json:"eni_id"`
+ AccountID string `json:"account_id"`
+ }
+ require.NoError(t, json.Unmarshal(entry.Value(), &v))
+ return v.ENIId, v.AccountID
+}
+
+// readIndexENIID is the eni_id-only convenience wrapper.
+func readIndexENIID(t *testing.T, kv nats.KeyValue, vpcID, ip string) string {
+ t.Helper()
+ eniID, _ := readIndexEntry(t, kv, vpcID, ip)
+ return eniID
+}
+
+func TestENIByIPIndex_PutGetDelete(t *testing.T) {
+ _, nc := setupTestVPCServiceWithNC(t)
+ kv := openTestENIByIPBucket(t, nc)
+ idx := NewENIByIPIndex(kv)
+
+ require.NoError(t, idx.Put("vpc-abc12345", "10.0.1.5", "eni-deadbeef", "111122223333"))
+ eniID, accountID := readIndexEntry(t, kv, "vpc-abc12345", "10.0.1.5")
+ assert.Equal(t, "eni-deadbeef", eniID)
+ assert.Equal(t, "111122223333", accountID)
+
+ // Overwrite is allowed (rebind is a Put).
+ require.NoError(t, idx.Put("vpc-abc12345", "10.0.1.5", "eni-cafef00d", "111122223333"))
+ assert.Equal(t, "eni-cafef00d", readIndexENIID(t, kv, "vpc-abc12345", "10.0.1.5"))
+
+ require.NoError(t, idx.Delete("vpc-abc12345", "10.0.1.5"))
+ assert.Equal(t, "", readIndexENIID(t, kv, "vpc-abc12345", "10.0.1.5"))
+}
+
+func TestENIByIPIndex_DeleteAbsentIsIdempotent(t *testing.T) {
+ _, nc := setupTestVPCServiceWithNC(t)
+ idx := NewENIByIPIndex(openTestENIByIPBucket(t, nc))
+ require.NoError(t, idx.Delete("vpc-nope", "10.0.0.1"))
+}
+
+func TestENIByIPIndex_KeysAreVPCScoped(t *testing.T) {
+ // Same IP in two VPCs must not collide — the key includes the VPC ID.
+ _, nc := setupTestVPCServiceWithNC(t)
+ kv := openTestENIByIPBucket(t, nc)
+ idx := NewENIByIPIndex(kv)
+
+ require.NoError(t, idx.Put("vpc-aaaaaaaa", "10.0.1.5", "eni-aaa", "111122223333"))
+ require.NoError(t, idx.Put("vpc-bbbbbbbb", "10.0.1.5", "eni-bbb", "444455556666"))
+
+ assert.Equal(t, "eni-aaa", readIndexENIID(t, kv, "vpc-aaaaaaaa", "10.0.1.5"))
+ assert.Equal(t, "eni-bbb", readIndexENIID(t, kv, "vpc-bbbbbbbb", "10.0.1.5"))
+}
+
+func TestCreateNetworkInterface_WritesENIIndex(t *testing.T) {
+ svc, nc := setupTestVPCServiceWithNC(t)
+ kv := openTestENIByIPBucket(t, nc)
+ svc.SetENIByIPIndex(NewENIByIPIndex(kv))
+
+ vpcID := createTestVPC(t, svc, "10.0.0.0/16")
+ subnetID := createTestSubnet(t, svc, vpcID, "10.0.1.0/24")
+
+ out, err := svc.CreateNetworkInterface(&ec2.CreateNetworkInterfaceInput{
+ SubnetId: aws.String(subnetID),
+ }, testAccountID)
+ require.NoError(t, err)
+
+ eniID := *out.NetworkInterface.NetworkInterfaceId
+ ip := *out.NetworkInterface.PrivateIpAddress
+ gotENI, gotAccount := readIndexEntry(t, kv, vpcID, ip)
+ assert.Equal(t, eniID, gotENI)
+ assert.Equal(t, testAccountID, gotAccount)
+}
+
+func TestDeleteNetworkInterface_RemovesENIIndex(t *testing.T) {
+ svc, nc := setupTestVPCServiceWithNC(t)
+ kv := openTestENIByIPBucket(t, nc)
+ svc.SetENIByIPIndex(NewENIByIPIndex(kv))
+
+ vpcID := createTestVPC(t, svc, "10.0.0.0/16")
+ subnetID := createTestSubnet(t, svc, vpcID, "10.0.1.0/24")
+
+ out, err := svc.CreateNetworkInterface(&ec2.CreateNetworkInterfaceInput{
+ SubnetId: aws.String(subnetID),
+ }, testAccountID)
+ require.NoError(t, err)
+ eniID := *out.NetworkInterface.NetworkInterfaceId
+ ip := *out.NetworkInterface.PrivateIpAddress
+ require.Equal(t, eniID, readIndexENIID(t, kv, vpcID, ip))
+
+ _, err = svc.DeleteNetworkInterface(&ec2.DeleteNetworkInterfaceInput{
+ NetworkInterfaceId: aws.String(eniID),
+ }, testAccountID)
+ require.NoError(t, err)
+
+ assert.Equal(t, "", readIndexENIID(t, kv, vpcID, ip))
+}
+
+// Regression: ENI lifecycle must not panic or error when no index is wired
+// (IMDS-less deployments, focused tests).
+func TestCreateDeleteNetworkInterface_NoIndexWired(t *testing.T) {
+ svc := setupTestVPCService(t)
+ vpcID := createTestVPC(t, svc, "10.0.0.0/16")
+ subnetID := createTestSubnet(t, svc, vpcID, "10.0.1.0/24")
+
+ out, err := svc.CreateNetworkInterface(&ec2.CreateNetworkInterfaceInput{
+ SubnetId: aws.String(subnetID),
+ }, testAccountID)
+ require.NoError(t, err)
+
+ _, err = svc.DeleteNetworkInterface(&ec2.DeleteNetworkInterfaceInput{
+ NetworkInterfaceId: out.NetworkInterface.NetworkInterfaceId,
+ }, testAccountID)
+ require.NoError(t, err)
+}
diff --git a/spinifex/handlers/ec2/vpc/external_ipam_test.go b/spinifex/handlers/ec2/vpc/external_ipam_test.go
index eb625c37c..0408f995c 100644
--- a/spinifex/handlers/ec2/vpc/external_ipam_test.go
+++ b/spinifex/handlers/ec2/vpc/external_ipam_test.go
@@ -100,10 +100,11 @@ func TestExternalIPAM_Release(t *testing.T) {
err = ipam.ReleaseIP("wan", "192.168.1.151")
require.NoError(t, err)
- // Next allocation reuses released IP
+ // Round-robin: a released IP is NOT reused immediately (siv-246). Allocation
+ // resumes past the cursor, so the next IP is .153, not the freed .151.
ip3, _, err := ipam.AllocateIP("", "", PurposeENIPublic, "", "eni-3", "i-3")
require.NoError(t, err)
- assert.Equal(t, "192.168.1.151", ip3)
+ assert.Equal(t, "192.168.1.153", ip3)
}
func TestExternalIPAM_Exhaustion(t *testing.T) {
diff --git a/spinifex/handlers/ec2/vpc/invariants_test.go b/spinifex/handlers/ec2/vpc/invariants_test.go
new file mode 100644
index 000000000..b33a70d7f
--- /dev/null
+++ b/spinifex/handlers/ec2/vpc/invariants_test.go
@@ -0,0 +1,72 @@
+package handlers_ec2_vpc
+
+import (
+ "encoding/json"
+ "net"
+ "strings"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+// IMDS-datapath invariant.
+
+// TestI4_ENIIndexBucketShape asserts the spinifex-network-eni-by-vpc-ip reverse
+// index keeps its contract: keys parse as "vpcID/ip" with a valid IP, values
+// carry a non-empty eni_id, and no mutable/denormalised fields creep back into
+// the value. The index is identity-only (eni_id + the immutable account_id) by
+// design — every mutable field (IPs, MAC, instance ID, profile ARN) is read
+// live off the ENIRecord + instance record at request time. A denormalised
+// field here resurrects the staleness class the design deliberately removed:
+// an IMDS handler trusting a stale profile ARN would mint credentials for the
+// wrong role.
+func TestI4_ENIIndexBucketShape(t *testing.T) {
+ _, nc := setupTestVPCServiceWithNC(t)
+ kv := openTestENIByIPBucket(t, nc)
+ idx := NewENIByIPIndex(kv)
+
+ // Seed a couple of entries through the real writer.
+ require.NoError(t, idx.Put("vpc-aaaaaaaa", "10.0.1.5", "eni-aaa", "111122223333"))
+ require.NoError(t, idx.Put("vpc-bbbbbbbb", "10.0.2.9", "eni-bbb", "444455556666"))
+
+ // allowedFields is the closed set the value JSON may contain. Adding a field
+ // to eniByIPValue without updating this set fails the test by design — a
+ // reviewer must justify any new field as immutable identity, not cached
+ // mutable state.
+ allowedFields := map[string]struct{}{"eni_id": {}, "account_id": {}}
+
+ keys, err := kv.Keys()
+ require.NoError(t, err)
+ require.NotEmpty(t, keys)
+
+ for _, key := range keys {
+ // Skip migration framework metadata (e.g. _version).
+ if strings.HasPrefix(key, "_") {
+ continue
+ }
+ vpcID, ip, ok := strings.Cut(key, "/")
+ require.Truef(t, ok, "key %q does not parse as vpcID/ip", key)
+ assert.NotEmptyf(t, vpcID, "key %q has empty vpcID", key)
+ assert.NotNilf(t, net.ParseIP(ip), "key %q has invalid IP %q", key, ip)
+
+ entry, err := kv.Get(key)
+ require.NoError(t, err)
+
+ var raw map[string]json.RawMessage
+ require.NoError(t, json.Unmarshal(entry.Value(), &raw))
+
+ var v struct {
+ ENIId string `json:"eni_id"`
+ }
+ require.NoError(t, json.Unmarshal(entry.Value(), &v))
+ assert.NotEmptyf(t, v.ENIId, "index entry %q has empty eni_id", key)
+
+ for field := range raw {
+ _, allowed := allowedFields[field]
+ assert.Truef(t, allowed, "index entry %q carries unexpected field %q: the "+
+ "eni-by-vpc-ip index is IP→ENI identity only — denormalised mutable "+
+ "fields reintroduce the staleness class the design removed", key, field)
+ }
+ }
+}
diff --git a/spinifex/handlers/ec2/vpc/service_impl.go b/spinifex/handlers/ec2/vpc/service_impl.go
index 6d6b5a9cd..898ad64f0 100644
--- a/spinifex/handlers/ec2/vpc/service_impl.go
+++ b/spinifex/handlers/ec2/vpc/service_impl.go
@@ -82,6 +82,11 @@ type VPCServiceImpl struct {
// Optional: injected after construction for public IP cleanup in DeleteNetworkInterface.
externalIPAM *ExternalIPAM
eipKV nats.KeyValue
+
+ // Optional: injected after construction. Maintains the eni-by-vpc-ip
+ // reverse index the IMDS handler reads. Nil-safe — focused tests and
+ // IMDS-less deployments leave it unset.
+ eniIndex *ENIByIPIndex
}
// SetExternalIPAM injects external IPAM and EIP KV store references so that
@@ -91,6 +96,13 @@ func (s *VPCServiceImpl) SetExternalIPAM(ipam *ExternalIPAM, eipKV nats.KeyValue
s.eipKV = eipKV
}
+// SetENIByIPIndex injects the eni-by-vpc-ip reverse-index writer so
+// CreateNetworkInterface / DeleteNetworkInterface keep the IMDS source-IP→ENI
+// lookup index in sync. Optional: when unset, the index is simply not written.
+func (s *VPCServiceImpl) SetENIByIPIndex(index *ENIByIPIndex) {
+ s.eniIndex = index
+}
+
// localAZ returns the node's local availability zone, sourced from
// s.config.AZ. Returns "" when no config is wired (mostly test paths),
// which the reconciler treats as a legacy record matching every AZ.
diff --git a/spinifex/handlers/eks/access_entry.go b/spinifex/handlers/eks/access_entry.go
new file mode 100644
index 000000000..736014afc
--- /dev/null
+++ b/spinifex/handlers/eks/access_entry.go
@@ -0,0 +1,263 @@
+package handlers_eks
+
+import (
+ "encoding/json"
+ "errors"
+ "fmt"
+ "strings"
+ "time"
+
+ "github.com/aws/aws-sdk-go/aws"
+ "github.com/aws/aws-sdk-go/service/eks"
+ "github.com/nats-io/nats.go"
+)
+
+// Access-entry types (Q9). v1 supports STANDARD principals only; the node
+// types (EC2_LINUX/EC2_WINDOWS/FARGATE_LINUX) are accepted by AWS but the
+// nodegroup join path (Sprint 6d) handles node identity, so they are rejected
+// here until that lands.
+const (
+ AccessEntryTypeStandard = "STANDARD"
+
+ accessScopeCluster = "cluster"
+ accessScopeNamespace = "namespace"
+)
+
+// Supported AWS-managed access policies (Q9). The map value is the K8s
+// ClusterRole the policy maps onto; the token webhook + RBAC bindings consume
+// it. Any policy ARN outside this set is rejected with InvalidParameterValue.
+var supportedAccessPolicies = map[string]string{
+ "arn:aws:eks::aws:cluster-access-policy/AmazonEKSClusterAdminPolicy": "cluster-admin",
+ "arn:aws:eks::aws:cluster-access-policy/AmazonEKSAdminPolicy": "admin",
+ "arn:aws:eks::aws:cluster-access-policy/AmazonEKSEditPolicy": "edit",
+ "arn:aws:eks::aws:cluster-access-policy/AmazonEKSViewPolicy": "view",
+}
+
+// ErrAccessEntryNotFound is returned by GetAccessEntryRecord / the CAS helper
+// when no entry exists for the principal. Callers translate to the AWS shape
+// (ResourceNotFoundException) at the service boundary.
+var ErrAccessEntryNotFound = errors.New("eks: access entry not found")
+
+// AccessEntryARN composes the access-entry ARN. AWS uses a per-entry UUID; this
+// build keys the discriminator off the principal-ARN hash instead so the ARN is
+// deterministic (no RNG) and one-per-principal — clients address access entries
+// by principalArn, not by this ARN, so the divergence is informational only.
+func AccessEntryARN(region, accountID, cluster, principalARN string) string {
+ return fmt.Sprintf("arn:aws:eks:%s:%s:access-entry/%s/%s",
+ region, accountID, cluster, PrincipalARNHash(principalARN))
+}
+
+// PutAccessEntryRecord writes the entry unconditionally.
+func PutAccessEntryRecord(kv nats.KeyValue, rec *AccessEntryRecord) error {
+ if rec == nil {
+ return errors.New("eks: PutAccessEntryRecord nil record")
+ }
+ if rec.ClusterName == "" || rec.PrincipalARN == "" {
+ return errors.New("eks: PutAccessEntryRecord missing cluster or principal ARN")
+ }
+ data, err := json.Marshal(rec)
+ if err != nil {
+ return fmt.Errorf("marshal access entry %s: %w", rec.PrincipalARN, err)
+ }
+ key := AccessEntryKey(rec.ClusterName, rec.PrincipalARN)
+ if _, err := kv.Put(key, data); err != nil {
+ return fmt.Errorf("kv put %s: %w", key, err)
+ }
+ return nil
+}
+
+// GetAccessEntryRecord reads one entry. Returns ErrAccessEntryNotFound if absent.
+func GetAccessEntryRecord(kv nats.KeyValue, cluster, principalARN string) (*AccessEntryRecord, error) {
+ if cluster == "" || principalARN == "" {
+ return nil, errors.New("eks: GetAccessEntryRecord empty cluster or principal ARN")
+ }
+ entry, err := kv.Get(AccessEntryKey(cluster, principalARN))
+ if err != nil {
+ if errors.Is(err, nats.ErrKeyNotFound) {
+ return nil, ErrAccessEntryNotFound
+ }
+ return nil, fmt.Errorf("kv get access entry: %w", err)
+ }
+ var rec AccessEntryRecord
+ if err := json.Unmarshal(entry.Value(), &rec); err != nil {
+ return nil, fmt.Errorf("unmarshal access entry %s: %w", principalARN, err)
+ }
+ return &rec, nil
+}
+
+// ListAccessEntryRecords returns every access entry under a cluster, sorted by
+// principal ARN for stable output.
+func ListAccessEntryRecords(kv nats.KeyValue, cluster string) ([]*AccessEntryRecord, error) {
+ if cluster == "" {
+ return nil, errors.New("eks: ListAccessEntryRecords empty cluster")
+ }
+ keys, err := kv.Keys()
+ if err != nil {
+ if errors.Is(err, nats.ErrNoKeysFound) {
+ return nil, nil
+ }
+ return nil, fmt.Errorf("kv keys: %w", err)
+ }
+ prefix := AccessEntriesPrefix(cluster)
+ out := make([]*AccessEntryRecord, 0)
+ for _, k := range keys {
+ if !strings.HasPrefix(k, prefix) {
+ continue
+ }
+ entry, err := kv.Get(k)
+ if err != nil {
+ if errors.Is(err, nats.ErrKeyNotFound) {
+ continue
+ }
+ return nil, fmt.Errorf("kv get %s: %w", k, err)
+ }
+ var rec AccessEntryRecord
+ if err := json.Unmarshal(entry.Value(), &rec); err != nil {
+ return nil, fmt.Errorf("unmarshal access entry %s: %w", k, err)
+ }
+ out = append(out, &rec)
+ }
+ sortAccessEntries(out)
+ return out, nil
+}
+
+// DeleteAccessEntryRecord removes one entry. Returns ErrAccessEntryNotFound if
+// it did not exist so DeleteAccessEntry can surface ResourceNotFoundException.
+func DeleteAccessEntryRecord(kv nats.KeyValue, cluster, principalARN string) error {
+ key := AccessEntryKey(cluster, principalARN)
+ if _, err := kv.Get(key); err != nil {
+ if errors.Is(err, nats.ErrKeyNotFound) {
+ return ErrAccessEntryNotFound
+ }
+ return fmt.Errorf("kv get %s: %w", key, err)
+ }
+ if err := kv.Delete(key); err != nil {
+ return fmt.Errorf("kv delete %s: %w", key, err)
+ }
+ return nil
+}
+
+// casUpdateAccessEntry does a revision-checked read-modify-write. mutate returns
+// true if it changed a field. Returns ErrAccessEntryNotFound if absent.
+func casUpdateAccessEntry(kv nats.KeyValue, cluster, principalARN string, mutate func(*AccessEntryRecord) bool) (*AccessEntryRecord, error) {
+ key := AccessEntryKey(cluster, principalARN)
+ for range maxClusterStateCASRetries {
+ entry, err := kv.Get(key)
+ if err != nil {
+ if errors.Is(err, nats.ErrKeyNotFound) {
+ return nil, ErrAccessEntryNotFound
+ }
+ return nil, fmt.Errorf("kv get %s: %w", key, err)
+ }
+ var rec AccessEntryRecord
+ if err := json.Unmarshal(entry.Value(), &rec); err != nil {
+ return nil, fmt.Errorf("unmarshal access entry %s: %w", principalARN, err)
+ }
+ if !mutate(&rec) {
+ return &rec, nil
+ }
+ data, err := json.Marshal(&rec)
+ if err != nil {
+ return nil, fmt.Errorf("marshal access entry %s: %w", principalARN, err)
+ }
+ _, err = kv.Update(key, data, entry.Revision())
+ if err == nil {
+ return &rec, nil
+ }
+ if errors.Is(err, nats.ErrKeyExists) {
+ continue
+ }
+ return nil, fmt.Errorf("kv update %s: %w", key, err)
+ }
+ return nil, fmt.Errorf("eks: casUpdateAccessEntry %s exhausted CAS retries", principalARN)
+}
+
+func sortAccessEntries(recs []*AccessEntryRecord) {
+ for i := 1; i < len(recs); i++ {
+ for j := i; j > 0 && recs[j-1].PrincipalARN > recs[j].PrincipalARN; j-- {
+ recs[j-1], recs[j] = recs[j], recs[j-1]
+ }
+ }
+}
+
+// validateAccessScope checks an AccessScope per AWS rules: type ∈
+// {cluster, namespace}; namespace scope requires at least one namespace and
+// cluster scope must not carry any.
+func validateAccessScope(scope *eks.AccessScope) (AccessScope, error) {
+ if scope == nil || aws.StringValue(scope.Type) == "" {
+ return AccessScope{}, errors.New("eks: accessScope.type is required")
+ }
+ t := strings.ToLower(aws.StringValue(scope.Type))
+ ns := aws.StringValueSlice(scope.Namespaces)
+ switch t {
+ case accessScopeCluster:
+ if len(ns) > 0 {
+ return AccessScope{}, errors.New("eks: cluster-scoped policy must not list namespaces")
+ }
+ return AccessScope{Type: accessScopeCluster}, nil
+ case accessScopeNamespace:
+ if len(ns) == 0 {
+ return AccessScope{}, errors.New("eks: namespace-scoped policy requires at least one namespace")
+ }
+ return AccessScope{Type: accessScopeNamespace, Namespaces: ns}, nil
+ default:
+ return AccessScope{}, fmt.Errorf("eks: unsupported accessScope.type %q", t)
+ }
+}
+
+// accessEntryRecordToAWS converts the persisted record to the SDK shape.
+func accessEntryRecordToAWS(rec *AccessEntryRecord) *eks.AccessEntry {
+ out := &eks.AccessEntry{
+ AccessEntryArn: aws.String(rec.ARN),
+ ClusterName: aws.String(rec.ClusterName),
+ PrincipalArn: aws.String(rec.PrincipalARN),
+ Username: aws.String(rec.KubernetesUsername),
+ KubernetesGroups: aws.StringSlice(rec.KubernetesGroups),
+ Type: aws.String(rec.Type),
+ CreatedAt: aws.Time(rec.CreatedAt),
+ ModifiedAt: aws.Time(rec.ModifiedAt),
+ }
+ if len(rec.Tags) > 0 {
+ out.Tags = aws.StringMap(rec.Tags)
+ }
+ return out
+}
+
+// associatedPolicyToAWS converts a persisted associated policy to the SDK shape.
+func associatedPolicyToAWS(p AssociatedAccessPolicy) *eks.AssociatedAccessPolicy {
+ scope := &eks.AccessScope{Type: aws.String(p.AccessScope.Type)}
+ if len(p.AccessScope.Namespaces) > 0 {
+ scope.Namespaces = aws.StringSlice(p.AccessScope.Namespaces)
+ }
+ return &eks.AssociatedAccessPolicy{
+ PolicyArn: aws.String(p.PolicyARN),
+ AccessScope: scope,
+ AssociatedAt: aws.Time(p.AssociatedAt),
+ ModifiedAt: aws.Time(p.ModifiedAt),
+ }
+}
+
+// defaultKubernetesUsername mirrors AWS: when the caller omits username, EKS
+// derives it from the principal ARN.
+func defaultKubernetesUsername(principalARN string) string {
+ return principalARN
+}
+
+// newAccessEntryRecord builds a record, defaulting the username to the
+// principal ARN when unset (AWS behaviour). now stamps Created/Modified.
+func newAccessEntryRecord(region, accountID, cluster, principalARN, username string, groups []string, entryType string, tags map[string]string, now time.Time) *AccessEntryRecord {
+ if username == "" {
+ username = defaultKubernetesUsername(principalARN)
+ }
+ return &AccessEntryRecord{
+ ARN: AccessEntryARN(region, accountID, cluster, principalARN),
+ ClusterName: cluster,
+ PrincipalARN: principalARN,
+ KubernetesUsername: username,
+ KubernetesGroups: groups,
+ Type: entryType,
+ Tags: tags,
+ CreatedAt: now,
+ ModifiedAt: now,
+ }
+}
diff --git a/spinifex/handlers/eks/access_entry_test.go b/spinifex/handlers/eks/access_entry_test.go
new file mode 100644
index 000000000..882609c28
--- /dev/null
+++ b/spinifex/handlers/eks/access_entry_test.go
@@ -0,0 +1,95 @@
+package handlers_eks
+
+import (
+ "testing"
+ "time"
+
+ "github.com/aws/aws-sdk-go/aws"
+ "github.com/aws/aws-sdk-go/service/eks"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestValidateAccessScope(t *testing.T) {
+ cases := []struct {
+ name string
+ scope *eks.AccessScope
+ want AccessScope
+ wantErr bool
+ }{
+ {"nil scope", nil, AccessScope{}, true},
+ {"empty type", &eks.AccessScope{}, AccessScope{}, true},
+ {"cluster ok", &eks.AccessScope{Type: aws.String("cluster")}, AccessScope{Type: "cluster"}, false},
+ {"cluster mixed case", &eks.AccessScope{Type: aws.String("Cluster")}, AccessScope{Type: "cluster"}, false},
+ {"cluster with namespaces", &eks.AccessScope{Type: aws.String("cluster"), Namespaces: aws.StringSlice([]string{"kube-system"})}, AccessScope{}, true},
+ {"namespace ok", &eks.AccessScope{Type: aws.String("namespace"), Namespaces: aws.StringSlice([]string{"team-a", "team-b"})}, AccessScope{Type: "namespace", Namespaces: []string{"team-a", "team-b"}}, false},
+ {"namespace without namespaces", &eks.AccessScope{Type: aws.String("namespace")}, AccessScope{}, true},
+ {"unsupported type", &eks.AccessScope{Type: aws.String("galaxy")}, AccessScope{}, true},
+ }
+ for _, tc := range cases {
+ t.Run(tc.name, func(t *testing.T) {
+ got, err := validateAccessScope(tc.scope)
+ if tc.wantErr {
+ require.Error(t, err)
+ return
+ }
+ require.NoError(t, err)
+ assert.Equal(t, tc.want, got)
+ })
+ }
+}
+
+func TestAccessEntryRecordToAWS_IncludesTags(t *testing.T) {
+ now := time.Unix(1700000000, 0).UTC()
+ rec := &AccessEntryRecord{
+ ARN: "arn:aws:eks:ap-southeast-2:000000000001:access-entry/dev1/abc",
+ ClusterName: "dev1",
+ PrincipalARN: "arn:aws:iam::000000000001:user/admin",
+ KubernetesUsername: "arn:aws:iam::000000000001:user/admin",
+ KubernetesGroups: []string{"system:masters"},
+ Type: AccessEntryTypeStandard,
+ Tags: map[string]string{"team": "platform"},
+ CreatedAt: now,
+ ModifiedAt: now,
+ }
+ out := accessEntryRecordToAWS(rec)
+ assert.Equal(t, "dev1", aws.StringValue(out.ClusterName))
+ assert.Equal(t, rec.PrincipalARN, aws.StringValue(out.PrincipalArn))
+ assert.Equal(t, []string{"system:masters"}, aws.StringValueSlice(out.KubernetesGroups))
+ require.NotNil(t, out.Tags)
+ assert.Equal(t, "platform", aws.StringValue(out.Tags["team"]))
+}
+
+func TestAssociatedPolicyToAWS_ScopeNamespaces(t *testing.T) {
+ now := time.Unix(1700000000, 0).UTC()
+ p := AssociatedAccessPolicy{
+ PolicyARN: "arn:aws:eks::aws:cluster-access-policy/AmazonEKSViewPolicy",
+ AccessScope: AccessScope{Type: "namespace", Namespaces: []string{"team-a"}},
+ AssociatedAt: now,
+ ModifiedAt: now,
+ }
+ out := associatedPolicyToAWS(p)
+ assert.Equal(t, p.PolicyARN, aws.StringValue(out.PolicyArn))
+ assert.Equal(t, "namespace", aws.StringValue(out.AccessScope.Type))
+ assert.Equal(t, []string{"team-a"}, aws.StringValueSlice(out.AccessScope.Namespaces))
+
+ cluster := associatedPolicyToAWS(AssociatedAccessPolicy{
+ PolicyARN: "arn:aws:eks::aws:cluster-access-policy/AmazonEKSClusterAdminPolicy",
+ AccessScope: AccessScope{Type: "cluster"},
+ })
+ assert.Equal(t, "cluster", aws.StringValue(cluster.AccessScope.Type))
+ assert.Empty(t, cluster.AccessScope.Namespaces)
+}
+
+// The record-store guard clauses reject malformed input before touching the KV,
+// so a nil handle is enough to exercise them.
+func TestAccessEntryRecordGuards(t *testing.T) {
+ require.Error(t, PutAccessEntryRecord(nil, nil))
+ require.Error(t, PutAccessEntryRecord(nil, &AccessEntryRecord{PrincipalARN: "arn:aws:iam::000000000001:user/admin"}))
+
+ _, err := GetAccessEntryRecord(nil, "", "arn:aws:iam::000000000001:user/admin")
+ require.Error(t, err)
+
+ _, err = ListAccessEntryRecords(nil, "")
+ require.Error(t, err)
+}
diff --git a/spinifex/handlers/eks/addon_catalog.go b/spinifex/handlers/eks/addon_catalog.go
new file mode 100644
index 000000000..e9c30d989
--- /dev/null
+++ b/spinifex/handlers/eks/addon_catalog.go
@@ -0,0 +1,89 @@
+package handlers_eks
+
+import "slices"
+
+// AddonSpec describes one Spinifex-supported managed add-on. The catalog is a
+// static in-binary registry (air-gapped; there is no internet add-on catalog),
+// so DescribeAddonVersions and CreateAddon validate against these entries.
+type AddonSpec struct {
+ // Name is the AWS add-on name (e.g. "aws-load-balancer-controller").
+ Name string
+ // Versions lists the supported versions newest-first; [0] is the default.
+ Versions []string
+ // DefaultVersion is the version CreateAddon applies when none is requested.
+ // Always equal to Versions[0].
+ DefaultVersion string
+ // RequiresIRSA reports whether the add-on needs an IAM-role-for-service-
+ // account binding to operate (surfaced as requiresIamPermissions).
+ RequiresIRSA bool
+ // Description is a short human-readable summary.
+ Description string
+}
+
+// addonCatalog is the bundled set of supported add-ons. Keep newest version
+// first in each Versions slice; newAddonSpec enforces DefaultVersion = [0].
+var addonCatalog = buildAddonCatalog(
+ newAddonSpec("aws-load-balancer-controller", true,
+ "Provisions ELBv2 load balancers for Kubernetes Service/Ingress resources.",
+ "2.8.1"),
+ newAddonSpec("coredns", false,
+ "Cluster DNS server.",
+ "1.11.1"),
+)
+
+// newAddonSpec builds a spec, pinning DefaultVersion to the first (newest)
+// version. Panics on an empty version list — the catalog is build-time data.
+func newAddonSpec(name string, requiresIRSA bool, description string, versions ...string) AddonSpec {
+ if len(versions) == 0 {
+ panic("eks: addon spec " + name + " has no versions")
+ }
+ return AddonSpec{
+ Name: name,
+ Versions: versions,
+ DefaultVersion: versions[0],
+ RequiresIRSA: requiresIRSA,
+ Description: description,
+ }
+}
+
+// buildAddonCatalog indexes the specs by name.
+func buildAddonCatalog(specs ...AddonSpec) map[string]AddonSpec {
+ out := make(map[string]AddonSpec, len(specs))
+ for _, s := range specs {
+ out[s.Name] = s
+ }
+ return out
+}
+
+// lookupAddon returns the spec for name and whether it is in the catalog.
+func lookupAddon(name string) (AddonSpec, bool) {
+ spec, ok := addonCatalog[name]
+ return spec, ok
+}
+
+// supportsVersion reports whether the spec lists the given version.
+func (s AddonSpec) supportsVersion(version string) bool {
+ return slices.Contains(s.Versions, version)
+}
+
+// catalogSpecs returns the catalog entries sorted by name for stable output.
+func catalogSpecs() []AddonSpec {
+ names := make([]string, 0, len(addonCatalog))
+ for n := range addonCatalog {
+ names = append(names, n)
+ }
+ insertionSortStrings(names)
+ out := make([]AddonSpec, 0, len(names))
+ for _, n := range names {
+ out = append(out, addonCatalog[n])
+ }
+ return out
+}
+
+func insertionSortStrings(s []string) {
+ for i := 1; i < len(s); i++ {
+ for j := i; j > 0 && s[j-1] > s[j]; j-- {
+ s[j-1], s[j] = s[j], s[j-1]
+ }
+ }
+}
diff --git a/spinifex/handlers/eks/addon_catalog_test.go b/spinifex/handlers/eks/addon_catalog_test.go
new file mode 100644
index 000000000..12fb1a7ee
--- /dev/null
+++ b/spinifex/handlers/eks/addon_catalog_test.go
@@ -0,0 +1,47 @@
+package handlers_eks
+
+import (
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestLookupAddon_KnownAndUnknown(t *testing.T) {
+ spec, ok := lookupAddon("aws-load-balancer-controller")
+ require.True(t, ok)
+ assert.Equal(t, "aws-load-balancer-controller", spec.Name)
+ assert.True(t, spec.RequiresIRSA)
+
+ _, ok = lookupAddon("does-not-exist")
+ assert.False(t, ok)
+}
+
+func TestAddonSpec_DefaultVersionIsNewest(t *testing.T) {
+ for name, spec := range addonCatalog {
+ require.NotEmpty(t, spec.Versions, "addon %s must list versions", name)
+ assert.Equal(t, spec.Versions[0], spec.DefaultVersion,
+ "addon %s default version must be the newest (first) version", name)
+ }
+}
+
+func TestAddonSpec_SupportsVersion(t *testing.T) {
+ spec, ok := lookupAddon("aws-load-balancer-controller")
+ require.True(t, ok)
+ assert.True(t, spec.supportsVersion(spec.DefaultVersion))
+ assert.False(t, spec.supportsVersion("0.0.0-nope"))
+}
+
+func TestCatalogSpecs_SortedByName(t *testing.T) {
+ specs := catalogSpecs()
+ require.Len(t, specs, len(addonCatalog))
+ for i := 1; i < len(specs); i++ {
+ assert.LessOrEqual(t, specs[i-1].Name, specs[i].Name, "catalog must be name-sorted")
+ }
+}
+
+func TestNewAddonSpec_PanicsWithoutVersions(t *testing.T) {
+ assert.Panics(t, func() {
+ newAddonSpec("broken", false, "no versions")
+ })
+}
diff --git a/spinifex/handlers/eks/addon_installer.go b/spinifex/handlers/eks/addon_installer.go
new file mode 100644
index 000000000..e181bf6ac
--- /dev/null
+++ b/spinifex/handlers/eks/addon_installer.go
@@ -0,0 +1,102 @@
+package handlers_eks
+
+import (
+ "encoding/json"
+ "errors"
+ "fmt"
+
+ "github.com/nats-io/nats.go"
+)
+
+// AddonInstaller delivers (and removes) a managed add-on's Kubernetes manifests
+// to a cluster. It is deliberately transport-agnostic: the add-on state machine
+// depends only on this narrow contract so the VM-side delivery transport (which
+// drops manifests into the K3s server auto-deploy dir) can drop in without
+// touching the op layer.
+//
+// The reachability constraint (cluster_reconciler.go:73-79) means delivery must
+// be VM-side, not a daemon apiserver apply — a private cluster's apiserver is
+// not host-reachable.
+type AddonInstaller interface {
+ // Install renders and delivers the add-on described by rec. It does not flip
+ // the record to ACTIVE — that transition is gated on the cluster's state
+ // report confirming the manifest applied and pods are ready.
+ Install(accountID, cluster string, rec *AddonRecord) error
+ // Uninstall removes the add-on's delivered manifests from the cluster.
+ Uninstall(accountID, cluster, addon string) error
+}
+
+// stagedManifest is the artifact the VM-side delivery transport consumes. It
+// names the bundled add-on + version and carries the operator-supplied config
+// so the VM can render the final Kubernetes objects from the baked manifests.
+type stagedManifest struct {
+ AddonName string `json:"addonName"`
+ AddonVersion string `json:"addonVersion"`
+ ServiceAccountRoleArn string `json:"serviceAccountRoleArn,omitempty"`
+ ConfigurationValues string `json:"configurationValues,omitempty"`
+}
+
+// stagingInstaller renders the bundled manifest descriptor for an add-on and
+// stages it in the per-account KV at AddonManifestKey. The VM-side delivery
+// slice (separate bead) pulls staged manifests and applies them via the K3s
+// auto-deploy dir; until then an installed add-on sits CREATING — honest, not a
+// false ACTIVE.
+type stagingInstaller struct {
+ nc *nats.Conn
+}
+
+var _ AddonInstaller = (*stagingInstaller)(nil)
+
+// newStagingInstaller builds the default installer bound to the daemon's NATS
+// connection.
+func newStagingInstaller(nc *nats.Conn) *stagingInstaller {
+ return &stagingInstaller{nc: nc}
+}
+
+func (i *stagingInstaller) acctKV(accountID string) (nats.KeyValue, error) {
+ if i.nc == nil {
+ return nil, errors.New("eks: stagingInstaller nil NATS connection")
+ }
+ js, err := i.nc.JetStream()
+ if err != nil {
+ return nil, fmt.Errorf("jetstream: %w", err)
+ }
+ return GetOrCreateAccountBucket(js, accountID)
+}
+
+func (i *stagingInstaller) Install(accountID, cluster string, rec *AddonRecord) error {
+ if rec == nil {
+ return errors.New("eks: stagingInstaller Install nil record")
+ }
+ kv, err := i.acctKV(accountID)
+ if err != nil {
+ return err
+ }
+ manifest := stagedManifest{
+ AddonName: rec.AddonName,
+ AddonVersion: rec.AddonVersion,
+ ServiceAccountRoleArn: rec.ServiceAccountRoleArn,
+ ConfigurationValues: rec.ConfigurationValues,
+ }
+ data, err := json.Marshal(&manifest)
+ if err != nil {
+ return fmt.Errorf("marshal staged manifest %s: %w", rec.AddonName, err)
+ }
+ key := AddonManifestKey(cluster, rec.AddonName)
+ if _, err := kv.Put(key, data); err != nil {
+ return fmt.Errorf("kv put %s: %w", key, err)
+ }
+ return nil
+}
+
+func (i *stagingInstaller) Uninstall(accountID, cluster, addon string) error {
+ kv, err := i.acctKV(accountID)
+ if err != nil {
+ return err
+ }
+ key := AddonManifestKey(cluster, addon)
+ if err := kv.Delete(key); err != nil && !errors.Is(err, nats.ErrKeyNotFound) {
+ return fmt.Errorf("kv delete %s: %w", key, err)
+ }
+ return nil
+}
diff --git a/spinifex/handlers/eks/addon_state.go b/spinifex/handlers/eks/addon_state.go
new file mode 100644
index 000000000..e9467dc14
--- /dev/null
+++ b/spinifex/handlers/eks/addon_state.go
@@ -0,0 +1,196 @@
+package handlers_eks
+
+import (
+ "encoding/json"
+ "errors"
+ "fmt"
+ "strings"
+ "time"
+
+ "github.com/nats-io/nats.go"
+)
+
+// AddonStatus mirrors the AWS EKS addon.status enum verbatim so clients match
+// against the values without translation.
+type AddonStatus string
+
+const (
+ AddonStatusCreating AddonStatus = "CREATING"
+ AddonStatusActive AddonStatus = "ACTIVE"
+ AddonStatusUpdating AddonStatus = "UPDATING"
+ AddonStatusDeleting AddonStatus = "DELETING"
+ AddonStatusDegraded AddonStatus = "DEGRADED"
+ AddonStatusCreateFailed AddonStatus = "CREATE_FAILED"
+)
+
+// AddonRecord is the persisted-state envelope for a managed add-on bound to a
+// cluster. It tracks the AWS-visible lifecycle status plus the IRSA role and
+// opaque configuration the installer needs.
+type AddonRecord struct {
+ AddonName string `json:"addonName"`
+ AddonVersion string `json:"addonVersion"`
+ Status AddonStatus `json:"status"`
+ ServiceAccountRoleArn string `json:"serviceAccountRoleArn,omitempty"`
+ ConfigurationValues string `json:"configurationValues,omitempty"`
+ Health string `json:"health,omitempty"`
+ Arn string `json:"arn"`
+ Tags map[string]string `json:"tags,omitempty"`
+ CreatedAt time.Time `json:"createdAt"`
+ ModifiedAt time.Time `json:"modifiedAt"`
+}
+
+// ErrAddonNotFound is returned by GetAddonRecord / the CAS helper when no
+// record exists for the add-on. Callers translate to the AWS shape
+// (ResourceNotFoundException) at the service boundary.
+var ErrAddonNotFound = errors.New("eks: addon not found")
+
+// AddonARN composes the deterministic ARN for a managed add-on (one per
+// add-on name per cluster).
+func AddonARN(region, accountID, cluster, addon string) string {
+ return fmt.Sprintf("arn:aws:eks:%s:%s:addon/%s/%s", region, accountID, cluster, addon)
+}
+
+// PutAddonRecord writes the record unconditionally.
+func PutAddonRecord(kv nats.KeyValue, cluster string, rec *AddonRecord) error {
+ if rec == nil {
+ return errors.New("eks: PutAddonRecord nil record")
+ }
+ if cluster == "" || rec.AddonName == "" {
+ return errors.New("eks: PutAddonRecord missing cluster or addon name")
+ }
+ data, err := json.Marshal(rec)
+ if err != nil {
+ return fmt.Errorf("marshal addon %s: %w", rec.AddonName, err)
+ }
+ key := AddonKey(cluster, rec.AddonName)
+ if _, err := kv.Put(key, data); err != nil {
+ return fmt.Errorf("kv put %s: %w", key, err)
+ }
+ return nil
+}
+
+// GetAddonRecord reads one record. Returns ErrAddonNotFound if absent.
+func GetAddonRecord(kv nats.KeyValue, cluster, addon string) (*AddonRecord, error) {
+ if cluster == "" || addon == "" {
+ return nil, errors.New("eks: GetAddonRecord empty cluster or addon name")
+ }
+ entry, err := kv.Get(AddonKey(cluster, addon))
+ if err != nil {
+ if errors.Is(err, nats.ErrKeyNotFound) {
+ return nil, ErrAddonNotFound
+ }
+ return nil, fmt.Errorf("kv get addon: %w", err)
+ }
+ var rec AddonRecord
+ if err := json.Unmarshal(entry.Value(), &rec); err != nil {
+ return nil, fmt.Errorf("unmarshal addon %s: %w", addon, err)
+ }
+ return &rec, nil
+}
+
+// ListAddonRecords returns every add-on record under a cluster, sorted by add-on
+// name for stable output. The staged-manifest sub-keys
+// (clusters/{c}/addons/{addon}/manifest) are skipped — only the record keys
+// (one path segment under the prefix) are returned.
+func ListAddonRecords(kv nats.KeyValue, cluster string) ([]*AddonRecord, error) {
+ if cluster == "" {
+ return nil, errors.New("eks: ListAddonRecords empty cluster")
+ }
+ keys, err := kv.Keys()
+ if err != nil {
+ if errors.Is(err, nats.ErrNoKeysFound) {
+ return nil, nil
+ }
+ return nil, fmt.Errorf("kv keys: %w", err)
+ }
+ prefix := AddonsPrefix(cluster)
+ out := make([]*AddonRecord, 0)
+ for _, k := range keys {
+ if !strings.HasPrefix(k, prefix) {
+ continue
+ }
+ // Skip sub-keys (e.g. the staged manifest); a record key is exactly one
+ // segment under the prefix.
+ if strings.Contains(strings.TrimPrefix(k, prefix), "/") {
+ continue
+ }
+ entry, err := kv.Get(k)
+ if err != nil {
+ if errors.Is(err, nats.ErrKeyNotFound) {
+ continue
+ }
+ return nil, fmt.Errorf("kv get %s: %w", k, err)
+ }
+ var rec AddonRecord
+ if err := json.Unmarshal(entry.Value(), &rec); err != nil {
+ return nil, fmt.Errorf("unmarshal addon %s: %w", k, err)
+ }
+ out = append(out, &rec)
+ }
+ sortAddonRecords(out)
+ return out, nil
+}
+
+// DeleteAddonRecord removes one record (and its staged manifest, if any).
+// Returns ErrAddonNotFound if the record did not exist.
+func DeleteAddonRecord(kv nats.KeyValue, cluster, addon string) error {
+ key := AddonKey(cluster, addon)
+ if _, err := kv.Get(key); err != nil {
+ if errors.Is(err, nats.ErrKeyNotFound) {
+ return ErrAddonNotFound
+ }
+ return fmt.Errorf("kv get %s: %w", key, err)
+ }
+ if err := kv.Delete(key); err != nil {
+ return fmt.Errorf("kv delete %s: %w", key, err)
+ }
+ // Best-effort: drop the staged manifest so a re-create starts clean.
+ manifestKey := AddonManifestKey(cluster, addon)
+ if err := kv.Delete(manifestKey); err != nil && !errors.Is(err, nats.ErrKeyNotFound) {
+ return fmt.Errorf("kv delete %s: %w", manifestKey, err)
+ }
+ return nil
+}
+
+// casUpdateAddon does a revision-checked read-modify-write. mutate returns true
+// if it changed a field. Returns ErrAddonNotFound if absent.
+func casUpdateAddon(kv nats.KeyValue, cluster, addon string, mutate func(*AddonRecord) bool) (*AddonRecord, error) {
+ key := AddonKey(cluster, addon)
+ for range maxClusterStateCASRetries {
+ entry, err := kv.Get(key)
+ if err != nil {
+ if errors.Is(err, nats.ErrKeyNotFound) {
+ return nil, ErrAddonNotFound
+ }
+ return nil, fmt.Errorf("kv get %s: %w", key, err)
+ }
+ var rec AddonRecord
+ if err := json.Unmarshal(entry.Value(), &rec); err != nil {
+ return nil, fmt.Errorf("unmarshal addon %s: %w", addon, err)
+ }
+ if !mutate(&rec) {
+ return &rec, nil
+ }
+ data, err := json.Marshal(&rec)
+ if err != nil {
+ return nil, fmt.Errorf("marshal addon %s: %w", addon, err)
+ }
+ _, err = kv.Update(key, data, entry.Revision())
+ if err == nil {
+ return &rec, nil
+ }
+ if errors.Is(err, nats.ErrKeyExists) {
+ continue
+ }
+ return nil, fmt.Errorf("kv update %s: %w", key, err)
+ }
+ return nil, fmt.Errorf("eks: casUpdateAddon %s exhausted CAS retries", addon)
+}
+
+func sortAddonRecords(recs []*AddonRecord) {
+ for i := 1; i < len(recs); i++ {
+ for j := i; j > 0 && recs[j-1].AddonName > recs[j].AddonName; j-- {
+ recs[j-1], recs[j] = recs[j], recs[j-1]
+ }
+ }
+}
diff --git a/spinifex/handlers/eks/addon_state_test.go b/spinifex/handlers/eks/addon_state_test.go
new file mode 100644
index 000000000..709e7a10a
--- /dev/null
+++ b/spinifex/handlers/eks/addon_state_test.go
@@ -0,0 +1,108 @@
+package handlers_eks
+
+import (
+ "testing"
+ "time"
+
+ "github.com/nats-io/nats.go"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func sampleAddonRecord(name string) *AddonRecord {
+ now := time.Date(2026, 6, 8, 0, 0, 0, 0, time.UTC)
+ return &AddonRecord{
+ AddonName: name,
+ AddonVersion: "1.0.0",
+ Status: AddonStatusCreating,
+ Arn: "arn:aws:eks:us-east-1:111122223333:addon/c1/" + name,
+ CreatedAt: now,
+ ModifiedAt: now,
+ }
+}
+
+func TestAddonRecordGuards(t *testing.T) {
+ require.Error(t, PutAddonRecord(nil, "c1", nil))
+ require.Error(t, PutAddonRecord(nil, "", sampleAddonRecord("x")))
+ require.Error(t, PutAddonRecord(nil, "c1", &AddonRecord{}))
+
+ _, err := GetAddonRecord(nil, "", "x")
+ require.Error(t, err)
+
+ _, err = ListAddonRecords(nil, "")
+ require.Error(t, err)
+}
+
+func TestAddonRecord_RoundTrip(t *testing.T) {
+ kv := newClusterStateTestKV(t)
+
+ in := sampleAddonRecord("aws-load-balancer-controller")
+ in.Tags = map[string]string{"team": "platform"}
+ require.NoError(t, PutAddonRecord(kv, "c1", in))
+
+ got, err := GetAddonRecord(kv, "c1", in.AddonName)
+ require.NoError(t, err)
+ assert.Equal(t, in.AddonName, got.AddonName)
+ assert.Equal(t, in.AddonVersion, got.AddonVersion)
+ assert.Equal(t, in.Status, got.Status)
+ assert.Equal(t, "platform", got.Tags["team"])
+}
+
+func TestGetAddonRecord_NotFound(t *testing.T) {
+ kv := newClusterStateTestKV(t)
+ _, err := GetAddonRecord(kv, "c1", "ghost")
+ assert.ErrorIs(t, err, ErrAddonNotFound)
+}
+
+func TestListAddonRecords_SkipsManifestSubKeys(t *testing.T) {
+ kv := newClusterStateTestKV(t)
+
+ require.NoError(t, PutAddonRecord(kv, "c1", sampleAddonRecord("coredns")))
+ require.NoError(t, PutAddonRecord(kv, "c1", sampleAddonRecord("aws-load-balancer-controller")))
+ // A staged manifest sub-key must not be mistaken for a record.
+ _, err := kv.Put(AddonManifestKey("c1", "coredns"), []byte(`{"addonName":"coredns"}`))
+ require.NoError(t, err)
+
+ recs, err := ListAddonRecords(kv, "c1")
+ require.NoError(t, err)
+ require.Len(t, recs, 2)
+ // Sorted by name.
+ assert.Equal(t, "aws-load-balancer-controller", recs[0].AddonName)
+ assert.Equal(t, "coredns", recs[1].AddonName)
+}
+
+func TestDeleteAddonRecord_RemovesRecordAndManifest(t *testing.T) {
+ kv := newClusterStateTestKV(t)
+ require.NoError(t, PutAddonRecord(kv, "c1", sampleAddonRecord("coredns")))
+ _, err := kv.Put(AddonManifestKey("c1", "coredns"), []byte(`{}`))
+ require.NoError(t, err)
+
+ require.NoError(t, DeleteAddonRecord(kv, "c1", "coredns"))
+
+ _, err = GetAddonRecord(kv, "c1", "coredns")
+ assert.ErrorIs(t, err, ErrAddonNotFound)
+ _, err = kv.Get(AddonManifestKey("c1", "coredns"))
+ assert.ErrorIs(t, err, nats.ErrKeyNotFound)
+
+ // Deleting again is a not-found.
+ assert.ErrorIs(t, DeleteAddonRecord(kv, "c1", "coredns"), ErrAddonNotFound)
+}
+
+func TestCasUpdateAddon(t *testing.T) {
+ kv := newClusterStateTestKV(t)
+ require.NoError(t, PutAddonRecord(kv, "c1", sampleAddonRecord("coredns")))
+
+ updated, err := casUpdateAddon(kv, "c1", "coredns", func(r *AddonRecord) bool {
+ r.Status = AddonStatusActive
+ return true
+ })
+ require.NoError(t, err)
+ assert.Equal(t, AddonStatusActive, updated.Status)
+
+ got, err := GetAddonRecord(kv, "c1", "coredns")
+ require.NoError(t, err)
+ assert.Equal(t, AddonStatusActive, got.Status)
+
+ _, err = casUpdateAddon(kv, "c1", "ghost", func(*AddonRecord) bool { return true })
+ assert.ErrorIs(t, err, ErrAddonNotFound)
+}
diff --git a/spinifex/handlers/eks/addons.go b/spinifex/handlers/eks/addons.go
new file mode 100644
index 000000000..8fcb1e0db
--- /dev/null
+++ b/spinifex/handlers/eks/addons.go
@@ -0,0 +1,279 @@
+package handlers_eks
+
+import (
+ "errors"
+ "log/slog"
+ "time"
+
+ "github.com/aws/aws-sdk-go/aws"
+ "github.com/aws/aws-sdk-go/service/eks"
+ "github.com/mulgadc/spinifex/spinifex/awserrors"
+ "github.com/nats-io/nats.go"
+)
+
+// ListAddons returns the names of every managed add-on installed on a cluster.
+func (s *EKSServiceImpl) ListAddons(input *eks.ListAddonsInput, accountID string) (*eks.ListAddonsOutput, error) {
+ if input == nil {
+ return nil, errors.New(awserrors.ErrorInvalidParameterValue)
+ }
+ cluster := aws.StringValue(input.ClusterName)
+ acctKV, err := s.acctKVForCluster(accountID, cluster)
+ if err != nil {
+ return nil, err
+ }
+ recs, err := ListAddonRecords(acctKV, cluster)
+ if err != nil {
+ return nil, err
+ }
+ names := make([]string, 0, len(recs))
+ for _, rec := range recs {
+ names = append(names, rec.AddonName)
+ }
+ return &eks.ListAddonsOutput{Addons: aws.StringSlice(names)}, nil
+}
+
+// DescribeAddonVersions returns the bundled add-on catalog, optionally filtered
+// to a single add-on by name. It is cluster-independent (the catalog is static)
+// so it does not touch the KV store.
+func (s *EKSServiceImpl) DescribeAddonVersions(input *eks.DescribeAddonVersionsInput, _ string) (*eks.DescribeAddonVersionsOutput, error) {
+ filter := ""
+ if input != nil {
+ filter = aws.StringValue(input.AddonName)
+ }
+ specs := catalogSpecs()
+ out := make([]*eks.AddonInfo, 0, len(specs))
+ for _, spec := range specs {
+ if filter != "" && spec.Name != filter {
+ continue
+ }
+ out = append(out, addonSpecToAWS(spec))
+ }
+ return &eks.DescribeAddonVersionsOutput{Addons: out}, nil
+}
+
+// CreateAddon validates the add-on against the bundled catalog, persists a
+// CREATING record, and stages it for delivery. The record reaches ACTIVE only
+// once the cluster's state report confirms delivery (separate slice).
+func (s *EKSServiceImpl) CreateAddon(input *eks.CreateAddonInput, accountID string) (*eks.CreateAddonOutput, error) {
+ if input == nil {
+ return nil, errors.New(awserrors.ErrorInvalidParameterValue)
+ }
+ cluster := aws.StringValue(input.ClusterName)
+ addonName := aws.StringValue(input.AddonName)
+ if addonName == "" {
+ return nil, errors.New(awserrors.ErrorInvalidParameterValue)
+ }
+ spec, ok := lookupAddon(addonName)
+ if !ok {
+ return nil, errors.New(awserrors.ErrorInvalidParameterValue)
+ }
+ version := aws.StringValue(input.AddonVersion)
+ if version == "" {
+ version = spec.DefaultVersion
+ } else if !spec.supportsVersion(version) {
+ return nil, errors.New(awserrors.ErrorInvalidParameterValue)
+ }
+ acctKV, err := s.acctKVForCluster(accountID, cluster)
+ if err != nil {
+ return nil, err
+ }
+ if _, err := GetAddonRecord(acctKV, cluster, addonName); err == nil {
+ return nil, errors.New(awserrors.ErrorEKSResourceInUse)
+ } else if !errors.Is(err, ErrAddonNotFound) {
+ return nil, err
+ }
+ now := time.Now().UTC()
+ rec := &AddonRecord{
+ AddonName: addonName,
+ AddonVersion: version,
+ Status: AddonStatusCreating,
+ ServiceAccountRoleArn: aws.StringValue(input.ServiceAccountRoleArn),
+ ConfigurationValues: aws.StringValue(input.ConfigurationValues),
+ Arn: AddonARN(s.deps.Region, accountID, cluster, addonName),
+ Tags: aws.StringValueMap(input.Tags),
+ CreatedAt: now,
+ ModifiedAt: now,
+ }
+ if err := PutAddonRecord(acctKV, cluster, rec); err != nil {
+ return nil, err
+ }
+ if err := s.addonInstaller().Install(accountID, cluster, rec); err != nil {
+ s.markAddonFailed(acctKV, cluster, addonName, err)
+ return nil, err
+ }
+ return &eks.CreateAddonOutput{Addon: addonRecordToAWS(cluster, rec)}, nil
+}
+
+// DescribeAddon returns one installed add-on's record.
+func (s *EKSServiceImpl) DescribeAddon(input *eks.DescribeAddonInput, accountID string) (*eks.DescribeAddonOutput, error) {
+ if input == nil {
+ return nil, errors.New(awserrors.ErrorInvalidParameterValue)
+ }
+ cluster := aws.StringValue(input.ClusterName)
+ addonName := aws.StringValue(input.AddonName)
+ acctKV, err := s.acctKVForCluster(accountID, cluster)
+ if err != nil {
+ return nil, err
+ }
+ rec, err := GetAddonRecord(acctKV, cluster, addonName)
+ if err != nil {
+ if errors.Is(err, ErrAddonNotFound) {
+ return nil, errors.New(awserrors.ErrorEKSResourceNotFound)
+ }
+ return nil, err
+ }
+ return &eks.DescribeAddonOutput{Addon: addonRecordToAWS(cluster, rec)}, nil
+}
+
+// UpdateAddon CASes the version/config/role onto the record, marks it UPDATING,
+// and re-stages it for delivery.
+func (s *EKSServiceImpl) UpdateAddon(input *eks.UpdateAddonInput, accountID string) (*eks.UpdateAddonOutput, error) {
+ if input == nil {
+ return nil, errors.New(awserrors.ErrorInvalidParameterValue)
+ }
+ cluster := aws.StringValue(input.ClusterName)
+ addonName := aws.StringValue(input.AddonName)
+ acctKV, err := s.acctKVForCluster(accountID, cluster)
+ if err != nil {
+ return nil, err
+ }
+ // Validate a requested version against the catalog before the CAS.
+ if v := aws.StringValue(input.AddonVersion); v != "" {
+ spec, ok := lookupAddon(addonName)
+ if !ok || !spec.supportsVersion(v) {
+ return nil, errors.New(awserrors.ErrorInvalidParameterValue)
+ }
+ }
+ now := time.Now().UTC()
+ rec, err := casUpdateAddon(acctKV, cluster, addonName, func(r *AddonRecord) bool {
+ if v := aws.StringValue(input.AddonVersion); v != "" {
+ r.AddonVersion = v
+ }
+ if input.ConfigurationValues != nil {
+ r.ConfigurationValues = aws.StringValue(input.ConfigurationValues)
+ }
+ if input.ServiceAccountRoleArn != nil {
+ r.ServiceAccountRoleArn = aws.StringValue(input.ServiceAccountRoleArn)
+ }
+ r.Status = AddonStatusUpdating
+ r.ModifiedAt = now
+ return true
+ })
+ if err != nil {
+ if errors.Is(err, ErrAddonNotFound) {
+ return nil, errors.New(awserrors.ErrorEKSResourceNotFound)
+ }
+ return nil, err
+ }
+ if err := s.addonInstaller().Install(accountID, cluster, rec); err != nil {
+ s.markAddonFailed(acctKV, cluster, addonName, err)
+ return nil, err
+ }
+ return &eks.UpdateAddonOutput{Update: &eks.Update{
+ Id: aws.String(rec.Arn),
+ Status: aws.String(eks.UpdateStatusSuccessful),
+ Type: aws.String(eks.UpdateTypeAddonUpdate),
+ CreatedAt: aws.Time(now),
+ }}, nil
+}
+
+// DeleteAddon marks the record DELETING, removes the staged manifest, and
+// deletes the record.
+func (s *EKSServiceImpl) DeleteAddon(input *eks.DeleteAddonInput, accountID string) (*eks.DeleteAddonOutput, error) {
+ if input == nil {
+ return nil, errors.New(awserrors.ErrorInvalidParameterValue)
+ }
+ cluster := aws.StringValue(input.ClusterName)
+ addonName := aws.StringValue(input.AddonName)
+ acctKV, err := s.acctKVForCluster(accountID, cluster)
+ if err != nil {
+ return nil, err
+ }
+ rec, err := GetAddonRecord(acctKV, cluster, addonName)
+ if err != nil {
+ if errors.Is(err, ErrAddonNotFound) {
+ return nil, errors.New(awserrors.ErrorEKSResourceNotFound)
+ }
+ return nil, err
+ }
+ rec.Status = AddonStatusDeleting
+ rec.ModifiedAt = time.Now().UTC()
+ out := &eks.DeleteAddonOutput{Addon: addonRecordToAWS(cluster, rec)}
+ if err := s.addonInstaller().Uninstall(accountID, cluster, addonName); err != nil {
+ return nil, err
+ }
+ if err := DeleteAddonRecord(acctKV, cluster, addonName); err != nil {
+ if errors.Is(err, ErrAddonNotFound) {
+ return nil, errors.New(awserrors.ErrorEKSResourceNotFound)
+ }
+ return nil, err
+ }
+ return out, nil
+}
+
+// addonRecordToAWS converts a persisted record to the SDK Addon shape.
+func addonRecordToAWS(cluster string, rec *AddonRecord) *eks.Addon {
+ out := &eks.Addon{
+ AddonArn: aws.String(rec.Arn),
+ AddonName: aws.String(rec.AddonName),
+ AddonVersion: aws.String(rec.AddonVersion),
+ ClusterName: aws.String(cluster),
+ Status: aws.String(string(rec.Status)),
+ CreatedAt: aws.Time(rec.CreatedAt),
+ ModifiedAt: aws.Time(rec.ModifiedAt),
+ }
+ if rec.ServiceAccountRoleArn != "" {
+ out.ServiceAccountRoleArn = aws.String(rec.ServiceAccountRoleArn)
+ }
+ if rec.ConfigurationValues != "" {
+ out.ConfigurationValues = aws.String(rec.ConfigurationValues)
+ }
+ if rec.Health != "" {
+ out.Health = &eks.AddonHealth{Issues: []*eks.AddonIssue{{
+ Message: aws.String(rec.Health),
+ }}}
+ }
+ if len(rec.Tags) > 0 {
+ out.Tags = aws.StringMap(rec.Tags)
+ }
+ return out
+}
+
+// addonSpecToAWS converts a catalog spec to the SDK AddonInfo shape, expanding
+// each supported version into an AddonVersionInfo.
+func addonSpecToAWS(spec AddonSpec) *eks.AddonInfo {
+ versions := make([]*eks.AddonVersionInfo, 0, len(spec.Versions))
+ for _, v := range spec.Versions {
+ versions = append(versions, &eks.AddonVersionInfo{
+ AddonVersion: aws.String(v),
+ RequiresIamPermissions: aws.Bool(spec.RequiresIRSA),
+ })
+ }
+ return &eks.AddonInfo{
+ AddonName: aws.String(spec.Name),
+ AddonVersions: versions,
+ }
+}
+
+// addonInstaller returns the wired installer, defaulting to the staging
+// installer bound to the daemon NATS connection when none was injected.
+func (s *EKSServiceImpl) addonInstaller() AddonInstaller {
+ if s.deps.AddonInstaller != nil {
+ return s.deps.AddonInstaller
+ }
+ return newStagingInstaller(s.deps.NATSConn)
+}
+
+// markAddonFailed best-effort flips a record to CREATE_FAILED with the reason
+// after an installer error so a failed install is not left looking healthy.
+func (s *EKSServiceImpl) markAddonFailed(acctKV nats.KeyValue, cluster, addon string, cause error) {
+ now := time.Now().UTC()
+ if _, err := casUpdateAddon(acctKV, cluster, addon, func(r *AddonRecord) bool {
+ r.Status = AddonStatusCreateFailed
+ r.Health = cause.Error()
+ r.ModifiedAt = now
+ return true
+ }); err != nil {
+ slog.Warn("markAddonFailed: CAS failed", "cluster", cluster, "addon", addon, "err", err)
+ }
+}
diff --git a/spinifex/handlers/eks/addons_test.go b/spinifex/handlers/eks/addons_test.go
new file mode 100644
index 000000000..2a7d9c17d
--- /dev/null
+++ b/spinifex/handlers/eks/addons_test.go
@@ -0,0 +1,241 @@
+package handlers_eks
+
+import (
+ "errors"
+ "testing"
+
+ "github.com/aws/aws-sdk-go/aws"
+ "github.com/aws/aws-sdk-go/service/eks"
+ "github.com/mulgadc/spinifex/spinifex/awserrors"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+// fakeAddonInstaller records calls so tests can assert the state machine drives
+// the installer with the right record.
+type fakeAddonInstaller struct {
+ installs []*AddonRecord
+ uninstalls []string
+ installErr error
+}
+
+var _ AddonInstaller = (*fakeAddonInstaller)(nil)
+
+func (f *fakeAddonInstaller) Install(_, _ string, rec *AddonRecord) error {
+ f.installs = append(f.installs, rec)
+ return f.installErr
+}
+
+func (f *fakeAddonInstaller) Uninstall(_, _, addon string) error {
+ f.uninstalls = append(f.uninstalls, addon)
+ return nil
+}
+
+func setupAddonService(t *testing.T) (*EKSServiceImpl, *fakeAddonInstaller) {
+ t.Helper()
+ svc := setupTestService(t)
+ seedTestCluster(t, svc, "c1")
+ fake := &fakeAddonInstaller{}
+ svc.deps.AddonInstaller = fake
+ return svc, fake
+}
+
+const albController = "aws-load-balancer-controller"
+
+func TestDescribeAddonVersions_ReturnsCatalog(t *testing.T) {
+ svc := setupTestService(t)
+
+ out, err := svc.DescribeAddonVersions(&eks.DescribeAddonVersionsInput{}, testAccountID)
+ require.NoError(t, err)
+ assert.Len(t, out.Addons, len(addonCatalog))
+
+ // Filtered to one addon.
+ out, err = svc.DescribeAddonVersions(&eks.DescribeAddonVersionsInput{
+ AddonName: aws.String(albController),
+ }, testAccountID)
+ require.NoError(t, err)
+ require.Len(t, out.Addons, 1)
+ assert.Equal(t, albController, aws.StringValue(out.Addons[0].AddonName))
+ require.NotEmpty(t, out.Addons[0].AddonVersions)
+ assert.True(t, aws.BoolValue(out.Addons[0].AddonVersions[0].RequiresIamPermissions))
+}
+
+func TestCreateAddon_UnknownClusterIsNotFound(t *testing.T) {
+ svc := setupTestService(t)
+ _, err := svc.CreateAddon(&eks.CreateAddonInput{
+ ClusterName: aws.String("missing"), AddonName: aws.String(albController),
+ }, testAccountID)
+ require.EqualError(t, err, awserrors.ErrorEKSResourceNotFound)
+}
+
+func TestCreateAddon_UnknownAddonRejected(t *testing.T) {
+ svc, _ := setupAddonService(t)
+ _, err := svc.CreateAddon(&eks.CreateAddonInput{
+ ClusterName: aws.String("c1"), AddonName: aws.String("not-a-real-addon"),
+ }, testAccountID)
+ require.EqualError(t, err, awserrors.ErrorInvalidParameterValue)
+}
+
+func TestCreateAddon_UnknownVersionRejected(t *testing.T) {
+ svc, _ := setupAddonService(t)
+ _, err := svc.CreateAddon(&eks.CreateAddonInput{
+ ClusterName: aws.String("c1"), AddonName: aws.String(albController),
+ AddonVersion: aws.String("9.9.9-nope"),
+ }, testAccountID)
+ require.EqualError(t, err, awserrors.ErrorInvalidParameterValue)
+}
+
+func TestCreateAddon_CreatesStagingAndDefaultsVersion(t *testing.T) {
+ svc, fake := setupAddonService(t)
+ spec, _ := lookupAddon(albController)
+
+ out, err := svc.CreateAddon(&eks.CreateAddonInput{
+ ClusterName: aws.String("c1"), AddonName: aws.String(albController),
+ }, testAccountID)
+ require.NoError(t, err)
+ require.NotNil(t, out.Addon)
+ // Version defaults to catalog default; status starts CREATING (honest until
+ // VM-side delivery confirms ACTIVE).
+ assert.Equal(t, spec.DefaultVersion, aws.StringValue(out.Addon.AddonVersion))
+ assert.Equal(t, string(AddonStatusCreating), aws.StringValue(out.Addon.Status))
+ assert.Contains(t, aws.StringValue(out.Addon.AddonArn), ":addon/c1/"+albController)
+
+ // Installer was driven with the persisted record.
+ require.Len(t, fake.installs, 1)
+ assert.Equal(t, albController, fake.installs[0].AddonName)
+
+ // Duplicate create → ResourceInUseException.
+ _, err = svc.CreateAddon(&eks.CreateAddonInput{
+ ClusterName: aws.String("c1"), AddonName: aws.String(albController),
+ }, testAccountID)
+ require.EqualError(t, err, awserrors.ErrorEKSResourceInUse)
+}
+
+func TestCreateAddon_InstallerFailureMarksCreateFailed(t *testing.T) {
+ svc, fake := setupAddonService(t)
+ fake.installErr = errors.New("delivery bus unreachable")
+
+ _, err := svc.CreateAddon(&eks.CreateAddonInput{
+ ClusterName: aws.String("c1"), AddonName: aws.String(albController),
+ }, testAccountID)
+ require.Error(t, err)
+
+ desc, err := svc.DescribeAddon(&eks.DescribeAddonInput{
+ ClusterName: aws.String("c1"), AddonName: aws.String(albController),
+ }, testAccountID)
+ require.NoError(t, err)
+ assert.Equal(t, string(AddonStatusCreateFailed), aws.StringValue(desc.Addon.Status))
+}
+
+func TestAddon_DescribeListDelete(t *testing.T) {
+ svc, fake := setupAddonService(t)
+
+ _, err := svc.CreateAddon(&eks.CreateAddonInput{
+ ClusterName: aws.String("c1"), AddonName: aws.String(albController),
+ }, testAccountID)
+ require.NoError(t, err)
+
+ desc, err := svc.DescribeAddon(&eks.DescribeAddonInput{
+ ClusterName: aws.String("c1"), AddonName: aws.String(albController),
+ }, testAccountID)
+ require.NoError(t, err)
+ assert.Equal(t, albController, aws.StringValue(desc.Addon.AddonName))
+
+ list, err := svc.ListAddons(&eks.ListAddonsInput{ClusterName: aws.String("c1")}, testAccountID)
+ require.NoError(t, err)
+ assert.Equal(t, []string{albController}, aws.StringValueSlice(list.Addons))
+
+ _, err = svc.DeleteAddon(&eks.DeleteAddonInput{
+ ClusterName: aws.String("c1"), AddonName: aws.String(albController),
+ }, testAccountID)
+ require.NoError(t, err)
+ assert.Equal(t, []string{albController}, fake.uninstalls)
+
+ _, err = svc.DescribeAddon(&eks.DescribeAddonInput{
+ ClusterName: aws.String("c1"), AddonName: aws.String(albController),
+ }, testAccountID)
+ require.EqualError(t, err, awserrors.ErrorEKSResourceNotFound)
+}
+
+func TestDescribeDeleteAddon_MissingIsNotFound(t *testing.T) {
+ svc, _ := setupAddonService(t)
+ _, err := svc.DescribeAddon(&eks.DescribeAddonInput{
+ ClusterName: aws.String("c1"), AddonName: aws.String(albController),
+ }, testAccountID)
+ require.EqualError(t, err, awserrors.ErrorEKSResourceNotFound)
+ _, err = svc.DeleteAddon(&eks.DeleteAddonInput{
+ ClusterName: aws.String("c1"), AddonName: aws.String(albController),
+ }, testAccountID)
+ require.EqualError(t, err, awserrors.ErrorEKSResourceNotFound)
+}
+
+func TestUpdateAddon_ChangesVersionAndReinstalls(t *testing.T) {
+ svc, fake := setupAddonService(t)
+ spec, _ := lookupAddon("coredns")
+
+ _, err := svc.CreateAddon(&eks.CreateAddonInput{
+ ClusterName: aws.String("c1"), AddonName: aws.String("coredns"),
+ }, testAccountID)
+ require.NoError(t, err)
+ require.Len(t, fake.installs, 1)
+
+ out, err := svc.UpdateAddon(&eks.UpdateAddonInput{
+ ClusterName: aws.String("c1"), AddonName: aws.String("coredns"),
+ ConfigurationValues: aws.String(`{"replicaCount":2}`),
+ }, testAccountID)
+ require.NoError(t, err)
+ require.NotNil(t, out.Update)
+ assert.Equal(t, eks.UpdateTypeAddonUpdate, aws.StringValue(out.Update.Type))
+ require.Len(t, fake.installs, 2, "update must re-drive the installer")
+
+ desc, err := svc.DescribeAddon(&eks.DescribeAddonInput{
+ ClusterName: aws.String("c1"), AddonName: aws.String("coredns"),
+ }, testAccountID)
+ require.NoError(t, err)
+ assert.Equal(t, `{"replicaCount":2}`, aws.StringValue(desc.Addon.ConfigurationValues))
+ assert.Equal(t, spec.DefaultVersion, aws.StringValue(desc.Addon.AddonVersion))
+ assert.Equal(t, string(AddonStatusUpdating), aws.StringValue(desc.Addon.Status))
+}
+
+func TestUpdateAddon_UnknownVersionRejected(t *testing.T) {
+ svc, _ := setupAddonService(t)
+ _, err := svc.CreateAddon(&eks.CreateAddonInput{
+ ClusterName: aws.String("c1"), AddonName: aws.String("coredns"),
+ }, testAccountID)
+ require.NoError(t, err)
+
+ _, err = svc.UpdateAddon(&eks.UpdateAddonInput{
+ ClusterName: aws.String("c1"), AddonName: aws.String("coredns"),
+ AddonVersion: aws.String("9.9.9-nope"),
+ }, testAccountID)
+ require.EqualError(t, err, awserrors.ErrorInvalidParameterValue)
+}
+
+func TestUpdateAddon_MissingIsNotFound(t *testing.T) {
+ svc, _ := setupAddonService(t)
+ _, err := svc.UpdateAddon(&eks.UpdateAddonInput{
+ ClusterName: aws.String("c1"), AddonName: aws.String("coredns"),
+ }, testAccountID)
+ require.EqualError(t, err, awserrors.ErrorEKSResourceNotFound)
+}
+
+// The default (uninjected) installer stages a manifest in KV that the VM-side
+// delivery slice consumes.
+func TestStagingInstaller_StagesManifest(t *testing.T) {
+ svc := setupTestService(t)
+ seedTestCluster(t, svc, "c1")
+
+ _, err := svc.CreateAddon(&eks.CreateAddonInput{
+ ClusterName: aws.String("c1"), AddonName: aws.String(albController),
+ ServiceAccountRoleArn: aws.String("arn:aws:iam::111122223333:role/alb"),
+ }, testAccountID)
+ require.NoError(t, err)
+
+ js, err := svc.deps.NATSConn.JetStream()
+ require.NoError(t, err)
+ kv, err := GetOrCreateAccountBucket(js, testAccountID)
+ require.NoError(t, err)
+ entry, err := kv.Get(AddonManifestKey("c1", albController))
+ require.NoError(t, err, "installer must stage a manifest for VM-side delivery")
+ assert.Contains(t, string(entry.Value()), albController)
+}
diff --git a/spinifex/handlers/eks/awsgw_endpoint.go b/spinifex/handlers/eks/awsgw_endpoint.go
new file mode 100644
index 000000000..4031512f3
--- /dev/null
+++ b/spinifex/handlers/eks/awsgw_endpoint.go
@@ -0,0 +1,47 @@
+package handlers_eks
+
+import (
+ "errors"
+ "fmt"
+ "net/url"
+ "strings"
+)
+
+// ClusterOIDCIssuer returns the public OIDC issuer URL for one cluster. This
+// is the URL kube-apiserver advertises as service-account-issuer and the URL
+// that AWS STS / external IRSA validators fetch /.well-known/openid-configuration
+// against. Format per plan §10.6:
+//
+// {gatewayBaseURL}/oidc/eks/{region}/{accountID}/{clusterName}
+//
+// gatewayBaseURL is the awsgw HTTPS endpoint (e.g. "https://10.0.0.1:9999"),
+// without a trailing /oidc. Validates that region/accountID/clusterName are
+// non-empty and URL-safe.
+func ClusterOIDCIssuer(gatewayBaseURL, region, accountID, clusterName string) (string, error) {
+ if gatewayBaseURL == "" {
+ return "", errors.New("eks: ClusterOIDCIssuer empty gatewayBaseURL")
+ }
+ if region == "" {
+ return "", errors.New("eks: ClusterOIDCIssuer empty region")
+ }
+ if accountID == "" {
+ return "", errors.New("eks: ClusterOIDCIssuer empty accountID")
+ }
+ if clusterName == "" {
+ return "", errors.New("eks: ClusterOIDCIssuer empty clusterName")
+ }
+ parsed, err := url.Parse(gatewayBaseURL)
+ if err != nil {
+ return "", fmt.Errorf("parse gatewayBaseURL %q: %w", gatewayBaseURL, err)
+ }
+ if parsed.Scheme == "" || parsed.Host == "" {
+ return "", fmt.Errorf("eks: gatewayBaseURL %q missing scheme or host", gatewayBaseURL)
+ }
+ for name, v := range map[string]string{"region": region, "accountID": accountID, "clusterName": clusterName} {
+ if strings.ContainsAny(v, "/?#") {
+ return "", fmt.Errorf("eks: %s %q contains URL-unsafe characters", name, v)
+ }
+ }
+ base := strings.TrimRight(gatewayBaseURL, "/")
+ return fmt.Sprintf("%s/oidc/eks/%s/%s/%s", base, region, accountID, clusterName), nil
+}
diff --git a/spinifex/handlers/eks/awsgw_endpoint_test.go b/spinifex/handlers/eks/awsgw_endpoint_test.go
new file mode 100644
index 000000000..7fec30e97
--- /dev/null
+++ b/spinifex/handlers/eks/awsgw_endpoint_test.go
@@ -0,0 +1,62 @@
+package handlers_eks
+
+import (
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestClusterOIDCIssuer_HappyPath(t *testing.T) {
+ got, err := ClusterOIDCIssuer("https://gw.example:9999", "us-east-1", "111122223333", "alpha")
+ require.NoError(t, err)
+ assert.Equal(t, "https://gw.example:9999/oidc/eks/us-east-1/111122223333/alpha", got)
+}
+
+func TestClusterOIDCIssuer_TrimsTrailingSlash(t *testing.T) {
+ got, err := ClusterOIDCIssuer("https://gw.example:9999/", "us-east-1", "111122223333", "alpha")
+ require.NoError(t, err)
+ assert.Equal(t, "https://gw.example:9999/oidc/eks/us-east-1/111122223333/alpha", got)
+}
+
+func TestClusterOIDCIssuer_EmptyInputsRejected(t *testing.T) {
+ cases := []struct {
+ name string
+ base, region, accountID, clusterName, want string
+ }{
+ {"empty base", "", "us-east-1", "111122223333", "alpha", "gatewayBaseURL"},
+ {"empty region", "https://gw", "", "111122223333", "alpha", "region"},
+ {"empty accountID", "https://gw", "us-east-1", "", "alpha", "accountID"},
+ {"empty clusterName", "https://gw", "us-east-1", "111122223333", "", "clusterName"},
+ }
+ for _, tc := range cases {
+ t.Run(tc.name, func(t *testing.T) {
+ _, err := ClusterOIDCIssuer(tc.base, tc.region, tc.accountID, tc.clusterName)
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), tc.want)
+ })
+ }
+}
+
+func TestClusterOIDCIssuer_MalformedBaseRejected(t *testing.T) {
+ _, err := ClusterOIDCIssuer("not-a-url", "us-east-1", "111122223333", "alpha")
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), "missing scheme or host")
+}
+
+func TestClusterOIDCIssuer_URLUnsafeInputsRejected(t *testing.T) {
+ cases := []struct {
+ field, region, accountID, clusterName string
+ }{
+ {"region", "us/east", "111122223333", "alpha"},
+ {"accountID", "us-east-1", "111?ab", "alpha"},
+ {"clusterName", "us-east-1", "111122223333", "alpha#frag"},
+ }
+ for _, tc := range cases {
+ t.Run(tc.field, func(t *testing.T) {
+ _, err := ClusterOIDCIssuer("https://gw", tc.region, tc.accountID, tc.clusterName)
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), "URL-unsafe")
+ })
+ }
+}
diff --git a/spinifex/handlers/eks/cluster_reconciler.go b/spinifex/handlers/eks/cluster_reconciler.go
new file mode 100644
index 000000000..3f0d8edd2
--- /dev/null
+++ b/spinifex/handlers/eks/cluster_reconciler.go
@@ -0,0 +1,421 @@
+package handlers_eks
+
+import (
+ "context"
+ "crypto/tls"
+ "errors"
+ "fmt"
+ "log/slog"
+ "net/http"
+ "sync/atomic"
+ "time"
+
+ "github.com/nats-io/nats.go"
+)
+
+const (
+ defaultReconcileLeaseRefresh = 20 * time.Second
+ defaultReconcileInterval = 30 * time.Second
+ defaultHealthzTimeout = 5 * time.Second
+ // defaultCreateTimeout bounds how long a cluster may sit in CREATING before
+ // the reconciler gives up and marks it FAILED. Measured from meta.CreatedAt
+ // (absolute), so a VM that never boots reaches a terminal state even across
+ // daemon restarts. Plan Risk #1.
+ defaultCreateTimeout = 15 * time.Minute
+ // defaultStateStaleAfter bounds how old the control plane's last NATS state
+ // report may be before health is treated as unknown/failed. The CP publishes
+ // every ~30s; 3x that tolerates a missed tick without flapping.
+ defaultStateStaleAfter = 90 * time.Second
+)
+
+// natsSubscriber is the minimum surface the reconciler needs to consume the
+// control-plane state report; *nats.Conn satisfies it, tests stub it.
+type natsSubscriber interface {
+ Subscribe(subject string, cb nats.MsgHandler) (*nats.Subscription, error)
+}
+
+// ErrReconcilerLeaseLost is returned by Run when the leader lease was lost
+// mid-loop. Callers can re-Acquire and re-Run if they want to keep driving
+// reconcile from the same node.
+var ErrReconcilerLeaseLost = errors.New("eks: reconciler lease lost")
+
+// ErrReconcilerClusterDeleting is returned by Run when meta.Status is
+// observed as DELETING; the DeleteCluster service-impl path is responsible
+// for teardown, so the reconciler exits without further work.
+var ErrReconcilerClusterDeleting = errors.New("eks: cluster deleting")
+
+// ErrReconcilerClusterFailed is returned by Run when meta.Status is FAILED.
+var ErrReconcilerClusterFailed = errors.New("eks: cluster failed")
+
+// HTTPDoer is the minimum http.Client surface the reconciler needs for
+// /healthz probes; tests stub it without a TLS server.
+type HTTPDoer interface {
+ Do(req *http.Request) (*http.Response, error)
+}
+
+// ClusterReconciler drives one cluster's CREATING → ACTIVE → DELETING
+// lifecycle under a leader lease. One instance per (accountID, clusterName);
+// the leader lease arbitrates which spinifex node actually drives reconcile.
+type ClusterReconciler struct {
+ leaderKV nats.KeyValue
+ acctKV nats.KeyValue
+ accountID string
+ clusterName string
+ holderID string
+ healthURL string
+
+ leaseRefresh time.Duration
+ interval time.Duration
+ healthTimeout time.Duration
+ createTimeout time.Duration
+ httpClient HTTPDoer
+
+ // State-report health source. When stateSub is non-nil the reconciler gates
+ // on the CP's NATS self-report (latest) rather than the HTTP /healthz probe —
+ // the apiserver is bound to the VPC node-ip and unreachable from the host.
+ stateSub natsSubscriber
+ stateSubject string
+ stateStaleAfter time.Duration
+ latest atomic.Pointer[ServerStateReport]
+}
+
+// ReconcilerOption tunes a ClusterReconciler. Tests use the With* helpers to
+// shrink tickers and inject a stub HTTP client.
+type ReconcilerOption func(*ClusterReconciler)
+
+// WithReconcileInterval overrides the default reconcile loop period.
+func WithReconcileInterval(d time.Duration) ReconcilerOption {
+ return func(r *ClusterReconciler) { r.interval = d }
+}
+
+// WithLeaseRefresh overrides the default leader-lease refresh period. Should
+// be less than KVBucketEKSLeaderTTL.
+func WithLeaseRefresh(d time.Duration) ReconcilerOption {
+ return func(r *ClusterReconciler) { r.leaseRefresh = d }
+}
+
+// WithHealthzTimeout overrides the per-probe HTTP timeout.
+func WithHealthzTimeout(d time.Duration) ReconcilerOption {
+ return func(r *ClusterReconciler) { r.healthTimeout = d }
+}
+
+// WithCreateTimeout overrides how long a cluster may remain CREATING before the
+// reconciler marks it FAILED.
+func WithCreateTimeout(d time.Duration) ReconcilerOption {
+ return func(r *ClusterReconciler) { r.createTimeout = d }
+}
+
+// WithHTTPClient injects a stub HTTPDoer (tests).
+func WithHTTPClient(c HTTPDoer) ReconcilerOption {
+ return func(r *ClusterReconciler) { r.httpClient = c }
+}
+
+// WithStateSource configures the reconciler to gate health on the control
+// plane's NATS state report (subject) consumed via sub, instead of the HTTP
+// /healthz probe. The subscription is opened in Run and closed when it returns.
+func WithStateSource(sub natsSubscriber, subject string) ReconcilerOption {
+ return func(r *ClusterReconciler) {
+ r.stateSub = sub
+ r.stateSubject = subject
+ }
+}
+
+// WithStateStaleAfter overrides how old the last state report may be before
+// health is treated as failing.
+func WithStateStaleAfter(d time.Duration) ReconcilerOption {
+ return func(r *ClusterReconciler) { r.stateStaleAfter = d }
+}
+
+// NewClusterReconciler constructs a ClusterReconciler. healthURL is the
+// fully-qualified probe target (e.g. https://nlb-dns:443/healthz) or empty
+// to skip /healthz probing (CREATING transitions on bootstrap state alone).
+func NewClusterReconciler(leaderKV, acctKV nats.KeyValue, accountID, clusterName, holderID, healthURL string, opts ...ReconcilerOption) (*ClusterReconciler, error) {
+ if leaderKV == nil {
+ return nil, errors.New("eks: NewClusterReconciler nil leaderKV")
+ }
+ if acctKV == nil {
+ return nil, errors.New("eks: NewClusterReconciler nil acctKV")
+ }
+ if accountID == "" {
+ return nil, errors.New("eks: NewClusterReconciler empty accountID")
+ }
+ if clusterName == "" {
+ return nil, errors.New("eks: NewClusterReconciler empty clusterName")
+ }
+ if holderID == "" {
+ return nil, errors.New("eks: NewClusterReconciler empty holderID")
+ }
+ r := &ClusterReconciler{
+ leaderKV: leaderKV,
+ acctKV: acctKV,
+ accountID: accountID,
+ clusterName: clusterName,
+ holderID: holderID,
+ healthURL: healthURL,
+ leaseRefresh: defaultReconcileLeaseRefresh,
+ interval: defaultReconcileInterval,
+ healthTimeout: defaultHealthzTimeout,
+ createTimeout: defaultCreateTimeout,
+ stateStaleAfter: defaultStateStaleAfter,
+ httpClient: &http.Client{
+ Timeout: defaultHealthzTimeout,
+ Transport: &http.Transport{
+ TLSClientConfig: &tls.Config{
+ InsecureSkipVerify: true, //nolint:gosec // K3s NLB is self-signed; CA pinning is a v2 hardening item
+ },
+ },
+ },
+ }
+ for _, o := range opts {
+ o(r)
+ }
+ return r, nil
+}
+
+func reconcilerLeaderKey(accountID, clusterName string) string {
+ return accountID + "/" + clusterName
+}
+
+// AcquireLease attempts a CAS Create on the leader bucket. Returns
+// (release, true) if this caller is now the leader; (nil, false) otherwise.
+// The release closure performs a best-effort kv.Delete; the bucket's 60s
+// TTL reaps stale leases when release does not run (crashed leader).
+func (r *ClusterReconciler) AcquireLease() (func(), bool) {
+ key := reconcilerLeaderKey(r.accountID, r.clusterName)
+ if _, err := r.leaderKV.Create(key, []byte(r.holderID)); err != nil {
+ slog.Debug("ClusterReconciler: lease held by another holder",
+ "key", key, "holderID", r.holderID, "err", err)
+ return nil, false
+ }
+ slog.Info("ClusterReconciler: lease acquired", "key", key, "holderID", r.holderID)
+ return func() {
+ if err := r.leaderKV.Delete(key); err != nil {
+ slog.Warn("ClusterReconciler: lease release failed (TTL will reap)",
+ "key", key, "holderID", r.holderID, "err", err)
+ }
+ }, true
+}
+
+// RefreshLease re-asserts ownership via a CAS update on the leader key.
+// Returns false if the key was taken by another holder or the CAS conflicts.
+func (r *ClusterReconciler) RefreshLease() bool {
+ key := reconcilerLeaderKey(r.accountID, r.clusterName)
+ entry, err := r.leaderKV.Get(key)
+ if err != nil {
+ slog.Warn("ClusterReconciler: lease get failed", "key", key, "err", err)
+ return false
+ }
+ if string(entry.Value()) != r.holderID {
+ slog.Info("ClusterReconciler: lease now held by another holder",
+ "key", key, "holderID", r.holderID, "got", string(entry.Value()))
+ return false
+ }
+ if _, err := r.leaderKV.Update(key, []byte(r.holderID), entry.Revision()); err != nil {
+ slog.Warn("ClusterReconciler: lease CAS update failed",
+ "key", key, "holderID", r.holderID, "err", err)
+ return false
+ }
+ return true
+}
+
+// Run drives reconcile until ctx is cancelled, the lease is lost, or the
+// cluster reaches a terminal state. Caller is expected to AcquireLease first;
+// Run does not re-acquire.
+func (r *ClusterReconciler) Run(ctx context.Context) error {
+ if r.stateSub != nil && r.stateSubject != "" {
+ sub, err := r.stateSub.Subscribe(r.stateSubject, func(m *nats.Msg) {
+ report, perr := unmarshalServerStateReport(m.Data)
+ if perr != nil {
+ slog.Warn("ClusterReconciler: bad state report",
+ "cluster", r.clusterName, "subject", m.Subject, "err", perr)
+ return
+ }
+ r.latest.Store(&report)
+ })
+ if err != nil {
+ return fmt.Errorf("subscribe state report %s: %w", r.stateSubject, err)
+ }
+ defer func() { _ = sub.Unsubscribe() }()
+ }
+
+ refreshT := time.NewTicker(r.leaseRefresh)
+ defer refreshT.Stop()
+ reconcileT := time.NewTicker(r.interval)
+ defer reconcileT.Stop()
+
+ if err := r.reconcileOnce(ctx); err != nil {
+ if terminalReconcileErr(err) {
+ return err
+ }
+ slog.Warn("ClusterReconciler: initial reconcile failed",
+ "cluster", r.clusterName, "err", err)
+ }
+
+ for {
+ select {
+ case <-ctx.Done():
+ return ctx.Err()
+ case <-refreshT.C:
+ if !r.RefreshLease() {
+ return ErrReconcilerLeaseLost
+ }
+ case <-reconcileT.C:
+ if err := r.reconcileOnce(ctx); err != nil {
+ if terminalReconcileErr(err) {
+ return err
+ }
+ slog.Warn("ClusterReconciler: reconcile failed",
+ "cluster", r.clusterName, "err", err)
+ }
+ }
+ }
+}
+
+func terminalReconcileErr(err error) bool {
+ return errors.Is(err, ErrReconcilerClusterDeleting) ||
+ errors.Is(err, ErrReconcilerClusterFailed) ||
+ errors.Is(err, ErrClusterNotFound)
+}
+
+func (r *ClusterReconciler) reconcileOnce(ctx context.Context) error {
+ meta, err := GetClusterMeta(r.acctKV, r.clusterName)
+ if err != nil {
+ return err
+ }
+ switch meta.Status {
+ case ClusterStatusCreating:
+ ready, reason := r.bootstrapReady(meta)
+ if !ready {
+ slog.Debug("ClusterReconciler: bootstrap not ready",
+ "cluster", r.clusterName, "reason", reason)
+ return r.failIfCreateTimedOut(meta, "bootstrap not ready: "+reason)
+ }
+ issue, nodeCount := r.observe(ctx)
+ if issue != "" {
+ slog.Debug("ClusterReconciler: health not ready",
+ "cluster", r.clusterName, "issue", issue)
+ return r.failIfCreateTimedOut(meta, "healthz not ready: "+issue)
+ }
+ if err := SetClusterStatus(r.acctKV, r.clusterName, ClusterStatusActive); err != nil {
+ return err
+ }
+ if err := SetClusterHealthState(r.acctKV, r.clusterName, "", nodeCount); err != nil {
+ if errors.Is(err, ErrClusterNotFound) {
+ return ErrClusterNotFound
+ }
+ return fmt.Errorf("record cluster health: %w", err)
+ }
+ slog.Info("ClusterReconciler: cluster transitioned to ACTIVE",
+ "cluster", r.clusterName, "nodes", nodeCount)
+ case ClusterStatusActive:
+ issue, nodeCount := r.observe(ctx)
+ if issue != "" {
+ slog.Warn("ClusterReconciler: health failing for ACTIVE cluster",
+ "cluster", r.clusterName, "issue", issue)
+ }
+ if err := SetClusterHealthState(r.acctKV, r.clusterName, issue, nodeCount); err != nil {
+ if errors.Is(err, ErrClusterNotFound) {
+ return ErrClusterNotFound
+ }
+ return fmt.Errorf("record cluster health: %w", err)
+ }
+ case ClusterStatusDeleting:
+ return ErrReconcilerClusterDeleting
+ case ClusterStatusFailed:
+ return ErrReconcilerClusterFailed
+ }
+ return nil
+}
+
+// observe returns the current health issue ("" when healthy) and the reported
+// node count. With a NATS state source it reads the control plane's latest
+// self-report (rejecting a stale or unhealthy one); otherwise it falls back to
+// the HTTP /healthz probe (node count is then 0, the probe carries no count).
+func (r *ClusterReconciler) observe(ctx context.Context) (issue string, nodeCount int) {
+ if r.stateSub != nil && r.stateSubject != "" {
+ report := r.latest.Load()
+ if report == nil {
+ return "no control-plane state report received yet", 0
+ }
+ age := time.Since(time.Unix(report.TS, 0))
+ if age > r.stateStaleAfter {
+ return fmt.Sprintf("control-plane state report stale (%s old)", age.Round(time.Second)), report.NodeCount
+ }
+ if !report.Healthy() {
+ return fmt.Sprintf("apiserver healthz=%q", report.Healthz), report.NodeCount
+ }
+ return "", report.NodeCount
+ }
+ if err := r.probeHealthz(ctx); err != nil {
+ return err.Error(), 0
+ }
+ return "", 0
+}
+
+// failIfCreateTimedOut transitions a still-CREATING cluster to FAILED once it
+// has exceeded createTimeout since meta.CreatedAt, and returns
+// ErrReconcilerClusterFailed so Run exits the loop. While still within the
+// window it returns nil so the next tick retries. A zero or absent CreatedAt
+// disables the deadline (no false-positive fail on a malformed record).
+func (r *ClusterReconciler) failIfCreateTimedOut(meta *ClusterMeta, reason string) error {
+ if r.createTimeout <= 0 || meta.CreatedAt.IsZero() {
+ return nil
+ }
+ if time.Since(meta.CreatedAt) < r.createTimeout {
+ return nil
+ }
+ failReason := fmt.Sprintf("cluster did not become ACTIVE within %s: %s", r.createTimeout, reason)
+ if err := MarkClusterFailed(r.acctKV, r.clusterName, failReason); err != nil {
+ if errors.Is(err, ErrClusterNotFound) {
+ return ErrClusterNotFound
+ }
+ return fmt.Errorf("mark create-timeout failed: %w", err)
+ }
+ slog.Warn("ClusterReconciler: CREATING timed out, marked FAILED",
+ "cluster", r.clusterName, "timeout", r.createTimeout, "reason", reason)
+ return ErrReconcilerClusterFailed
+}
+
+// bootstrapReady returns true once the four NATS bootstrap KV artifacts are
+// present: node token, admin kubeconfig, the OIDC JWKS verified-marker, and the
+// CA on meta. The JWKS gate is the verified-marker (written only after the VM's
+// published JWKS passes the controller cross-check), NOT the pre-seeded
+// OIDCJWKSKey — so a cluster never transitions to ACTIVE on an unverified or
+// mismatched signing key.
+func (r *ClusterReconciler) bootstrapReady(meta *ClusterMeta) (bool, string) {
+ if meta.CertificateAuthorityB64 == "" {
+ return false, "CA absent on meta"
+ }
+ keys := []string{
+ NodeTokenKey(r.clusterName),
+ AdminKubeconfigKey(r.clusterName),
+ OIDCJWKSVerifiedKey(r.clusterName),
+ }
+ for _, k := range keys {
+ if _, err := r.acctKV.Get(k); err != nil {
+ return false, fmt.Sprintf("%s missing: %s", k, err)
+ }
+ }
+ return true, ""
+}
+
+func (r *ClusterReconciler) probeHealthz(ctx context.Context) error {
+ if r.healthURL == "" {
+ return nil
+ }
+ probeCtx, cancel := context.WithTimeout(ctx, r.healthTimeout)
+ defer cancel()
+ req, err := http.NewRequestWithContext(probeCtx, http.MethodGet, r.healthURL, nil)
+ if err != nil {
+ return fmt.Errorf("new request: %w", err)
+ }
+ resp, err := r.httpClient.Do(req)
+ if err != nil {
+ return fmt.Errorf("healthz request: %w", err)
+ }
+ defer resp.Body.Close()
+ if resp.StatusCode != http.StatusOK {
+ return fmt.Errorf("eks: /healthz status %d", resp.StatusCode)
+ }
+ return nil
+}
diff --git a/spinifex/handlers/eks/cluster_reconciler_state_test.go b/spinifex/handlers/eks/cluster_reconciler_state_test.go
new file mode 100644
index 000000000..49335d17a
--- /dev/null
+++ b/spinifex/handlers/eks/cluster_reconciler_state_test.go
@@ -0,0 +1,154 @@
+package handlers_eks
+
+import (
+ "context"
+ "encoding/json"
+ "testing"
+ "time"
+
+ "github.com/mulgadc/spinifex/spinifex/testutil"
+ "github.com/nats-io/nats.go"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+// newStateReconcilerHarness builds a reconciler wired to a real NATS conn as its
+// state source (so observe() reads r.latest) and returns the conn + account KV.
+// It does NOT start a subscription — tests drive r.latest directly for
+// determinism; the integration test below exercises the real Subscribe path.
+func newStateReconcilerHarness(t *testing.T, opts ...ReconcilerOption) (*ClusterReconciler, *nats.Conn, nats.KeyValue) {
+ t.Helper()
+ _, nc, js := testutil.StartTestJetStream(t)
+ leaderKV, err := InitLeaderBucket(js)
+ require.NoError(t, err)
+ acctKV, err := GetOrCreateAccountBucket(js, testAccountID)
+ require.NoError(t, err)
+ require.NoError(t, PutClusterMeta(acctKV, sampleClusterMeta("alpha")))
+
+ base := []ReconcilerOption{WithStateSource(nc, StateSubject(testAccountID, "alpha"))}
+ r, err := NewClusterReconciler(leaderKV, acctKV, testAccountID, "alpha", "holder-1", "", append(base, opts...)...)
+ require.NoError(t, err)
+ return r, nc, acctKV
+}
+
+func freshReport(healthz string, nodes int) *ServerStateReport {
+ return &ServerStateReport{Healthz: healthz, NodeCount: nodes, TS: time.Now().Unix()}
+}
+
+func TestClusterReconciler_CreatingTransitionsToActiveOnStateReport(t *testing.T) {
+ r, _, acctKV := newStateReconcilerHarness(t)
+ freshenClusterCreatedAt(t, acctKV)
+ seedBootstrapState(t, acctKV)
+ r.latest.Store(freshReport("ok", 3))
+
+ require.NoError(t, r.reconcileOnce(context.Background()))
+
+ meta, err := GetClusterMeta(acctKV, "alpha")
+ require.NoError(t, err)
+ assert.Equal(t, ClusterStatusActive, meta.Status)
+ assert.Equal(t, 3, meta.NodeCount)
+ assert.Empty(t, meta.HealthIssue)
+}
+
+func TestClusterReconciler_CreatingStaysWithoutStateReport(t *testing.T) {
+ r, _, acctKV := newStateReconcilerHarness(t)
+ freshenClusterCreatedAt(t, acctKV)
+ seedBootstrapState(t, acctKV)
+ // No report stored.
+
+ require.NoError(t, r.reconcileOnce(context.Background()))
+
+ meta, err := GetClusterMeta(acctKV, "alpha")
+ require.NoError(t, err)
+ assert.Equal(t, ClusterStatusCreating, meta.Status, "no state report yet → stays CREATING")
+}
+
+func TestClusterReconciler_CreatingStaysOnUnhealthyReport(t *testing.T) {
+ r, _, acctKV := newStateReconcilerHarness(t)
+ freshenClusterCreatedAt(t, acctKV)
+ seedBootstrapState(t, acctKV)
+ r.latest.Store(freshReport("fail", 0))
+
+ require.NoError(t, r.reconcileOnce(context.Background()))
+
+ meta, err := GetClusterMeta(acctKV, "alpha")
+ require.NoError(t, err)
+ assert.Equal(t, ClusterStatusCreating, meta.Status, "unhealthy apiserver → stays CREATING")
+}
+
+func TestClusterReconciler_ActiveRecordsNodeCountAndClearsIssue(t *testing.T) {
+ r, _, acctKV := newStateReconcilerHarness(t)
+ require.NoError(t, SetClusterStatus(acctKV, "alpha", ClusterStatusActive))
+ require.NoError(t, SetClusterHealthState(acctKV, "alpha", "stale", 0))
+ r.latest.Store(freshReport("ok", 4))
+
+ require.NoError(t, r.reconcileOnce(context.Background()))
+
+ meta, err := GetClusterMeta(acctKV, "alpha")
+ require.NoError(t, err)
+ assert.Equal(t, ClusterStatusActive, meta.Status)
+ assert.Equal(t, 4, meta.NodeCount)
+ assert.Empty(t, meta.HealthIssue, "healthy report clears the prior issue")
+}
+
+func TestClusterReconciler_ActiveFlagsStaleReport(t *testing.T) {
+ r, _, acctKV := newStateReconcilerHarness(t, WithStateStaleAfter(90*time.Second))
+ require.NoError(t, SetClusterStatus(acctKV, "alpha", ClusterStatusActive))
+ r.latest.Store(&ServerStateReport{Healthz: "ok", NodeCount: 2, TS: time.Now().Add(-5 * time.Minute).Unix()})
+
+ require.NoError(t, r.reconcileOnce(context.Background()))
+
+ meta, err := GetClusterMeta(acctKV, "alpha")
+ require.NoError(t, err)
+ assert.Contains(t, meta.HealthIssue, "stale", "report older than stale window flags an issue")
+ assert.Equal(t, 2, meta.NodeCount, "last known node count still recorded")
+}
+
+func TestClusterReconciler_StateReportSubscriptionDrivesActive(t *testing.T) {
+ r, nc, acctKV := newStateReconcilerHarness(t,
+ WithReconcileInterval(10*time.Millisecond),
+ WithLeaseRefresh(10*time.Second),
+ )
+ freshenClusterCreatedAt(t, acctKV)
+ seedBootstrapState(t, acctKV)
+
+ release, ok := r.AcquireLease()
+ require.True(t, ok)
+ defer release()
+
+ ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
+ defer cancel()
+ runErr := make(chan error, 1)
+ go func() { runErr <- r.Run(ctx) }()
+
+ // Publish healthy reports until the cluster goes ACTIVE (core NATS only
+ // delivers post-subscribe, so keep emitting while Run's subscription opens).
+ stop := make(chan struct{})
+ defer close(stop)
+ go func() {
+ payload, _ := json.Marshal(ServerStateReport{Healthz: "ok", NodeCount: 3, TS: time.Now().Unix()})
+ tick := time.NewTicker(20 * time.Millisecond)
+ defer tick.Stop()
+ for {
+ select {
+ case <-stop:
+ return
+ case <-tick.C:
+ _ = nc.Publish(StateSubject(testAccountID, "alpha"), payload)
+ }
+ }
+ }()
+
+ require.Eventually(t, func() bool {
+ meta, err := GetClusterMeta(acctKV, "alpha")
+ return err == nil && meta.Status == ClusterStatusActive && meta.NodeCount == 3
+ }, 1500*time.Millisecond, 10*time.Millisecond, "state reports should drive CREATING → ACTIVE")
+
+ cancel()
+ select {
+ case err := <-runErr:
+ assert.ErrorIs(t, err, context.Canceled)
+ case <-time.After(2 * time.Second):
+ t.Fatal("Run did not return after cancel")
+ }
+}
diff --git a/spinifex/handlers/eks/cluster_reconciler_test.go b/spinifex/handlers/eks/cluster_reconciler_test.go
new file mode 100644
index 000000000..f327e5571
--- /dev/null
+++ b/spinifex/handlers/eks/cluster_reconciler_test.go
@@ -0,0 +1,469 @@
+package handlers_eks
+
+import (
+ "context"
+ "errors"
+ "io"
+ "net/http"
+ "strings"
+ "sync"
+ "sync/atomic"
+ "testing"
+ "time"
+
+ "github.com/mulgadc/spinifex/spinifex/testutil"
+ "github.com/nats-io/nats.go"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+// stubHTTPDoer is a fake HTTPDoer that returns a configurable status code +
+// optional error, and tracks the number of calls for assertion.
+type stubHTTPDoer struct {
+ mu sync.Mutex
+ calls atomic.Int32
+ status int
+ err error
+}
+
+func (s *stubHTTPDoer) Do(_ *http.Request) (*http.Response, error) {
+ s.calls.Add(1)
+ s.mu.Lock()
+ err := s.err
+ status := s.status
+ s.mu.Unlock()
+ if err != nil {
+ return nil, err
+ }
+ return &http.Response{StatusCode: status, Body: io.NopCloser(strings.NewReader(""))}, nil
+}
+
+func (s *stubHTTPDoer) setResponse(status int, err error) {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ s.status = status
+ s.err = err
+}
+
+func newReconcilerHarness(t *testing.T, healthURL string, opts ...ReconcilerOption) (*ClusterReconciler, nats.KeyValue, nats.KeyValue) {
+ t.Helper()
+ _, _, js := testutil.StartTestJetStream(t)
+ leaderKV, err := InitLeaderBucket(js)
+ require.NoError(t, err)
+ acctKV, err := GetOrCreateAccountBucket(js, testAccountID)
+ require.NoError(t, err)
+ require.NoError(t, PutClusterMeta(acctKV, sampleClusterMeta("alpha")))
+
+ r, err := NewClusterReconciler(leaderKV, acctKV, testAccountID, "alpha", "holder-1", healthURL, opts...)
+ require.NoError(t, err)
+ return r, leaderKV, acctKV
+}
+
+func TestNewClusterReconciler_EmptyInputsRejected(t *testing.T) {
+ _, _, js := testutil.StartTestJetStream(t)
+ leaderKV, err := InitLeaderBucket(js)
+ require.NoError(t, err)
+ acctKV, err := GetOrCreateAccountBucket(js, testAccountID)
+ require.NoError(t, err)
+
+ _, err = NewClusterReconciler(nil, acctKV, testAccountID, "alpha", "h", "")
+ require.Error(t, err)
+ _, err = NewClusterReconciler(leaderKV, nil, testAccountID, "alpha", "h", "")
+ require.Error(t, err)
+ _, err = NewClusterReconciler(leaderKV, acctKV, "", "alpha", "h", "")
+ require.Error(t, err)
+ _, err = NewClusterReconciler(leaderKV, acctKV, testAccountID, "", "h", "")
+ require.Error(t, err)
+ _, err = NewClusterReconciler(leaderKV, acctKV, testAccountID, "alpha", "", "")
+ require.Error(t, err)
+}
+
+func TestClusterReconciler_AcquireLeaseFirstHolderWins(t *testing.T) {
+ r, _, _ := newReconcilerHarness(t, "")
+
+ release, ok := r.AcquireLease()
+ require.True(t, ok)
+ require.NotNil(t, release)
+ defer release()
+
+ // Second AcquireLease from same holder must fail (Create returns KeyExists).
+ release2, ok2 := r.AcquireLease()
+ assert.False(t, ok2)
+ assert.Nil(t, release2)
+}
+
+func TestClusterReconciler_AcquireLeaseSecondHolderLoses(t *testing.T) {
+ r1, leaderKV, acctKV := newReconcilerHarness(t, "")
+
+ release, ok := r1.AcquireLease()
+ require.True(t, ok)
+ defer release()
+
+ r2, err := NewClusterReconciler(leaderKV, acctKV, testAccountID, "alpha", "holder-2", "")
+ require.NoError(t, err)
+ release2, ok2 := r2.AcquireLease()
+ assert.False(t, ok2)
+ assert.Nil(t, release2)
+}
+
+func TestClusterReconciler_RefreshLeaseFailsAfterStolen(t *testing.T) {
+ r, leaderKV, _ := newReconcilerHarness(t, "")
+
+ release, ok := r.AcquireLease()
+ require.True(t, ok)
+ defer release()
+
+ assert.True(t, r.RefreshLease(), "refresh should succeed while we own the key")
+
+ // Steal the key by overwriting its value via Put.
+ _, err := leaderKV.Put(reconcilerLeaderKey(testAccountID, "alpha"), []byte("holder-2"))
+ require.NoError(t, err)
+
+ assert.False(t, r.RefreshLease(), "refresh should fail after another holder stole the key")
+}
+
+func TestClusterReconciler_RefreshLeaseFailsWhenKeyDeleted(t *testing.T) {
+ r, leaderKV, _ := newReconcilerHarness(t, "")
+ release, ok := r.AcquireLease()
+ require.True(t, ok)
+ defer release()
+
+ require.NoError(t, leaderKV.Delete(reconcilerLeaderKey(testAccountID, "alpha")))
+ assert.False(t, r.RefreshLease())
+}
+
+// freshenClusterCreatedAt re-stamps the cluster meta's CreatedAt to now so the
+// reconciler's CREATING deadline is far from expiry. sampleClusterMeta uses a
+// fixed past date, which would otherwise trip the create-timeout in tests that
+// assert the cluster stays CREATING.
+func freshenClusterCreatedAt(t *testing.T, kv nats.KeyValue) {
+ t.Helper()
+ meta, err := GetClusterMeta(kv, "alpha")
+ require.NoError(t, err)
+ meta.CreatedAt = time.Now().UTC()
+ require.NoError(t, PutClusterMeta(kv, meta))
+}
+
+func seedBootstrapState(t *testing.T, kv nats.KeyValue) {
+ t.Helper()
+ _, err := kv.Put(NodeTokenKey("alpha"), []byte("enc-token"))
+ require.NoError(t, err)
+ _, err = kv.Put(AdminKubeconfigKey("alpha"), []byte("enc-kc"))
+ require.NoError(t, err)
+ // Pre-seeded JWKS plus the verified-marker persistJWKS writes on a passing
+ // cross-check — bootstrapReady gates ACTIVE on the marker, not the seed.
+ _, err = kv.Put(OIDCJWKSKey("alpha"), []byte(`{"keys":[]}`))
+ require.NoError(t, err)
+ _, err = kv.Put(OIDCJWKSVerifiedKey("alpha"), []byte("verified"))
+ require.NoError(t, err)
+ require.NoError(t, SetClusterCertificateAuthority(kv, "alpha", "ca-blob-b64"))
+}
+
+func TestClusterReconciler_CreatingTransitionsToActiveOnReadyAndHealthz(t *testing.T) {
+ stub := &stubHTTPDoer{status: http.StatusOK}
+ r, _, acctKV := newReconcilerHarness(t,
+ "https://nlb.example/healthz",
+ WithHTTPClient(stub),
+ WithReconcileInterval(10*time.Millisecond),
+ WithLeaseRefresh(10*time.Second),
+ )
+ release, ok := r.AcquireLease()
+ require.True(t, ok)
+ defer release()
+
+ seedBootstrapState(t, acctKV)
+
+ ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
+ defer cancel()
+
+ runErr := make(chan error, 1)
+ go func() { runErr <- r.Run(ctx) }()
+
+ require.Eventually(t, func() bool {
+ meta, err := GetClusterMeta(acctKV, "alpha")
+ if err != nil {
+ return false
+ }
+ return meta.Status == ClusterStatusActive
+ }, 1500*time.Millisecond, 10*time.Millisecond, "CREATING should transition to ACTIVE")
+
+ cancel()
+ select {
+ case err := <-runErr:
+ assert.ErrorIs(t, err, context.Canceled)
+ case <-time.After(2 * time.Second):
+ t.Fatal("Run did not return after cancel")
+ }
+ assert.Positive(t, stub.calls.Load(), "/healthz should have been probed at least once")
+}
+
+func TestClusterReconciler_CreatingStaysWhenBootstrapMissing(t *testing.T) {
+ stub := &stubHTTPDoer{status: http.StatusOK}
+ r, _, acctKV := newReconcilerHarness(t,
+ "https://nlb.example/healthz",
+ WithHTTPClient(stub),
+ WithReconcileInterval(10*time.Millisecond),
+ WithLeaseRefresh(10*time.Second),
+ )
+ release, ok := r.AcquireLease()
+ require.True(t, ok)
+ defer release()
+
+ freshenClusterCreatedAt(t, acctKV)
+
+ // Only seed two of the three KV keys (no CA, no JWKS).
+ _, err := acctKV.Put(NodeTokenKey("alpha"), []byte("enc-token"))
+ require.NoError(t, err)
+ _, err = acctKV.Put(AdminKubeconfigKey("alpha"), []byte("enc-kc"))
+ require.NoError(t, err)
+
+ ctx, cancel := context.WithTimeout(context.Background(), 300*time.Millisecond)
+ defer cancel()
+ _ = r.Run(ctx)
+
+ meta, err := GetClusterMeta(acctKV, "alpha")
+ require.NoError(t, err)
+ assert.Equal(t, ClusterStatusCreating, meta.Status, "must remain CREATING when bootstrap incomplete")
+}
+
+func TestClusterReconciler_CreatingStaysWhenJWKSUnverified(t *testing.T) {
+ // Regression for the ACTIVE-on-pre-seeded-JWKS race: every artifact is
+ // present INCLUDING the controller-pre-seeded OIDCJWKSKey, but the VM's
+ // JWKS cross-check has not passed (no verified-marker). The cluster must
+ // stay CREATING — reaching ACTIVE here would mean signing IRSA tokens under
+ // a key the controller never confirmed.
+ stub := &stubHTTPDoer{status: http.StatusOK}
+ r, _, acctKV := newReconcilerHarness(t,
+ "https://nlb.example/healthz",
+ WithHTTPClient(stub),
+ WithReconcileInterval(10*time.Millisecond),
+ WithLeaseRefresh(10*time.Second),
+ )
+ release, ok := r.AcquireLease()
+ require.True(t, ok)
+ defer release()
+
+ freshenClusterCreatedAt(t, acctKV)
+
+ _, err := acctKV.Put(NodeTokenKey("alpha"), []byte("enc-token"))
+ require.NoError(t, err)
+ _, err = acctKV.Put(AdminKubeconfigKey("alpha"), []byte("enc-kc"))
+ require.NoError(t, err)
+ require.NoError(t, SetClusterCertificateAuthority(acctKV, "alpha", "ca-blob-b64"))
+ // Pre-seeded JWKS present, but NO verified-marker.
+ _, err = acctKV.Put(OIDCJWKSKey("alpha"), []byte(`{"keys":[]}`))
+ require.NoError(t, err)
+
+ ctx, cancel := context.WithTimeout(context.Background(), 300*time.Millisecond)
+ defer cancel()
+ _ = r.Run(ctx)
+
+ meta, err := GetClusterMeta(acctKV, "alpha")
+ require.NoError(t, err)
+ assert.Equal(t, ClusterStatusCreating, meta.Status,
+ "must remain CREATING until the JWKS cross-check positively passes")
+}
+
+func TestClusterReconciler_CreatingStaysWhenHealthzFails(t *testing.T) {
+ stub := &stubHTTPDoer{err: errors.New("connection refused")}
+ r, _, acctKV := newReconcilerHarness(t,
+ "https://nlb.example/healthz",
+ WithHTTPClient(stub),
+ WithReconcileInterval(10*time.Millisecond),
+ WithLeaseRefresh(10*time.Second),
+ )
+ release, ok := r.AcquireLease()
+ require.True(t, ok)
+ defer release()
+
+ freshenClusterCreatedAt(t, acctKV)
+ seedBootstrapState(t, acctKV)
+
+ ctx, cancel := context.WithTimeout(context.Background(), 300*time.Millisecond)
+ defer cancel()
+ _ = r.Run(ctx)
+
+ meta, err := GetClusterMeta(acctKV, "alpha")
+ require.NoError(t, err)
+ assert.Equal(t, ClusterStatusCreating, meta.Status, "must remain CREATING when /healthz fails")
+}
+
+func TestClusterReconciler_CreatingTimesOutToFailed(t *testing.T) {
+ stub := &stubHTTPDoer{err: errors.New("connection refused")}
+ r, _, acctKV := newReconcilerHarness(t,
+ "https://nlb.example/healthz",
+ WithHTTPClient(stub),
+ WithReconcileInterval(10*time.Millisecond),
+ WithLeaseRefresh(10*time.Second),
+ WithCreateTimeout(1*time.Millisecond),
+ )
+ release, ok := r.AcquireLease()
+ require.True(t, ok)
+ defer release()
+
+ // Stamp CreatedAt in the past so the 1ms deadline is already exceeded; the
+ // cluster never becomes ready, so the reconciler must mark it FAILED.
+ meta, err := GetClusterMeta(acctKV, "alpha")
+ require.NoError(t, err)
+ meta.CreatedAt = time.Now().Add(-time.Hour).UTC()
+ require.NoError(t, PutClusterMeta(acctKV, meta))
+
+ ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
+ defer cancel()
+
+ err = r.Run(ctx)
+ assert.ErrorIs(t, err, ErrReconcilerClusterFailed)
+
+ got, err := GetClusterMeta(acctKV, "alpha")
+ require.NoError(t, err)
+ assert.Equal(t, ClusterStatusFailed, got.Status)
+ assert.Contains(t, got.StatusReason, "did not become ACTIVE")
+}
+
+func TestClusterReconciler_CreatingWithinDeadlineStaysCreating(t *testing.T) {
+ stub := &stubHTTPDoer{err: errors.New("connection refused")}
+ r, _, acctKV := newReconcilerHarness(t,
+ "https://nlb.example/healthz",
+ WithHTTPClient(stub),
+ WithReconcileInterval(10*time.Millisecond),
+ WithLeaseRefresh(10*time.Second),
+ WithCreateTimeout(time.Hour),
+ )
+ release, ok := r.AcquireLease()
+ require.True(t, ok)
+ defer release()
+
+ freshenClusterCreatedAt(t, acctKV)
+
+ ctx, cancel := context.WithTimeout(context.Background(), 200*time.Millisecond)
+ defer cancel()
+ _ = r.Run(ctx)
+
+ meta, err := GetClusterMeta(acctKV, "alpha")
+ require.NoError(t, err)
+ assert.Equal(t, ClusterStatusCreating, meta.Status, "within deadline must stay CREATING")
+}
+
+func TestClusterReconciler_DeletingExitsLoop(t *testing.T) {
+ stub := &stubHTTPDoer{status: http.StatusOK}
+ r, _, acctKV := newReconcilerHarness(t,
+ "https://nlb.example/healthz",
+ WithHTTPClient(stub),
+ WithReconcileInterval(10*time.Millisecond),
+ WithLeaseRefresh(10*time.Second),
+ )
+ release, ok := r.AcquireLease()
+ require.True(t, ok)
+ defer release()
+
+ require.NoError(t, SetClusterStatus(acctKV, "alpha", ClusterStatusDeleting))
+
+ ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
+ defer cancel()
+
+ err := r.Run(ctx)
+ assert.ErrorIs(t, err, ErrReconcilerClusterDeleting)
+}
+
+func TestClusterReconciler_LostLeaseExitsLoop(t *testing.T) {
+ stub := &stubHTTPDoer{status: http.StatusOK}
+ r, leaderKV, acctKV := newReconcilerHarness(t,
+ "https://nlb.example/healthz",
+ WithHTTPClient(stub),
+ WithReconcileInterval(10*time.Second),
+ WithLeaseRefresh(20*time.Millisecond),
+ )
+ release, ok := r.AcquireLease()
+ require.True(t, ok)
+ defer release()
+
+ seedBootstrapState(t, acctKV)
+
+ ctx, cancel := context.WithCancel(t.Context())
+ defer cancel()
+
+ runErr := make(chan error, 1)
+ go func() { runErr <- r.Run(ctx) }()
+
+ // Steal the lease in another holder's name. The next refresh tick should
+ // notice and return ErrReconcilerLeaseLost.
+ time.Sleep(40 * time.Millisecond)
+ _, err := leaderKV.Put(reconcilerLeaderKey(testAccountID, "alpha"), []byte("holder-2"))
+ require.NoError(t, err)
+
+ select {
+ case err := <-runErr:
+ assert.ErrorIs(t, err, ErrReconcilerLeaseLost)
+ case <-time.After(2 * time.Second):
+ t.Fatal("Run did not exit after lease loss")
+ }
+}
+
+func TestClusterReconciler_ActiveProbesAndWarnsOnHealthzFail(t *testing.T) {
+ stub := &stubHTTPDoer{status: http.StatusOK}
+ r, _, acctKV := newReconcilerHarness(t,
+ "https://nlb.example/healthz",
+ WithHTTPClient(stub),
+ WithReconcileInterval(10*time.Millisecond),
+ WithLeaseRefresh(10*time.Second),
+ )
+ release, ok := r.AcquireLease()
+ require.True(t, ok)
+ defer release()
+
+ require.NoError(t, SetClusterStatus(acctKV, "alpha", ClusterStatusActive))
+ stub.setResponse(http.StatusServiceUnavailable, nil)
+
+ ctx, cancel := context.WithTimeout(context.Background(), 200*time.Millisecond)
+ defer cancel()
+ _ = r.Run(ctx)
+
+ meta, err := GetClusterMeta(acctKV, "alpha")
+ require.NoError(t, err)
+ assert.Equal(t, ClusterStatusActive, meta.Status, "ACTIVE must not flip on healthz fail in this PR")
+ assert.Positive(t, stub.calls.Load())
+ assert.NotEmpty(t, meta.HealthIssue, "failing /healthz must be recorded on meta")
+ assert.False(t, meta.LastHealthProbe.IsZero(), "health probe time must be stamped")
+}
+
+func TestClusterReconciler_ActiveHealthRecoversClearsIssue(t *testing.T) {
+ stub := &stubHTTPDoer{status: http.StatusServiceUnavailable}
+ r, _, acctKV := newReconcilerHarness(t,
+ "https://nlb.example/healthz",
+ WithHTTPClient(stub),
+ WithReconcileInterval(10*time.Millisecond),
+ WithLeaseRefresh(10*time.Second),
+ )
+ release, ok := r.AcquireLease()
+ require.True(t, ok)
+ defer release()
+
+ require.NoError(t, SetClusterStatus(acctKV, "alpha", ClusterStatusActive))
+
+ // First probe fails and records the issue.
+ require.Error(t, r.probeHealthz(context.Background()))
+ require.NoError(t, r.reconcileOnce(context.Background()))
+ meta, err := GetClusterMeta(acctKV, "alpha")
+ require.NoError(t, err)
+ require.NotEmpty(t, meta.HealthIssue)
+
+ // Probe recovers: the issue must be cleared.
+ stub.setResponse(http.StatusOK, nil)
+ require.NoError(t, r.reconcileOnce(context.Background()))
+ meta, err = GetClusterMeta(acctKV, "alpha")
+ require.NoError(t, err)
+ assert.Empty(t, meta.HealthIssue, "recovered /healthz must clear the recorded issue")
+}
+
+func TestClusterReconciler_ProbeHealthzEmptyURLNoop(t *testing.T) {
+ stub := &stubHTTPDoer{}
+ r, _, _ := newReconcilerHarness(t,
+ "",
+ WithHTTPClient(stub),
+ WithReconcileInterval(10*time.Millisecond),
+ WithLeaseRefresh(10*time.Second),
+ )
+
+ require.NoError(t, r.probeHealthz(context.Background()))
+ assert.EqualValues(t, 0, stub.calls.Load(), "no HTTP call when URL empty")
+}
diff --git a/spinifex/handlers/eks/cluster_state.go b/spinifex/handlers/eks/cluster_state.go
new file mode 100644
index 000000000..dcea217a6
--- /dev/null
+++ b/spinifex/handlers/eks/cluster_state.go
@@ -0,0 +1,303 @@
+package handlers_eks
+
+import (
+ "encoding/json"
+ "errors"
+ "fmt"
+ "strings"
+ "time"
+
+ "github.com/nats-io/nats.go"
+)
+
+// ClusterStatus is the persisted lifecycle state for a cluster. Values match
+// the AWS EKS Cluster.status string set so DescribeCluster can pass them
+// through verbatim.
+type ClusterStatus string
+
+const (
+ ClusterStatusCreating ClusterStatus = "CREATING"
+ ClusterStatusActive ClusterStatus = "ACTIVE"
+ ClusterStatusDeleting ClusterStatus = "DELETING"
+ ClusterStatusFailed ClusterStatus = "FAILED"
+)
+
+// ClusterVpcConfig is the persisted subset of eks.VpcConfigResponse that the
+// reconciler + DescribeCluster need at-rest.
+type ClusterVpcConfig struct {
+ SubnetIds []string `json:"subnetIds"`
+ SecurityGroupIds []string `json:"securityGroupIds,omitempty"`
+ VpcId string `json:"vpcId,omitempty"`
+ // EndpointPublicAccess / EndpointPrivateAccess control apiserver endpoint
+ // exposure (AWS resourcesVpcConfig parity). Public ⇒ internet-facing cluster
+ // NLB (external-pool front-end IP, LAN-reachable); private ⇒ internal NLB
+ // (VPC-only). PublicAccessCidrs records the allowed source ranges for the
+ // public endpoint (stored for parity + Describe; ingress enforcement is a
+ // follow-up).
+ EndpointPublicAccess bool `json:"endpointPublicAccess"`
+ EndpointPrivateAccess bool `json:"endpointPrivateAccess"`
+ PublicAccessCidrs []string `json:"publicAccessCidrs,omitempty"`
+}
+
+// ClusterMeta is the persisted control-plane record for one cluster. The
+// blob lives at ClusterMetaKey(name) inside the per-account KV bucket and is
+// the source of truth for DescribeCluster.
+type ClusterMeta struct {
+ Name string `json:"name"`
+ Arn string `json:"arn"`
+ Status ClusterStatus `json:"status"`
+ StatusReason string `json:"statusReason,omitempty"`
+ Version string `json:"version"`
+ RoleArn string `json:"roleArn"`
+ Endpoint string `json:"endpoint,omitempty"`
+ // EndpointIP is the host/LAN-reachable front-end IP the cluster NLB exposes
+ // (public-access ⇒ external-pool IP; private ⇒ VPC IP). The apiserver serving
+ // cert SANs this IP, so kubectl reaches Endpoint with TLS verification on.
+ EndpointIP string `json:"endpointIp,omitempty"`
+ OIDCIssuer string `json:"oidcIssuer,omitempty"`
+ CertificateAuthorityB64 string `json:"certificateAuthorityB64,omitempty"`
+ ResourcesVpcConfig *ClusterVpcConfig `json:"resourcesVpcConfig,omitempty"`
+ ControlPlaneInstanceID string `json:"controlPlaneInstanceId,omitempty"`
+ ControlPlaneENIID string `json:"controlPlaneEniId,omitempty"`
+ ControlPlaneENIIP string `json:"controlPlaneEniIp,omitempty"`
+ // ControlPlaneMgmtIP is the control-plane VM's br-mgmt NIC address. Until
+ // authoritative DNS (Eclipso/Route53) lands, the host-side reconciler cannot
+ // resolve or route to the VPC-internal NLB DNS endpoint, so it probes
+ // /healthz on this host-reachable address instead.
+ ControlPlaneMgmtIP string `json:"controlPlaneMgmtIp,omitempty"`
+ // EgressEIPAllocationID / EgressEIPPublicIP track the hidden pool address
+ // SNAT'd to the control-plane VM for egress-only internet (image pulls).
+ // Released + the snat removed on DeleteCluster.
+ EgressEIPAllocationID string `json:"egressEipAllocationId,omitempty"`
+ EgressEIPPublicIP string `json:"egressEipPublicIp,omitempty"`
+ NLBArn string `json:"nlbArn,omitempty"`
+ NLBTargetGroupArn string `json:"nlbTargetGroupArn,omitempty"`
+ CreatedAt time.Time `json:"createdAt"`
+ // HealthIssue is the last /healthz probe failure reason for an ACTIVE
+ // cluster; empty means healthy. DescribeCluster surfaces a non-empty value
+ // as a ClusterHealth issue so a dead control plane is visible behind the
+ // still-ACTIVE status. LastHealthProbe stamps when the health state changed.
+ HealthIssue string `json:"healthIssue,omitempty"`
+ // No omitempty/omitzero: stdlib encoding/json ignores omitzero (json/v2
+ // only) and never treats a time.Time struct as empty, so this field always
+ // serializes. The tag states that honestly rather than implying it elides.
+ LastHealthProbe time.Time `json:"lastHealthProbe"`
+ // NodeCount is the node total from the control plane's last NATS state
+ // report (server + joined agents). Surfaces nodegroup join/scale progress
+ // without the host needing apiserver reachability.
+ NodeCount int `json:"nodeCount,omitempty"`
+ // BuiltinIngress records whether the cluster opted into K3s' bundled
+ // traefik + servicelb (dev / interim in-VPC app exposure), derived from the
+ // managed-ingress tag at CreateCluster. Default false = AWS parity (built-ins
+ // disabled; ingress via the AWS Load Balancer Controller).
+ BuiltinIngress bool `json:"builtinIngress,omitempty"`
+}
+
+// ErrClusterNotFound is returned by GetClusterMeta / SetClusterStatus /
+// DeleteClusterPrefix when the cluster meta key is absent. Callers translate
+// to the AWS shape (ResourceNotFoundException) at the service boundary.
+var ErrClusterNotFound = errors.New("eks: cluster not found")
+
+const maxClusterStateCASRetries = 5
+
+// PutClusterMeta writes the meta record unconditionally. Used at
+// CreateCluster time to lay down the initial CREATING record.
+func PutClusterMeta(kv nats.KeyValue, meta *ClusterMeta) error {
+ if meta == nil {
+ return errors.New("eks: PutClusterMeta nil meta")
+ }
+ if meta.Name == "" {
+ return errors.New("eks: PutClusterMeta meta.Name empty")
+ }
+ data, err := json.Marshal(meta)
+ if err != nil {
+ return fmt.Errorf("marshal cluster meta %s: %w", meta.Name, err)
+ }
+ if _, err := kv.Put(ClusterMetaKey(meta.Name), data); err != nil {
+ return fmt.Errorf("kv put %s: %w", ClusterMetaKey(meta.Name), err)
+ }
+ return nil
+}
+
+// GetClusterMeta reads the meta record. Returns ErrClusterNotFound if the
+// key is absent.
+func GetClusterMeta(kv nats.KeyValue, name string) (*ClusterMeta, error) {
+ if name == "" {
+ return nil, errors.New("eks: GetClusterMeta empty name")
+ }
+ entry, err := kv.Get(ClusterMetaKey(name))
+ if err != nil {
+ if errors.Is(err, nats.ErrKeyNotFound) {
+ return nil, ErrClusterNotFound
+ }
+ return nil, fmt.Errorf("kv get %s: %w", ClusterMetaKey(name), err)
+ }
+ var meta ClusterMeta
+ if err := json.Unmarshal(entry.Value(), &meta); err != nil {
+ return nil, fmt.Errorf("unmarshal cluster meta %s: %w", name, err)
+ }
+ return &meta, nil
+}
+
+// casUpdateMeta does a revision-checked read-modify-write of the cluster meta
+// record. mutate receives the decoded meta and returns true if it changed a
+// field (false short-circuits to a no-op success). Retries on KV CAS conflict
+// (concurrent reconciler write) up to maxClusterStateCASRetries. Returns
+// ErrClusterNotFound if the meta record was deleted underneath us.
+func casUpdateMeta(kv nats.KeyValue, name string, mutate func(*ClusterMeta) bool) error {
+ if name == "" {
+ return errors.New("eks: casUpdateMeta empty name")
+ }
+ for range maxClusterStateCASRetries {
+ entry, err := kv.Get(ClusterMetaKey(name))
+ if err != nil {
+ if errors.Is(err, nats.ErrKeyNotFound) {
+ return ErrClusterNotFound
+ }
+ return fmt.Errorf("kv get %s: %w", ClusterMetaKey(name), err)
+ }
+ var meta ClusterMeta
+ if err := json.Unmarshal(entry.Value(), &meta); err != nil {
+ return fmt.Errorf("unmarshal cluster meta %s: %w", name, err)
+ }
+ if !mutate(&meta) {
+ return nil
+ }
+ data, err := json.Marshal(&meta)
+ if err != nil {
+ return fmt.Errorf("marshal cluster meta %s: %w", name, err)
+ }
+ _, err = kv.Update(ClusterMetaKey(name), data, entry.Revision())
+ if err == nil {
+ return nil
+ }
+ if errors.Is(err, nats.ErrKeyExists) {
+ continue
+ }
+ return fmt.Errorf("kv update %s: %w", ClusterMetaKey(name), err)
+ }
+ return fmt.Errorf("eks: casUpdateMeta %s exhausted CAS retries", name)
+}
+
+// SetClusterStatus does a revision-checked update of the meta.Status field.
+// Returns ErrClusterNotFound if the meta record was deleted underneath us.
+func SetClusterStatus(kv nats.KeyValue, name string, status ClusterStatus) error {
+ if name == "" {
+ return errors.New("eks: SetClusterStatus empty name")
+ }
+ return casUpdateMeta(kv, name, func(m *ClusterMeta) bool {
+ if m.Status == status {
+ return false
+ }
+ m.Status = status
+ return true
+ })
+}
+
+// MarkClusterFailed transitions a cluster to FAILED with a human-readable
+// reason, but only from CREATING — a cluster already DELETING/ACTIVE/FAILED is
+// left untouched so a late bootstrap error cannot clobber a concurrent delete
+// or an already-healthy cluster. Returns ErrClusterNotFound if the meta record
+// was deleted underneath us.
+func MarkClusterFailed(kv nats.KeyValue, name, reason string) error {
+ if name == "" {
+ return errors.New("eks: MarkClusterFailed empty name")
+ }
+ return casUpdateMeta(kv, name, func(m *ClusterMeta) bool {
+ if m.Status != ClusterStatusCreating {
+ return false
+ }
+ m.Status = ClusterStatusFailed
+ m.StatusReason = reason
+ return true
+ })
+}
+
+// SetClusterCertificateAuthority does a revision-checked update of the
+// meta.CertificateAuthorityB64 field. The NATS bootstrap subscriber calls
+// this once the K3s server VM publishes its CA on the bootstrap bus. Returns
+// ErrClusterNotFound if the meta record was deleted underneath us.
+func SetClusterCertificateAuthority(kv nats.KeyValue, name, caB64 string) error {
+ if name == "" {
+ return errors.New("eks: SetClusterCertificateAuthority empty name")
+ }
+ if caB64 == "" {
+ return errors.New("eks: SetClusterCertificateAuthority empty CA")
+ }
+ return casUpdateMeta(kv, name, func(m *ClusterMeta) bool {
+ if m.CertificateAuthorityB64 == caB64 {
+ return false
+ }
+ m.CertificateAuthorityB64 = caB64
+ return true
+ })
+}
+
+// SetClusterHealth records the latest /healthz probe outcome for a cluster:
+// issue is the failure reason (empty clears it / marks healthy). Writes only
+// when the health state changes, stamping LastHealthProbe at the transition so
+// a persistently-unhealthy cluster carries the time it first went bad rather
+// than churning the KV every probe interval. Returns ErrClusterNotFound if the
+// meta record was deleted underneath us.
+func SetClusterHealth(kv nats.KeyValue, name, issue string) error {
+ if name == "" {
+ return errors.New("eks: SetClusterHealth empty name")
+ }
+ return casUpdateMeta(kv, name, func(m *ClusterMeta) bool {
+ if m.HealthIssue == issue {
+ return false
+ }
+ m.HealthIssue = issue
+ m.LastHealthProbe = time.Now().UTC()
+ return true
+ })
+}
+
+// SetClusterHealthState records the latest health outcome together with the
+// node count from the control-plane state report. issue is the failure reason
+// (empty clears it / marks healthy). LastHealthProbe is stamped whenever health
+// or node count changes; a steady-state healthy cluster with an unchanging node
+// count produces no KV write. Returns ErrClusterNotFound if the meta record was
+// deleted underneath us.
+func SetClusterHealthState(kv nats.KeyValue, name, issue string, nodeCount int) error {
+ if name == "" {
+ return errors.New("eks: SetClusterHealthState empty name")
+ }
+ return casUpdateMeta(kv, name, func(m *ClusterMeta) bool {
+ if m.HealthIssue == issue && m.NodeCount == nodeCount {
+ return false
+ }
+ m.HealthIssue = issue
+ m.NodeCount = nodeCount
+ m.LastHealthProbe = time.Now().UTC()
+ return true
+ })
+}
+
+// DeleteClusterPrefix removes every KV key under clusters/{name}/. Called
+// from DeleteCluster after the OIDC private key has been zeroized.
+// Returns the first delete error encountered but continues sweeping so
+// best-effort cleanup proceeds.
+func DeleteClusterPrefix(kv nats.KeyValue, name string) error {
+ if name == "" {
+ return errors.New("eks: DeleteClusterPrefix empty name")
+ }
+ prefix := fmt.Sprintf("clusters/%s/", name)
+ keys, err := kv.Keys()
+ if err != nil {
+ if errors.Is(err, nats.ErrNoKeysFound) {
+ return nil
+ }
+ return fmt.Errorf("kv keys: %w", err)
+ }
+ var firstErr error
+ for _, k := range keys {
+ if !strings.HasPrefix(k, prefix) {
+ continue
+ }
+ if err := kv.Delete(k); err != nil && firstErr == nil {
+ firstErr = fmt.Errorf("kv delete %s: %w", k, err)
+ }
+ }
+ return firstErr
+}
diff --git a/spinifex/handlers/eks/cluster_state_test.go b/spinifex/handlers/eks/cluster_state_test.go
new file mode 100644
index 000000000..d25d2ca52
--- /dev/null
+++ b/spinifex/handlers/eks/cluster_state_test.go
@@ -0,0 +1,256 @@
+package handlers_eks
+
+import (
+ "encoding/json"
+ "errors"
+ "testing"
+ "time"
+
+ "github.com/mulgadc/spinifex/spinifex/testutil"
+ "github.com/nats-io/nats.go"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func newClusterStateTestKV(t *testing.T) nats.KeyValue {
+ t.Helper()
+ _, _, js := testutil.StartTestJetStream(t)
+ kv, err := GetOrCreateAccountBucket(js, testAccountID)
+ require.NoError(t, err)
+ return kv
+}
+
+func sampleClusterMeta(name string) *ClusterMeta {
+ return &ClusterMeta{
+ Name: name,
+ Arn: "arn:aws:eks:us-east-1:111122223333:cluster/" + name,
+ Status: ClusterStatusCreating,
+ Version: "1.32",
+ RoleArn: "arn:aws:iam::111122223333:role/eks-cluster",
+ ResourcesVpcConfig: &ClusterVpcConfig{
+ SubnetIds: []string{"subnet-aaa"},
+ SecurityGroupIds: []string{"sg-aaa"},
+ },
+ CreatedAt: time.Date(2026, 6, 1, 0, 0, 0, 0, time.UTC),
+ }
+}
+
+func TestPutClusterMeta_NilOrEmptyNameRejected(t *testing.T) {
+ kv := newClusterStateTestKV(t)
+
+ require.Error(t, PutClusterMeta(kv, nil))
+ require.Error(t, PutClusterMeta(kv, &ClusterMeta{}))
+}
+
+func TestPutClusterMeta_RoundTrip(t *testing.T) {
+ kv := newClusterStateTestKV(t)
+
+ in := sampleClusterMeta("alpha")
+ require.NoError(t, PutClusterMeta(kv, in))
+
+ got, err := GetClusterMeta(kv, "alpha")
+ require.NoError(t, err)
+ assert.Equal(t, in.Name, got.Name)
+ assert.Equal(t, in.Arn, got.Arn)
+ assert.Equal(t, in.Status, got.Status)
+ assert.Equal(t, in.Version, got.Version)
+ assert.Equal(t, in.RoleArn, got.RoleArn)
+ require.NotNil(t, got.ResourcesVpcConfig)
+ assert.Equal(t, []string{"subnet-aaa"}, got.ResourcesVpcConfig.SubnetIds)
+ assert.True(t, got.CreatedAt.Equal(in.CreatedAt))
+}
+
+func TestGetClusterMeta_MissingReturnsErrClusterNotFound(t *testing.T) {
+ kv := newClusterStateTestKV(t)
+
+ _, err := GetClusterMeta(kv, "missing")
+ require.ErrorIs(t, err, ErrClusterNotFound)
+}
+
+func TestGetClusterMeta_EmptyNameRejected(t *testing.T) {
+ kv := newClusterStateTestKV(t)
+
+ _, err := GetClusterMeta(kv, "")
+ require.Error(t, err)
+ assert.NotErrorIs(t, err, ErrClusterNotFound)
+}
+
+func TestGetClusterMeta_CorruptBlobRejected(t *testing.T) {
+ kv := newClusterStateTestKV(t)
+
+ _, err := kv.Put(ClusterMetaKey("corrupt"), []byte("{not json"))
+ require.NoError(t, err)
+
+ _, err = GetClusterMeta(kv, "corrupt")
+ require.Error(t, err)
+ assert.NotErrorIs(t, err, ErrClusterNotFound)
+}
+
+func TestSetClusterStatus_TransitionsAndIsIdempotent(t *testing.T) {
+ kv := newClusterStateTestKV(t)
+ require.NoError(t, PutClusterMeta(kv, sampleClusterMeta("alpha")))
+
+ require.NoError(t, SetClusterStatus(kv, "alpha", ClusterStatusActive))
+ got, err := GetClusterMeta(kv, "alpha")
+ require.NoError(t, err)
+ assert.Equal(t, ClusterStatusActive, got.Status)
+
+ require.NoError(t, SetClusterStatus(kv, "alpha", ClusterStatusActive))
+ got, err = GetClusterMeta(kv, "alpha")
+ require.NoError(t, err)
+ assert.Equal(t, ClusterStatusActive, got.Status)
+}
+
+func TestSetClusterStatus_DeletingAlwaysAllowedFromAnyState(t *testing.T) {
+ kv := newClusterStateTestKV(t)
+
+ for _, from := range []ClusterStatus{ClusterStatusCreating, ClusterStatusActive, ClusterStatusFailed} {
+ meta := sampleClusterMeta("alpha")
+ meta.Status = from
+ require.NoError(t, PutClusterMeta(kv, meta))
+ require.NoError(t, SetClusterStatus(kv, "alpha", ClusterStatusDeleting))
+ got, err := GetClusterMeta(kv, "alpha")
+ require.NoError(t, err)
+ assert.Equal(t, ClusterStatusDeleting, got.Status, "from=%s", from)
+ }
+}
+
+func TestSetClusterStatus_MissingReturnsErrClusterNotFound(t *testing.T) {
+ kv := newClusterStateTestKV(t)
+
+ err := SetClusterStatus(kv, "missing", ClusterStatusActive)
+ require.ErrorIs(t, err, ErrClusterNotFound)
+}
+
+func TestSetClusterStatus_RecoversFromConcurrentRevisionBump(t *testing.T) {
+ kv := newClusterStateTestKV(t)
+ require.NoError(t, PutClusterMeta(kv, sampleClusterMeta("alpha")))
+
+ // Read once to get a stale revision, then bump the record from another
+ // goroutine via Put to force one CAS conflict on the next Update.
+ entry, err := kv.Get(ClusterMetaKey("alpha"))
+ require.NoError(t, err)
+
+ bumped := sampleClusterMeta("alpha")
+ bumped.Version = "1.32-bumped"
+ data, err := json.Marshal(bumped)
+ require.NoError(t, err)
+ _, err = kv.Update(ClusterMetaKey("alpha"), data, entry.Revision())
+ require.NoError(t, err)
+
+ // SetClusterStatus must succeed even though there has been an intervening
+ // revision bump — its internal loop re-reads + retries.
+ require.NoError(t, SetClusterStatus(kv, "alpha", ClusterStatusActive))
+ got, err := GetClusterMeta(kv, "alpha")
+ require.NoError(t, err)
+ assert.Equal(t, ClusterStatusActive, got.Status)
+ assert.Equal(t, "1.32-bumped", got.Version)
+}
+
+func TestMarkClusterFailed_FromCreatingSetsStatusAndReason(t *testing.T) {
+ kv := newClusterStateTestKV(t)
+ require.NoError(t, PutClusterMeta(kv, sampleClusterMeta("alpha")))
+
+ require.NoError(t, MarkClusterFailed(kv, "alpha", "bootstrap failed: boom"))
+ got, err := GetClusterMeta(kv, "alpha")
+ require.NoError(t, err)
+ assert.Equal(t, ClusterStatusFailed, got.Status)
+ assert.Equal(t, "bootstrap failed: boom", got.StatusReason)
+}
+
+func TestMarkClusterFailed_NoopFromNonCreating(t *testing.T) {
+ kv := newClusterStateTestKV(t)
+
+ for _, from := range []ClusterStatus{ClusterStatusActive, ClusterStatusDeleting, ClusterStatusFailed} {
+ meta := sampleClusterMeta("alpha")
+ meta.Status = from
+ require.NoError(t, PutClusterMeta(kv, meta))
+
+ require.NoError(t, MarkClusterFailed(kv, "alpha", "late bootstrap error"))
+ got, err := GetClusterMeta(kv, "alpha")
+ require.NoError(t, err)
+ assert.Equal(t, from, got.Status, "from=%s must be untouched", from)
+ assert.Empty(t, got.StatusReason, "from=%s reason must not be set", from)
+ }
+}
+
+func TestMarkClusterFailed_MissingReturnsErrClusterNotFound(t *testing.T) {
+ kv := newClusterStateTestKV(t)
+
+ err := MarkClusterFailed(kv, "ghost", "boom")
+ require.ErrorIs(t, err, ErrClusterNotFound)
+}
+
+func TestSetClusterCertificateAuthority_WritesAndIsIdempotent(t *testing.T) {
+ kv := newClusterStateTestKV(t)
+ require.NoError(t, PutClusterMeta(kv, sampleClusterMeta("alpha")))
+
+ require.NoError(t, SetClusterCertificateAuthority(kv, "alpha", "ca-blob-b64"))
+ got, err := GetClusterMeta(kv, "alpha")
+ require.NoError(t, err)
+ assert.Equal(t, "ca-blob-b64", got.CertificateAuthorityB64)
+
+ require.NoError(t, SetClusterCertificateAuthority(kv, "alpha", "ca-blob-b64"))
+ got, err = GetClusterMeta(kv, "alpha")
+ require.NoError(t, err)
+ assert.Equal(t, "ca-blob-b64", got.CertificateAuthorityB64)
+}
+
+func TestSetClusterCertificateAuthority_MissingReturnsErrClusterNotFound(t *testing.T) {
+ kv := newClusterStateTestKV(t)
+
+ err := SetClusterCertificateAuthority(kv, "ghost", "ca-blob")
+ require.ErrorIs(t, err, ErrClusterNotFound)
+}
+
+func TestSetClusterCertificateAuthority_EmptyInputsRejected(t *testing.T) {
+ kv := newClusterStateTestKV(t)
+
+ require.Error(t, SetClusterCertificateAuthority(kv, "", "ca-blob"))
+ require.Error(t, SetClusterCertificateAuthority(kv, "alpha", ""))
+}
+
+func TestDeleteClusterPrefix_SweepsEveryKey(t *testing.T) {
+ kv := newClusterStateTestKV(t)
+
+ require.NoError(t, PutClusterMeta(kv, sampleClusterMeta("alpha")))
+ _, err := kv.Put(NodegroupKey("alpha", "ng-1"), []byte(`{"name":"ng-1"}`))
+ require.NoError(t, err)
+ _, err = kv.Put(OIDCSigningKeyKey("alpha"), []byte("enc-blob"))
+ require.NoError(t, err)
+ _, err = kv.Put(OIDCJWKSKey("alpha"), []byte(`{"keys":[]}`))
+ require.NoError(t, err)
+ _, err = kv.Put(EventKey("alpha", "1700000000"), []byte(`{"ts":"1700000000"}`))
+ require.NoError(t, err)
+ // Sibling cluster must survive the sweep.
+ require.NoError(t, PutClusterMeta(kv, sampleClusterMeta("beta")))
+
+ require.NoError(t, DeleteClusterPrefix(kv, "alpha"))
+
+ for _, k := range []string{
+ ClusterMetaKey("alpha"),
+ NodegroupKey("alpha", "ng-1"),
+ OIDCSigningKeyKey("alpha"),
+ OIDCJWKSKey("alpha"),
+ EventKey("alpha", "1700000000"),
+ } {
+ _, err := kv.Get(k)
+ assert.True(t, errors.Is(err, nats.ErrKeyNotFound), "key %s should be gone, got err=%v", k, err)
+ }
+
+ got, err := GetClusterMeta(kv, "beta")
+ require.NoError(t, err)
+ assert.Equal(t, "beta", got.Name)
+}
+
+func TestDeleteClusterPrefix_EmptyBucketIsNoop(t *testing.T) {
+ kv := newClusterStateTestKV(t)
+
+ require.NoError(t, DeleteClusterPrefix(kv, "ghost"))
+}
+
+func TestDeleteClusterPrefix_EmptyNameRejected(t *testing.T) {
+ kv := newClusterStateTestKV(t)
+
+ require.Error(t, DeleteClusterPrefix(kv, ""))
+}
diff --git a/spinifex/handlers/eks/create_cluster_test.go b/spinifex/handlers/eks/create_cluster_test.go
new file mode 100644
index 000000000..5d4be8481
--- /dev/null
+++ b/spinifex/handlers/eks/create_cluster_test.go
@@ -0,0 +1,280 @@
+package handlers_eks
+
+import (
+ "errors"
+ "testing"
+
+ "github.com/aws/aws-sdk-go/aws"
+ "github.com/aws/aws-sdk-go/service/ec2"
+ "github.com/aws/aws-sdk-go/service/eks"
+ "github.com/mulgadc/spinifex/spinifex/awserrors"
+ "github.com/mulgadc/spinifex/spinifex/tags"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func createInput(name string) *eks.CreateClusterInput {
+ return &eks.CreateClusterInput{
+ Name: aws.String(name),
+ RoleArn: aws.String("arn:aws:iam::111122223333:role/eks-cluster"),
+ Version: aws.String("1.32"),
+ ResourcesVpcConfig: &eks.VpcConfigRequest{
+ SubnetIds: aws.StringSlice([]string{"subnet-aaa"}),
+ },
+ }
+}
+
+func TestCreateCluster_BuiltinIngressDefaultsOn(t *testing.T) {
+ f := newEKSServiceFixture(t)
+
+ _, err := f.svc.CreateCluster(createInput("alpha"), testAccountID, "")
+ require.NoError(t, err)
+
+ meta, err := GetClusterMeta(f.kv, "alpha")
+ require.NoError(t, err)
+ assert.True(t, meta.BuiltinIngress, "interim default: built-in ingress on until aws-lb-controller ships")
+}
+
+func TestCreateCluster_BuiltinIngressOptOutFromTag(t *testing.T) {
+ f := newEKSServiceFixture(t)
+
+ in := createInput("alpha")
+ in.Tags = map[string]*string{managedIngressTagKey: aws.String("false")}
+ _, err := f.svc.CreateCluster(in, testAccountID, "")
+ require.NoError(t, err)
+
+ meta, err := GetClusterMeta(f.kv, "alpha")
+ require.NoError(t, err)
+ assert.False(t, meta.BuiltinIngress, "managed-ingress=false opts out for AWS parity")
+}
+
+func TestCreateCluster_BuiltinIngressFromTag(t *testing.T) {
+ f := newEKSServiceFixture(t)
+
+ in := createInput("alpha")
+ in.Tags = map[string]*string{managedIngressTagKey: aws.String("true")}
+ _, err := f.svc.CreateCluster(in, testAccountID, "")
+ require.NoError(t, err)
+
+ meta, err := GetClusterMeta(f.kv, "alpha")
+ require.NoError(t, err)
+ assert.True(t, meta.BuiltinIngress, "managed-ingress=true keeps built-in ingress")
+}
+
+// A create that fails after the NLB is provisioned must leave the NLB ARNs
+// persisted on the (now FAILED) meta, otherwise the resources leak with no
+// owning record and DeleteCluster cannot reclaim them (bead 165.3).
+func TestCreateCluster_NLBArnPersistedBeforeLaterFailure(t *testing.T) {
+ f := newEKSServiceFixture(t)
+
+ // Force the K3s VM launch to fail — this is after EnsureClusterNLB and the
+ // NLB-arn persist checkpoint, but before the final PutClusterMeta.
+ f.inst.launchErr = errors.New("no capacity")
+
+ _, err := f.svc.CreateCluster(createInput("alpha"), testAccountID, "")
+ require.Error(t, err)
+
+ meta, getErr := GetClusterMeta(f.kv, "alpha")
+ require.NoError(t, getErr, "meta must remain so the leaked NLB has an owning record")
+ assert.Equal(t, ClusterStatusFailed, meta.Status)
+ assert.NotEmpty(t, meta.NLBArn, "NLB ARN must be persisted before the VM-launch step")
+ assert.NotEmpty(t, meta.NLBTargetGroupArn)
+}
+
+// End-to-end: a failed create followed by delete-cluster leaves zero orphaned
+// NLB resources. The persisted ARNs from the partial create drive teardown.
+func TestCreateCluster_FailedCreateThenDeleteReclaimsNLB(t *testing.T) {
+ f := newEKSServiceFixture(t)
+ f.inst.launchErr = errors.New("no capacity")
+
+ _, err := f.svc.CreateCluster(createInput("alpha"), testAccountID, "")
+ require.Error(t, err)
+
+ // The NLB the partial create provisioned exists in the fake.
+ require.NotEmpty(t, f.nlb.createLBCalls, "create provisioned an NLB")
+
+ _, err = f.svc.DeleteCluster(deleteInput("alpha"), testAccountID)
+ require.NoError(t, err)
+
+ assert.NotEmpty(t, f.nlb.deleteLBCalls, "DeleteCluster must tear down the NLB recorded by the failed create")
+ _, getErr := GetClusterMeta(f.kv, "alpha")
+ assert.ErrorIs(t, getErr, ErrClusterNotFound)
+}
+
+// A live (CREATING/ACTIVE) cluster of the same name blocks create with a
+// cluster-scoped ResourceInUseException, not the ELBv2 target-group message.
+func TestCreateCluster_ExistingClusterReturnsResourceInUse(t *testing.T) {
+ f := newEKSServiceFixture(t)
+
+ meta := sampleClusterMeta("alpha")
+ meta.Status = ClusterStatusActive
+ require.NoError(t, PutClusterMeta(f.kv, meta))
+
+ _, err := f.svc.CreateCluster(createInput("alpha"), testAccountID, "")
+ require.EqualError(t, err, awserrors.ErrorEKSResourceInUse)
+}
+
+// A FAILED cluster from a prior attempt must not block a retry: create reclaims
+// it (tearing down recorded resources) and proceeds to a fresh CREATING cluster.
+func TestCreateCluster_FailedClusterIsReclaimedOnRetry(t *testing.T) {
+ f := newEKSServiceFixture(t)
+
+ // First attempt fails at VM launch, leaving a FAILED meta with NLB ARNs.
+ f.inst.launchErr = errors.New("no capacity")
+ _, err := f.svc.CreateCluster(createInput("alpha"), testAccountID, "")
+ require.Error(t, err)
+ failed, err := GetClusterMeta(f.kv, "alpha")
+ require.NoError(t, err)
+ require.Equal(t, ClusterStatusFailed, failed.Status)
+
+ // Retry now succeeds: the reclaim path purges the failed attempt's NLB and
+ // the create starts clean.
+ f.inst.launchErr = nil
+ _, err = f.svc.CreateCluster(createInput("alpha"), testAccountID, "")
+ require.NoError(t, err)
+
+ got, err := GetClusterMeta(f.kv, "alpha")
+ require.NoError(t, err)
+ assert.Equal(t, ClusterStatusCreating, got.Status)
+ assert.NotEmpty(t, f.nlb.deleteLBCalls, "reclaim must tear down the failed attempt's NLB")
+}
+
+// A FAILED cluster is observable via Describe and List (bead 165.6) — no state
+// where create blocks but describe/list show nothing.
+func TestCreateCluster_FailedClusterVisibleInDescribeAndList(t *testing.T) {
+ f := newEKSServiceFixture(t)
+
+ meta := sampleClusterMeta("alpha")
+ meta.Status = ClusterStatusFailed
+ meta.StatusReason = "bootstrap failed: boom"
+ require.NoError(t, PutClusterMeta(f.kv, meta))
+
+ desc, err := f.svc.DescribeCluster(&eks.DescribeClusterInput{Name: aws.String("alpha")}, testAccountID)
+ require.NoError(t, err)
+ assert.Equal(t, eks.ClusterStatusFailed, aws.StringValue(desc.Cluster.Status))
+
+ list, err := f.svc.ListClusters(&eks.ListClustersInput{}, testAccountID)
+ require.NoError(t, err)
+ assert.Contains(t, aws.StringValueSlice(list.Clusters), "alpha")
+}
+
+// An ACTIVE cluster whose reconciler recorded a /healthz failure must surface
+// that as a ClusterHealth issue in describe-cluster, so a dead control plane is
+// visible behind the still-ACTIVE status (bead 165.13).
+func TestDescribeCluster_SurfacesHealthIssueForActiveCluster(t *testing.T) {
+ f := newEKSServiceFixture(t)
+
+ meta := sampleClusterMeta("alpha")
+ meta.Status = ClusterStatusActive
+ require.NoError(t, PutClusterMeta(f.kv, meta))
+ require.NoError(t, SetClusterHealth(f.kv, "alpha", "healthz request: connection refused"))
+
+ desc, err := f.svc.DescribeCluster(&eks.DescribeClusterInput{Name: aws.String("alpha")}, testAccountID)
+ require.NoError(t, err)
+ assert.Equal(t, eks.ClusterStatusActive, aws.StringValue(desc.Cluster.Status))
+ require.NotNil(t, desc.Cluster.Health)
+ require.Len(t, desc.Cluster.Health.Issues, 1)
+ assert.Equal(t, eks.ClusterIssueCodeClusterUnreachable, aws.StringValue(desc.Cluster.Health.Issues[0].Code))
+ assert.Contains(t, aws.StringValue(desc.Cluster.Health.Issues[0].Message), "connection refused")
+}
+
+// A healthy ACTIVE cluster carries no health issue in describe-cluster.
+func TestDescribeCluster_HealthyActiveClusterHasNoIssues(t *testing.T) {
+ f := newEKSServiceFixture(t)
+
+ meta := sampleClusterMeta("alpha")
+ meta.Status = ClusterStatusActive
+ require.NoError(t, PutClusterMeta(f.kv, meta))
+
+ desc, err := f.svc.DescribeCluster(&eks.DescribeClusterInput{Name: aws.String("alpha")}, testAccountID)
+ require.NoError(t, err)
+ assert.Nil(t, desc.Cluster.Health, "healthy cluster must not report a ClusterHealth issue")
+}
+
+// A missing eks-server AMI is an operator/config gap, not an unrecoverable
+// internal fault: CreateCluster must surface ServiceUnavailable (not a generic
+// ServerInternal) and mark the half-built cluster FAILED (bead 165.4).
+func TestCreateCluster_MissingAMIReturnsServiceUnavailable(t *testing.T) {
+ f := newEKSServiceFixture(t)
+ f.ami.describeOut = &ec2.DescribeImagesOutput{} // no images tagged managed-by=eks
+
+ _, err := f.svc.CreateCluster(createInput("alpha"), testAccountID, "")
+ require.EqualError(t, err, awserrors.ErrorServiceUnavailable)
+
+ meta, getErr := GetClusterMeta(f.kv, "alpha")
+ require.NoError(t, getErr)
+ assert.Equal(t, ClusterStatusFailed, meta.Status)
+}
+
+func TestCreateCluster_HappyPathPersistsActiveCreatingMeta(t *testing.T) {
+ f := newEKSServiceFixture(t)
+
+ out, err := f.svc.CreateCluster(createInput("alpha"), testAccountID, "")
+ require.NoError(t, err)
+ require.NotNil(t, out)
+
+ meta, err := GetClusterMeta(f.kv, "alpha")
+ require.NoError(t, err)
+ assert.Equal(t, ClusterStatusCreating, meta.Status)
+ assert.NotEmpty(t, meta.NLBArn)
+ assert.NotEmpty(t, meta.ControlPlaneInstanceID)
+ assert.NotEmpty(t, meta.ControlPlaneENIID)
+}
+
+// bootstrapClusterCreatorAdminPermissions defaults true: a successful create
+// with a known caller principal ARN mints a system:masters AccessEntry for the
+// caller, keyed by their exact ARN (the token webhook looks it up by the same).
+func TestCreateCluster_SeedsCreatorAdminAccessEntry(t *testing.T) {
+ f := newEKSServiceFixture(t)
+ const caller = "arn:aws:iam::111122223333:role/admin"
+
+ _, err := f.svc.CreateCluster(createInput("alpha"), testAccountID, caller)
+ require.NoError(t, err)
+
+ rec, err := GetAccessEntryRecord(f.kv, "alpha", caller)
+ require.NoError(t, err)
+ assert.Equal(t, []string{"system:masters"}, rec.KubernetesGroups)
+ assert.Equal(t, caller, rec.KubernetesUsername)
+ assert.Equal(t, AccessEntryTypeStandard, rec.Type)
+}
+
+// With the bootstrap flag explicitly false, no creator-admin entry is minted.
+func TestCreateCluster_SkipsCreatorAdminWhenDisabled(t *testing.T) {
+ f := newEKSServiceFixture(t)
+ const caller = "arn:aws:iam::111122223333:role/admin"
+ in := createInput("alpha")
+ in.AccessConfig = &eks.CreateAccessConfigRequest{
+ BootstrapClusterCreatorAdminPermissions: aws.Bool(false),
+ }
+
+ _, err := f.svc.CreateCluster(in, testAccountID, caller)
+ require.NoError(t, err)
+
+ _, err = GetAccessEntryRecord(f.kv, "alpha", caller)
+ assert.ErrorIs(t, err, ErrAccessEntryNotFound)
+}
+
+func TestCreateCluster_AllocatesHiddenEgressEIP(t *testing.T) {
+ f := newEKSServiceFixture(t)
+
+ _, err := f.svc.CreateCluster(createInput("egress"), testAccountID, "")
+ require.NoError(t, err)
+
+ meta, err := GetClusterMeta(f.kv, "egress")
+ require.NoError(t, err)
+ assert.Equal(t, f.eip.publicIP, meta.EgressEIPPublicIP)
+ assert.Equal(t, f.eip.allocationID, meta.EgressEIPAllocationID)
+
+ require.Len(t, f.eip.allocateCalls, 1)
+ // The pool address is tagged ManagedBy=eks so it stays out of customer
+ // DescribeAddresses listings.
+ tagged := false
+ for _, spec := range f.eip.allocateCalls[0].TagSpecifications {
+ for _, tg := range spec.Tags {
+ if aws.StringValue(tg.Key) == tags.ManagedByKey && aws.StringValue(tg.Value) == tags.ManagedByEKS {
+ tagged = true
+ }
+ }
+ }
+ assert.True(t, tagged, "egress EIP must carry the ManagedBy=eks tag")
+}
diff --git a/spinifex/handlers/eks/delete_cluster_test.go b/spinifex/handlers/eks/delete_cluster_test.go
new file mode 100644
index 000000000..66b18593f
--- /dev/null
+++ b/spinifex/handlers/eks/delete_cluster_test.go
@@ -0,0 +1,156 @@
+package handlers_eks
+
+import (
+ "errors"
+ "testing"
+
+ "github.com/aws/aws-sdk-go/aws"
+ "github.com/aws/aws-sdk-go/service/eks"
+ "github.com/aws/aws-sdk-go/service/elbv2"
+ "github.com/mulgadc/spinifex/spinifex/testutil"
+ "github.com/nats-io/nats.go"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+type fakeSubnetResolver struct{}
+
+var _ SubnetVPCResolver = (*fakeSubnetResolver)(nil)
+
+func (fakeSubnetResolver) GetSubnetVPC(_, _ string) (string, error) { return "vpc-aaa", nil }
+func (fakeSubnetResolver) GetVPCCIDR(_, _ string) (string, error) { return "10.0.0.0/16", nil }
+
+func deleteInput(name string) *eks.DeleteClusterInput {
+ return &eks.DeleteClusterInput{Name: aws.String(name)}
+}
+
+// eksServiceFixture stands up an EKSServiceImpl with fake orchestration deps
+// and an embedded JetStream KV. Tests poke the fakes' *Err fields to force
+// failures at specific lifecycle steps.
+type eksServiceFixture struct {
+ svc *EKSServiceImpl
+ kv nats.KeyValue
+ nlb *fakeNLBProvisioner
+ inst *fakeK3sInst
+ vpc *fakeK3sVPC
+ ami *fakeK3sAMI
+ eip *fakeEIPProvisioner
+ sg *fakeSGProvisioner
+ worker *fakeWorkerLauncher
+}
+
+func newEKSServiceFixture(t *testing.T) *eksServiceFixture {
+ t.Helper()
+ _, nc, js := testutil.StartTestJetStream(t)
+ kv, err := GetOrCreateAccountBucket(js, testAccountID)
+ require.NoError(t, err)
+
+ nlb := newFakeNLBProvisioner()
+ inst := &fakeK3sInst{}
+ vpc := &fakeK3sVPC{}
+ ami := &fakeK3sAMI{}
+ eip := newFakeEIPProvisioner()
+ sg := newFakeSGProvisioner()
+ worker := newFakeWorkerLauncher()
+
+ svc, err := NewEKSServiceImpl(EKSServiceDeps{
+ NATSConn: nc,
+ MasterKey: bootstrapTestMasterKey,
+ GatewayBaseURL: "https://gw.local:9999",
+ Region: "us-east-1",
+ HolderID: "node-1",
+ SystemGatewayURL: "https://gw.local:9999",
+ SystemAccessKey: "AKIAEXAMPLE",
+ SystemSecretKey: "s3cr3t-key",
+ GatewayCACert: "-----BEGIN CERTIFICATE-----\nFAKECA\n-----END CERTIFICATE-----\n",
+ VPCSG: sg,
+ VPCK3s: vpc,
+ VPCSubnet: fakeSubnetResolver{},
+ NLB: nlb,
+ Instance: inst,
+ Image: ami,
+ EIP: eip,
+ Worker: worker,
+ })
+ require.NoError(t, err)
+ t.Cleanup(svc.Shutdown)
+
+ return &eksServiceFixture{svc: svc, kv: kv, nlb: nlb, inst: inst, vpc: vpc, ami: ami, eip: eip, sg: sg, worker: worker}
+}
+
+// deleteClusterFixture is an eksServiceFixture pre-seeded with a CREATING
+// cluster meta whose NLB/VM/SG resource references are all populated so
+// DeleteCluster exercises every teardown branch.
+type deleteClusterFixture struct {
+ svc *EKSServiceImpl
+ kv nats.KeyValue
+ nlb *fakeNLBProvisioner
+ inst *fakeK3sInst
+ vpc *fakeK3sVPC
+}
+
+func newDeleteClusterFixture(t *testing.T, clusterName string) *deleteClusterFixture {
+ t.Helper()
+ f := newEKSServiceFixture(t)
+ kv := f.kv
+
+ meta := sampleClusterMeta(clusterName)
+ meta.NLBArn = "arn:aws:elasticloadbalancing:us-east-1:111122223333:loadbalancer/net/" + ClusterNLBName(clusterName) + "/lb-001"
+ meta.NLBTargetGroupArn = "arn:aws:elasticloadbalancing:us-east-1:111122223333:targetgroup/" + ClusterTargetGroupName(clusterName) + "/tg-001"
+ meta.ControlPlaneENIIP = "10.0.1.42"
+ meta.ControlPlaneInstanceID = "i-aaa111"
+ meta.ControlPlaneENIID = "eni-aaa111"
+ meta.ResourcesVpcConfig.VpcId = "vpc-aaa"
+ require.NoError(t, PutClusterMeta(kv, meta))
+
+ // Real OIDC key so ZeroizeClusterOIDCKey has material to wipe.
+ _, _, err := GenerateClusterOIDCKeypair(kv, clusterName, bootstrapTestMasterKey)
+ require.NoError(t, err)
+
+ return &deleteClusterFixture{svc: f.svc, kv: kv, nlb: f.nlb, inst: f.inst, vpc: f.vpc}
+}
+
+func TestDeleteCluster_AllTeardownSucceedsSweepsKV(t *testing.T) {
+ f := newDeleteClusterFixture(t, "alpha")
+
+ out, err := f.svc.DeleteCluster(deleteInput("alpha"), testAccountID)
+ require.NoError(t, err)
+ require.NotNil(t, out)
+
+ _, err = GetClusterMeta(f.kv, "alpha")
+ assert.ErrorIs(t, err, ErrClusterNotFound, "meta must be swept after successful teardown")
+ assert.Len(t, f.inst.terminateCalls, 1)
+ assert.Len(t, f.vpc.deleteCalls, 1)
+}
+
+func TestDeleteCluster_NLBFailureLeavesMetaAndDELETING(t *testing.T) {
+ f := newDeleteClusterFixture(t, "alpha")
+
+ // Seed the LB so delete is attempted, then force it to fail.
+ lbName := ClusterNLBName("alpha")
+ f.nlb.lbByName[lbName] = &elbv2.LoadBalancer{
+ LoadBalancerArn: aws.String("arn:lb"),
+ LoadBalancerName: aws.String(lbName),
+ }
+ f.nlb.deleteLBErr = errors.New("elbv2 unavailable")
+
+ _, err := f.svc.DeleteCluster(deleteInput("alpha"), testAccountID)
+ require.Error(t, err, "teardown failure must surface, not be swallowed")
+
+ meta, getErr := GetClusterMeta(f.kv, "alpha")
+ require.NoError(t, getErr, "meta must survive so a retry can find the resource ARNs")
+ assert.Equal(t, ClusterStatusDeleting, meta.Status)
+ assert.NotEmpty(t, meta.NLBArn, "NLB ARN must remain recorded for retry")
+}
+
+func TestDeleteCluster_VMTerminateFailureLeavesMeta(t *testing.T) {
+ f := newDeleteClusterFixture(t, "alpha")
+ f.inst.terminateErr = errors.New("hypervisor unreachable")
+
+ _, err := f.svc.DeleteCluster(deleteInput("alpha"), testAccountID)
+ require.Error(t, err)
+
+ meta, getErr := GetClusterMeta(f.kv, "alpha")
+ require.NoError(t, getErr)
+ assert.Equal(t, ClusterStatusDeleting, meta.Status)
+}
diff --git a/spinifex/handlers/eks/eip_test.go b/spinifex/handlers/eks/eip_test.go
new file mode 100644
index 000000000..209f6c0fc
--- /dev/null
+++ b/spinifex/handlers/eks/eip_test.go
@@ -0,0 +1,44 @@
+package handlers_eks
+
+import (
+ "github.com/aws/aws-sdk-go/aws"
+ ec2 "github.com/aws/aws-sdk-go/service/ec2"
+)
+
+// fakeEIPProvisioner records allocate/release calls and hands back a
+// deterministic pool address so CreateCluster/DeleteCluster egress wiring is
+// exercised without a live EIP service.
+type fakeEIPProvisioner struct {
+ allocateCalls []*ec2.AllocateAddressInput
+ releaseCalls []*ec2.ReleaseAddressInput
+
+ publicIP string
+ allocationID string
+ allocateErr error
+ releaseErr error
+}
+
+var _ eipProvisioner = (*fakeEIPProvisioner)(nil)
+
+func newFakeEIPProvisioner() *fakeEIPProvisioner {
+ return &fakeEIPProvisioner{publicIP: "203.0.113.50", allocationID: "eipalloc-fake01"}
+}
+
+func (f *fakeEIPProvisioner) AllocateAddress(input *ec2.AllocateAddressInput, _ string) (*ec2.AllocateAddressOutput, error) {
+ f.allocateCalls = append(f.allocateCalls, input)
+ if f.allocateErr != nil {
+ return nil, f.allocateErr
+ }
+ return &ec2.AllocateAddressOutput{
+ PublicIp: aws.String(f.publicIP),
+ AllocationId: aws.String(f.allocationID),
+ }, nil
+}
+
+func (f *fakeEIPProvisioner) ReleaseAddress(input *ec2.ReleaseAddressInput, _ string) (*ec2.ReleaseAddressOutput, error) {
+ f.releaseCalls = append(f.releaseCalls, input)
+ if f.releaseErr != nil {
+ return nil, f.releaseErr
+ }
+ return &ec2.ReleaseAddressOutput{}, nil
+}
diff --git a/spinifex/handlers/eks/k3s_server_vm.go b/spinifex/handlers/eks/k3s_server_vm.go
new file mode 100644
index 000000000..870a413d9
--- /dev/null
+++ b/spinifex/handlers/eks/k3s_server_vm.go
@@ -0,0 +1,553 @@
+package handlers_eks
+
+import (
+ "errors"
+ "fmt"
+ "log/slog"
+ "net"
+ "strconv"
+ "strings"
+
+ "github.com/aws/aws-sdk-go/aws"
+ "github.com/aws/aws-sdk-go/service/ec2"
+ "github.com/mulgadc/spinifex/spinifex/awserrors"
+ "github.com/mulgadc/spinifex/spinifex/handlers/sysinstance"
+ "github.com/mulgadc/spinifex/spinifex/tags"
+)
+
+// imdsServerIP is the link-local IMDS address (mirror of
+// handlers/imds.MetaDataServerIP, duplicated to avoid an import cycle:
+// imds → sts → eks).
+const imdsServerIP = "169.254.169.254"
+
+// ErrEKSServerAMINotFound is returned by the launcher when no AMI carrying the
+// EKS managed-by tag exists in the account. CreateCluster maps it to a precise
+// client error: it signals an operator/config gap (the eks-server image was
+// never built/imported), not an unrecoverable internal fault.
+var ErrEKSServerAMINotFound = errors.New("eks: eks-server AMI not found")
+
+// k3sVPCProvisioner is the subset of handlers_ec2_vpc.VPCService that the K3s
+// server VM launcher needs. Narrow so tests can fake it without implementing
+// the full VPC surface.
+type k3sVPCProvisioner interface {
+ CreateNetworkInterface(input *ec2.CreateNetworkInterfaceInput, accountID string) (*ec2.CreateNetworkInterfaceOutput, error)
+ DeleteNetworkInterface(input *ec2.DeleteNetworkInterfaceInput, accountID string) (*ec2.DeleteNetworkInterfaceOutput, error)
+}
+
+// k3sInstanceLauncher is the system-instance launch surface the K3s control-plane
+// VM needs. The VM boots from the eks-server AMI (BootAMI) and must get a
+// management-bridge NIC so it can reach the daemon's NATS endpoint off its
+// tenant VPC subnet — which only the system-instance path provides.
+// *daemon.Daemon satisfies this interface.
+type k3sInstanceLauncher interface {
+ LaunchSystemInstance(input *sysinstance.SystemInstanceInput) (*sysinstance.SystemInstanceOutput, error)
+ TerminateSystemInstance(instanceID string) error
+}
+
+// k3sAMIResolver is the subset of handlers_ec2_image.ImageService the launcher
+// uses to resolve the eks-server AMI name to an AMI ID at launch time.
+type k3sAMIResolver interface {
+ DescribeImages(input *ec2.DescribeImagesInput, accountID string) (*ec2.DescribeImagesOutput, error)
+}
+
+const (
+ // defaultK3sServerInstanceType is the spinifex instance type closest to
+ // the AWS EKS minimum control-plane footprint (2 vCPU / 8 GB / 40 GB).
+ // Callers can override via K3sServerInput.InstanceType.
+ defaultK3sServerInstanceType = "t3.medium"
+
+ // k3sOIDCSigningKeyPath is the on-VM path where cloud-init drops the
+ // per-cluster OIDC private key PEM. K3s reads it via the
+ // service-account-signing-key-file kube-apiserver arg (signs SA tokens).
+ k3sOIDCSigningKeyPath = "/etc/rancher/k3s/oidc-signing-key.pem"
+
+ // k3sOIDCPublicKeyPath is where cloud-init drops the matching public key
+ // PEM. kube-apiserver's service-account-key-file requires a PUBLIC key
+ // (it rejects a private-key PEM with "data does not contain any valid RSA
+ // or ECDSA public keys" and crash-loops), so it must be a separate file.
+ k3sOIDCPublicKeyPath = "/etc/rancher/k3s/oidc-signing-key.pub.pem"
+
+ // k3sFirstBootEnvPath is the env file k3s-first-boot.sh sources at boot
+ // (see scripts/images/eks-server/k3s-first-boot.sh ENVFILE).
+ k3sFirstBootEnvPath = "/etc/spinifex-eks/first-boot.env"
+
+ // agentEnvPath is the env file the k3s-agent OpenRC service sources for its
+ // K3S_URL/K3S_TOKEN/K3S_NODE_NAME/K3S_NODE_LABEL. Path matches the AGENT_ENVFILE
+ // in scripts/images/eks-node/eks-node-role.sh and k3s-agent.initd.
+ agentEnvPath = "/etc/spinifex-eks/agent.env"
+
+ // k3sGatewayCAPath is the on-VM destination for the AWS gateway TLS CA cert
+ // PEM. The K3s VM uses it to verify the gateway's HTTPS cert when the
+ // eks-gateway-publish helper POSTs bootstrap envelopes + state reports.
+ // Path matches k3s-first-boot.sh EKS_GATEWAY_CA.
+ k3sGatewayCAPath = "/etc/spinifex-eks/gateway-ca.pem"
+
+ // k3sTokenWebhookKubeconfigPath is the apiserver token-webhook config the
+ // eks-token-webhook service (ordered `before k3s`) writes before the
+ // apiserver starts. Wired via the authentication-token-webhook-config-file
+ // apiserver arg so bearer tokens minted by `aws eks get-token` resolve
+ // through the webhook. Must match the webhook's EKS_WEBHOOK_KUBECONFIG default.
+ k3sTokenWebhookKubeconfigPath = "/etc/spinifex-eks/token-webhook.kubeconfig" //nolint:gosec // file path, not a credential
+
+ // k3sConfigPath is the K3s server config file cloud-init writes; K3s
+ // reads it at startup (overrides the AMI-baked config.yaml.skel).
+ k3sConfigPath = "/etc/rancher/k3s/config.yaml"
+
+ // k3sResolvConfPath is the on-VM resolver file. The Alpine eks-server AMI
+ // runs dhcpcd, whose resolv.conf hook fails to persist the DHCP-supplied
+ // nameservers ("can't create /etc/resolv.conf: nonexistent directory"), so
+ // the VM boots with no resolver and containerd cannot resolve registry-1.
+ // docker.io to pull the system-pod images. cloud-init writes a static
+ // resolver here; the dhcpcd hook never clobbers it (it errors before it
+ // would). Reachable via the cluster's egress SNAT.
+ k3sResolvConfPath = "/etc/resolv.conf"
+
+ // k3sResolvConf is the static resolver content. Public anycast resolvers
+ // reached over the control-plane VM's egress path.
+ k3sResolvConf = "nameserver 1.1.1.1\nnameserver 8.8.8.8"
+)
+
+// K3sServerInput is the launcher's input shape. AccountID is the customer
+// account; the ENI + VM both live there in v1 (SystemAccount-owned VM is
+// deferred behind cross-account-ENI work). Region is carried for future
+// region-aware AMI lookups but not consumed today.
+type K3sServerInput struct {
+ AccountID string
+ ClusterName string
+ Region string
+ SubnetID string
+ ControlPlaneSGID string
+ NLBDNS string
+ // EndpointIP is the reachable cluster NLB front-end IP. When set it is added
+ // to the apiserver serving-cert SANs (tls-san) so kubectl reaching the
+ // cluster on this IP validates TLS. Empty for an internal endpoint with no
+ // front-end IP read back.
+ EndpointIP string
+ OIDCIssuer string
+ OIDCPrivateKeyPEM string
+ OIDCPublicKeyPEM string
+ // Gateway broker config: the control-plane VM publishes its bootstrap
+ // envelopes + state reports via SigV4-signed HTTPS POST to the AWS gateway
+ // (the ELBv2 lb-agent model), not by dialing core NATS. GatewayURL is the
+ // mgmt-reachable AWSGW endpoint; AccessKey/SecretKey are the system
+ // (Predastore) SigV4 creds; GatewayCACert is the PEM that signs the gateway
+ // server cert.
+ GatewayURL string
+ AccessKey string
+ SecretKey string
+ GatewayCACert string
+ InstanceType string
+ // BuiltinIngress keeps K3s' bundled traefik + servicelb enabled (interim
+ // in-VPC app exposure). When false the config disables them for AWS parity,
+ // where Service type=LoadBalancer / Ingress are satisfied by the AWS Load
+ // Balancer Controller instead. The CreateCluster default is ON until that
+ // controller ships (see managedIngressTagKey).
+ BuiltinIngress bool
+}
+
+// K3sServerOutput carries the identifiers the caller needs to persist into
+// ClusterMeta and to register the ENI IP with the cluster NLB target group.
+type K3sServerOutput struct {
+ InstanceID string
+ ENIID string
+ ENIIP string
+ MgmtIP string
+}
+
+// LaunchK3sServerVM provisions the K3s control-plane VM for an EKS cluster.
+// Sequence: resolve the eks-server AMI, pre-create the customer-account ENI
+// in the supplied subnet with the control-plane SG, render cloud-init user
+// data (env vars, OIDC PEM, K3s config), then call RunInstances with
+// NetworkInterfaces[0].NetworkInterfaceId pointing at the pre-created ENI.
+// Returns instance/ENI identity so the caller can register the ENI IP with
+// the cluster NLB target group and persist the IDs in ClusterMeta. On
+// RunInstances failure the pre-created ENI is deleted best-effort so the
+// caller does not leak a customer-account resource.
+func LaunchK3sServerVM(
+ vpcSvc k3sVPCProvisioner,
+ instSvc k3sInstanceLauncher,
+ amiSvc k3sAMIResolver,
+ in K3sServerInput,
+) (*K3sServerOutput, error) {
+ if err := validateK3sServerInput(in); err != nil {
+ return nil, err
+ }
+ instanceType := in.InstanceType
+ if instanceType == "" {
+ instanceType = defaultK3sServerInstanceType
+ }
+
+ amiID, err := lookupEKSServerAMI(amiSvc, in.AccountID)
+ if err != nil {
+ return nil, err
+ }
+
+ eniOut, err := vpcSvc.CreateNetworkInterface(&ec2.CreateNetworkInterfaceInput{
+ SubnetId: aws.String(in.SubnetID),
+ Description: aws.String("EKS K3s server ENI for " + in.ClusterName),
+ Groups: aws.StringSlice([]string{in.ControlPlaneSGID}),
+ TagSpecifications: []*ec2.TagSpecification{{
+ ResourceType: aws.String("network-interface"),
+ Tags: []*ec2.Tag{
+ {Key: aws.String(tags.ManagedByKey), Value: aws.String(tags.ManagedByEKS)},
+ {Key: aws.String(clusterEKSClusterTagKey), Value: aws.String(in.ClusterName)},
+ {Key: aws.String(clusterEKSRoleTagKey), Value: aws.String(clusterEKSRoleControlPlane)},
+ },
+ }},
+ }, in.AccountID)
+ if err != nil {
+ return nil, fmt.Errorf("create K3s ENI in subnet %s: %w", in.SubnetID, err)
+ }
+ if eniOut == nil || eniOut.NetworkInterface == nil ||
+ aws.StringValue(eniOut.NetworkInterface.NetworkInterfaceId) == "" ||
+ aws.StringValue(eniOut.NetworkInterface.PrivateIpAddress) == "" {
+ return nil, errors.New("eks: CreateNetworkInterface returned incomplete ENI")
+ }
+ eniID := aws.StringValue(eniOut.NetworkInterface.NetworkInterfaceId)
+ eniIP := aws.StringValue(eniOut.NetworkInterface.PrivateIpAddress)
+
+ userData := buildK3sUserData(in)
+
+ sysOut, err := instSvc.LaunchSystemInstance(&sysinstance.SystemInstanceInput{
+ BootMode: sysinstance.BootAMI,
+ ManagedBy: tags.ManagedByEKS,
+ InstanceType: instanceType,
+ ImageID: amiID,
+ AccountID: in.AccountID,
+ ENIID: eniID,
+ ENIMac: aws.StringValue(eniOut.NetworkInterface.MacAddress),
+ ENIIP: eniIP,
+ UserData: userData,
+ })
+ if err != nil {
+ rollbackK3sENI(vpcSvc, in.AccountID, eniID)
+ return nil, fmt.Errorf("run K3s server instance for cluster %s: %w", in.ClusterName, err)
+ }
+ if sysOut == nil || sysOut.InstanceID == "" {
+ rollbackK3sENI(vpcSvc, in.AccountID, eniID)
+ return nil, fmt.Errorf("eks: LaunchSystemInstance returned no instance for cluster %s", in.ClusterName)
+ }
+ instanceID := sysOut.InstanceID
+
+ slog.Info("LaunchK3sServerVM completed",
+ "clusterName", in.ClusterName,
+ "accountID", in.AccountID,
+ "instanceId", instanceID,
+ "eniId", eniID,
+ "eniIp", eniIP,
+ )
+
+ return &K3sServerOutput{
+ InstanceID: instanceID,
+ ENIID: eniID,
+ ENIIP: eniIP,
+ MgmtIP: sysOut.MgmtIP,
+ }, nil
+}
+
+// TerminateK3sServerVM tears down the K3s server VM for a cluster. The
+// instance termination cascades ENI detach inside the customer instance path,
+// then this helper deletes the ENI explicitly so DeleteCluster does not leak
+// it. Missing instance/ENI is a no-op so retries stay safe.
+func TerminateK3sServerVM(
+ vpcSvc k3sVPCProvisioner,
+ instSvc k3sInstanceLauncher,
+ accountID, instanceID, eniID string,
+) error {
+ if instanceID == "" && eniID == "" {
+ return nil
+ }
+ var firstErr error
+ if instanceID != "" {
+ // A retried DeleteCluster reaches here after the VM already drained, so
+ // "instance not found" is the steady-state success case, not a failure —
+ // treat it as idempotent so teardown can proceed to the ENI/SG/KV sweep.
+ if err := instSvc.TerminateSystemInstance(instanceID); err != nil {
+ if errors.Is(err, sysinstance.ErrSystemInstanceNotFound) {
+ slog.Debug("TerminateK3sServerVM: instance already gone", "instanceId", instanceID)
+ } else {
+ slog.Warn("TerminateK3sServerVM: terminate failed", "instanceId", instanceID, "err", err)
+ firstErr = fmt.Errorf("terminate instance %s: %w", instanceID, err)
+ }
+ }
+ }
+ if eniID != "" {
+ if _, err := vpcSvc.DeleteNetworkInterface(&ec2.DeleteNetworkInterfaceInput{
+ NetworkInterfaceId: aws.String(eniID),
+ }, accountID); err != nil {
+ switch {
+ case awserrors.IsErrorCode(err, awserrors.ErrorInvalidNetworkInterfaceIDNotFound),
+ awserrors.IsErrorCode(err, awserrors.ErrorInvalidNetworkInterfaceNotFound):
+ // The instance-terminate cascade (or a prior retry) already
+ // deleted the ENI. Idempotent success — must NOT block the SG +
+ // KV sweep, or the cluster wedges in DELETING permanently.
+ slog.Debug("TerminateK3sServerVM: ENI already gone", "eniId", eniID)
+ default:
+ // InvalidNetworkInterface.InUse (the VM is still terminating
+ // async and holds the ENI) and any other error are retryable:
+ // surface so the cluster stays DELETING and the reconciler
+ // retries once the instance releases the ENI.
+ slog.Warn("TerminateK3sServerVM: ENI delete failed", "eniId", eniID, "err", err)
+ if firstErr == nil {
+ firstErr = fmt.Errorf("delete ENI %s: %w", eniID, err)
+ }
+ }
+ }
+ }
+ return firstErr
+}
+
+// lookupEKSServerAMI resolves the EKS control-plane AMI by the
+// spinifex:managed-by=eks tag the build pipeline stamps on it, rather than a
+// brittle exact name. This survives the planned server+agent → single EKS AMI
+// unify untouched (the unified AMI keeps the tag; role is chosen per-instance
+// at launch). If more than one AMI carries the tag (e.g. server + agent both
+// imported pre-unify), the newest by CreationDate wins.
+func lookupEKSServerAMI(amiSvc k3sAMIResolver, accountID string) (string, error) {
+ out, err := amiSvc.DescribeImages(&ec2.DescribeImagesInput{
+ Filters: []*ec2.Filter{
+ {Name: aws.String("tag:" + tags.ManagedByKey), Values: aws.StringSlice([]string{tags.ManagedByEKS})},
+ },
+ }, accountID)
+ if err != nil {
+ return "", fmt.Errorf("describe eks AMI (tag:%s=%s): %w", tags.ManagedByKey, tags.ManagedByEKS, err)
+ }
+
+ var (
+ newestID string
+ newestCreated string
+ matches int
+ )
+ for _, img := range out.Images {
+ if img == nil || img.ImageId == nil || *img.ImageId == "" {
+ continue
+ }
+ matches++
+ // CreationDate is a fixed-width RFC3339 timestamp, so lexicographic
+ // comparison orders it correctly without parsing.
+ if created := aws.StringValue(img.CreationDate); newestID == "" || created > newestCreated {
+ newestID, newestCreated = *img.ImageId, created
+ }
+ }
+ if newestID == "" {
+ return "", fmt.Errorf("%w (tag:%s=%s, account %s)", ErrEKSServerAMINotFound, tags.ManagedByKey, tags.ManagedByEKS, accountID)
+ }
+ if matches > 1 {
+ slog.Warn("eks: multiple AMIs match managed-by=eks; using newest",
+ "count", matches, "imageId", newestID, "created", newestCreated)
+ }
+ return newestID, nil
+}
+
+func rollbackK3sENI(vpcSvc k3sVPCProvisioner, accountID, eniID string) {
+ if _, err := vpcSvc.DeleteNetworkInterface(&ec2.DeleteNetworkInterfaceInput{
+ NetworkInterfaceId: aws.String(eniID),
+ }, accountID); err != nil {
+ slog.Warn("LaunchK3sServerVM: rollback ENI delete failed", "eniId", eniID, "err", err)
+ }
+}
+
+func validateK3sServerInput(in K3sServerInput) error {
+ switch {
+ case in.AccountID == "":
+ return errors.New("eks: K3sServerInput empty AccountID")
+ case in.ClusterName == "":
+ return errors.New("eks: K3sServerInput empty ClusterName")
+ case in.SubnetID == "":
+ return errors.New("eks: K3sServerInput empty SubnetID")
+ case in.ControlPlaneSGID == "":
+ return errors.New("eks: K3sServerInput empty ControlPlaneSGID")
+ case in.NLBDNS == "":
+ return errors.New("eks: K3sServerInput empty NLBDNS")
+ case in.OIDCIssuer == "":
+ return errors.New("eks: K3sServerInput empty OIDCIssuer")
+ case strings.TrimSpace(in.OIDCPrivateKeyPEM) == "":
+ return errors.New("eks: K3sServerInput empty OIDCPrivateKeyPEM")
+ case strings.TrimSpace(in.OIDCPublicKeyPEM) == "":
+ return errors.New("eks: K3sServerInput empty OIDCPublicKeyPEM")
+ case in.GatewayURL == "":
+ return errors.New("eks: K3sServerInput empty GatewayURL")
+ case in.AccessKey == "":
+ return errors.New("eks: K3sServerInput empty AccessKey")
+ case in.SecretKey == "":
+ return errors.New("eks: K3sServerInput empty SecretKey")
+ case strings.TrimSpace(in.GatewayCACert) == "":
+ return errors.New("eks: K3sServerInput empty GatewayCACert")
+ }
+ return nil
+}
+
+// boolFlag renders a bool as the "1"/"0" string the shell first-boot env reads.
+func boolFlag(b bool) string {
+ if b {
+ return "1"
+ }
+ return "0"
+}
+
+// userDataFile is one entry in the cloud-config write_files block.
+type userDataFile struct {
+ Path string
+ Perms string
+ Body string
+}
+
+// buildK3sUserData renders the cloud-config YAML the K3s server VM consumes
+// at first boot. Output is unencoded YAML; the caller base64-wraps it for
+// the RunInstances UserData field.
+func buildK3sUserData(in K3sServerInput) string {
+ nlbEndpoint := "https://" + net.JoinHostPort(in.NLBDNS, strconv.FormatInt(clusterNLBListenPort, 10))
+
+ envBody := strings.Join([]string{
+ // Role the eks-node-role first-boot selector reads to enable the
+ // control-plane services (k3s server + token webhook + bootstrap
+ // publisher). Nodegroup workers seed "agent" through their own path.
+ "SPINIFEX_K3S_ROLE=server",
+ "EKS_GATEWAY_URL=" + in.GatewayURL,
+ "EKS_GATEWAY_CA=" + k3sGatewayCAPath,
+ "EKS_ACCESS_KEY=" + in.AccessKey,
+ "EKS_SECRET_KEY=" + in.SecretKey,
+ "EKS_REGION=" + in.Region,
+ "EKS_ACCOUNT_ID=" + in.AccountID,
+ "EKS_CLUSTER_NAME=" + in.ClusterName,
+ "EKS_NLB_ENDPOINT=" + nlbEndpoint,
+ "EKS_OIDC_ISSUER=" + in.OIDCIssuer,
+ // Gate deferred built-in ingress: k3s.initd stages a traefik .skip marker
+ // before boot and the state-reporter removes it once the apiserver is
+ // stable. Set only for clusters that want built-in ingress.
+ "EKS_DEFER_TRAEFIK=" + boolFlag(in.BuiltinIngress),
+ }, "\n")
+
+ // cluster-init selects the embedded etcd datastore (required for multi-server
+ // HA). etcd-expose-metrics surfaces etcd's own wal_fsync_duration_seconds and
+ // backend_commit_duration_seconds on 127.0.0.1:2381/metrics so control-plane
+ // commit latency is measurable directly, not inferred.
+ //
+ // anonymous-auth=true: k3s hardens it off by default, which makes the
+ // apiserver answer 401 to an unauthenticated /healthz. The cluster reconciler
+ // probes https:///healthz anonymously to gate ACTIVE, so it must be
+ // reachable; the default RBAC binds only the health/version non-resource
+ // paths to system:unauthenticated, so this exposes nothing else.
+ // In real EKS neither traefik nor servicelb exists; Service type=LoadBalancer
+ // and Ingress are reconciled by the AWS Load Balancer Controller. When a
+ // cluster does not opt into built-in ingress (BuiltinIngress=false) the K3s
+ // built-ins are disabled outright for parity.
+ //
+ // When a cluster DOES want built-in ingress (dev / interim, until the AWS LB
+ // controller add-on lands) traefik stays ENABLED in config so k3s writes its
+ // manifest — but its helm-install Job (klipper-helm image pull + chart
+ // install) hammers the embedded etcd during the fragile bootstrap window,
+ // which can tip etcd fsync latency past the apiserver / leaderelection
+ // deadlines and crash the control plane. So traefik is deferred, NOT disabled:
+ // k3s.initd stages a deploy-controller .skip marker before the apiserver
+ // starts (EKS_DEFER_TRAEFIK gates it), and the state-reporter removes that
+ // marker once the apiserver is sustainedly healthy — traefik then installs
+ // with etcd headroom. Disabling traefik (`disable: traefik`) is wrong for this:
+ // k3s never writes traefik.yaml, leaving no manifest to un-skip. servicelb
+ // (klipper) is lazy — it acts only when a LoadBalancer Service exists — so it
+ // carries no bootstrap cost and stays enabled whenever built-in ingress is on.
+ configLines := []string{
+ "cluster-init: true",
+ "etcd-expose-metrics: true",
+ // Keep user workloads off the control plane (EKS parity — the CP is never
+ // a worker). Critical here because the server node is schedulable by
+ // default: a workload landing on the CP pulls its images onto the same
+ // NBD/viperblock disk the embedded etcd fsyncs to, starving etcd until the
+ // apiserver crashes and the VM OOMs. k3s' packaged addons tolerate
+ // CriticalAddonsOnly, so they still run (here, draining to the nodegroup);
+ // NoExecute also evicts anything already scheduled, not just future pods.
+ "node-taint:",
+ " - CriticalAddonsOnly=true:NoExecute",
+ }
+ if !in.BuiltinIngress {
+ configLines = append(configLines,
+ "disable:",
+ " - traefik",
+ " - servicelb",
+ )
+ }
+ configLines = append(configLines, "tls-san:", " - "+in.NLBDNS)
+ // The reachable front-end IP must be a cert SAN so kubectl reaching the
+ // cluster on https://:443 validates TLS. k3s accepts IP literals
+ // in tls-san and classifies them as IP SANs.
+ if in.EndpointIP != "" {
+ configLines = append(configLines, " - "+in.EndpointIP)
+ }
+ configLines = append(configLines,
+ "kube-apiserver-arg:",
+ " - service-account-key-file="+k3sOIDCPublicKeyPath,
+ " - service-account-signing-key-file="+k3sOIDCSigningKeyPath,
+ " - service-account-issuer="+in.OIDCIssuer,
+ " - api-audiences=sts.amazonaws.com",
+ " - anonymous-auth=true",
+ " - authentication-token-webhook-config-file="+k3sTokenWebhookKubeconfigPath,
+ " - authentication-token-webhook-cache-ttl=5m",
+ // Pin v1 so the apiserver decodes the webhook's authentication.k8s.io/v1
+ // TokenReview response; the default v1beta1 rejects the GVK mismatch (401).
+ " - authentication-token-webhook-version=v1",
+ )
+ k3sConfig := strings.Join(configLines, "\n")
+
+ files := []userDataFile{
+ // first-boot.env carries the system SigV4 secret key, so keep it
+ // root-only (0600).
+ {Path: k3sFirstBootEnvPath, Perms: "0600", Body: envBody},
+ {Path: k3sOIDCSigningKeyPath, Perms: "0600", Body: strings.TrimRight(in.OIDCPrivateKeyPEM, "\n")},
+ {Path: k3sOIDCPublicKeyPath, Perms: "0644", Body: strings.TrimRight(in.OIDCPublicKeyPEM, "\n")},
+ {Path: k3sConfigPath, Perms: "0644", Body: k3sConfig},
+ {Path: k3sGatewayCAPath, Perms: "0644", Body: strings.TrimRight(in.GatewayCACert, "\n")},
+ // IMDS on-link route. Alpine's cloud-init eni renderer crashes on a
+ // gateway-less route in network-config, so it's delivered out-of-band:
+ // a persistent local.d script (re-applied every boot by the OpenRC
+ // `local` service, enabled via runcmd below) ARPs 169.254.169.254
+ // directly on the VPC subnet. This block owns the only write_files key,
+ // so it must carry the route itself — appending a second write_files via
+ // the generic cloud-init template would collide (last key wins, silently
+ // dropping the k3s config). The device is resolved via the default route
+ // (the VPC egress NIC), not a name: the persistent-net rename to vpc0
+ // doesn't apply on the Alpine AMI, so the kernel name is eth0 at boot.
+ {
+ Path: "/etc/local.d/imds-onlink-route.start",
+ Perms: "0755",
+ Body: "#!/bin/sh\n" +
+ "dev=$(ip route show default | awk '{print $5; exit}')\n" +
+ "[ -n \"$dev\" ] && ip route replace " + imdsServerIP + "/32 dev \"$dev\" scope link",
+ },
+ }
+
+ var buf strings.Builder
+ buf.WriteString("#cloud-config\n")
+
+ // Resolver via bootcmd, NOT write_files: on the Alpine AMI /etc/resolv.conf
+ // is a dangling symlink (its target dir does not exist — which is why the
+ // dhcpcd hook cannot persist DHCP DNS), and pointing write_files at it makes
+ // cloud-init follow the dead link, fail, and abort the WHOLE write_files
+ // block (dropping first-boot.env + the k3s config). bootcmd runs before
+ // write_files in the init stage and as a shell, so it can drop the symlink
+ // and write a real file. Containerd needs this to resolve registry-1.docker.
+ // io for system-pod image pulls; reachable over the cluster egress SNAT.
+ buf.WriteString("bootcmd:\n")
+ buf.WriteString(" - rm -f " + k3sResolvConfPath + "\n")
+ fmt.Fprintf(&buf, " - printf '%s\\n' > %s\n",
+ strings.ReplaceAll(k3sResolvConf, "\n", "\\n"), k3sResolvConfPath)
+
+ buf.WriteString("write_files:\n")
+ for _, f := range files {
+ fmt.Fprintf(&buf, " - path: %s\n", f.Path)
+ fmt.Fprintf(&buf, " permissions: '%s'\n", f.Perms)
+ buf.WriteString(" content: |\n")
+ for line := range strings.SplitSeq(f.Body, "\n") {
+ buf.WriteString(" ")
+ buf.WriteString(line)
+ buf.WriteString("\n")
+ }
+ }
+
+ // Enable + start the OpenRC `local` service so the IMDS route script runs on
+ // first boot and is re-applied on every subsequent boot.
+ buf.WriteString("runcmd:\n")
+ buf.WriteString(" - [ rc-update, add, local, default ]\n")
+ buf.WriteString(" - [ rc-service, local, start ]\n")
+
+ return buf.String()
+}
diff --git a/spinifex/handlers/eks/k3s_server_vm_test.go b/spinifex/handlers/eks/k3s_server_vm_test.go
new file mode 100644
index 000000000..77f7bb407
--- /dev/null
+++ b/spinifex/handlers/eks/k3s_server_vm_test.go
@@ -0,0 +1,471 @@
+package handlers_eks
+
+import (
+ "errors"
+ "fmt"
+ "strings"
+ "testing"
+
+ "github.com/aws/aws-sdk-go/aws"
+ "github.com/aws/aws-sdk-go/service/ec2"
+ "github.com/mulgadc/spinifex/spinifex/awserrors"
+ "github.com/mulgadc/spinifex/spinifex/handlers/sysinstance"
+ "github.com/mulgadc/spinifex/spinifex/tags"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+type fakeK3sVPC struct {
+ createCalls []*ec2.CreateNetworkInterfaceInput
+ deleteCalls []*ec2.DeleteNetworkInterfaceInput
+
+ createOut *ec2.CreateNetworkInterfaceOutput
+ createErr error
+ deleteErr error
+}
+
+var _ k3sVPCProvisioner = (*fakeK3sVPC)(nil)
+
+func (f *fakeK3sVPC) CreateNetworkInterface(input *ec2.CreateNetworkInterfaceInput, _ string) (*ec2.CreateNetworkInterfaceOutput, error) {
+ f.createCalls = append(f.createCalls, input)
+ if f.createErr != nil {
+ return nil, f.createErr
+ }
+ if f.createOut != nil {
+ return f.createOut, nil
+ }
+ return &ec2.CreateNetworkInterfaceOutput{
+ NetworkInterface: &ec2.NetworkInterface{
+ NetworkInterfaceId: aws.String("eni-aaa111"),
+ PrivateIpAddress: aws.String("10.0.1.42"),
+ SubnetId: input.SubnetId,
+ },
+ }, nil
+}
+
+func (f *fakeK3sVPC) DeleteNetworkInterface(input *ec2.DeleteNetworkInterfaceInput, _ string) (*ec2.DeleteNetworkInterfaceOutput, error) {
+ f.deleteCalls = append(f.deleteCalls, input)
+ if f.deleteErr != nil {
+ return nil, f.deleteErr
+ }
+ return &ec2.DeleteNetworkInterfaceOutput{}, nil
+}
+
+type fakeK3sInst struct {
+ launchCalls []*sysinstance.SystemInstanceInput
+ terminateCalls []string
+
+ launchOut *sysinstance.SystemInstanceOutput
+ launchErr error
+ terminateErr error
+}
+
+var _ k3sInstanceLauncher = (*fakeK3sInst)(nil)
+
+func (f *fakeK3sInst) LaunchSystemInstance(input *sysinstance.SystemInstanceInput) (*sysinstance.SystemInstanceOutput, error) {
+ f.launchCalls = append(f.launchCalls, input)
+ if f.launchErr != nil {
+ return nil, f.launchErr
+ }
+ if f.launchOut != nil {
+ return f.launchOut, nil
+ }
+ return &sysinstance.SystemInstanceOutput{InstanceID: "i-aaa111"}, nil
+}
+
+func (f *fakeK3sInst) TerminateSystemInstance(instanceID string) error {
+ f.terminateCalls = append(f.terminateCalls, instanceID)
+ return f.terminateErr
+}
+
+type fakeK3sAMI struct {
+ describeCalls []*ec2.DescribeImagesInput
+
+ describeOut *ec2.DescribeImagesOutput
+ describeErr error
+}
+
+var _ k3sAMIResolver = (*fakeK3sAMI)(nil)
+
+func (f *fakeK3sAMI) DescribeImages(input *ec2.DescribeImagesInput, _ string) (*ec2.DescribeImagesOutput, error) {
+ f.describeCalls = append(f.describeCalls, input)
+ if f.describeErr != nil {
+ return nil, f.describeErr
+ }
+ if f.describeOut != nil {
+ return f.describeOut, nil
+ }
+ return &ec2.DescribeImagesOutput{
+ Images: []*ec2.Image{{
+ ImageId: aws.String("ami-eks-server-001"),
+ Name: aws.String("spinifex-eks-server"),
+ CreationDate: aws.String("2026-06-01T00:00:00.000Z"),
+ Tags: []*ec2.Tag{
+ {Key: aws.String(tags.ManagedByKey), Value: aws.String(tags.ManagedByEKS)},
+ },
+ }},
+ }, nil
+}
+
+func validK3sInput() K3sServerInput {
+ return K3sServerInput{
+ AccountID: "111122223333",
+ ClusterName: "alpha",
+ Region: "us-east-1",
+ SubnetID: "subnet-aaa",
+ ControlPlaneSGID: "sg-cp-aaa",
+ NLBDNS: "eks-alpha-lb-001.us-east-1.elb.spinifex.local",
+ OIDCIssuer: "https://oidc.spinifex.local/clusters/111122223333/alpha",
+ OIDCPrivateKeyPEM: "-----BEGIN PRIVATE KEY-----\nFAKEKEY\n-----END PRIVATE KEY-----\n",
+ OIDCPublicKeyPEM: "-----BEGIN PUBLIC KEY-----\nFAKEPUB\n-----END PUBLIC KEY-----\n",
+ GatewayURL: "https://10.15.8.1:9999",
+ AccessKey: "AKIAEXAMPLE",
+ SecretKey: "s3cr3t-key",
+ GatewayCACert: "-----BEGIN CERTIFICATE-----\nFAKECA\n-----END CERTIFICATE-----\n",
+ }
+}
+
+func TestLaunchK3sServerVM_EmptyInputsRejected(t *testing.T) {
+ mk := func(mutate func(*K3sServerInput)) K3sServerInput {
+ in := validK3sInput()
+ mutate(&in)
+ return in
+ }
+ cases := []struct {
+ name string
+ in K3sServerInput
+ }{
+ {"empty AccountID", mk(func(in *K3sServerInput) { in.AccountID = "" })},
+ {"empty ClusterName", mk(func(in *K3sServerInput) { in.ClusterName = "" })},
+ {"empty SubnetID", mk(func(in *K3sServerInput) { in.SubnetID = "" })},
+ {"empty ControlPlaneSGID", mk(func(in *K3sServerInput) { in.ControlPlaneSGID = "" })},
+ {"empty NLBDNS", mk(func(in *K3sServerInput) { in.NLBDNS = "" })},
+ {"empty OIDCIssuer", mk(func(in *K3sServerInput) { in.OIDCIssuer = "" })},
+ {"empty OIDCPrivateKeyPEM", mk(func(in *K3sServerInput) { in.OIDCPrivateKeyPEM = " \n " })},
+ {"empty OIDCPublicKeyPEM", mk(func(in *K3sServerInput) { in.OIDCPublicKeyPEM = " \n " })},
+ {"empty GatewayURL", mk(func(in *K3sServerInput) { in.GatewayURL = "" })},
+ {"empty AccessKey", mk(func(in *K3sServerInput) { in.AccessKey = "" })},
+ {"empty SecretKey", mk(func(in *K3sServerInput) { in.SecretKey = "" })},
+ {"empty GatewayCACert", mk(func(in *K3sServerInput) { in.GatewayCACert = " \n" })},
+ }
+ for _, tc := range cases {
+ t.Run(tc.name, func(t *testing.T) {
+ vpc, inst, ami := &fakeK3sVPC{}, &fakeK3sInst{}, &fakeK3sAMI{}
+ _, err := LaunchK3sServerVM(vpc, inst, ami, tc.in)
+ require.Error(t, err)
+ assert.Empty(t, vpc.createCalls)
+ assert.Empty(t, inst.launchCalls)
+ assert.Empty(t, ami.describeCalls)
+ })
+ }
+}
+
+func TestLaunchK3sServerVM_AMINotFound(t *testing.T) {
+ vpc, inst := &fakeK3sVPC{}, &fakeK3sInst{}
+ ami := &fakeK3sAMI{describeOut: &ec2.DescribeImagesOutput{}}
+
+ _, err := LaunchK3sServerVM(vpc, inst, ami, validK3sInput())
+ require.ErrorIs(t, err, ErrEKSServerAMINotFound)
+ assert.Contains(t, err.Error(), "spinifex:managed-by=eks")
+ assert.Empty(t, vpc.createCalls, "no ENI created when AMI lookup fails")
+ assert.Empty(t, inst.launchCalls)
+}
+
+func TestLaunchK3sServerVM_AMILookupErrorPropagated(t *testing.T) {
+ vpc, inst := &fakeK3sVPC{}, &fakeK3sInst{}
+ ami := &fakeK3sAMI{describeErr: errors.New("DescribeImages backend down")}
+
+ _, err := LaunchK3sServerVM(vpc, inst, ami, validK3sInput())
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), "describe eks AMI")
+ assert.Empty(t, vpc.createCalls)
+ assert.Empty(t, inst.launchCalls)
+}
+
+func TestLaunchK3sServerVM_ENICreateFailureNoRunInstances(t *testing.T) {
+ vpc := &fakeK3sVPC{createErr: errors.New("InsufficientFreeAddressesInSubnet")}
+ inst, ami := &fakeK3sInst{}, &fakeK3sAMI{}
+
+ _, err := LaunchK3sServerVM(vpc, inst, ami, validK3sInput())
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), "create K3s ENI in subnet subnet-aaa")
+ assert.Empty(t, inst.launchCalls)
+ assert.Empty(t, vpc.deleteCalls, "no ENI to roll back when create itself failed")
+}
+
+func TestLaunchK3sServerVM_RunInstancesFailureRollsBackENI(t *testing.T) {
+ vpc := &fakeK3sVPC{}
+ inst := &fakeK3sInst{launchErr: errors.New("InsufficientInstanceCapacity")}
+ ami := &fakeK3sAMI{}
+
+ _, err := LaunchK3sServerVM(vpc, inst, ami, validK3sInput())
+ require.Error(t, err)
+ require.Len(t, vpc.createCalls, 1)
+ require.Len(t, vpc.deleteCalls, 1, "ENI must roll back when RunInstances fails")
+ assert.Equal(t, "eni-aaa111", aws.StringValue(vpc.deleteCalls[0].NetworkInterfaceId))
+}
+
+func TestLaunchK3sServerVM_HappyPath(t *testing.T) {
+ vpc, inst, ami := &fakeK3sVPC{}, &fakeK3sInst{}, &fakeK3sAMI{}
+
+ out, err := LaunchK3sServerVM(vpc, inst, ami, validK3sInput())
+ require.NoError(t, err)
+ require.NotNil(t, out)
+ assert.Equal(t, "i-aaa111", out.InstanceID)
+ assert.Equal(t, "eni-aaa111", out.ENIID)
+ assert.Equal(t, "10.0.1.42", out.ENIIP)
+
+ require.Len(t, vpc.createCalls, 1)
+ eniIn := vpc.createCalls[0]
+ assert.Equal(t, "subnet-aaa", aws.StringValue(eniIn.SubnetId))
+ assert.Equal(t, []string{"sg-cp-aaa"}, aws.StringValueSlice(eniIn.Groups))
+ require.Len(t, eniIn.TagSpecifications, 1)
+ assertEC2TaggedAsEKSControlPlane(t, eniIn.TagSpecifications[0].Tags, "alpha")
+
+ require.Len(t, inst.launchCalls, 1)
+ runIn := inst.launchCalls[0]
+ assert.Equal(t, sysinstance.BootAMI, runIn.BootMode)
+ assert.Equal(t, tags.ManagedByEKS, runIn.ManagedBy)
+ assert.Equal(t, "ami-eks-server-001", runIn.ImageID)
+ assert.Equal(t, defaultK3sServerInstanceType, runIn.InstanceType)
+ assert.Equal(t, "111122223333", runIn.AccountID)
+ assert.Equal(t, "eni-aaa111", runIn.ENIID)
+ assert.Equal(t, "10.0.1.42", runIn.ENIIP)
+
+ assert.Empty(t, vpc.deleteCalls, "no rollback on success")
+}
+
+func TestLaunchK3sServerVM_HonorsCustomInstanceType(t *testing.T) {
+ vpc, inst, ami := &fakeK3sVPC{}, &fakeK3sInst{}, &fakeK3sAMI{}
+ in := validK3sInput()
+ in.InstanceType = "t3.large"
+
+ _, err := LaunchK3sServerVM(vpc, inst, ami, in)
+ require.NoError(t, err)
+ require.Len(t, inst.launchCalls, 1)
+ assert.Equal(t, "t3.large", inst.launchCalls[0].InstanceType)
+}
+
+func TestLaunchK3sServerVM_UserDataContainsAllArtifacts(t *testing.T) {
+ vpc, inst, ami := &fakeK3sVPC{}, &fakeK3sInst{}, &fakeK3sAMI{}
+
+ _, err := LaunchK3sServerVM(vpc, inst, ami, validK3sInput())
+ require.NoError(t, err)
+ require.Len(t, inst.launchCalls, 1)
+
+ udata := inst.launchCalls[0].UserData
+
+ assert.True(t, strings.HasPrefix(udata, "#cloud-config\n"))
+ assert.Contains(t, udata, "write_files:")
+
+ // Static resolver via bootcmd (NOT write_files — /etc/resolv.conf is a
+ // dangling symlink on the AMI; write_files would follow it, fail, and abort
+ // the whole block). Without it containerd image pulls cannot resolve.
+ assert.Contains(t, udata, "bootcmd:")
+ assert.Contains(t, udata, "rm -f "+k3sResolvConfPath)
+ assert.Contains(t, udata, "nameserver 1.1.1.1")
+ // Resolver must NOT be a write_files entry (that path is what broke seeding).
+ assert.NotContains(t, udata, "path: "+k3sResolvConfPath)
+
+ assert.Contains(t, udata, "path: "+k3sFirstBootEnvPath)
+ // The eks-node-role first-boot selector keys off this to start control-plane
+ // services; without it the unified AMI boots into no role.
+ assert.Contains(t, udata, "SPINIFEX_K3S_ROLE=server")
+ assert.Contains(t, udata, "EKS_GATEWAY_URL=https://10.15.8.1:9999")
+ assert.Contains(t, udata, "EKS_GATEWAY_CA="+k3sGatewayCAPath)
+ assert.Contains(t, udata, "EKS_ACCESS_KEY=AKIAEXAMPLE")
+ assert.Contains(t, udata, "EKS_SECRET_KEY=s3cr3t-key")
+ assert.Contains(t, udata, "EKS_REGION=us-east-1")
+ assert.Contains(t, udata, "EKS_ACCOUNT_ID=111122223333")
+ assert.Contains(t, udata, "EKS_CLUSTER_NAME=alpha")
+ assert.Contains(t, udata, "EKS_NLB_ENDPOINT=https://eks-alpha-lb-001.us-east-1.elb.spinifex.local:443")
+ assert.Contains(t, udata, "EKS_OIDC_ISSUER=https://oidc.spinifex.local/clusters/111122223333/alpha")
+
+ assert.Contains(t, udata, "path: "+k3sOIDCSigningKeyPath)
+ assert.Contains(t, udata, "permissions: '0600'")
+ assert.Contains(t, udata, "-----BEGIN PRIVATE KEY-----")
+ assert.Contains(t, udata, "FAKEKEY")
+
+ assert.Contains(t, udata, "path: "+k3sOIDCPublicKeyPath)
+ assert.Contains(t, udata, "-----BEGIN PUBLIC KEY-----")
+ assert.Contains(t, udata, "FAKEPUB")
+
+ assert.Contains(t, udata, "path: "+k3sConfigPath)
+ // The control plane is tainted so user workloads never schedule on it (EKS
+ // parity, and it keeps image pulls off the etcd disk).
+ assert.Contains(t, udata, "node-taint:")
+ assert.Contains(t, udata, " - CriticalAddonsOnly=true:NoExecute")
+ // Parity default (BuiltinIngress=false): K3s' bundled traefik + servicelb are
+ // disabled; Service type=LoadBalancer / Ingress are the AWS LB Controller's job.
+ // With built-in ingress off there is nothing to defer, so EKS_DEFER_TRAEFIK=0.
+ assert.Contains(t, udata, "disable:")
+ assert.Contains(t, udata, " - traefik")
+ assert.Contains(t, udata, " - servicelb")
+ assert.Contains(t, udata, "EKS_DEFER_TRAEFIK=0")
+ assert.Contains(t, udata, "tls-san:")
+ assert.Contains(t, udata, " - eks-alpha-lb-001.us-east-1.elb.spinifex.local")
+ // service-account-key-file must point at the PUBLIC key; the signing key
+ // (private) is a separate file — pointing key-file at the private key
+ // crash-loops kube-apiserver.
+ assert.Contains(t, udata, "service-account-key-file="+k3sOIDCPublicKeyPath)
+ assert.Contains(t, udata, "service-account-signing-key-file="+k3sOIDCSigningKeyPath)
+ assert.NotContains(t, udata, "service-account-key-file="+k3sOIDCSigningKeyPath)
+ assert.Contains(t, udata, "service-account-issuer=https://oidc.spinifex.local/clusters/111122223333/alpha")
+ assert.Contains(t, udata, "api-audiences=sts.amazonaws.com")
+ // IAM bearer-token auth: the generated config overrides the AMI skel, so the
+ // token-webhook arg MUST be emitted here or `aws eks get-token` 401s with the
+ // webhook never invoked.
+ assert.Contains(t, udata, "authentication-token-webhook-config-file="+k3sTokenWebhookKubeconfigPath)
+
+ assert.Contains(t, udata, "path: "+k3sGatewayCAPath)
+ assert.Contains(t, udata, "-----BEGIN CERTIFICATE-----")
+ assert.Contains(t, udata, "FAKECA")
+
+ // IMDS on-link route delivered out-of-band (Alpine's eni renderer can't emit
+ // the gateway-less route in network-config). It rides this payload's own
+ // write_files/runcmd, enabled via the OpenRC local service.
+ assert.Contains(t, udata, "path: /etc/local.d/imds-onlink-route.start")
+ assert.Contains(t, udata, "ip route show default")
+ assert.Contains(t, udata, `ip route replace 169.254.169.254/32 dev "$dev" scope link`)
+ assert.Contains(t, udata, "runcmd:")
+ assert.Contains(t, udata, "[ rc-update, add, local, default ]")
+
+ // Exactly one write_files / runcmd key — a second would collide and silently
+ // drop a block under yaml.safe_load (last key wins).
+ assert.Equal(t, 1, strings.Count(udata, "\nwrite_files:"))
+ assert.Equal(t, 1, strings.Count(udata, "\nruncmd:"))
+}
+
+func TestLaunchK3sServerVM_BuiltinIngressOptInDefersTraefik(t *testing.T) {
+ vpc, inst, ami := &fakeK3sVPC{}, &fakeK3sInst{}, &fakeK3sAMI{}
+
+ in := validK3sInput()
+ in.BuiltinIngress = true
+ _, err := LaunchK3sServerVM(vpc, inst, ami, in)
+ require.NoError(t, err)
+ require.Len(t, inst.launchCalls, 1)
+
+ udata := inst.launchCalls[0].UserData
+ // Opted in: traefik stays ENABLED in config so k3s writes traefik.yaml, but
+ // it is deferred — k3s.initd stages a .skip marker and the state-reporter
+ // removes it once the apiserver is stable, gated by EKS_DEFER_TRAEFIK=1.
+ // Disabling traefik would leave no manifest to un-skip, so the config must
+ // carry no disable block at all. servicelb is lazy and likewise enabled.
+ assert.NotContains(t, udata, "disable:")
+ assert.NotContains(t, udata, " - traefik")
+ assert.NotContains(t, udata, " - servicelb")
+ assert.Contains(t, udata, "EKS_DEFER_TRAEFIK=1")
+}
+
+func TestLaunchK3sServerVM_UsesEmbeddedEtcd(t *testing.T) {
+ vpc, inst, ami := &fakeK3sVPC{}, &fakeK3sInst{}, &fakeK3sAMI{}
+
+ _, err := LaunchK3sServerVM(vpc, inst, ami, validK3sInput())
+ require.NoError(t, err)
+ require.Len(t, inst.launchCalls, 1)
+
+ udata := inst.launchCalls[0].UserData
+ // cluster-init selects the embedded etcd datastore (not SQLite/kine);
+ // etcd-expose-metrics surfaces wal_fsync/backend_commit on 127.0.0.1:2381.
+ assert.Contains(t, udata, "cluster-init: true")
+ assert.Contains(t, udata, "etcd-expose-metrics: true")
+}
+
+func TestLaunchK3sServerVM_RunInstancesEmptyReservationRollsBack(t *testing.T) {
+ vpc, ami := &fakeK3sVPC{}, &fakeK3sAMI{}
+ inst := &fakeK3sInst{launchOut: &sysinstance.SystemInstanceOutput{}}
+
+ _, err := LaunchK3sServerVM(vpc, inst, ami, validK3sInput())
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), "LaunchSystemInstance returned no instance")
+ require.Len(t, vpc.deleteCalls, 1, "must roll back ENI when reservation empty")
+}
+
+func TestLaunchK3sServerVM_AMIFilterShape(t *testing.T) {
+ vpc, inst, ami := &fakeK3sVPC{}, &fakeK3sInst{}, &fakeK3sAMI{}
+
+ _, err := LaunchK3sServerVM(vpc, inst, ami, validK3sInput())
+ require.NoError(t, err)
+ require.Len(t, ami.describeCalls, 1)
+ filters := ami.describeCalls[0].Filters
+ require.Len(t, filters, 1)
+ assert.Equal(t, "tag:"+tags.ManagedByKey, aws.StringValue(filters[0].Name))
+ assert.Equal(t, []string{tags.ManagedByEKS}, aws.StringValueSlice(filters[0].Values))
+}
+
+func TestTerminateK3sServerVM_BothNoopOnEmpty(t *testing.T) {
+ vpc, inst := &fakeK3sVPC{}, &fakeK3sInst{}
+
+ require.NoError(t, TerminateK3sServerVM(vpc, inst, "111122223333", "", ""))
+ assert.Empty(t, inst.terminateCalls)
+ assert.Empty(t, vpc.deleteCalls)
+}
+
+func TestTerminateK3sServerVM_TerminatesInstanceAndDeletesENI(t *testing.T) {
+ vpc, inst := &fakeK3sVPC{}, &fakeK3sInst{}
+
+ require.NoError(t, TerminateK3sServerVM(vpc, inst, "111122223333", "i-aaa111", "eni-aaa111"))
+ require.Len(t, inst.terminateCalls, 1)
+ assert.Equal(t, "i-aaa111", inst.terminateCalls[0])
+ require.Len(t, vpc.deleteCalls, 1)
+ assert.Equal(t, "eni-aaa111", aws.StringValue(vpc.deleteCalls[0].NetworkInterfaceId))
+}
+
+func TestTerminateK3sServerVM_InstanceErrorReturnedENIStillDeleted(t *testing.T) {
+ vpc := &fakeK3sVPC{}
+ inst := &fakeK3sInst{terminateErr: errors.New("IncorrectInstanceState")}
+
+ err := TerminateK3sServerVM(vpc, inst, "111122223333", "i-aaa111", "eni-aaa111")
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), "terminate instance i-aaa111")
+ require.Len(t, vpc.deleteCalls, 1, "ENI delete should still run after instance terminate fails")
+}
+
+func TestTerminateK3sServerVM_ENINotFoundIsIdempotent(t *testing.T) {
+ // A retried delete-cluster after the instance-terminate cascade already
+ // removed the ENI: DeleteNetworkInterface returns NotFound. This must be
+ // treated as success so the SG + KV sweep downstream is not blocked.
+ vpc := &fakeK3sVPC{deleteErr: errors.New(awserrors.ErrorInvalidNetworkInterfaceIDNotFound)}
+ inst := &fakeK3sInst{}
+
+ err := TerminateK3sServerVM(vpc, inst, "111122223333", "i-aaa111", "eni-aaa111")
+ require.NoError(t, err, "ENI already gone must be idempotent success, not a blocking error")
+ require.Len(t, vpc.deleteCalls, 1)
+}
+
+func TestTerminateK3sServerVM_ENIInUseIsRetryable(t *testing.T) {
+ // The VM is still terminating async and holds the ENI. InUse must surface
+ // as an error so the cluster stays DELETING and the reconciler retries.
+ vpc := &fakeK3sVPC{deleteErr: errors.New(awserrors.ErrorInvalidNetworkInterfaceInUse)}
+ inst := &fakeK3sInst{}
+
+ err := TerminateK3sServerVM(vpc, inst, "111122223333", "i-aaa111", "eni-aaa111")
+ require.Error(t, err, "ENI still in use must be retryable, not swallowed")
+ assert.Contains(t, err.Error(), "delete ENI eni-aaa111")
+}
+
+func TestTerminateK3sServerVM_InstanceAlreadyGoneIsIdempotent(t *testing.T) {
+ // On a reconciler retry the VM already drained, so TerminateSystemInstance
+ // returns ErrSystemInstanceNotFound. This must not block teardown; the ENI
+ // delete still runs.
+ vpc := &fakeK3sVPC{}
+ inst := &fakeK3sInst{terminateErr: fmt.Errorf("%w: i-aaa111", sysinstance.ErrSystemInstanceNotFound)}
+
+ err := TerminateK3sServerVM(vpc, inst, "111122223333", "i-aaa111", "eni-aaa111")
+ require.NoError(t, err, "instance already gone must be idempotent success")
+ require.Len(t, vpc.deleteCalls, 1, "ENI delete still runs after a tolerated instance-gone")
+}
+
+func assertEC2TaggedAsEKSControlPlane(t *testing.T, ec2Tags []*ec2.Tag, clusterName string) {
+ t.Helper()
+ got := map[string]string{}
+ for _, tg := range ec2Tags {
+ if tg == nil || tg.Key == nil || tg.Value == nil {
+ continue
+ }
+ got[*tg.Key] = *tg.Value
+ }
+ assert.Equal(t, tags.ManagedByEKS, got[tags.ManagedByKey])
+ assert.Equal(t, clusterName, got[clusterEKSClusterTagKey])
+ assert.Equal(t, clusterEKSRoleControlPlane, got[clusterEKSRoleTagKey])
+}
diff --git a/spinifex/handlers/eks/list_clusters_test.go b/spinifex/handlers/eks/list_clusters_test.go
new file mode 100644
index 000000000..a2ff78ea1
--- /dev/null
+++ b/spinifex/handlers/eks/list_clusters_test.go
@@ -0,0 +1,82 @@
+package handlers_eks
+
+import (
+ "errors"
+ "fmt"
+ "testing"
+
+ "github.com/aws/aws-sdk-go/aws"
+ "github.com/aws/aws-sdk-go/service/eks"
+ "github.com/mulgadc/spinifex/spinifex/awserrors"
+ "github.com/nats-io/nats.go"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+// TestEKSServiceImpl_ListClustersPagination walks the NextToken/MaxResults
+// paging path end-to-end: each page honours MaxResults, hands back a NextToken
+// pointing at the next name, and the final page carries no token.
+func TestEKSServiceImpl_ListClustersPagination(t *testing.T) {
+ f := newEKSServiceFixture(t)
+ for _, n := range []string{"c01", "c02", "c03", "c04", "c05"} {
+ require.NoError(t, PutClusterMeta(f.kv, sampleClusterMeta(n)))
+ }
+
+ p1, err := f.svc.ListClusters(&eks.ListClustersInput{MaxResults: aws.Int64(2)}, testAccountID)
+ require.NoError(t, err)
+ assert.Equal(t, []string{"c01", "c02"}, aws.StringValueSlice(p1.Clusters))
+ require.NotNil(t, p1.NextToken)
+ assert.Equal(t, "c03", aws.StringValue(p1.NextToken))
+
+ p2, err := f.svc.ListClusters(&eks.ListClustersInput{MaxResults: aws.Int64(2), NextToken: p1.NextToken}, testAccountID)
+ require.NoError(t, err)
+ assert.Equal(t, []string{"c03", "c04"}, aws.StringValueSlice(p2.Clusters))
+ require.NotNil(t, p2.NextToken)
+ assert.Equal(t, "c05", aws.StringValue(p2.NextToken))
+
+ p3, err := f.svc.ListClusters(&eks.ListClustersInput{MaxResults: aws.Int64(2), NextToken: p2.NextToken}, testAccountID)
+ require.NoError(t, err)
+ assert.Equal(t, []string{"c05"}, aws.StringValueSlice(p3.Clusters))
+ assert.Nil(t, p3.NextToken, "final page must not advertise a NextToken")
+}
+
+// TestEKSServiceImpl_ListClustersMaxResultsClamped confirms an out-of-range
+// MaxResults (zero, negative, or above the 100 ceiling) clamps to a single full
+// page rather than truncating or erroring.
+func TestEKSServiceImpl_ListClustersMaxResultsClamped(t *testing.T) {
+ f := newEKSServiceFixture(t)
+ want := []string{"c01", "c02", "c03"}
+ for _, n := range want {
+ require.NoError(t, PutClusterMeta(f.kv, sampleClusterMeta(n)))
+ }
+
+ for _, mr := range []int64{0, -5, 250} {
+ out, err := f.svc.ListClusters(&eks.ListClustersInput{MaxResults: aws.Int64(mr)}, testAccountID)
+ require.NoError(t, err)
+ assert.Equal(t, want, aws.StringValueSlice(out.Clusters), "MaxResults=%d must clamp to one full page", mr)
+ assert.Nil(t, out.NextToken, "MaxResults=%d single page must not advertise a NextToken", mr)
+ }
+}
+
+// TestEKSReadUnavailableClassification pins the post-restart behaviour: a
+// transient JetStream error (no leader yet) surfaces as a retryable
+// ServiceUnavailable, while a genuine fault stays wrapped (the daemon maps it
+// to InternalError). Regression guard for list-clusters returning a misleading
+// 500 during the KV warmup window.
+func TestEKSReadUnavailableClassification(t *testing.T) {
+ transient := []error{
+ nats.ErrNoResponders,
+ nats.ErrTimeout,
+ nats.ErrNoStreamResponse,
+ nats.ErrConnectionClosed,
+ fmt.Errorf("kv keys: %w", nats.ErrNoResponders), // wrapped, as ListClusters wraps it
+ }
+ for _, e := range transient {
+ assert.True(t, natsTransient(e), "%v must be classified transient", e)
+ assert.EqualError(t, eksReadUnavailableOr(e, "op"), awserrors.ErrorServiceUnavailable)
+ }
+
+ genuine := errors.New("corrupt cluster meta")
+ assert.False(t, natsTransient(genuine))
+ assert.ErrorContains(t, eksReadUnavailableOr(genuine, "list cluster names"), "list cluster names: corrupt cluster meta")
+}
diff --git a/spinifex/handlers/eks/nats_bootstrap.go b/spinifex/handlers/eks/nats_bootstrap.go
new file mode 100644
index 000000000..fd7a64dbe
--- /dev/null
+++ b/spinifex/handlers/eks/nats_bootstrap.go
@@ -0,0 +1,386 @@
+package handlers_eks
+
+import (
+ "context"
+ "encoding/base64"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "log/slog"
+ "sync"
+ "time"
+
+ handlers_iam "github.com/mulgadc/spinifex/spinifex/handlers/iam"
+ "github.com/nats-io/nats.go"
+)
+
+// errBootstrapPermanent marks a bootstrap persist failure that no amount of
+// retrying will fix — a malformed envelope, a non-base64 CA, or a JWKS whose
+// kid does not match the controller-generated keypair. Transient failures
+// (KV write blips, JWKS-not-yet-propagated) are NOT wrapped with this, so
+// persistWithRetry keeps retrying them. A permanent error fails the cluster.
+var errBootstrapPermanent = errors.New("bootstrap permanent error")
+
+const (
+ // bootstrapPersistAttempts bounds inline retries of a single subject's
+ // persist op on transient errors. A core-NATS publication is delivered
+ // once, so a transient KV blip must be ridden out in-handler rather than
+ // by tearing down the subscription and waiting for a re-publish that
+ // never comes.
+ bootstrapPersistAttempts = 3
+ bootstrapPersistBackoff = 200 * time.Millisecond
+)
+
+// persistWithRetry runs op, retrying on transient errors up to
+// bootstrapPersistAttempts. A nil result or an errBootstrapPermanent result
+// returns immediately (no retry).
+func persistWithRetry(op func() error) error {
+ var err error
+ for attempt := 1; attempt <= bootstrapPersistAttempts; attempt++ {
+ err = op()
+ if err == nil || errors.Is(err, errBootstrapPermanent) {
+ return err
+ }
+ if attempt < bootstrapPersistAttempts {
+ time.Sleep(bootstrapPersistBackoff)
+ }
+ }
+ return err
+}
+
+// Bootstrap subject kinds. Full subject is
+// "eks.bus.{accountID}.{clusterName}.{kind}" — see BootstrapSubject.
+const (
+ BootstrapSubjectToken = "k3s-bootstrap-token" //nolint:gosec // subject suffix, not a credential
+ BootstrapSubjectKubeconfig = "k3s-admin-kubeconfig"
+ BootstrapSubjectJWKS = "k3s-oidc-jwks"
+ BootstrapSubjectCA = "k3s-ca"
+)
+
+// BootstrapSubject returns the per-cluster bootstrap subject for one kind.
+func BootstrapSubject(accountID, clusterName, kind string) string {
+ return fmt.Sprintf("eks.bus.%s.%s.%s", accountID, clusterName, kind)
+}
+
+// BootstrapEnvelope is the JSON wire shape every k3s-first-boot.sh
+// publication uses. Each subject populates the field matching its kind; the
+// rest stay empty. Versioned implicitly by field shape — adding new optional
+// fields is backwards-compatible.
+type BootstrapEnvelope struct {
+ // Token is the K3s bootstrap node-token for the cluster (k3s-bootstrap-token).
+ Token string `json:"token,omitempty"`
+ // AdminKubeconfig is the cluster-admin kubeconfig YAML
+ // (k3s-admin-kubeconfig).
+ AdminKubeconfig string `json:"adminKubeconfig,omitempty"`
+ // JWKS is the K3s-published RFC 7517 JWKS document. The bootstrap
+ // subscriber cross-checks it against the controller-generated keypair
+ // (k3s-oidc-jwks).
+ JWKS string `json:"jwks,omitempty"`
+ // CertificateAuthority is the base64-encoded K3s server CA PEM
+ // (k3s-ca). Written onto meta.CertificateAuthorityB64.
+ CertificateAuthority string `json:"certificateAuthority,omitempty"`
+}
+
+// NATSBootstrap collects the four single-shot bootstrap publications from a
+// K3s server VM, validates the OIDC JWKS against what the controller wrote
+// pre-VM, persists each artifact into the per-cluster KV bucket, and writes
+// the CA onto meta.CertificateAuthorityB64. The reconciler observes
+// completion via the four KV keys + meta.CertificateAuthorityB64.
+type NATSBootstrap struct {
+ nc *nats.Conn
+ kv nats.KeyValue
+ masterKey []byte
+ accountID string
+ clusterName string
+}
+
+// NewNATSBootstrap validates inputs and returns a ready subscriber. Run must
+// be called to actually subscribe; constructor is side-effect-free.
+func NewNATSBootstrap(nc *nats.Conn, kv nats.KeyValue, masterKey []byte, accountID, clusterName string) (*NATSBootstrap, error) {
+ if nc == nil {
+ return nil, errors.New("eks: NewNATSBootstrap nil nats conn")
+ }
+ if kv == nil {
+ return nil, errors.New("eks: NewNATSBootstrap nil kv")
+ }
+ if len(masterKey) == 0 {
+ return nil, errors.New("eks: NewNATSBootstrap empty master key")
+ }
+ if accountID == "" {
+ return nil, errors.New("eks: NewNATSBootstrap empty accountID")
+ }
+ if clusterName == "" {
+ return nil, errors.New("eks: NewNATSBootstrap empty clusterName")
+ }
+ return &NATSBootstrap{
+ nc: nc,
+ kv: kv,
+ masterKey: masterKey,
+ accountID: accountID,
+ clusterName: clusterName,
+ }, nil
+}
+
+// BootstrapPendingKinds reports which bootstrap subjects still lack their
+// persisted artifact for a cluster, so a daemon-restart resume re-subscribes
+// only to what is missing. The K3s VM publishes each subject once over core
+// NATS and never republishes, so re-waiting on an already-arrived subject
+// would deadlock.
+//
+// JWKS is tracked by its verified-marker (OIDCJWKSVerifiedKey), written by
+// persistJWKS only after the cross-check passes — NOT by OIDCJWKSKey, which the
+// controller pre-seeds at create time. Until the marker exists the cross-check
+// has not positively passed, so resume re-subscribes to JWKS like any other
+// pending subject. (If the VM published JWKS during the restart window the
+// re-subscribe waits for a republish that never comes and the cluster fails on
+// the create timeout — the same one-shot limitation every bootstrap subject
+// shares, now applied uniformly so ACTIVE always implies a verified key.)
+func BootstrapPendingKinds(kv nats.KeyValue, clusterName string, meta *ClusterMeta) []string {
+ var pending []string
+ if _, err := kv.Get(NodeTokenKey(clusterName)); err != nil {
+ pending = append(pending, BootstrapSubjectToken)
+ }
+ if _, err := kv.Get(AdminKubeconfigKey(clusterName)); err != nil {
+ pending = append(pending, BootstrapSubjectKubeconfig)
+ }
+ if _, err := kv.Get(OIDCJWKSVerifiedKey(clusterName)); err != nil {
+ pending = append(pending, BootstrapSubjectJWKS)
+ }
+ if meta == nil || meta.CertificateAuthorityB64 == "" {
+ pending = append(pending, BootstrapSubjectCA)
+ }
+ return pending
+}
+
+type bootstrapSubjectHandler struct {
+ subject string
+ kind string
+ handler func([]byte) error
+}
+
+// subjectHandlers returns the subject/handler entry for each requested kind.
+// An unknown kind is an error — callers pass kinds from BootstrapSubject*
+// constants. A nil/empty kinds slice means all four.
+func (b *NATSBootstrap) subjectHandlers(kinds []string) ([]bootstrapSubjectHandler, error) {
+ all := map[string]func([]byte) error{
+ BootstrapSubjectToken: b.persistToken,
+ BootstrapSubjectKubeconfig: b.persistKubeconfig,
+ BootstrapSubjectJWKS: b.persistJWKS,
+ BootstrapSubjectCA: b.persistCA,
+ }
+ if len(kinds) == 0 {
+ kinds = []string{BootstrapSubjectToken, BootstrapSubjectKubeconfig, BootstrapSubjectJWKS, BootstrapSubjectCA}
+ }
+ out := make([]bootstrapSubjectHandler, 0, len(kinds))
+ for _, kind := range kinds {
+ handler, ok := all[kind]
+ if !ok {
+ return nil, fmt.Errorf("eks: unknown bootstrap kind %q", kind)
+ }
+ out = append(out, bootstrapSubjectHandler{
+ subject: BootstrapSubject(b.accountID, b.clusterName, kind),
+ kind: kind,
+ handler: handler,
+ })
+ }
+ return out, nil
+}
+
+// Run subscribes to all four bootstrap subjects and returns when all four have
+// arrived, ctx is cancelled, or a handler errors. See RunForKinds.
+func (b *NATSBootstrap) Run(ctx context.Context) error {
+ return b.RunForKinds(ctx, nil)
+}
+
+// RunForKinds subscribes only to the requested bootstrap subjects (nil = all
+// four) and returns when:
+// - every requested subject has arrived and persisted successfully (nil),
+// - ctx is cancelled (ctx.Err()),
+// - one of the persistence handlers returns an error (first error).
+//
+// The subset form drives daemon-restart recovery: a cluster that already has
+// some artifacts in KV re-subscribes only to the missing ones, since the K3s
+// VM published each subject once over core NATS and will not republish.
+//
+// Each subject is consumed at most once; subsequent publications on the same
+// subject are dropped. Subscriptions are always cleaned up before return.
+func (b *NATSBootstrap) RunForKinds(ctx context.Context, kinds []string) error {
+ subs, err := b.subjectHandlers(kinds)
+ if err != nil {
+ return err
+ }
+ want := len(subs)
+
+ errCh := make(chan error, want)
+ doneCh := make(chan struct{}, 1)
+ var (
+ mu sync.Mutex
+ done = make(map[string]struct{}, want)
+ )
+
+ var subscriptions []*nats.Subscription
+ defer func() {
+ for _, sub := range subscriptions {
+ _ = sub.Unsubscribe()
+ }
+ }()
+
+ for _, s := range subs {
+ sub, err := b.nc.Subscribe(s.subject, func(m *nats.Msg) {
+ mu.Lock()
+ if _, ok := done[s.kind]; ok {
+ mu.Unlock()
+ return
+ }
+ mu.Unlock()
+ if err := persistWithRetry(func() error { return s.handler(m.Data) }); err != nil {
+ errCh <- fmt.Errorf("persist %s: %w", s.kind, err)
+ return
+ }
+ mu.Lock()
+ done[s.kind] = struct{}{}
+ full := len(done) == want
+ mu.Unlock()
+ if full {
+ select {
+ case doneCh <- struct{}{}:
+ default:
+ }
+ }
+ })
+ if err != nil {
+ return fmt.Errorf("subscribe %s: %w", s.subject, err)
+ }
+ subscriptions = append(subscriptions, sub)
+ }
+
+ for {
+ select {
+ case <-ctx.Done():
+ return ctx.Err()
+ case err := <-errCh:
+ return err
+ case <-doneCh:
+ slog.Info("NATSBootstrap: all requested bootstrap messages received",
+ "accountID", b.accountID, "cluster", b.clusterName, "kinds", want)
+ return nil
+ }
+ }
+}
+
+func (b *NATSBootstrap) persistToken(data []byte) error {
+ env, err := unmarshalBootstrapEnvelope(data)
+ if err != nil {
+ return err
+ }
+ if env.Token == "" {
+ return fmt.Errorf("%w: token envelope empty", errBootstrapPermanent)
+ }
+ ct, err := handlers_iam.EncryptSecret(env.Token, b.masterKey)
+ if err != nil {
+ return fmt.Errorf("encrypt token: %w", err)
+ }
+ if _, err := b.kv.Put(NodeTokenKey(b.clusterName), []byte(ct)); err != nil {
+ return fmt.Errorf("kv put %s: %w", NodeTokenKey(b.clusterName), err)
+ }
+ return nil
+}
+
+func (b *NATSBootstrap) persistKubeconfig(data []byte) error {
+ env, err := unmarshalBootstrapEnvelope(data)
+ if err != nil {
+ return err
+ }
+ if env.AdminKubeconfig == "" {
+ return fmt.Errorf("%w: adminKubeconfig envelope empty", errBootstrapPermanent)
+ }
+ ct, err := handlers_iam.EncryptSecret(env.AdminKubeconfig, b.masterKey)
+ if err != nil {
+ return fmt.Errorf("encrypt kubeconfig: %w", err)
+ }
+ if _, err := b.kv.Put(AdminKubeconfigKey(b.clusterName), []byte(ct)); err != nil {
+ return fmt.Errorf("kv put %s: %w", AdminKubeconfigKey(b.clusterName), err)
+ }
+ return nil
+}
+
+func (b *NATSBootstrap) persistJWKS(data []byte) error {
+ env, err := unmarshalBootstrapEnvelope(data)
+ if err != nil {
+ return err
+ }
+ if env.JWKS == "" {
+ return fmt.Errorf("%w: jwks envelope empty", errBootstrapPermanent)
+ }
+ existing, err := b.kv.Get(OIDCJWKSKey(b.clusterName))
+ if err != nil {
+ return fmt.Errorf("kv get %s: %w", OIDCJWKSKey(b.clusterName), err)
+ }
+ if err := assertJWKSMatch(existing.Value(), []byte(env.JWKS)); err != nil {
+ return err
+ }
+ // Record the positive cross-check as a distinct marker. The reconciler
+ // gates ACTIVE on this key — never on OIDCJWKSKey, which the controller
+ // pre-seeds at create time — so a cluster cannot reach ACTIVE on an
+ // unverified or mismatched signing key.
+ if _, err := b.kv.Put(OIDCJWKSVerifiedKey(b.clusterName), []byte("verified")); err != nil {
+ return fmt.Errorf("kv put %s: %w", OIDCJWKSVerifiedKey(b.clusterName), err)
+ }
+ return nil
+}
+
+func (b *NATSBootstrap) persistCA(data []byte) error {
+ env, err := unmarshalBootstrapEnvelope(data)
+ if err != nil {
+ return err
+ }
+ if env.CertificateAuthority == "" {
+ return fmt.Errorf("%w: CA envelope empty", errBootstrapPermanent)
+ }
+ if _, err := base64.StdEncoding.DecodeString(env.CertificateAuthority); err != nil {
+ return fmt.Errorf("%w: CA not base64: %v", errBootstrapPermanent, err)
+ }
+ return SetClusterCertificateAuthority(b.kv, b.clusterName, env.CertificateAuthority)
+}
+
+func unmarshalBootstrapEnvelope(data []byte) (BootstrapEnvelope, error) {
+ var env BootstrapEnvelope
+ if err := json.Unmarshal(data, &env); err != nil {
+ return env, fmt.Errorf("%w: unmarshal envelope: %v", errBootstrapPermanent, err)
+ }
+ return env, nil
+}
+
+// assertJWKSMatch verifies that every key in the incoming JWKS matches a
+// controller-generated key by both key id (kid) and key type (kty). A kid
+// mismatch means the VM generated its own signing key instead of consuming our
+// PEM; a kty mismatch (e.g. an RSA key carrying our EC key's kid) means the
+// published key cannot be the one the controller distributed even though the
+// kid collides. Either rejects the publication.
+func assertJWKSMatch(existing, incoming []byte) error {
+ var ex, in oidcJWKS
+ if err := json.Unmarshal(existing, &ex); err != nil {
+ return fmt.Errorf("unmarshal existing JWKS: %w", err)
+ }
+ if err := json.Unmarshal(incoming, &in); err != nil {
+ return fmt.Errorf("%w: unmarshal incoming JWKS: %v", errBootstrapPermanent, err)
+ }
+ if len(in.Keys) == 0 {
+ return fmt.Errorf("%w: incoming JWKS has no keys", errBootstrapPermanent)
+ }
+ have := make(map[string]oidcJWK, len(ex.Keys))
+ for _, k := range ex.Keys {
+ have[k.Kid] = k
+ }
+ for _, k := range in.Keys {
+ exKey, ok := have[k.Kid]
+ if !ok {
+ return fmt.Errorf("%w: bootstrap JWKS kid %q does not match generated keypair", errBootstrapPermanent, k.Kid)
+ }
+ if k.Kty != exKey.Kty {
+ return fmt.Errorf("%w: bootstrap JWKS kid %q key type %q does not match generated %q",
+ errBootstrapPermanent, k.Kid, k.Kty, exKey.Kty)
+ }
+ }
+ return nil
+}
diff --git a/spinifex/handlers/eks/nats_bootstrap_test.go b/spinifex/handlers/eks/nats_bootstrap_test.go
new file mode 100644
index 000000000..51518e3bc
--- /dev/null
+++ b/spinifex/handlers/eks/nats_bootstrap_test.go
@@ -0,0 +1,410 @@
+package handlers_eks
+
+import (
+ "context"
+ "encoding/base64"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "strings"
+ "testing"
+ "time"
+
+ handlers_iam "github.com/mulgadc/spinifex/spinifex/handlers/iam"
+ "github.com/mulgadc/spinifex/spinifex/testutil"
+ "github.com/nats-io/nats.go"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+const (
+ bootstrapTestCluster = "alpha"
+)
+
+var bootstrapTestMasterKey = []byte("0123456789abcdef0123456789abcdef")
+
+// natsBootstrapHarness wires an embedded JetStream NATS server, a per-account
+// KV bucket, a published controller JWKS (so persistJWKS has something to
+// cross-check against), and a NATSBootstrap subscriber.
+type natsBootstrapHarness struct {
+ t *testing.T
+ nc *nats.Conn
+ kv nats.KeyValue
+
+ subscriber *NATSBootstrap
+}
+
+func newBootstrapHarness(t *testing.T) *natsBootstrapHarness {
+ t.Helper()
+ _, nc, js := testutil.StartTestJetStream(t)
+ kv, err := GetOrCreateAccountBucket(js, testAccountID)
+ require.NoError(t, err)
+
+ require.NoError(t, PutClusterMeta(kv, sampleClusterMeta(bootstrapTestCluster)))
+
+ // Lay down the controller-generated JWKS so persistJWKS has something
+ // to cross-check against.
+ _, _, err = GenerateClusterOIDCKeypair(kv, bootstrapTestCluster, bootstrapTestMasterKey)
+ require.NoError(t, err)
+
+ sub, err := NewNATSBootstrap(nc, kv, bootstrapTestMasterKey, testAccountID, bootstrapTestCluster)
+ require.NoError(t, err)
+
+ return &natsBootstrapHarness{t: t, nc: nc, kv: kv, subscriber: sub}
+}
+
+func (h *natsBootstrapHarness) publish(kind string, env BootstrapEnvelope) {
+ h.t.Helper()
+ data, err := json.Marshal(env)
+ require.NoError(h.t, err)
+ subject := BootstrapSubject(testAccountID, bootstrapTestCluster, kind)
+ require.NoError(h.t, h.nc.Publish(subject, data))
+ require.NoError(h.t, h.nc.Flush())
+}
+
+func (h *natsBootstrapHarness) loadControllerJWKS() []byte {
+ h.t.Helper()
+ entry, err := h.kv.Get(OIDCJWKSKey(bootstrapTestCluster))
+ require.NoError(h.t, err)
+ return entry.Value()
+}
+
+// waitSubsBound blocks until the subscriber goroutine has bound `want` new
+// subscriptions on the connection (base = NumSubscriptions captured before the
+// goroutine started). Polling the real subscription count replaces a fixed
+// sleep that raced the goroutine and went flaky under CI load.
+func (h *natsBootstrapHarness) waitSubsBound(base, want int) {
+ h.t.Helper()
+ require.Eventually(h.t, func() bool {
+ return h.nc.NumSubscriptions() >= base+want
+ }, 2*time.Second, 2*time.Millisecond, "bootstrap subscriptions never bound")
+}
+
+func TestNewNATSBootstrap_RejectsBadInputs(t *testing.T) {
+ _, nc, js := testutil.StartTestJetStream(t)
+ kv, err := GetOrCreateAccountBucket(js, testAccountID)
+ require.NoError(t, err)
+
+ _, err = NewNATSBootstrap(nil, kv, bootstrapTestMasterKey, testAccountID, "alpha")
+ require.Error(t, err)
+ _, err = NewNATSBootstrap(nc, nil, bootstrapTestMasterKey, testAccountID, "alpha")
+ require.Error(t, err)
+ _, err = NewNATSBootstrap(nc, kv, nil, testAccountID, "alpha")
+ require.Error(t, err)
+ _, err = NewNATSBootstrap(nc, kv, bootstrapTestMasterKey, "", "alpha")
+ require.Error(t, err)
+ _, err = NewNATSBootstrap(nc, kv, bootstrapTestMasterKey, testAccountID, "")
+ require.Error(t, err)
+}
+
+func TestNATSBootstrap_HappyPath(t *testing.T) {
+ h := newBootstrapHarness(t)
+ ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
+ defer cancel()
+
+ jwks := string(h.loadControllerJWKS())
+ caB64 := base64.StdEncoding.EncodeToString([]byte("-----BEGIN CERTIFICATE-----\nfakeCA\n-----END CERTIFICATE-----\n"))
+
+ base := h.nc.NumSubscriptions()
+ runErr := make(chan error, 1)
+ go func() { runErr <- h.subscriber.Run(ctx) }()
+ h.waitSubsBound(base, 4)
+
+ h.publish(BootstrapSubjectToken, BootstrapEnvelope{Token: "k3s::server-node-token::v1"})
+ h.publish(BootstrapSubjectKubeconfig, BootstrapEnvelope{AdminKubeconfig: "apiVersion: v1\nkind: Config\n"})
+ h.publish(BootstrapSubjectJWKS, BootstrapEnvelope{JWKS: jwks})
+ h.publish(BootstrapSubjectCA, BootstrapEnvelope{CertificateAuthority: caB64})
+
+ select {
+ case err := <-runErr:
+ require.NoError(t, err)
+ case <-time.After(3 * time.Second):
+ t.Fatal("Run did not return after all four publications")
+ }
+
+ tokEntry, err := h.kv.Get(NodeTokenKey(bootstrapTestCluster))
+ require.NoError(t, err)
+ dec, err := handlers_iam.DecryptSecret(string(tokEntry.Value()), bootstrapTestMasterKey)
+ require.NoError(t, err)
+ assert.Equal(t, "k3s::server-node-token::v1", dec)
+
+ kcEntry, err := h.kv.Get(AdminKubeconfigKey(bootstrapTestCluster))
+ require.NoError(t, err)
+ dec, err = handlers_iam.DecryptSecret(string(kcEntry.Value()), bootstrapTestMasterKey)
+ require.NoError(t, err)
+ assert.Equal(t, "apiVersion: v1\nkind: Config\n", dec)
+
+ meta, err := GetClusterMeta(h.kv, bootstrapTestCluster)
+ require.NoError(t, err)
+ assert.Equal(t, caB64, meta.CertificateAuthorityB64)
+
+ // The passing JWKS cross-check must record the verified-marker the
+ // reconciler gates ACTIVE on.
+ _, err = h.kv.Get(OIDCJWKSVerifiedKey(bootstrapTestCluster))
+ require.NoError(t, err, "persistJWKS must write the verified-marker on a passing cross-check")
+
+ // Run's deferred cleanup must tear down every subscription it bound, so the
+ // connection is back to its pre-Run count once Run returns.
+ assert.Equal(t, base, h.nc.NumSubscriptions(), "Run must unsubscribe all bootstrap subs on return")
+}
+
+func TestNATSBootstrap_ContextCancelReturnsErr(t *testing.T) {
+ h := newBootstrapHarness(t)
+ ctx, cancel := context.WithCancel(context.Background())
+
+ base := h.nc.NumSubscriptions()
+ runErr := make(chan error, 1)
+ go func() { runErr <- h.subscriber.Run(ctx) }()
+ h.waitSubsBound(base, 4)
+
+ cancel()
+
+ select {
+ case err := <-runErr:
+ require.ErrorIs(t, err, context.Canceled)
+ case <-time.After(2 * time.Second):
+ t.Fatal("Run did not return after ctx cancel")
+ }
+}
+
+func TestNATSBootstrap_JWKSMismatchReturnsErr(t *testing.T) {
+ h := newBootstrapHarness(t)
+ ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
+ defer cancel()
+
+ base := h.nc.NumSubscriptions()
+ runErr := make(chan error, 1)
+ go func() { runErr <- h.subscriber.Run(ctx) }()
+ h.waitSubsBound(base, 4)
+
+ // kid mismatch — controller generated a real kid, we send a bogus one.
+ badJWKS := `{"keys":[{"kty":"EC","kid":"not-the-controller-kid","crv":"P-256","x":"AAA","y":"BBB"}]}`
+ h.publish(BootstrapSubjectJWKS, BootstrapEnvelope{JWKS: badJWKS})
+
+ select {
+ case err := <-runErr:
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), "JWKS kid")
+ case <-time.After(3 * time.Second):
+ t.Fatal("Run did not return on JWKS mismatch")
+ }
+}
+
+func TestAssertJWKSMatch_RejectsKtyMismatch(t *testing.T) {
+ // Same kid as the controller key, but RSA instead of EC — a published key
+ // that cannot be the one the controller distributed despite the kid
+ // collision. Must be rejected.
+ existing := []byte(`{"keys":[{"kty":"EC","kid":"abc","crv":"P-256","x":"AAA","y":"BBB"}]}`)
+ incoming := []byte(`{"keys":[{"kty":"RSA","kid":"abc","n":"AAA","e":"AQAB"}]}`)
+
+ err := assertJWKSMatch(existing, incoming)
+ require.Error(t, err)
+ require.ErrorIs(t, err, errBootstrapPermanent)
+ assert.Contains(t, err.Error(), "key type")
+}
+
+func TestAssertJWKSMatch_AcceptsMatchingKidAndKty(t *testing.T) {
+ jwks := []byte(`{"keys":[{"kty":"EC","kid":"abc","crv":"P-256","x":"AAA","y":"BBB"}]}`)
+ require.NoError(t, assertJWKSMatch(jwks, jwks))
+}
+
+func TestNATSBootstrap_EmptyEnvelopeFieldsRejected(t *testing.T) {
+ h := newBootstrapHarness(t)
+ ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
+ defer cancel()
+
+ base := h.nc.NumSubscriptions()
+ runErr := make(chan error, 1)
+ go func() { runErr <- h.subscriber.Run(ctx) }()
+ h.waitSubsBound(base, 4)
+
+ h.publish(BootstrapSubjectToken, BootstrapEnvelope{})
+
+ select {
+ case err := <-runErr:
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), "token envelope empty")
+ case <-time.After(3 * time.Second):
+ t.Fatal("Run did not return on empty envelope")
+ }
+}
+
+func TestNATSBootstrap_OneShotIgnoresSubsequentMessages(t *testing.T) {
+ h := newBootstrapHarness(t)
+ ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
+ defer cancel()
+
+ jwks := string(h.loadControllerJWKS())
+ caB64 := base64.StdEncoding.EncodeToString([]byte("ca-pem"))
+
+ base := h.nc.NumSubscriptions()
+ runErr := make(chan error, 1)
+ go func() { runErr <- h.subscriber.Run(ctx) }()
+ h.waitSubsBound(base, 4)
+
+ // First, send only token so subsequent token publications are dropped.
+ h.publish(BootstrapSubjectToken, BootstrapEnvelope{Token: "first-token"})
+ // Wait until the first token is persisted before racing in a second, so the
+ // one-shot drop is what's under test rather than publish ordering.
+ require.Eventually(t, func() bool {
+ _, err := h.kv.Get(NodeTokenKey(bootstrapTestCluster))
+ return err == nil
+ }, 2*time.Second, 5*time.Millisecond, "first token never persisted")
+ // Second token publication has a different value; persistToken should not
+ // re-encrypt/overwrite because the one-shot map prevents reprocess.
+ h.publish(BootstrapSubjectToken, BootstrapEnvelope{Token: "second-token"})
+
+ h.publish(BootstrapSubjectKubeconfig, BootstrapEnvelope{AdminKubeconfig: "kc"})
+ h.publish(BootstrapSubjectJWKS, BootstrapEnvelope{JWKS: jwks})
+ h.publish(BootstrapSubjectCA, BootstrapEnvelope{CertificateAuthority: caB64})
+
+ select {
+ case err := <-runErr:
+ require.NoError(t, err)
+ case <-time.After(3 * time.Second):
+ t.Fatal("Run did not return")
+ }
+
+ entry, err := h.kv.Get(NodeTokenKey(bootstrapTestCluster))
+ require.NoError(t, err)
+ dec, err := handlers_iam.DecryptSecret(string(entry.Value()), bootstrapTestMasterKey)
+ require.NoError(t, err)
+ assert.Equal(t, "first-token", dec, "second token publication must be dropped")
+}
+
+func TestNATSBootstrap_CANotBase64Rejected(t *testing.T) {
+ h := newBootstrapHarness(t)
+ ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
+ defer cancel()
+
+ base := h.nc.NumSubscriptions()
+ runErr := make(chan error, 1)
+ go func() { runErr <- h.subscriber.Run(ctx) }()
+ h.waitSubsBound(base, 4)
+
+ h.publish(BootstrapSubjectCA, BootstrapEnvelope{CertificateAuthority: "not!base64@@@"})
+
+ select {
+ case err := <-runErr:
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), "CA not base64")
+ case <-time.After(3 * time.Second):
+ t.Fatal("Run did not return")
+ }
+}
+
+func TestPersistWithRetry(t *testing.T) {
+ t.Run("succeeds first try", func(t *testing.T) {
+ calls := 0
+ err := persistWithRetry(func() error { calls++; return nil })
+ require.NoError(t, err)
+ assert.Equal(t, 1, calls)
+ })
+
+ t.Run("retries transient then succeeds", func(t *testing.T) {
+ calls := 0
+ err := persistWithRetry(func() error {
+ calls++
+ if calls < 2 {
+ return errors.New("transient kv blip")
+ }
+ return nil
+ })
+ require.NoError(t, err)
+ assert.Equal(t, 2, calls)
+ })
+
+ t.Run("permanent error returns immediately without retry", func(t *testing.T) {
+ calls := 0
+ err := persistWithRetry(func() error {
+ calls++
+ return fmt.Errorf("%w: malformed envelope", errBootstrapPermanent)
+ })
+ require.ErrorIs(t, err, errBootstrapPermanent)
+ assert.Equal(t, 1, calls, "permanent error must not be retried")
+ })
+
+ t.Run("transient error exhausts attempts", func(t *testing.T) {
+ calls := 0
+ err := persistWithRetry(func() error { calls++; return errors.New("always transient") })
+ require.Error(t, err)
+ assert.Equal(t, bootstrapPersistAttempts, calls)
+ })
+}
+
+func TestBootstrapPendingKinds(t *testing.T) {
+ h := newBootstrapHarness(t)
+
+ meta, err := GetClusterMeta(h.kv, bootstrapTestCluster)
+ require.NoError(t, err)
+
+ // Fresh cluster: all four pending. The JWKS verified-marker is absent even
+ // though the controller pre-seeded OIDCJWKSKey, so JWKS is still pending.
+ assert.Equal(t,
+ []string{BootstrapSubjectToken, BootstrapSubjectKubeconfig, BootstrapSubjectJWKS, BootstrapSubjectCA},
+ BootstrapPendingKinds(h.kv, bootstrapTestCluster, meta))
+
+ // Persist token only -> token drops out of pending.
+ ct, err := handlers_iam.EncryptSecret("tok", bootstrapTestMasterKey)
+ require.NoError(t, err)
+ _, err = h.kv.Put(NodeTokenKey(bootstrapTestCluster), []byte(ct))
+ require.NoError(t, err)
+ assert.Equal(t,
+ []string{BootstrapSubjectKubeconfig, BootstrapSubjectJWKS, BootstrapSubjectCA},
+ BootstrapPendingKinds(h.kv, bootstrapTestCluster, meta))
+
+ // Persist kubeconfig + CA-on-meta -> only JWKS (unverified) still pending.
+ _, err = h.kv.Put(AdminKubeconfigKey(bootstrapTestCluster), []byte(ct))
+ require.NoError(t, err)
+ meta.CertificateAuthorityB64 = "ca-b64"
+ assert.Equal(t,
+ []string{BootstrapSubjectJWKS},
+ BootstrapPendingKinds(h.kv, bootstrapTestCluster, meta))
+
+ // Write the JWKS verified-marker (persistJWKS does this on a passing
+ // cross-check) -> nothing pending.
+ _, err = h.kv.Put(OIDCJWKSVerifiedKey(bootstrapTestCluster), []byte("verified"))
+ require.NoError(t, err)
+ assert.Empty(t, BootstrapPendingKinds(h.kv, bootstrapTestCluster, meta))
+}
+
+func TestNATSBootstrap_RunForKindsWaitsOnlyForSubset(t *testing.T) {
+ h := newBootstrapHarness(t)
+ ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
+ defer cancel()
+
+ caB64 := base64.StdEncoding.EncodeToString([]byte("ca-pem"))
+
+ base := h.nc.NumSubscriptions()
+ runErr := make(chan error, 1)
+ go func() { runErr <- h.subscriber.RunForKinds(ctx, []string{BootstrapSubjectCA}) }()
+ h.waitSubsBound(base, 1)
+
+ // Only publish CA; the subset subscriber must complete without token,
+ // kubeconfig, or JWKS ever arriving (they were consumed pre-restart).
+ h.publish(BootstrapSubjectCA, BootstrapEnvelope{CertificateAuthority: caB64})
+
+ select {
+ case err := <-runErr:
+ require.NoError(t, err)
+ case <-time.After(3 * time.Second):
+ t.Fatal("RunForKinds did not return after CA-only publication")
+ }
+
+ meta, err := GetClusterMeta(h.kv, bootstrapTestCluster)
+ require.NoError(t, err)
+ assert.Equal(t, caB64, meta.CertificateAuthorityB64)
+}
+
+func TestNATSBootstrap_RunForKindsRejectsUnknownKind(t *testing.T) {
+ h := newBootstrapHarness(t)
+ err := h.subscriber.RunForKinds(context.Background(), []string{"bogus-kind"})
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), "unknown bootstrap kind")
+}
+
+func TestBootstrapSubject_Shape(t *testing.T) {
+ got := BootstrapSubject("111122223333", "alpha", BootstrapSubjectToken)
+ assert.Equal(t, "eks.bus.111122223333.alpha.k3s-bootstrap-token", got)
+ assert.True(t, strings.HasPrefix(got, "eks.bus."))
+}
diff --git a/spinifex/handlers/eks/nlb.go b/spinifex/handlers/eks/nlb.go
new file mode 100644
index 000000000..1aa6b04d2
--- /dev/null
+++ b/spinifex/handlers/eks/nlb.go
@@ -0,0 +1,428 @@
+package handlers_eks
+
+import (
+ "errors"
+ "fmt"
+ "log/slog"
+
+ "github.com/aws/aws-sdk-go/aws"
+ "github.com/aws/aws-sdk-go/service/elbv2"
+ "github.com/mulgadc/spinifex/spinifex/tags"
+)
+
+// nlbProvisioner is the subset of handlers_elbv2.ELBv2Service that the
+// cluster NLB helpers need. Narrow so tests can fake with hand-rolled
+// recorders without implementing the full ELBv2 surface.
+type nlbProvisioner interface {
+ CreateLoadBalancer(input *elbv2.CreateLoadBalancerInput, accountID string) (*elbv2.CreateLoadBalancerOutput, error)
+ DescribeLoadBalancers(input *elbv2.DescribeLoadBalancersInput, accountID string) (*elbv2.DescribeLoadBalancersOutput, error)
+ DeleteLoadBalancer(input *elbv2.DeleteLoadBalancerInput, accountID string) (*elbv2.DeleteLoadBalancerOutput, error)
+
+ CreateTargetGroup(input *elbv2.CreateTargetGroupInput, accountID string) (*elbv2.CreateTargetGroupOutput, error)
+ DescribeTargetGroups(input *elbv2.DescribeTargetGroupsInput, accountID string) (*elbv2.DescribeTargetGroupsOutput, error)
+ DeleteTargetGroup(input *elbv2.DeleteTargetGroupInput, accountID string) (*elbv2.DeleteTargetGroupOutput, error)
+
+ CreateListener(input *elbv2.CreateListenerInput, accountID string) (*elbv2.CreateListenerOutput, error)
+ DescribeListeners(input *elbv2.DescribeListenersInput, accountID string) (*elbv2.DescribeListenersOutput, error)
+
+ RegisterTargets(input *elbv2.RegisterTargetsInput, accountID string) (*elbv2.RegisterTargetsOutput, error)
+ DeregisterTargets(input *elbv2.DeregisterTargetsInput, accountID string) (*elbv2.DeregisterTargetsOutput, error)
+
+ SetLoadBalancerIngressCIDRs(lbArn string, cidrs []string, accountID string) error
+}
+
+// AWS load-balancer + target-group names are bounded to 32 alphanumeric or
+// hyphen characters. EnsureClusterNLB rejects clusters whose derived name
+// would exceed this before issuing the create call so callers see a clear
+// error instead of a generic ELBv2 InvalidParameterValue.
+const maxELBv2NameLen = 32
+
+// k3sAPIServerPort is the in-VM K3s api-server bind port. The NLB target
+// group sends traffic to this port on the K3s server VM's ENI.
+const k3sAPIServerPort int64 = 6443
+
+// clusterNLBListenPort is the customer-facing port the cluster NLB exposes.
+// kubectl + the AWS SDK both expect TLS on 443.
+const clusterNLBListenPort int64 = 443
+
+// ClusterNLB is the in-process tuple produced by EnsureClusterNLB. Each ARN
+// is persisted into ClusterMeta so the reconciler + DeleteCluster can act on
+// the resources without re-resolving by name.
+type ClusterNLB struct {
+ LoadBalancerArn string
+ TargetGroupArn string
+ ListenerArn string
+ DNSName string
+ // FrontendIP is the NLB's reachable front-end address: the external-pool
+ // public IP for an internet-facing cluster NLB, or the VPC private IP for an
+ // internal one. Used as the kubeconfig endpoint host + apiserver cert SAN.
+ // Empty if the backing LB record has no address yet.
+ FrontendIP string
+}
+
+// ClusterNLBName returns the deterministic NLB name for a cluster. Stable so
+// DeleteCluster can locate the NLB from clusterName alone after a daemon
+// restart that lost the in-memory ARN.
+func ClusterNLBName(clusterName string) string {
+ return "eks-" + clusterName
+}
+
+// ClusterTargetGroupName returns the deterministic target-group name for a
+// cluster's control-plane TG.
+func ClusterTargetGroupName(clusterName string) string {
+ return "eks-" + clusterName + "-cp"
+}
+
+// EnsureClusterNLB provisions (or returns) the cluster's NLB + target group +
+// :443→:6443 TCP listener. Idempotent on clusterName: existing resources are
+// reused so DeleteCluster failure-retry and reconciler crash-recovery both
+// converge without duplicate creates.
+//
+// The K3s server VM ENI IP is NOT registered here — Stage 4
+// (k3s_server_vm.go) calls RegisterClusterTarget once the VM has its ENI IP.
+//
+// internetFacing selects the NLB scheme: true ⇒ internet-facing (external-pool
+// front-end IP, LAN-reachable, for a public cluster endpoint); false ⇒ internal
+// (VPC-only). After ensuring the LB, the reachable FrontendIP is read back from
+// the backing LB record so the caller can use it as the endpoint host + cert SAN.
+//
+// publicAccessCidrs narrows who may reach an internet-facing front-end below the
+// scheme default (0.0.0.0/0). It maps the cluster's publicAccessCidrs onto the
+// NLB managed-SG ingress; ignored for an internal NLB (its ingress already
+// tracks the VPC CIDR) and for the wide-open default, which the LB carries out
+// of the box.
+func EnsureClusterNLB(nlbp nlbProvisioner, accountID, clusterName string, subnetIDs []string, internetFacing bool, publicAccessCidrs []string) (*ClusterNLB, error) {
+ if clusterName == "" {
+ return nil, errors.New("eks: EnsureClusterNLB empty cluster name")
+ }
+ if len(subnetIDs) == 0 {
+ return nil, errors.New("eks: EnsureClusterNLB empty subnet list")
+ }
+ lbName := ClusterNLBName(clusterName)
+ tgName := ClusterTargetGroupName(clusterName)
+ if len(lbName) > maxELBv2NameLen {
+ return nil, fmt.Errorf("eks: NLB name %q exceeds %d chars (cluster name too long)", lbName, maxELBv2NameLen)
+ }
+ if len(tgName) > maxELBv2NameLen {
+ return nil, fmt.Errorf("eks: TG name %q exceeds %d chars (cluster name too long)", tgName, maxELBv2NameLen)
+ }
+
+ out := &ClusterNLB{}
+
+ if err := ensureClusterLB(nlbp, accountID, clusterName, lbName, subnetIDs, internetFacing, out); err != nil {
+ return nil, err
+ }
+ if err := ensureClusterTG(nlbp, accountID, clusterName, tgName, out); err != nil {
+ return nil, err
+ }
+ if err := ensureClusterListener(nlbp, accountID, lbName, out); err != nil {
+ return nil, err
+ }
+ // Narrow the internet-facing front-end to the cluster's publicAccessCidrs
+ // when they restrict below the wide-open scheme default the LB already
+ // carries. The override is idempotent, so re-running on retry converges.
+ if internetFacing && narrowsPublicAccess(publicAccessCidrs) {
+ if err := nlbp.SetLoadBalancerIngressCIDRs(out.LoadBalancerArn, publicAccessCidrs, accountID); err != nil {
+ return nil, fmt.Errorf("eks: set NLB ingress CIDRs for %s: %w", lbName, err)
+ }
+ }
+ // Read the front-end IP back from the LB record (DescribeLoadBalancers →
+ // populated LoadBalancerAddresses). Best-effort: an empty IP is not fatal
+ // here — the caller falls back to the DNS-name endpoint.
+ if lb, err := lookupLBByName(nlbp, accountID, lbName); err == nil && lb != nil {
+ out.FrontendIP = frontendIPFromLB(lb, internetFacing)
+ } else if err != nil {
+ slog.Warn("EnsureClusterNLB: front-end IP read-back failed", "name", lbName, "err", err)
+ }
+ return out, nil
+}
+
+// narrowsPublicAccess reports whether publicAccessCidrs restrict the front-end
+// below the wide-open default an internet-facing NLB already carries. Empty (no
+// override) or the lone 0.0.0.0/0 default are no-ops and skip the extra ELBv2
+// round-trip.
+func narrowsPublicAccess(cidrs []string) bool {
+ if len(cidrs) == 0 {
+ return false
+ }
+ if len(cidrs) == 1 && cidrs[0] == defaultPublicAccessCidr {
+ return false
+ }
+ return true
+}
+
+// frontendIPFromLB extracts the reachable front-end address from a described
+// load balancer: the public IpAddress for an internet-facing LB, else the VPC
+// PrivateIPv4Address. Returns the first non-empty match across AZs.
+func frontendIPFromLB(lb *elbv2.LoadBalancer, internetFacing bool) string {
+ for _, az := range lb.AvailabilityZones {
+ for _, addr := range az.LoadBalancerAddresses {
+ if addr == nil {
+ continue
+ }
+ if internetFacing {
+ if ip := aws.StringValue(addr.IpAddress); ip != "" {
+ return ip
+ }
+ continue
+ }
+ if ip := aws.StringValue(addr.PrivateIPv4Address); ip != "" {
+ return ip
+ }
+ }
+ }
+ return ""
+}
+
+// RegisterClusterTarget attaches the K3s server ENI IP to the cluster TG so
+// the NLB starts forwarding :443 traffic to :6443 on the VM. The ELBv2
+// RegisterTargets impl already dedupes on (id, port), so re-invocation by
+// the reconciler is idempotent.
+func RegisterClusterTarget(nlbp nlbProvisioner, accountID, tgArn, eniIP string) error {
+ if tgArn == "" {
+ return errors.New("eks: RegisterClusterTarget empty TG arn")
+ }
+ if eniIP == "" {
+ return errors.New("eks: RegisterClusterTarget empty ENI IP")
+ }
+ if _, err := nlbp.RegisterTargets(&elbv2.RegisterTargetsInput{
+ TargetGroupArn: aws.String(tgArn),
+ Targets: []*elbv2.TargetDescription{{
+ Id: aws.String(eniIP),
+ Port: aws.Int64(k3sAPIServerPort),
+ }},
+ }, accountID); err != nil {
+ return fmt.Errorf("register target %s on TG %s: %w", eniIP, tgArn, err)
+ }
+ return nil
+}
+
+// DeregisterClusterTarget removes the K3s server ENI IP from the cluster TG.
+// Called by DeleteCluster before VM termination so health checks stop hitting
+// a dying target.
+func DeregisterClusterTarget(nlbp nlbProvisioner, accountID, tgArn, eniIP string) error {
+ if tgArn == "" {
+ return errors.New("eks: DeregisterClusterTarget empty TG arn")
+ }
+ if eniIP == "" {
+ return errors.New("eks: DeregisterClusterTarget empty ENI IP")
+ }
+ if _, err := nlbp.DeregisterTargets(&elbv2.DeregisterTargetsInput{
+ TargetGroupArn: aws.String(tgArn),
+ Targets: []*elbv2.TargetDescription{{
+ Id: aws.String(eniIP),
+ Port: aws.Int64(k3sAPIServerPort),
+ }},
+ }, accountID); err != nil {
+ return fmt.Errorf("deregister target %s on TG %s: %w", eniIP, tgArn, err)
+ }
+ return nil
+}
+
+// DeleteClusterNLB tears down the cluster NLB + TG. DeleteLoadBalancer
+// cascades listener cleanup inside the ELBv2 impl, so listeners do not need
+// an explicit delete pass. Missing resources are no-ops to keep DeleteCluster
+// retries safe. Both sub-sweeps always run; LB delete error takes precedence
+// over TG delete error when both fail.
+func DeleteClusterNLB(nlbp nlbProvisioner, accountID, clusterName string) error {
+ if clusterName == "" {
+ return errors.New("eks: DeleteClusterNLB empty cluster name")
+ }
+ lbErr := deleteClusterLB(nlbp, accountID, ClusterNLBName(clusterName))
+ tgErr := deleteClusterTG(nlbp, accountID, ClusterTargetGroupName(clusterName))
+ if lbErr != nil {
+ return lbErr
+ }
+ return tgErr
+}
+
+func deleteClusterLB(nlbp nlbProvisioner, accountID, lbName string) error {
+ lb, err := lookupLBByName(nlbp, accountID, lbName)
+ if err != nil {
+ slog.Warn("DeleteClusterNLB: LB lookup failed", "name", lbName, "err", err)
+ return err
+ }
+ if lb == nil || lb.LoadBalancerArn == nil {
+ return nil
+ }
+ if _, err := nlbp.DeleteLoadBalancer(&elbv2.DeleteLoadBalancerInput{LoadBalancerArn: lb.LoadBalancerArn}, accountID); err != nil {
+ slog.Warn("DeleteClusterNLB: delete NLB failed", "name", lbName, "err", err)
+ return fmt.Errorf("delete NLB %s: %w", lbName, err)
+ }
+ return nil
+}
+
+func deleteClusterTG(nlbp nlbProvisioner, accountID, tgName string) error {
+ tg, err := lookupTGByName(nlbp, accountID, tgName)
+ if err != nil {
+ slog.Warn("DeleteClusterNLB: TG lookup failed", "name", tgName, "err", err)
+ return err
+ }
+ if tg == nil || tg.TargetGroupArn == nil {
+ return nil
+ }
+ if _, err := nlbp.DeleteTargetGroup(&elbv2.DeleteTargetGroupInput{TargetGroupArn: tg.TargetGroupArn}, accountID); err != nil {
+ slog.Warn("DeleteClusterNLB: delete TG failed", "name", tgName, "err", err)
+ return fmt.Errorf("delete TG %s: %w", tgName, err)
+ }
+ return nil
+}
+
+func ensureClusterLB(nlbp nlbProvisioner, accountID, clusterName, lbName string, subnetIDs []string, internetFacing bool, out *ClusterNLB) error {
+ if lb, err := lookupLBByName(nlbp, accountID, lbName); err != nil {
+ return err
+ } else if lb != nil {
+ out.LoadBalancerArn = aws.StringValue(lb.LoadBalancerArn)
+ out.DNSName = aws.StringValue(lb.DNSName)
+ if out.LoadBalancerArn == "" || out.DNSName == "" {
+ return fmt.Errorf("eks: existing NLB %s missing arn or DNS name", lbName)
+ }
+ return nil
+ }
+
+ scheme := elbv2.LoadBalancerSchemeEnumInternal
+ if internetFacing {
+ scheme = elbv2.LoadBalancerSchemeEnumInternetFacing
+ }
+ created, err := nlbp.CreateLoadBalancer(&elbv2.CreateLoadBalancerInput{
+ Name: aws.String(lbName),
+ Type: aws.String(elbv2.LoadBalancerTypeEnumNetwork),
+ Scheme: aws.String(scheme),
+ Subnets: aws.StringSlice(subnetIDs),
+ Tags: []*elbv2.Tag{
+ {Key: aws.String(tags.ManagedByKey), Value: aws.String(tags.ManagedByEKS)},
+ {Key: aws.String(clusterEKSClusterTagKey), Value: aws.String(clusterName)},
+ },
+ }, accountID)
+ if err != nil {
+ return fmt.Errorf("create NLB %s: %w", lbName, err)
+ }
+ if created == nil || len(created.LoadBalancers) == 0 || created.LoadBalancers[0] == nil {
+ return fmt.Errorf("eks: CreateLoadBalancer returned no LB for %s", lbName)
+ }
+ lb := created.LoadBalancers[0]
+ out.LoadBalancerArn = aws.StringValue(lb.LoadBalancerArn)
+ out.DNSName = aws.StringValue(lb.DNSName)
+ if out.LoadBalancerArn == "" || out.DNSName == "" {
+ return fmt.Errorf("eks: CreateLoadBalancer returned empty arn or DNS name for %s", lbName)
+ }
+ return nil
+}
+
+func ensureClusterTG(nlbp nlbProvisioner, accountID, clusterName, tgName string, out *ClusterNLB) error {
+ if tg, err := lookupTGByName(nlbp, accountID, tgName); err != nil {
+ return err
+ } else if tg != nil {
+ out.TargetGroupArn = aws.StringValue(tg.TargetGroupArn)
+ if out.TargetGroupArn == "" {
+ return fmt.Errorf("eks: existing TG %s missing arn", tgName)
+ }
+ return nil
+ }
+
+ created, err := nlbp.CreateTargetGroup(&elbv2.CreateTargetGroupInput{
+ Name: aws.String(tgName),
+ Protocol: aws.String(elbv2.ProtocolEnumTcp),
+ Port: aws.Int64(k3sAPIServerPort),
+ TargetType: aws.String(elbv2.TargetTypeEnumIp),
+ Tags: []*elbv2.Tag{
+ {Key: aws.String(tags.ManagedByKey), Value: aws.String(tags.ManagedByEKS)},
+ {Key: aws.String(clusterEKSClusterTagKey), Value: aws.String(clusterName)},
+ },
+ }, accountID)
+ if err != nil {
+ return fmt.Errorf("create TG %s: %w", tgName, err)
+ }
+ if created == nil || len(created.TargetGroups) == 0 || created.TargetGroups[0] == nil {
+ return fmt.Errorf("eks: CreateTargetGroup returned no TG for %s", tgName)
+ }
+ out.TargetGroupArn = aws.StringValue(created.TargetGroups[0].TargetGroupArn)
+ if out.TargetGroupArn == "" {
+ return fmt.Errorf("eks: CreateTargetGroup returned empty arn for %s", tgName)
+ }
+ return nil
+}
+
+func ensureClusterListener(nlbp nlbProvisioner, accountID, lbName string, out *ClusterNLB) error {
+ if l, err := lookupListenerByPort(nlbp, accountID, out.LoadBalancerArn, clusterNLBListenPort); err != nil {
+ return err
+ } else if l != nil {
+ out.ListenerArn = aws.StringValue(l.ListenerArn)
+ if out.ListenerArn == "" {
+ return fmt.Errorf("eks: existing listener for %s:%d missing arn", lbName, clusterNLBListenPort)
+ }
+ return nil
+ }
+
+ created, err := nlbp.CreateListener(&elbv2.CreateListenerInput{
+ LoadBalancerArn: aws.String(out.LoadBalancerArn),
+ Protocol: aws.String(elbv2.ProtocolEnumTcp),
+ Port: aws.Int64(clusterNLBListenPort),
+ DefaultActions: []*elbv2.Action{{
+ Type: aws.String(elbv2.ActionTypeEnumForward),
+ TargetGroupArn: aws.String(out.TargetGroupArn),
+ }},
+ }, accountID)
+ if err != nil {
+ return fmt.Errorf("create listener %s:%d: %w", lbName, clusterNLBListenPort, err)
+ }
+ if created == nil || len(created.Listeners) == 0 || created.Listeners[0] == nil {
+ return fmt.Errorf("eks: CreateListener returned no listener for %s:%d", lbName, clusterNLBListenPort)
+ }
+ out.ListenerArn = aws.StringValue(created.Listeners[0].ListenerArn)
+ if out.ListenerArn == "" {
+ return fmt.Errorf("eks: CreateListener returned empty arn for %s:%d", lbName, clusterNLBListenPort)
+ }
+ return nil
+}
+
+func lookupLBByName(nlbp nlbProvisioner, accountID, name string) (*elbv2.LoadBalancer, error) {
+ out, err := nlbp.DescribeLoadBalancers(&elbv2.DescribeLoadBalancersInput{
+ Names: aws.StringSlice([]string{name}),
+ }, accountID)
+ if err != nil {
+ return nil, fmt.Errorf("describe NLB %s: %w", name, err)
+ }
+ for _, lb := range out.LoadBalancers {
+ if lb != nil {
+ return lb, nil
+ }
+ }
+ return nil, nil
+}
+
+func lookupTGByName(nlbp nlbProvisioner, accountID, name string) (*elbv2.TargetGroup, error) {
+ out, err := nlbp.DescribeTargetGroups(&elbv2.DescribeTargetGroupsInput{
+ Names: aws.StringSlice([]string{name}),
+ }, accountID)
+ if err != nil {
+ return nil, fmt.Errorf("describe TG %s: %w", name, err)
+ }
+ for _, tg := range out.TargetGroups {
+ if tg != nil {
+ return tg, nil
+ }
+ }
+ return nil, nil
+}
+
+func lookupListenerByPort(nlbp nlbProvisioner, accountID, lbArn string, port int64) (*elbv2.Listener, error) {
+ if lbArn == "" {
+ return nil, errors.New("eks: lookupListenerByPort empty LB arn")
+ }
+ out, err := nlbp.DescribeListeners(&elbv2.DescribeListenersInput{
+ LoadBalancerArn: aws.String(lbArn),
+ }, accountID)
+ if err != nil {
+ return nil, fmt.Errorf("describe listeners on %s: %w", lbArn, err)
+ }
+ for _, l := range out.Listeners {
+ if l == nil || l.Port == nil {
+ continue
+ }
+ if *l.Port == port {
+ return l, nil
+ }
+ }
+ return nil, nil
+}
diff --git a/spinifex/handlers/eks/nlb_test.go b/spinifex/handlers/eks/nlb_test.go
new file mode 100644
index 000000000..632c4a4e2
--- /dev/null
+++ b/spinifex/handlers/eks/nlb_test.go
@@ -0,0 +1,501 @@
+package handlers_eks
+
+import (
+ "errors"
+ "strings"
+ "testing"
+
+ "github.com/aws/aws-sdk-go/aws"
+ "github.com/aws/aws-sdk-go/service/elbv2"
+ "github.com/mulgadc/spinifex/spinifex/tags"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+type fakeNLBProvisioner struct {
+ createLBCalls []*elbv2.CreateLoadBalancerInput
+ describeLBCalls []*elbv2.DescribeLoadBalancersInput
+ deleteLBCalls []*elbv2.DeleteLoadBalancerInput
+ createTGCalls []*elbv2.CreateTargetGroupInput
+ describeTGCalls []*elbv2.DescribeTargetGroupsInput
+ deleteTGCalls []*elbv2.DeleteTargetGroupInput
+ createListenerCalls []*elbv2.CreateListenerInput
+ describeListeners []*elbv2.DescribeListenersInput
+ registerCalls []*elbv2.RegisterTargetsInput
+ deregisterCalls []*elbv2.DeregisterTargetsInput
+ setIngressCalls []setIngressCIDRsCall
+
+ lbByName map[string]*elbv2.LoadBalancer
+ tgByName map[string]*elbv2.TargetGroup
+ listenerByPort map[string]map[int64]*elbv2.Listener // lbArn → port → listener
+
+ createLBOut *elbv2.CreateLoadBalancerOutput
+ createTGOut *elbv2.CreateTargetGroupOutput
+ createListenerOut *elbv2.CreateListenerOutput
+ createLBErr error
+ createTGErr error
+ createListenerErr error
+ deleteLBErr error
+ deleteTGErr error
+ registerErr error
+ deregisterErr error
+ setIngressErr error
+}
+
+type setIngressCIDRsCall struct {
+ lbArn string
+ cidrs []string
+}
+
+var _ nlbProvisioner = (*fakeNLBProvisioner)(nil)
+
+func newFakeNLBProvisioner() *fakeNLBProvisioner {
+ return &fakeNLBProvisioner{
+ lbByName: map[string]*elbv2.LoadBalancer{},
+ tgByName: map[string]*elbv2.TargetGroup{},
+ listenerByPort: map[string]map[int64]*elbv2.Listener{},
+ }
+}
+
+func (f *fakeNLBProvisioner) CreateLoadBalancer(input *elbv2.CreateLoadBalancerInput, _ string) (*elbv2.CreateLoadBalancerOutput, error) {
+ f.createLBCalls = append(f.createLBCalls, input)
+ if f.createLBErr != nil {
+ return nil, f.createLBErr
+ }
+ if f.createLBOut == nil {
+ name := aws.StringValue(input.Name)
+ arn := "arn:aws:elasticloadbalancing:us-east-1:111122223333:loadbalancer/net/" + name + "/lb-001"
+ dns := name + "-lb-001.us-east-1.elb.spinifex.local"
+ f.createLBOut = &elbv2.CreateLoadBalancerOutput{
+ LoadBalancers: []*elbv2.LoadBalancer{{
+ LoadBalancerArn: aws.String(arn),
+ LoadBalancerName: aws.String(name),
+ DNSName: aws.String(dns),
+ Type: aws.String(elbv2.LoadBalancerTypeEnumNetwork),
+ Scheme: aws.String(elbv2.LoadBalancerSchemeEnumInternal),
+ }},
+ }
+ }
+ out := f.createLBOut
+ if len(out.LoadBalancers) > 0 {
+ name := aws.StringValue(out.LoadBalancers[0].LoadBalancerName)
+ f.lbByName[name] = out.LoadBalancers[0]
+ }
+ return out, nil
+}
+
+func (f *fakeNLBProvisioner) DescribeLoadBalancers(input *elbv2.DescribeLoadBalancersInput, _ string) (*elbv2.DescribeLoadBalancersOutput, error) {
+ f.describeLBCalls = append(f.describeLBCalls, input)
+ out := &elbv2.DescribeLoadBalancersOutput{}
+ for _, n := range input.Names {
+ if n == nil {
+ continue
+ }
+ if lb, ok := f.lbByName[*n]; ok {
+ out.LoadBalancers = append(out.LoadBalancers, lb)
+ }
+ }
+ return out, nil
+}
+
+func (f *fakeNLBProvisioner) DeleteLoadBalancer(input *elbv2.DeleteLoadBalancerInput, _ string) (*elbv2.DeleteLoadBalancerOutput, error) {
+ f.deleteLBCalls = append(f.deleteLBCalls, input)
+ if f.deleteLBErr != nil {
+ return nil, f.deleteLBErr
+ }
+ if input.LoadBalancerArn != nil {
+ for name, lb := range f.lbByName {
+ if aws.StringValue(lb.LoadBalancerArn) == *input.LoadBalancerArn {
+ delete(f.lbByName, name)
+ }
+ }
+ }
+ return &elbv2.DeleteLoadBalancerOutput{}, nil
+}
+
+func (f *fakeNLBProvisioner) CreateTargetGroup(input *elbv2.CreateTargetGroupInput, _ string) (*elbv2.CreateTargetGroupOutput, error) {
+ f.createTGCalls = append(f.createTGCalls, input)
+ if f.createTGErr != nil {
+ return nil, f.createTGErr
+ }
+ if f.createTGOut == nil {
+ name := aws.StringValue(input.Name)
+ arn := "arn:aws:elasticloadbalancing:us-east-1:111122223333:targetgroup/" + name + "/tg-001"
+ f.createTGOut = &elbv2.CreateTargetGroupOutput{
+ TargetGroups: []*elbv2.TargetGroup{{
+ TargetGroupArn: aws.String(arn),
+ TargetGroupName: aws.String(name),
+ Protocol: input.Protocol,
+ Port: input.Port,
+ TargetType: input.TargetType,
+ }},
+ }
+ }
+ out := f.createTGOut
+ if len(out.TargetGroups) > 0 {
+ name := aws.StringValue(out.TargetGroups[0].TargetGroupName)
+ f.tgByName[name] = out.TargetGroups[0]
+ }
+ return out, nil
+}
+
+func (f *fakeNLBProvisioner) DescribeTargetGroups(input *elbv2.DescribeTargetGroupsInput, _ string) (*elbv2.DescribeTargetGroupsOutput, error) {
+ f.describeTGCalls = append(f.describeTGCalls, input)
+ out := &elbv2.DescribeTargetGroupsOutput{}
+ for _, n := range input.Names {
+ if n == nil {
+ continue
+ }
+ if tg, ok := f.tgByName[*n]; ok {
+ out.TargetGroups = append(out.TargetGroups, tg)
+ }
+ }
+ return out, nil
+}
+
+func (f *fakeNLBProvisioner) DeleteTargetGroup(input *elbv2.DeleteTargetGroupInput, _ string) (*elbv2.DeleteTargetGroupOutput, error) {
+ f.deleteTGCalls = append(f.deleteTGCalls, input)
+ if f.deleteTGErr != nil {
+ return nil, f.deleteTGErr
+ }
+ if input.TargetGroupArn != nil {
+ for name, tg := range f.tgByName {
+ if aws.StringValue(tg.TargetGroupArn) == *input.TargetGroupArn {
+ delete(f.tgByName, name)
+ }
+ }
+ }
+ return &elbv2.DeleteTargetGroupOutput{}, nil
+}
+
+func (f *fakeNLBProvisioner) CreateListener(input *elbv2.CreateListenerInput, _ string) (*elbv2.CreateListenerOutput, error) {
+ f.createListenerCalls = append(f.createListenerCalls, input)
+ if f.createListenerErr != nil {
+ return nil, f.createListenerErr
+ }
+ if f.createListenerOut == nil {
+ lbArn := aws.StringValue(input.LoadBalancerArn)
+ port := aws.Int64Value(input.Port)
+ listenerArn := lbArn + "/listener/lst-001"
+ f.createListenerOut = &elbv2.CreateListenerOutput{
+ Listeners: []*elbv2.Listener{{
+ ListenerArn: aws.String(listenerArn),
+ LoadBalancerArn: input.LoadBalancerArn,
+ Port: input.Port,
+ Protocol: input.Protocol,
+ DefaultActions: input.DefaultActions,
+ }},
+ }
+ if f.listenerByPort[lbArn] == nil {
+ f.listenerByPort[lbArn] = map[int64]*elbv2.Listener{}
+ }
+ f.listenerByPort[lbArn][port] = f.createListenerOut.Listeners[0]
+ }
+ return f.createListenerOut, nil
+}
+
+func (f *fakeNLBProvisioner) DescribeListeners(input *elbv2.DescribeListenersInput, _ string) (*elbv2.DescribeListenersOutput, error) {
+ f.describeListeners = append(f.describeListeners, input)
+ out := &elbv2.DescribeListenersOutput{}
+ if input.LoadBalancerArn != nil {
+ for _, l := range f.listenerByPort[*input.LoadBalancerArn] {
+ out.Listeners = append(out.Listeners, l)
+ }
+ }
+ return out, nil
+}
+
+func (f *fakeNLBProvisioner) RegisterTargets(input *elbv2.RegisterTargetsInput, _ string) (*elbv2.RegisterTargetsOutput, error) {
+ f.registerCalls = append(f.registerCalls, input)
+ if f.registerErr != nil {
+ return nil, f.registerErr
+ }
+ return &elbv2.RegisterTargetsOutput{}, nil
+}
+
+func (f *fakeNLBProvisioner) DeregisterTargets(input *elbv2.DeregisterTargetsInput, _ string) (*elbv2.DeregisterTargetsOutput, error) {
+ f.deregisterCalls = append(f.deregisterCalls, input)
+ if f.deregisterErr != nil {
+ return nil, f.deregisterErr
+ }
+ return &elbv2.DeregisterTargetsOutput{}, nil
+}
+
+func (f *fakeNLBProvisioner) SetLoadBalancerIngressCIDRs(lbArn string, cidrs []string, _ string) error {
+ f.setIngressCalls = append(f.setIngressCalls, setIngressCIDRsCall{lbArn: lbArn, cidrs: cidrs})
+ return f.setIngressErr
+}
+
+func TestEnsureClusterNLB_EmptyInputsRejected(t *testing.T) {
+ nlbp := newFakeNLBProvisioner()
+
+ _, err := EnsureClusterNLB(nlbp, "111122223333", "", []string{"subnet-aaa"}, false, nil)
+ require.Error(t, err)
+
+ _, err = EnsureClusterNLB(nlbp, "111122223333", "alpha", nil, false, nil)
+ require.Error(t, err)
+
+ assert.Empty(t, nlbp.createLBCalls)
+}
+
+func TestEnsureClusterNLB_NameTooLongRejected(t *testing.T) {
+ nlbp := newFakeNLBProvisioner()
+
+ longName := strings.Repeat("x", maxELBv2NameLen) // "eks-" + 32x = 36 chars
+ _, err := EnsureClusterNLB(nlbp, "111122223333", longName, []string{"subnet-aaa"}, false, nil)
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), "exceeds")
+ assert.Empty(t, nlbp.createLBCalls)
+}
+
+func TestEnsureClusterNLB_FreshCreatesAllThree(t *testing.T) {
+ nlbp := newFakeNLBProvisioner()
+
+ out, err := EnsureClusterNLB(nlbp, "111122223333", "alpha", []string{"subnet-aaa", "subnet-bbb"}, false, nil)
+ require.NoError(t, err)
+ require.NotNil(t, out)
+ assert.NotEmpty(t, out.LoadBalancerArn)
+ assert.NotEmpty(t, out.TargetGroupArn)
+ assert.NotEmpty(t, out.ListenerArn)
+ assert.Contains(t, out.DNSName, "eks-alpha")
+
+ require.Len(t, nlbp.createLBCalls, 1)
+ lbIn := nlbp.createLBCalls[0]
+ assert.Equal(t, "eks-alpha", aws.StringValue(lbIn.Name))
+ assert.Equal(t, elbv2.LoadBalancerTypeEnumNetwork, aws.StringValue(lbIn.Type))
+ assert.Equal(t, elbv2.LoadBalancerSchemeEnumInternal, aws.StringValue(lbIn.Scheme))
+ assert.Equal(t, []string{"subnet-aaa", "subnet-bbb"}, aws.StringValueSlice(lbIn.Subnets))
+ assertELBv2TaggedAsEKS(t, lbIn.Tags, "alpha")
+
+ require.Len(t, nlbp.createTGCalls, 1)
+ tgIn := nlbp.createTGCalls[0]
+ assert.Equal(t, "eks-alpha-cp", aws.StringValue(tgIn.Name))
+ assert.Equal(t, elbv2.ProtocolEnumTcp, aws.StringValue(tgIn.Protocol))
+ assert.Equal(t, k3sAPIServerPort, aws.Int64Value(tgIn.Port))
+ assert.Equal(t, elbv2.TargetTypeEnumIp, aws.StringValue(tgIn.TargetType))
+ assertELBv2TaggedAsEKS(t, tgIn.Tags, "alpha")
+
+ require.Len(t, nlbp.createListenerCalls, 1)
+ lstIn := nlbp.createListenerCalls[0]
+ assert.Equal(t, out.LoadBalancerArn, aws.StringValue(lstIn.LoadBalancerArn))
+ assert.Equal(t, elbv2.ProtocolEnumTcp, aws.StringValue(lstIn.Protocol))
+ assert.Equal(t, clusterNLBListenPort, aws.Int64Value(lstIn.Port))
+ require.Len(t, lstIn.DefaultActions, 1)
+ assert.Equal(t, elbv2.ActionTypeEnumForward, aws.StringValue(lstIn.DefaultActions[0].Type))
+ assert.Equal(t, out.TargetGroupArn, aws.StringValue(lstIn.DefaultActions[0].TargetGroupArn))
+}
+
+func TestEnsureClusterNLB_IdempotentReusesExisting(t *testing.T) {
+ nlbp := newFakeNLBProvisioner()
+
+ first, err := EnsureClusterNLB(nlbp, "111122223333", "alpha", []string{"subnet-aaa"}, false, nil)
+ require.NoError(t, err)
+
+ createLBCount := len(nlbp.createLBCalls)
+ createTGCount := len(nlbp.createTGCalls)
+ createListenerCount := len(nlbp.createListenerCalls)
+
+ second, err := EnsureClusterNLB(nlbp, "111122223333", "alpha", []string{"subnet-aaa"}, false, nil)
+ require.NoError(t, err)
+
+ assert.Equal(t, first.LoadBalancerArn, second.LoadBalancerArn)
+ assert.Equal(t, first.TargetGroupArn, second.TargetGroupArn)
+ assert.Equal(t, first.ListenerArn, second.ListenerArn)
+ assert.Equal(t, first.DNSName, second.DNSName)
+
+ assert.Equal(t, createLBCount, len(nlbp.createLBCalls), "no new LB create on idempotent call")
+ assert.Equal(t, createTGCount, len(nlbp.createTGCalls), "no new TG create on idempotent call")
+ assert.Equal(t, createListenerCount, len(nlbp.createListenerCalls), "no new listener create on idempotent call")
+}
+
+func TestEnsureClusterNLB_LBCreateErrorSurfaced(t *testing.T) {
+ nlbp := newFakeNLBProvisioner()
+ nlbp.createLBErr = errors.New("InsufficientCapacity")
+
+ _, err := EnsureClusterNLB(nlbp, "111122223333", "alpha", []string{"subnet-aaa"}, false, nil)
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), "create NLB eks-alpha")
+ assert.Empty(t, nlbp.createTGCalls, "TG create should not run when LB create fails")
+}
+
+func TestEnsureClusterNLB_InternetFacingSchemeAndPublicFrontendIP(t *testing.T) {
+ nlbp := newFakeNLBProvisioner()
+ // Internet-facing LB record exposes a public IpAddress in its AZ addresses.
+ arn := "arn:aws:elasticloadbalancing:us-east-1:111122223333:loadbalancer/net/eks-alpha/lb-001"
+ nlbp.createLBOut = &elbv2.CreateLoadBalancerOutput{
+ LoadBalancers: []*elbv2.LoadBalancer{{
+ LoadBalancerArn: aws.String(arn),
+ LoadBalancerName: aws.String("eks-alpha"),
+ DNSName: aws.String("eks-alpha-lb-001.us-east-1.elb.spinifex.local"),
+ Type: aws.String(elbv2.LoadBalancerTypeEnumNetwork),
+ Scheme: aws.String(elbv2.LoadBalancerSchemeEnumInternetFacing),
+ AvailabilityZones: []*elbv2.AvailabilityZone{{
+ LoadBalancerAddresses: []*elbv2.LoadBalancerAddress{
+ {PrivateIPv4Address: aws.String("10.0.1.5")},
+ {IpAddress: aws.String("203.0.113.10")},
+ },
+ }},
+ }},
+ }
+
+ out, err := EnsureClusterNLB(nlbp, "111122223333", "alpha", []string{"subnet-aaa"}, true, nil)
+ require.NoError(t, err)
+ require.Len(t, nlbp.createLBCalls, 1)
+ assert.Equal(t, elbv2.LoadBalancerSchemeEnumInternetFacing, aws.StringValue(nlbp.createLBCalls[0].Scheme))
+ assert.Equal(t, "203.0.113.10", out.FrontendIP, "internet-facing front-end uses the public IpAddress")
+}
+
+func TestEnsureClusterNLB_InternalSchemeUsesPrivateFrontendIP(t *testing.T) {
+ nlbp := newFakeNLBProvisioner()
+ arn := "arn:aws:elasticloadbalancing:us-east-1:111122223333:loadbalancer/net/eks-alpha/lb-001"
+ nlbp.createLBOut = &elbv2.CreateLoadBalancerOutput{
+ LoadBalancers: []*elbv2.LoadBalancer{{
+ LoadBalancerArn: aws.String(arn),
+ LoadBalancerName: aws.String("eks-alpha"),
+ DNSName: aws.String("eks-alpha-lb-001.us-east-1.elb.spinifex.local"),
+ Type: aws.String(elbv2.LoadBalancerTypeEnumNetwork),
+ Scheme: aws.String(elbv2.LoadBalancerSchemeEnumInternal),
+ AvailabilityZones: []*elbv2.AvailabilityZone{{
+ LoadBalancerAddresses: []*elbv2.LoadBalancerAddress{
+ {PrivateIPv4Address: aws.String("10.0.1.5")},
+ },
+ }},
+ }},
+ }
+
+ out, err := EnsureClusterNLB(nlbp, "111122223333", "alpha", []string{"subnet-aaa"}, false, nil)
+ require.NoError(t, err)
+ require.Len(t, nlbp.createLBCalls, 1)
+ assert.Equal(t, elbv2.LoadBalancerSchemeEnumInternal, aws.StringValue(nlbp.createLBCalls[0].Scheme))
+ assert.Equal(t, "10.0.1.5", out.FrontendIP, "internal front-end uses the private IPv4 address")
+}
+
+func TestEnsureClusterNLB_NarrowedPublicAccessSetsIngressCIDRs(t *testing.T) {
+ nlbp := newFakeNLBProvisioner()
+
+ cidrs := []string{"203.0.113.0/24", "198.51.100.7/32"}
+ out, err := EnsureClusterNLB(nlbp, "111122223333", "alpha", []string{"subnet-aaa"}, true, cidrs)
+ require.NoError(t, err)
+
+ require.Len(t, nlbp.setIngressCalls, 1, "narrowed public access should drive SetLoadBalancerIngressCIDRs")
+ assert.Equal(t, out.LoadBalancerArn, nlbp.setIngressCalls[0].lbArn)
+ assert.Equal(t, cidrs, nlbp.setIngressCalls[0].cidrs)
+}
+
+func TestEnsureClusterNLB_DefaultPublicAccessSkipsIngressCIDRs(t *testing.T) {
+ for _, tc := range []struct {
+ name string
+ cidrs []string
+ }{
+ {"nil", nil},
+ {"empty", []string{}},
+ {"wide-open default", []string{defaultPublicAccessCidr}},
+ } {
+ t.Run(tc.name, func(t *testing.T) {
+ nlbp := newFakeNLBProvisioner()
+ _, err := EnsureClusterNLB(nlbp, "111122223333", "alpha", []string{"subnet-aaa"}, true, tc.cidrs)
+ require.NoError(t, err)
+ assert.Empty(t, nlbp.setIngressCalls, "wide-open front-end needs no ingress override")
+ })
+ }
+}
+
+func TestEnsureClusterNLB_InternalSkipsIngressCIDRs(t *testing.T) {
+ nlbp := newFakeNLBProvisioner()
+
+ // Even with narrowed CIDRs, an internal NLB ignores them — its ingress
+ // already tracks the VPC CIDR, not a public front-end.
+ _, err := EnsureClusterNLB(nlbp, "111122223333", "alpha", []string{"subnet-aaa"}, false, []string{"203.0.113.0/24"})
+ require.NoError(t, err)
+ assert.Empty(t, nlbp.setIngressCalls)
+}
+
+func TestEnsureClusterNLB_SetIngressErrorSurfaced(t *testing.T) {
+ nlbp := newFakeNLBProvisioner()
+ nlbp.setIngressErr = errors.New("InvalidLoadBalancer")
+
+ _, err := EnsureClusterNLB(nlbp, "111122223333", "alpha", []string{"subnet-aaa"}, true, []string{"203.0.113.0/24"})
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), "set NLB ingress CIDRs for eks-alpha")
+}
+
+func TestRegisterClusterTarget_PostsENIIPAndAPIPort(t *testing.T) {
+ nlbp := newFakeNLBProvisioner()
+
+ err := RegisterClusterTarget(nlbp, "111122223333", "arn:tg/alpha", "10.0.1.42")
+ require.NoError(t, err)
+ require.Len(t, nlbp.registerCalls, 1)
+
+ in := nlbp.registerCalls[0]
+ assert.Equal(t, "arn:tg/alpha", aws.StringValue(in.TargetGroupArn))
+ require.Len(t, in.Targets, 1)
+ assert.Equal(t, "10.0.1.42", aws.StringValue(in.Targets[0].Id))
+ assert.Equal(t, k3sAPIServerPort, aws.Int64Value(in.Targets[0].Port))
+}
+
+func TestRegisterClusterTarget_EmptyInputsRejected(t *testing.T) {
+ nlbp := newFakeNLBProvisioner()
+ require.Error(t, RegisterClusterTarget(nlbp, "111122223333", "", "10.0.1.42"))
+ require.Error(t, RegisterClusterTarget(nlbp, "111122223333", "arn:tg/alpha", ""))
+ assert.Empty(t, nlbp.registerCalls)
+}
+
+func TestDeregisterClusterTarget_PostsENIIPAndAPIPort(t *testing.T) {
+ nlbp := newFakeNLBProvisioner()
+
+ err := DeregisterClusterTarget(nlbp, "111122223333", "arn:tg/alpha", "10.0.1.42")
+ require.NoError(t, err)
+ require.Len(t, nlbp.deregisterCalls, 1)
+
+ in := nlbp.deregisterCalls[0]
+ assert.Equal(t, "arn:tg/alpha", aws.StringValue(in.TargetGroupArn))
+ require.Len(t, in.Targets, 1)
+ assert.Equal(t, "10.0.1.42", aws.StringValue(in.Targets[0].Id))
+ assert.Equal(t, k3sAPIServerPort, aws.Int64Value(in.Targets[0].Port))
+}
+
+func TestDeleteClusterNLB_DeletesBoth(t *testing.T) {
+ nlbp := newFakeNLBProvisioner()
+ out, err := EnsureClusterNLB(nlbp, "111122223333", "alpha", []string{"subnet-aaa"}, false, nil)
+ require.NoError(t, err)
+
+ require.NoError(t, DeleteClusterNLB(nlbp, "111122223333", "alpha"))
+ require.Len(t, nlbp.deleteLBCalls, 1)
+ assert.Equal(t, out.LoadBalancerArn, aws.StringValue(nlbp.deleteLBCalls[0].LoadBalancerArn))
+ require.Len(t, nlbp.deleteTGCalls, 1)
+ assert.Equal(t, out.TargetGroupArn, aws.StringValue(nlbp.deleteTGCalls[0].TargetGroupArn))
+}
+
+func TestDeleteClusterNLB_MissingResourcesNoOp(t *testing.T) {
+ nlbp := newFakeNLBProvisioner()
+
+ require.NoError(t, DeleteClusterNLB(nlbp, "111122223333", "alpha"))
+ assert.Empty(t, nlbp.deleteLBCalls)
+ assert.Empty(t, nlbp.deleteTGCalls)
+}
+
+func TestDeleteClusterNLB_FirstErrorSurfacedSweepContinues(t *testing.T) {
+ nlbp := newFakeNLBProvisioner()
+ _, err := EnsureClusterNLB(nlbp, "111122223333", "alpha", []string{"subnet-aaa"}, false, nil)
+ require.NoError(t, err)
+ nlbp.deleteLBErr = errors.New("LoadBalancerInUse")
+
+ err = DeleteClusterNLB(nlbp, "111122223333", "alpha")
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), "delete NLB eks-alpha")
+ assert.Len(t, nlbp.deleteLBCalls, 1)
+ assert.Len(t, nlbp.deleteTGCalls, 1, "TG delete should still be attempted after LB delete fails")
+}
+
+func assertELBv2TaggedAsEKS(t *testing.T, tgs []*elbv2.Tag, clusterName string) {
+ t.Helper()
+ got := map[string]string{}
+ for _, tg := range tgs {
+ if tg == nil || tg.Key == nil || tg.Value == nil {
+ continue
+ }
+ got[*tg.Key] = *tg.Value
+ }
+ assert.Equal(t, tags.ManagedByEKS, got[tags.ManagedByKey])
+ assert.Equal(t, clusterName, got[clusterEKSClusterTagKey])
+}
diff --git a/spinifex/handlers/eks/nodegroup.go b/spinifex/handlers/eks/nodegroup.go
new file mode 100644
index 000000000..38e4cf081
--- /dev/null
+++ b/spinifex/handlers/eks/nodegroup.go
@@ -0,0 +1,605 @@
+package handlers_eks
+
+import (
+ "encoding/json"
+ "errors"
+ "fmt"
+ "log/slog"
+ "maps"
+ "sort"
+ "strings"
+ "time"
+
+ "github.com/aws/aws-sdk-go/aws"
+ "github.com/aws/aws-sdk-go/service/ec2"
+ "github.com/aws/aws-sdk-go/service/eks"
+ "github.com/google/uuid"
+ "github.com/mulgadc/spinifex/spinifex/awserrors"
+ handlers_iam "github.com/mulgadc/spinifex/spinifex/handlers/iam"
+ "github.com/nats-io/nats.go"
+)
+
+// defaultNodegroupInstanceType mirrors the AWS EKS managed-nodegroup default
+// when the caller omits instanceTypes.
+const defaultNodegroupInstanceType = "t3.medium"
+
+// ErrNodegroupNotFound is returned by GetNodegroupRecord when the record key is
+// absent. Callers translate to the AWS shape (ResourceNotFoundException) at the
+// service boundary.
+var ErrNodegroupNotFound = errors.New("eks: nodegroup not found")
+
+// NodegroupARN composes a nodegroup ARN matching the AWS shape
+// (.../nodegroup/{cluster}/{ng}/{uuid}). The trailing UUID is the per-nodegroup
+// discriminator AWS appends; it is generated once at create time and persisted.
+func NodegroupARN(region, accountID, cluster, ng, id string) string {
+ return fmt.Sprintf("arn:aws:eks:%s:%s:nodegroup/%s/%s/%s", region, accountID, cluster, ng, id)
+}
+
+// PutNodegroupRecord writes the record unconditionally.
+func PutNodegroupRecord(kv nats.KeyValue, rec *NodegroupRecord) error {
+ if rec == nil {
+ return errors.New("eks: PutNodegroupRecord nil record")
+ }
+ if rec.ClusterName == "" || rec.Name == "" {
+ return errors.New("eks: PutNodegroupRecord missing cluster or name")
+ }
+ data, err := json.Marshal(rec)
+ if err != nil {
+ return fmt.Errorf("marshal nodegroup %s: %w", rec.Name, err)
+ }
+ key := NodegroupKey(rec.ClusterName, rec.Name)
+ if _, err := kv.Put(key, data); err != nil {
+ return fmt.Errorf("kv put %s: %w", key, err)
+ }
+ return nil
+}
+
+// GetNodegroupRecord reads one record. Returns ErrNodegroupNotFound if absent.
+func GetNodegroupRecord(kv nats.KeyValue, cluster, ng string) (*NodegroupRecord, error) {
+ if cluster == "" || ng == "" {
+ return nil, errors.New("eks: GetNodegroupRecord empty cluster or name")
+ }
+ entry, err := kv.Get(NodegroupKey(cluster, ng))
+ if err != nil {
+ if errors.Is(err, nats.ErrKeyNotFound) {
+ return nil, ErrNodegroupNotFound
+ }
+ return nil, fmt.Errorf("kv get nodegroup: %w", err)
+ }
+ var rec NodegroupRecord
+ if err := json.Unmarshal(entry.Value(), &rec); err != nil {
+ return nil, fmt.Errorf("unmarshal nodegroup %s: %w", ng, err)
+ }
+ return &rec, nil
+}
+
+// ListNodegroupRecords returns every nodegroup record under a cluster, sorted
+// by name for stable output.
+func ListNodegroupRecords(kv nats.KeyValue, cluster string) ([]*NodegroupRecord, error) {
+ if cluster == "" {
+ return nil, errors.New("eks: ListNodegroupRecords empty cluster")
+ }
+ keys, err := kv.Keys()
+ if err != nil {
+ if errors.Is(err, nats.ErrNoKeysFound) {
+ return nil, nil
+ }
+ return nil, fmt.Errorf("kv keys: %w", err)
+ }
+ prefix := NodegroupsPrefix(cluster)
+ out := make([]*NodegroupRecord, 0)
+ for _, k := range keys {
+ if !strings.HasPrefix(k, prefix) {
+ continue
+ }
+ entry, err := kv.Get(k)
+ if err != nil {
+ if errors.Is(err, nats.ErrKeyNotFound) {
+ continue
+ }
+ return nil, fmt.Errorf("kv get %s: %w", k, err)
+ }
+ var rec NodegroupRecord
+ if err := json.Unmarshal(entry.Value(), &rec); err != nil {
+ return nil, fmt.Errorf("unmarshal nodegroup %s: %w", k, err)
+ }
+ out = append(out, &rec)
+ }
+ sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name })
+ return out, nil
+}
+
+// DeleteNodegroupRecord removes one record. A missing key is a no-op so
+// DeleteNodegroup stays idempotent.
+func DeleteNodegroupRecord(kv nats.KeyValue, cluster, ng string) error {
+ key := NodegroupKey(cluster, ng)
+ if err := kv.Delete(key); err != nil && !errors.Is(err, nats.ErrKeyNotFound) {
+ return fmt.Errorf("kv delete %s: %w", key, err)
+ }
+ return nil
+}
+
+// nodegroupAcctKV opens the per-account bucket for nodegroup handlers.
+func (s *EKSServiceImpl) nodegroupAcctKV(accountID string) (nats.KeyValue, error) {
+ js, err := s.deps.NATSConn.JetStream()
+ if err != nil {
+ return nil, fmt.Errorf("jetstream: %w", err)
+ }
+ acctKV, err := GetOrCreateAccountBucket(js, accountID)
+ if err != nil {
+ return nil, fmt.Errorf("get account bucket: %w", err)
+ }
+ return acctKV, nil
+}
+
+func (s *EKSServiceImpl) createNodegroup(acctKV nats.KeyValue, input *eks.CreateNodegroupInput, accountID string) (*eks.CreateNodegroupOutput, error) {
+ if input == nil {
+ return nil, errors.New(awserrors.ErrorInvalidParameterValue)
+ }
+ cluster := aws.StringValue(input.ClusterName)
+ ng := aws.StringValue(input.NodegroupName)
+ if cluster == "" || ng == "" {
+ return nil, errors.New(awserrors.ErrorInvalidParameterValue)
+ }
+ subnets := aws.StringValueSlice(input.Subnets)
+ if len(subnets) == 0 {
+ return nil, errors.New(awserrors.ErrorInvalidParameterValue)
+ }
+
+ meta, err := GetClusterMeta(acctKV, cluster)
+ if err != nil {
+ if errors.Is(err, ErrClusterNotFound) {
+ return nil, errors.New(awserrors.ErrorEKSResourceNotFound)
+ }
+ return nil, err
+ }
+ if meta.Status != ClusterStatusActive {
+ slog.Warn("createNodegroup: cluster not ACTIVE", "cluster", cluster, "status", meta.Status)
+ return nil, errors.New(awserrors.ErrorInvalidRequest)
+ }
+ if meta.ControlPlaneENIIP == "" {
+ slog.Error("createNodegroup: cluster has no control-plane ENI IP", "cluster", cluster)
+ return nil, errors.New(awserrors.ErrorInvalidRequest)
+ }
+ if meta.ResourcesVpcConfig == nil || meta.ResourcesVpcConfig.VpcId == "" {
+ slog.Error("createNodegroup: cluster has no VPC", "cluster", cluster)
+ return nil, errors.New(awserrors.ErrorInvalidRequest)
+ }
+
+ if _, err := GetNodegroupRecord(acctKV, cluster, ng); err == nil {
+ return nil, errors.New(awserrors.ErrorEKSResourceInUse)
+ } else if !errors.Is(err, ErrNodegroupNotFound) {
+ return nil, err
+ }
+
+ instanceTypes := aws.StringValueSlice(input.InstanceTypes)
+ if len(instanceTypes) == 0 {
+ instanceTypes = []string{defaultNodegroupInstanceType}
+ }
+ minSize, maxSize, desired := scalingFromInput(input.ScalingConfig)
+
+ amiType := aws.StringValue(input.AmiType)
+ if amiType == "" {
+ amiType = eks.AMITypesAl2X8664
+ }
+ version := aws.StringValue(input.Version)
+ if version == "" {
+ version = meta.Version
+ }
+
+ now := time.Now().UTC()
+ rec := &NodegroupRecord{
+ ClusterName: cluster,
+ Name: ng,
+ Arn: NodegroupARN(s.deps.Region, accountID, cluster, ng, uuid.NewString()),
+ Status: eks.NodegroupStatusCreating,
+ Subnets: subnets,
+ InstanceTypes: instanceTypes,
+ AMIType: amiType,
+ DiskSize: aws.Int64Value(input.DiskSize),
+ ScalingMin: minSize,
+ ScalingMax: maxSize,
+ ScalingDesired: desired,
+ Version: version,
+ NodeRole: aws.StringValue(input.NodeRole),
+ Labels: aws.StringValueMap(input.Labels),
+ CreatedAt: now,
+ ModifiedAt: now,
+ }
+
+ cpSGID, ngSGID, err := EnsureClusterSGs(s.deps.VPCSG, accountID, cluster, meta.ResourcesVpcConfig.VpcId)
+ if err != nil {
+ return nil, fmt.Errorf("ensure cluster SGs: %w", err)
+ }
+ if err := EnsureNodegroupSGRules(s.deps.VPCSG, accountID, cluster, cpSGID, ngSGID); err != nil {
+ return nil, fmt.Errorf("ensure nodegroup SG rules: %w", err)
+ }
+
+ if err := PutNodegroupRecord(acctKV, rec); err != nil {
+ return nil, err
+ }
+
+ token, err := s.decryptNodeToken(acctKV, cluster)
+ if err != nil {
+ s.markNodegroupFailed(acctKV, cluster, ng, "decrypt node token: "+err.Error())
+ return nil, fmt.Errorf("decrypt node token: %w", err)
+ }
+
+ amiID, err := lookupEKSServerAMI(s.deps.Image, accountID)
+ if err != nil {
+ s.markNodegroupFailed(acctKV, cluster, ng, "resolve eks-node AMI: "+err.Error())
+ if errors.Is(err, ErrEKSServerAMINotFound) {
+ return nil, errors.New(awserrors.ErrorServiceUnavailable)
+ }
+ return nil, fmt.Errorf("resolve eks-node AMI: %w", err)
+ }
+
+ instanceIDs, err := s.launchWorkers(accountID, rec, meta, ngSGID, amiID, token, int(desired))
+ rec.InstanceIDs = instanceIDs
+ if err != nil {
+ // Persist whatever launched so DeleteNodegroup can reclaim it, then fail.
+ rec.Status = eks.NodegroupStatusCreateFailed
+ rec.StatusReason = "launch workers: " + err.Error()
+ rec.ModifiedAt = time.Now().UTC()
+ if perr := PutNodegroupRecord(acctKV, rec); perr != nil {
+ slog.Error("createNodegroup: persist CREATE_FAILED record", "cluster", cluster, "nodegroup", ng, "err", perr)
+ }
+ return nil, fmt.Errorf("launch workers: %w", err)
+ }
+
+ // v1 marks ACTIVE on successful RunInstances (instance-level), not on
+ // k8s node-Ready — node-Ready gating needs a kubectl probe from the
+ // reconciler and lands in a follow-up.
+ rec.Status = eks.NodegroupStatusActive
+ rec.ModifiedAt = time.Now().UTC()
+ if err := PutNodegroupRecord(acctKV, rec); err != nil {
+ return nil, err
+ }
+
+ return &eks.CreateNodegroupOutput{Nodegroup: nodegroupRecordToAWS(rec)}, nil
+}
+
+// launchWorkers issues count customer-owned RunInstances calls (one per worker
+// so each gets a distinct node name + node label in its user-data) and returns
+// the launched instance IDs. On a partial failure it returns the IDs that did
+// launch plus the error so the caller can persist them for teardown.
+func (s *EKSServiceImpl) launchWorkers(accountID string, rec *NodegroupRecord, meta *ClusterMeta, ngSGID, amiID, token string, count int) ([]string, error) {
+ instanceType := defaultNodegroupInstanceType
+ if len(rec.InstanceTypes) > 0 {
+ instanceType = rec.InstanceTypes[0]
+ }
+ subnet := rec.Subnets[0]
+
+ ids := make([]string, 0, count)
+ for i := range count {
+ shortID := uuid.NewString()[:8]
+ userData := buildAgentUserData(agentUserDataInput{
+ ClusterName: rec.ClusterName,
+ NodegroupName: rec.Name,
+ ControlPlaneENIIP: meta.ControlPlaneENIIP,
+ JoinToken: token,
+ NodeName: fmt.Sprintf("%s-%s-%s", rec.ClusterName, rec.Name, shortID),
+ })
+ runInput := &ec2.RunInstancesInput{
+ ImageId: aws.String(amiID),
+ InstanceType: aws.String(instanceType),
+ MinCount: aws.Int64(1),
+ MaxCount: aws.Int64(1),
+ SubnetId: aws.String(subnet),
+ SecurityGroupIds: aws.StringSlice([]string{ngSGID}),
+ UserData: aws.String(userData),
+ TagSpecifications: []*ec2.TagSpecification{{
+ ResourceType: aws.String("instance"),
+ Tags: []*ec2.Tag{
+ {Key: aws.String(clusterEKSClusterTagKey), Value: aws.String(rec.ClusterName)},
+ {Key: aws.String(clusterEKSNodegroupTagKey), Value: aws.String(rec.Name)},
+ },
+ }},
+ }
+ res, err := s.deps.Worker.RunWorkerInstance(runInput, accountID)
+ if err != nil {
+ return ids, fmt.Errorf("run worker %d/%d: %w", i+1, count, err)
+ }
+ for _, inst := range res.Instances {
+ if id := aws.StringValue(inst.InstanceId); id != "" {
+ ids = append(ids, id)
+ }
+ }
+ }
+ return ids, nil
+}
+
+func (s *EKSServiceImpl) describeNodegroup(acctKV nats.KeyValue, input *eks.DescribeNodegroupInput) (*eks.DescribeNodegroupOutput, error) {
+ cluster := aws.StringValue(input.ClusterName)
+ ng := aws.StringValue(input.NodegroupName)
+ if cluster == "" || ng == "" {
+ return nil, errors.New(awserrors.ErrorInvalidParameterValue)
+ }
+ rec, err := GetNodegroupRecord(acctKV, cluster, ng)
+ if err != nil {
+ if errors.Is(err, ErrNodegroupNotFound) {
+ return nil, errors.New(awserrors.ErrorEKSResourceNotFound)
+ }
+ return nil, err
+ }
+ return &eks.DescribeNodegroupOutput{Nodegroup: nodegroupRecordToAWS(rec)}, nil
+}
+
+func (s *EKSServiceImpl) listNodegroups(acctKV nats.KeyValue, input *eks.ListNodegroupsInput) (*eks.ListNodegroupsOutput, error) {
+ cluster := aws.StringValue(input.ClusterName)
+ if cluster == "" {
+ return nil, errors.New(awserrors.ErrorInvalidParameterValue)
+ }
+ if _, err := GetClusterMeta(acctKV, cluster); err != nil {
+ if errors.Is(err, ErrClusterNotFound) {
+ return nil, errors.New(awserrors.ErrorEKSResourceNotFound)
+ }
+ return nil, err
+ }
+ recs, err := ListNodegroupRecords(acctKV, cluster)
+ if err != nil {
+ return nil, err
+ }
+ names := make([]string, 0, len(recs))
+ for _, rec := range recs {
+ names = append(names, rec.Name)
+ }
+ return &eks.ListNodegroupsOutput{Nodegroups: aws.StringSlice(names)}, nil
+}
+
+func (s *EKSServiceImpl) updateNodegroupConfig(acctKV nats.KeyValue, input *eks.UpdateNodegroupConfigInput, accountID string) (*eks.UpdateNodegroupConfigOutput, error) {
+ if input == nil {
+ return nil, errors.New(awserrors.ErrorInvalidParameterValue)
+ }
+ cluster := aws.StringValue(input.ClusterName)
+ ng := aws.StringValue(input.NodegroupName)
+ if cluster == "" || ng == "" {
+ return nil, errors.New(awserrors.ErrorInvalidParameterValue)
+ }
+ meta, err := GetClusterMeta(acctKV, cluster)
+ if err != nil {
+ if errors.Is(err, ErrClusterNotFound) {
+ return nil, errors.New(awserrors.ErrorEKSResourceNotFound)
+ }
+ return nil, err
+ }
+ rec, err := GetNodegroupRecord(acctKV, cluster, ng)
+ if err != nil {
+ if errors.Is(err, ErrNodegroupNotFound) {
+ return nil, errors.New(awserrors.ErrorEKSResourceNotFound)
+ }
+ return nil, err
+ }
+
+ if input.ScalingConfig != nil {
+ if v := input.ScalingConfig.MinSize; v != nil {
+ rec.ScalingMin = *v
+ }
+ if v := input.ScalingConfig.MaxSize; v != nil {
+ rec.ScalingMax = *v
+ }
+ if v := input.ScalingConfig.DesiredSize; v != nil {
+ rec.ScalingDesired = *v
+ }
+ }
+ if input.Labels != nil {
+ rec.Labels = applyLabelUpdate(rec.Labels, input.Labels)
+ }
+
+ if err := s.reconcileWorkerCount(acctKV, accountID, rec, meta); err != nil {
+ // reconcileWorkerCount may have launched some workers before failing; their
+ // IDs are already on rec. Persist so DeleteNodegroup can reclaim them rather
+ // than leaking the partial scale-up.
+ rec.ModifiedAt = time.Now().UTC()
+ if perr := PutNodegroupRecord(acctKV, rec); perr != nil {
+ slog.Error("updateNodegroupConfig: persist after reconcile failure",
+ "cluster", cluster, "nodegroup", ng, "err", perr)
+ }
+ return nil, err
+ }
+
+ rec.ModifiedAt = time.Now().UTC()
+ if err := PutNodegroupRecord(acctKV, rec); err != nil {
+ return nil, err
+ }
+
+ return &eks.UpdateNodegroupConfigOutput{Update: &eks.Update{
+ Id: aws.String(uuid.NewString()),
+ Status: aws.String(eks.UpdateStatusSuccessful),
+ Type: aws.String(eks.UpdateTypeConfigUpdate),
+ CreatedAt: aws.Time(rec.ModifiedAt),
+ }}, nil
+}
+
+// reconcileWorkerCount launches or terminates workers so len(InstanceIDs)
+// matches ScalingDesired. Surplus removal terminates the last (highest-index)
+// instance IDs so scale-down is deterministic.
+func (s *EKSServiceImpl) reconcileWorkerCount(acctKV nats.KeyValue, accountID string, rec *NodegroupRecord, meta *ClusterMeta) error {
+ current := len(rec.InstanceIDs)
+ desired := int(rec.ScalingDesired)
+
+ switch {
+ case desired > current:
+ token, err := s.decryptNodeToken(acctKV, rec.ClusterName)
+ if err != nil {
+ return fmt.Errorf("decrypt node token: %w", err)
+ }
+ amiID, err := lookupEKSServerAMI(s.deps.Image, accountID)
+ if err != nil {
+ if errors.Is(err, ErrEKSServerAMINotFound) {
+ return errors.New(awserrors.ErrorServiceUnavailable)
+ }
+ return fmt.Errorf("resolve eks-node AMI: %w", err)
+ }
+ _, ngSGID, err := EnsureClusterSGs(s.deps.VPCSG, accountID, rec.ClusterName, meta.ResourcesVpcConfig.VpcId)
+ if err != nil {
+ return fmt.Errorf("ensure cluster SGs: %w", err)
+ }
+ newIDs, err := s.launchWorkers(accountID, rec, meta, ngSGID, amiID, token, desired-current)
+ rec.InstanceIDs = append(rec.InstanceIDs, newIDs...)
+ if err != nil {
+ return fmt.Errorf("launch workers: %w", err)
+ }
+ case desired < current:
+ surplus := rec.InstanceIDs[desired:]
+ if err := s.deps.Worker.TerminateWorkerInstances(surplus, accountID); err != nil {
+ return fmt.Errorf("terminate surplus workers: %w", err)
+ }
+ rec.InstanceIDs = rec.InstanceIDs[:desired]
+ }
+ return nil
+}
+
+func (s *EKSServiceImpl) updateNodegroupVersion(input *eks.UpdateNodegroupVersionInput) (*eks.UpdateNodegroupVersionOutput, error) {
+ // Worker AMI version upgrades (drain + replace) land in a follow-up; v1
+ // nodegroups are fixed at their create-time version.
+ if input != nil {
+ slog.Info("UpdateNodegroupVersion not implemented in v1",
+ "cluster", aws.StringValue(input.ClusterName), "nodegroup", aws.StringValue(input.NodegroupName))
+ }
+ return nil, notImpl()
+}
+
+func (s *EKSServiceImpl) deleteNodegroup(acctKV nats.KeyValue, input *eks.DeleteNodegroupInput, accountID string) (*eks.DeleteNodegroupOutput, error) {
+ if input == nil {
+ return nil, errors.New(awserrors.ErrorInvalidParameterValue)
+ }
+ cluster := aws.StringValue(input.ClusterName)
+ ng := aws.StringValue(input.NodegroupName)
+ if cluster == "" || ng == "" {
+ return nil, errors.New(awserrors.ErrorInvalidParameterValue)
+ }
+ rec, err := GetNodegroupRecord(acctKV, cluster, ng)
+ if err != nil {
+ if errors.Is(err, ErrNodegroupNotFound) {
+ return nil, errors.New(awserrors.ErrorEKSResourceNotFound)
+ }
+ return nil, err
+ }
+
+ if len(rec.InstanceIDs) > 0 {
+ if err := s.deps.Worker.TerminateWorkerInstances(rec.InstanceIDs, accountID); err != nil {
+ return nil, fmt.Errorf("terminate workers: %w", err)
+ }
+ }
+ if err := DeleteNodegroupRecord(acctKV, cluster, ng); err != nil {
+ return nil, err
+ }
+
+ rec.Status = eks.NodegroupStatusDeleting
+ return &eks.DeleteNodegroupOutput{Nodegroup: nodegroupRecordToAWS(rec)}, nil
+}
+
+// decryptNodeToken reads + decrypts the cluster's K3s join token from KV.
+func (s *EKSServiceImpl) decryptNodeToken(kv nats.KeyValue, cluster string) (string, error) {
+ if kv == nil {
+ return "", errors.New("eks: nil KV for node token")
+ }
+ entry, err := kv.Get(NodeTokenKey(cluster))
+ if err != nil {
+ if errors.Is(err, nats.ErrKeyNotFound) {
+ return "", errors.New("eks: cluster join token not yet provisioned")
+ }
+ return "", fmt.Errorf("kv get node token: %w", err)
+ }
+ token, err := handlers_iam.DecryptSecret(string(entry.Value()), s.deps.MasterKey)
+ if err != nil {
+ return "", fmt.Errorf("decrypt node token: %w", err)
+ }
+ if token == "" {
+ return "", errors.New("eks: decrypted node token is empty")
+ }
+ return token, nil
+}
+
+func (s *EKSServiceImpl) markNodegroupFailed(kv nats.KeyValue, cluster, ng, reason string) {
+ rec, err := GetNodegroupRecord(kv, cluster, ng)
+ if err != nil {
+ return
+ }
+ rec.Status = eks.NodegroupStatusCreateFailed
+ rec.StatusReason = reason
+ rec.ModifiedAt = time.Now().UTC()
+ if err := PutNodegroupRecord(kv, rec); err != nil {
+ slog.Warn("markNodegroupFailed: persist failed", "cluster", cluster, "nodegroup", ng, "err", err)
+ }
+}
+
+// scalingFromInput derives min/max/desired from a NodegroupScalingConfig,
+// defaulting to a single node when the caller omits the config or fields.
+func scalingFromInput(cfg *eks.NodegroupScalingConfig) (minSize, maxSize, desired int64) {
+ minSize, maxSize, desired = 1, 1, 1
+ if cfg == nil {
+ return minSize, maxSize, desired
+ }
+ if cfg.MinSize != nil {
+ minSize = *cfg.MinSize
+ }
+ if cfg.DesiredSize != nil {
+ desired = *cfg.DesiredSize
+ } else {
+ desired = minSize
+ }
+ if cfg.MaxSize != nil {
+ maxSize = *cfg.MaxSize
+ } else if desired > maxSize {
+ maxSize = desired
+ }
+ return minSize, maxSize, desired
+}
+
+// applyLabelUpdate applies an UpdateLabelsPayload (addOrUpdate + removeLabels)
+// onto the record's label map.
+func applyLabelUpdate(existing map[string]string, payload *eks.UpdateLabelsPayload) map[string]string {
+ if payload == nil {
+ return existing
+ }
+ out := map[string]string{}
+ maps.Copy(out, existing)
+ for k, v := range payload.AddOrUpdateLabels {
+ out[k] = aws.StringValue(v)
+ }
+ for _, k := range payload.RemoveLabels {
+ delete(out, aws.StringValue(k))
+ }
+ return out
+}
+
+func nodegroupRecordToAWS(rec *NodegroupRecord) *eks.Nodegroup {
+ if rec == nil {
+ return nil
+ }
+ out := &eks.Nodegroup{
+ ClusterName: aws.String(rec.ClusterName),
+ NodegroupName: aws.String(rec.Name),
+ NodegroupArn: aws.String(rec.Arn),
+ Status: aws.String(rec.Status),
+ Subnets: aws.StringSlice(rec.Subnets),
+ InstanceTypes: aws.StringSlice(rec.InstanceTypes),
+ AmiType: aws.String(rec.AMIType),
+ NodeRole: aws.String(rec.NodeRole),
+ Version: aws.String(rec.Version),
+ CreatedAt: aws.Time(rec.CreatedAt),
+ ModifiedAt: aws.Time(rec.ModifiedAt),
+ ScalingConfig: &eks.NodegroupScalingConfig{
+ MinSize: aws.Int64(rec.ScalingMin),
+ MaxSize: aws.Int64(rec.ScalingMax),
+ DesiredSize: aws.Int64(rec.ScalingDesired),
+ },
+ Resources: &eks.NodegroupResources{},
+ }
+ if rec.DiskSize > 0 {
+ out.DiskSize = aws.Int64(rec.DiskSize)
+ }
+ if len(rec.Labels) > 0 {
+ out.Labels = aws.StringMap(rec.Labels)
+ }
+ if rec.StatusReason != "" {
+ out.Health = &eks.NodegroupHealth{Issues: []*eks.Issue{{
+ Code: aws.String(eks.NodegroupIssueCodeNodeCreationFailure),
+ Message: aws.String(rec.StatusReason),
+ ResourceIds: aws.StringSlice(rec.InstanceIDs),
+ }}}
+ }
+ return out
+}
diff --git a/spinifex/handlers/eks/nodegroup_test.go b/spinifex/handlers/eks/nodegroup_test.go
new file mode 100644
index 000000000..d3432f9f3
--- /dev/null
+++ b/spinifex/handlers/eks/nodegroup_test.go
@@ -0,0 +1,321 @@
+package handlers_eks
+
+import (
+ "errors"
+ "strconv"
+ "strings"
+ "testing"
+
+ "github.com/aws/aws-sdk-go/aws"
+ "github.com/aws/aws-sdk-go/service/ec2"
+ "github.com/aws/aws-sdk-go/service/eks"
+ "github.com/mulgadc/spinifex/spinifex/awserrors"
+ handlers_iam "github.com/mulgadc/spinifex/spinifex/handlers/iam"
+ "github.com/mulgadc/spinifex/spinifex/testutil"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+// fakeWorkerLauncher records RunWorkerInstance / TerminateWorkerInstances calls
+// and hands back sequential instance IDs so tests can assert on the IDs a
+// nodegroup tracks.
+type fakeWorkerLauncher struct {
+ runCalls []*ec2.RunInstancesInput
+ terminateCalls [][]string
+
+ nextID int
+ runErr error
+ termErr error
+}
+
+var _ WorkerLauncher = (*fakeWorkerLauncher)(nil)
+
+func newFakeWorkerLauncher() *fakeWorkerLauncher {
+ return &fakeWorkerLauncher{}
+}
+
+func (f *fakeWorkerLauncher) RunWorkerInstance(input *ec2.RunInstancesInput, _ string) (*ec2.Reservation, error) {
+ f.runCalls = append(f.runCalls, input)
+ if f.runErr != nil {
+ return nil, f.runErr
+ }
+ f.nextID++
+ id := "i-worker" + strconv.Itoa(f.nextID)
+ return &ec2.Reservation{Instances: []*ec2.Instance{{InstanceId: aws.String(id)}}}, nil
+}
+
+func (f *fakeWorkerLauncher) TerminateWorkerInstances(ids []string, _ string) error {
+ f.terminateCalls = append(f.terminateCalls, ids)
+ return f.termErr
+}
+
+// seedActiveClusterWithToken lays down an ACTIVE cluster meta with the control-
+// plane ENI IP + VPC populated and an encrypted K3s join token, so the
+// nodegroup path can run end-to-end.
+func seedActiveClusterWithToken(t *testing.T, f *eksServiceFixture, cluster string) {
+ t.Helper()
+ meta := &ClusterMeta{
+ Name: cluster,
+ Status: ClusterStatusActive,
+ Version: "1.32",
+ ControlPlaneENIIP: "10.0.1.42",
+ ResourcesVpcConfig: &ClusterVpcConfig{
+ SubnetIds: []string{"subnet-aaa"},
+ VpcId: "vpc-aaa",
+ },
+ }
+ require.NoError(t, PutClusterMeta(f.kv, meta))
+
+ ct, err := handlers_iam.EncryptSecret("K10node-join-token::server:abc", bootstrapTestMasterKey)
+ require.NoError(t, err)
+ _, err = f.kv.Put(NodeTokenKey(cluster), []byte(ct))
+ require.NoError(t, err)
+}
+
+func createNGInput(cluster, ng string, desired int64) *eks.CreateNodegroupInput {
+ return &eks.CreateNodegroupInput{
+ ClusterName: aws.String(cluster),
+ NodegroupName: aws.String(ng),
+ NodeRole: aws.String("arn:aws:iam::111122223333:role/eks-node"),
+ Subnets: aws.StringSlice([]string{"subnet-aaa"}),
+ ScalingConfig: &eks.NodegroupScalingConfig{
+ MinSize: aws.Int64(1),
+ MaxSize: aws.Int64(3),
+ DesiredSize: aws.Int64(desired),
+ },
+ }
+}
+
+func TestNodegroupRecord_CRUDRoundTrip(t *testing.T) {
+ _, _, js := testutil.StartTestJetStream(t)
+ kv, err := GetOrCreateAccountBucket(js, testAccountID)
+ require.NoError(t, err)
+
+ rec := &NodegroupRecord{
+ ClusterName: "c1",
+ Name: "ng1",
+ Arn: "arn:aws:eks:us-east-1:111122223333:nodegroup/c1/ng1/abc",
+ Status: eks.NodegroupStatusActive,
+ Subnets: []string{"subnet-aaa"},
+ InstanceTypes: []string{"t3.medium"},
+ ScalingMin: 1,
+ ScalingMax: 3,
+ ScalingDesired: 2,
+ InstanceIDs: []string{"i-1", "i-2"},
+ }
+ require.NoError(t, PutNodegroupRecord(kv, rec))
+
+ got, err := GetNodegroupRecord(kv, "c1", "ng1")
+ require.NoError(t, err)
+ assert.Equal(t, rec.InstanceIDs, got.InstanceIDs)
+ assert.Equal(t, int64(2), got.ScalingDesired)
+
+ list, err := ListNodegroupRecords(kv, "c1")
+ require.NoError(t, err)
+ require.Len(t, list, 1)
+ assert.Equal(t, "ng1", list[0].Name)
+
+ require.NoError(t, DeleteNodegroupRecord(kv, "c1", "ng1"))
+ _, err = GetNodegroupRecord(kv, "c1", "ng1")
+ assert.ErrorIs(t, err, ErrNodegroupNotFound)
+
+ // Delete of an absent record is idempotent.
+ require.NoError(t, DeleteNodegroupRecord(kv, "c1", "ng1"))
+}
+
+func TestBuildAgentUserData_Shape(t *testing.T) {
+ ud := buildAgentUserData(agentUserDataInput{
+ ClusterName: "c1",
+ NodegroupName: "ng1",
+ ControlPlaneENIIP: "10.0.1.42",
+ JoinToken: "K10secret::server:xyz",
+ NodeName: "c1-ng1-abc123de",
+ })
+
+ assert.True(t, strings.HasPrefix(ud, "#cloud-config\n"))
+ assert.Contains(t, ud, "SPINIFEX_K3S_ROLE=agent")
+ assert.Contains(t, ud, "K3S_URL=https://10.0.1.42:6443")
+ assert.Contains(t, ud, "K3S_TOKEN=K10secret::server:xyz")
+ assert.Contains(t, ud, "K3S_NODE_NAME=c1-ng1-abc123de")
+ assert.Contains(t, ud, "eks.amazonaws.com/nodegroup=ng1")
+ assert.Contains(t, ud, agentEnvPath)
+ // IMDS on-link route line present.
+ assert.Contains(t, ud, "ip route replace "+imdsServerIP+"/32")
+ // Exactly one write_files block (single "write_files:" key).
+ assert.Equal(t, 1, strings.Count(ud, "write_files:"))
+}
+
+func TestEnsureNodegroupSGRules_AuthorizesExpectedRules(t *testing.T) {
+ sg := newFakeSGProvisioner()
+
+ require.NoError(t, EnsureNodegroupSGRules(sg, testAccountID, "c1", "sg-cp", "sg-ng"))
+
+ require.Len(t, sg.authorizeCalls, 6)
+
+ // Verify the apiserver rule: 6443/tcp on cpSG sourced from ngSG.
+ var found6443 bool
+ for _, call := range sg.authorizeCalls {
+ require.Len(t, call.IpPermissions, 1)
+ perm := call.IpPermissions[0]
+ if aws.StringValue(call.GroupId) == "sg-cp" &&
+ aws.StringValue(perm.IpProtocol) == "tcp" &&
+ aws.Int64Value(perm.FromPort) == 6443 {
+ require.Len(t, perm.UserIdGroupPairs, 1)
+ assert.Equal(t, "sg-ng", aws.StringValue(perm.UserIdGroupPairs[0].GroupId))
+ assert.Empty(t, perm.IpRanges, "rules must be group-to-group, not CIDR")
+ found6443 = true
+ }
+ }
+ assert.True(t, found6443, "expected agent→apiserver 6443/tcp rule")
+}
+
+func TestEnsureNodegroupSGRules_DuplicateRuleTolerated(t *testing.T) {
+ sg := newFakeSGProvisioner()
+ sg.authorizeErr = errors.New(awserrors.ErrorInvalidPermissionDuplicate)
+
+ require.NoError(t, EnsureNodegroupSGRules(sg, testAccountID, "c1", "sg-cp", "sg-ng"),
+ "duplicate-rule error must be treated as success")
+}
+
+func TestCreateNodegroup_HappyPath(t *testing.T) {
+ f := newEKSServiceFixture(t)
+ seedActiveClusterWithToken(t, f, "c1")
+
+ out, err := f.svc.CreateNodegroup(createNGInput("c1", "ng1", 2), testAccountID)
+ require.NoError(t, err)
+ require.NotNil(t, out.Nodegroup)
+ assert.Equal(t, eks.NodegroupStatusActive, aws.StringValue(out.Nodegroup.Status))
+ assert.Equal(t, int64(2), aws.Int64Value(out.Nodegroup.ScalingConfig.DesiredSize))
+ assert.Contains(t, aws.StringValue(out.Nodegroup.NodegroupArn), ":nodegroup/c1/ng1/")
+
+ // Two workers launched and tracked.
+ assert.Len(t, f.worker.runCalls, 2)
+ rec, err := GetNodegroupRecord(f.kv, "c1", "ng1")
+ require.NoError(t, err)
+ assert.Len(t, rec.InstanceIDs, 2)
+
+ // Workers go through the customer path: no ManagedBy tag, SG = nodegroup SG.
+ for _, call := range f.worker.runCalls {
+ require.NotEmpty(t, call.SecurityGroupIds)
+ assert.Equal(t, "subnet-aaa", aws.StringValue(call.SubnetId))
+ for _, ts := range call.TagSpecifications {
+ for _, tg := range ts.Tags {
+ assert.NotEqual(t, "spinifex:managed-by", aws.StringValue(tg.Key))
+ }
+ }
+ }
+
+ // SG ingress rules were authorized.
+ assert.NotEmpty(t, f.sg.authorizeCalls)
+
+ // Duplicate create → ResourceInUseException.
+ _, err = f.svc.CreateNodegroup(createNGInput("c1", "ng1", 2), testAccountID)
+ require.EqualError(t, err, awserrors.ErrorEKSResourceInUse)
+}
+
+func TestCreateNodegroup_ClusterNotActive(t *testing.T) {
+ f := newEKSServiceFixture(t)
+ require.NoError(t, PutClusterMeta(f.kv, &ClusterMeta{
+ Name: "c1",
+ Status: ClusterStatusCreating,
+ ControlPlaneENIIP: "10.0.1.42",
+ ResourcesVpcConfig: &ClusterVpcConfig{VpcId: "vpc-aaa"},
+ }))
+
+ _, err := f.svc.CreateNodegroup(createNGInput("c1", "ng1", 1), testAccountID)
+ require.EqualError(t, err, awserrors.ErrorInvalidRequest)
+}
+
+func TestCreateNodegroup_UnknownCluster(t *testing.T) {
+ f := newEKSServiceFixture(t)
+ _, err := f.svc.CreateNodegroup(createNGInput("ghost", "ng1", 1), testAccountID)
+ require.EqualError(t, err, awserrors.ErrorEKSResourceNotFound)
+}
+
+func TestDescribeAndListNodegroups(t *testing.T) {
+ f := newEKSServiceFixture(t)
+ seedActiveClusterWithToken(t, f, "c1")
+ _, err := f.svc.CreateNodegroup(createNGInput("c1", "ng1", 1), testAccountID)
+ require.NoError(t, err)
+
+ desc, err := f.svc.DescribeNodegroup(&eks.DescribeNodegroupInput{
+ ClusterName: aws.String("c1"), NodegroupName: aws.String("ng1"),
+ }, testAccountID)
+ require.NoError(t, err)
+ assert.Equal(t, "ng1", aws.StringValue(desc.Nodegroup.NodegroupName))
+ assert.Equal(t, "c1", aws.StringValue(desc.Nodegroup.ClusterName))
+
+ list, err := f.svc.ListNodegroups(&eks.ListNodegroupsInput{ClusterName: aws.String("c1")}, testAccountID)
+ require.NoError(t, err)
+ assert.Equal(t, []string{"ng1"}, aws.StringValueSlice(list.Nodegroups))
+
+ _, err = f.svc.DescribeNodegroup(&eks.DescribeNodegroupInput{
+ ClusterName: aws.String("c1"), NodegroupName: aws.String("ghost"),
+ }, testAccountID)
+ require.EqualError(t, err, awserrors.ErrorEKSResourceNotFound)
+}
+
+func TestUpdateNodegroupConfig_ScaleUpAndDown(t *testing.T) {
+ f := newEKSServiceFixture(t)
+ seedActiveClusterWithToken(t, f, "c1")
+ _, err := f.svc.CreateNodegroup(createNGInput("c1", "ng1", 1), testAccountID)
+ require.NoError(t, err)
+ require.Len(t, f.worker.runCalls, 1)
+
+ // Scale up 1 → 3: two new workers launched.
+ upOut, err := f.svc.UpdateNodegroupConfig(&eks.UpdateNodegroupConfigInput{
+ ClusterName: aws.String("c1"),
+ NodegroupName: aws.String("ng1"),
+ ScalingConfig: &eks.NodegroupScalingConfig{DesiredSize: aws.Int64(3)},
+ }, testAccountID)
+ require.NoError(t, err)
+ assert.Equal(t, eks.UpdateStatusSuccessful, aws.StringValue(upOut.Update.Status))
+ assert.Len(t, f.worker.runCalls, 3)
+ rec, err := GetNodegroupRecord(f.kv, "c1", "ng1")
+ require.NoError(t, err)
+ assert.Len(t, rec.InstanceIDs, 3)
+
+ // Scale down 3 → 1: surplus (last two) terminated.
+ _, err = f.svc.UpdateNodegroupConfig(&eks.UpdateNodegroupConfigInput{
+ ClusterName: aws.String("c1"),
+ NodegroupName: aws.String("ng1"),
+ ScalingConfig: &eks.NodegroupScalingConfig{DesiredSize: aws.Int64(1)},
+ }, testAccountID)
+ require.NoError(t, err)
+ require.Len(t, f.worker.terminateCalls, 1)
+ assert.Len(t, f.worker.terminateCalls[0], 2)
+ rec, err = GetNodegroupRecord(f.kv, "c1", "ng1")
+ require.NoError(t, err)
+ assert.Len(t, rec.InstanceIDs, 1)
+}
+
+func TestDeleteNodegroup_TerminatesAndIsIdempotent(t *testing.T) {
+ f := newEKSServiceFixture(t)
+ seedActiveClusterWithToken(t, f, "c1")
+ _, err := f.svc.CreateNodegroup(createNGInput("c1", "ng1", 2), testAccountID)
+ require.NoError(t, err)
+
+ _, err = f.svc.DeleteNodegroup(&eks.DeleteNodegroupInput{
+ ClusterName: aws.String("c1"), NodegroupName: aws.String("ng1"),
+ }, testAccountID)
+ require.NoError(t, err)
+ require.Len(t, f.worker.terminateCalls, 1)
+ assert.Len(t, f.worker.terminateCalls[0], 2)
+
+ _, err = GetNodegroupRecord(f.kv, "c1", "ng1")
+ assert.ErrorIs(t, err, ErrNodegroupNotFound)
+
+ // Second delete → ResourceNotFoundException (record already gone).
+ _, err = f.svc.DeleteNodegroup(&eks.DeleteNodegroupInput{
+ ClusterName: aws.String("c1"), NodegroupName: aws.String("ng1"),
+ }, testAccountID)
+ require.EqualError(t, err, awserrors.ErrorEKSResourceNotFound)
+}
+
+func TestUpdateNodegroupVersion_NotImplemented(t *testing.T) {
+ f := newEKSServiceFixture(t)
+ _, err := f.svc.UpdateNodegroupVersion(&eks.UpdateNodegroupVersionInput{
+ ClusterName: aws.String("c1"), NodegroupName: aws.String("ng1"),
+ }, testAccountID)
+ require.EqualError(t, err, awserrors.ErrorNotImplemented)
+}
diff --git a/spinifex/handlers/eks/nodegroup_userdata.go b/spinifex/handlers/eks/nodegroup_userdata.go
new file mode 100644
index 000000000..40329d2bd
--- /dev/null
+++ b/spinifex/handlers/eks/nodegroup_userdata.go
@@ -0,0 +1,90 @@
+package handlers_eks
+
+import (
+ "fmt"
+ "net"
+ "strings"
+)
+
+// agentUserDataInput is the input shape for buildAgentUserData. ControlPlaneENIIP
+// is the cluster's in-VPC control-plane ENI private IP (the K3s join target);
+// JoinToken is the decrypted K3s node token.
+type agentUserDataInput struct {
+ ClusterName string
+ NodegroupName string
+ ControlPlaneENIIP string
+ JoinToken string
+ NodeName string
+}
+
+// buildAgentUserData renders the cloud-config YAML a nodegroup worker consumes
+// at first boot. It mirrors buildK3sUserData: a bootcmd resolver fix, a single
+// write_files block (first-boot.env + agent.env + the IMDS on-link route
+// script), and a runcmd that enables the OpenRC `local` service. Output is
+// unencoded YAML; the caller base64-wraps it for the RunInstances UserData
+// field. The eks-node-role first-boot selector reads SPINIFEX_K3S_ROLE=agent
+// (and the presence of agent.env) to enable the k3s-agent service, which sources
+// agent.env for K3S_URL/K3S_TOKEN.
+func buildAgentUserData(in agentUserDataInput) string {
+ // K3S_URL targets the control-plane ENI's in-VPC private IP on :6443
+ // because the cluster NLB DNS endpoint is not resolvable in-VPC yet.
+ // Workers share the VPC with the server, so this is directly reachable and
+ // TLS-pinned by the join token's CA hash (SAN/hostname irrelevant for the
+ // k3s agent join). Switch to the NLB endpoint on :443 once authoritative
+ // in-VPC DNS is available.
+ k3sURL := "https://" + net.JoinHostPort(in.ControlPlaneENIIP, "6443")
+ nodeLabel := "eks.amazonaws.com/nodegroup=" + in.NodegroupName
+
+ envBody := strings.Join([]string{
+ "SPINIFEX_K3S_ROLE=agent",
+ }, "\n")
+
+ agentBody := strings.Join([]string{
+ "K3S_URL=" + k3sURL,
+ "K3S_TOKEN=" + in.JoinToken,
+ "K3S_NODE_NAME=" + in.NodeName,
+ "K3S_NODE_LABEL=" + nodeLabel,
+ }, "\n")
+
+ files := []userDataFile{
+ {Path: k3sFirstBootEnvPath, Perms: "0600", Body: envBody},
+ {Path: agentEnvPath, Perms: "0600", Body: agentBody},
+ {
+ Path: "/etc/local.d/imds-onlink-route.start",
+ Perms: "0755",
+ Body: "#!/bin/sh\n" +
+ "dev=$(ip route show default | awk '{print $5; exit}')\n" +
+ "[ -n \"$dev\" ] && ip route replace " + imdsServerIP + "/32 dev \"$dev\" scope link",
+ },
+ }
+
+ var buf strings.Builder
+ buf.WriteString("#cloud-config\n")
+
+ // Resolver fix via bootcmd (not write_files) for the same reason as the
+ // server path: /etc/resolv.conf is a dangling symlink on the Alpine AMI, so
+ // write_files would follow the dead link and abort the whole block.
+ // Containerd needs a working resolver to pull the worker's system images.
+ buf.WriteString("bootcmd:\n")
+ buf.WriteString(" - rm -f " + k3sResolvConfPath + "\n")
+ fmt.Fprintf(&buf, " - printf '%s\\n' > %s\n",
+ strings.ReplaceAll(k3sResolvConf, "\n", "\\n"), k3sResolvConfPath)
+
+ buf.WriteString("write_files:\n")
+ for _, f := range files {
+ fmt.Fprintf(&buf, " - path: %s\n", f.Path)
+ fmt.Fprintf(&buf, " permissions: '%s'\n", f.Perms)
+ buf.WriteString(" content: |\n")
+ for line := range strings.SplitSeq(f.Body, "\n") {
+ buf.WriteString(" ")
+ buf.WriteString(line)
+ buf.WriteString("\n")
+ }
+ }
+
+ buf.WriteString("runcmd:\n")
+ buf.WriteString(" - [ rc-update, add, local, default ]\n")
+ buf.WriteString(" - [ rc-service, local, start ]\n")
+
+ return buf.String()
+}
diff --git a/spinifex/handlers/eks/oidc_keypair.go b/spinifex/handlers/eks/oidc_keypair.go
new file mode 100644
index 000000000..fc81999ea
--- /dev/null
+++ b/spinifex/handlers/eks/oidc_keypair.go
@@ -0,0 +1,214 @@
+package handlers_eks
+
+import (
+ "crypto"
+ "crypto/ecdsa"
+ "crypto/elliptic"
+ "crypto/rand"
+ "crypto/sha256"
+ "crypto/x509"
+ "encoding/base64"
+ "encoding/json"
+ "encoding/pem"
+ "errors"
+ "fmt"
+
+ handlers_iam "github.com/mulgadc/spinifex/spinifex/handlers/iam"
+ "github.com/nats-io/nats.go"
+)
+
+// PublicKeyPEMFromPrivate parses a PKCS#8 private-key PEM and returns the PKIX
+// public-key PEM. kube-apiserver's --service-account-key-file requires a PUBLIC
+// key (ParsePublicKeysPEM rejects private-key blocks), while the matching
+// --service-account-signing-key-file takes the private key; the K3s server VM
+// needs both, seeded to separate files.
+func PublicKeyPEMFromPrivate(privPEM string) (string, error) {
+ block, _ := pem.Decode([]byte(privPEM))
+ if block == nil {
+ return "", errors.New("eks: PublicKeyPEMFromPrivate: no PEM block in private key")
+ }
+ key, err := x509.ParsePKCS8PrivateKey(block.Bytes)
+ if err != nil {
+ return "", fmt.Errorf("parse pkcs8 private key: %w", err)
+ }
+ signer, ok := key.(crypto.Signer)
+ if !ok {
+ return "", errors.New("eks: PublicKeyPEMFromPrivate: private key is not a crypto.Signer")
+ }
+ pubDER, err := x509.MarshalPKIXPublicKey(signer.Public())
+ if err != nil {
+ return "", fmt.Errorf("marshal pkix public key: %w", err)
+ }
+ return string(pem.EncodeToMemory(&pem.Block{Type: "PUBLIC KEY", Bytes: pubDER})), nil
+}
+
+// oidcJWK and oidcJWKS are the RFC 7517 wire shapes the per-cluster JWKS
+// document uses. handlers_sts decodes the same JSON via its own JWK / JWKS
+// types — wire compatibility (not Go type identity) is the contract.
+type oidcJWK struct {
+ Kty string `json:"kty"`
+ Kid string `json:"kid"`
+ Use string `json:"use,omitempty"`
+ Alg string `json:"alg,omitempty"`
+ Crv string `json:"crv,omitempty"`
+ X string `json:"x,omitempty"`
+ Y string `json:"y,omitempty"`
+}
+
+type oidcJWKS struct {
+ Keys []oidcJWK `json:"keys"`
+}
+
+// P-256 coordinate width per SEC1 — JWK requires fixed-length base64url, not
+// the variable-length big.Int byte representation.
+const p256CoordLen = 32
+
+// GenerateClusterOIDCKeypair creates a fresh ECDSA-P256 keypair for the
+// cluster, PEM-marshals the private key as PKCS8, envelope-encrypts the PEM
+// with masterKey, writes the ciphertext to OIDCSigningKeyKey and the JWKS
+// document to OIDCJWKSKey. Returns the plaintext private-key PEM and the JWKS
+// bytes so the caller (CreateCluster) can pass them straight to user-data
+// without a follow-up KV read + decrypt.
+func GenerateClusterOIDCKeypair(kv nats.KeyValue, clusterName string, masterKey []byte) (privPEM string, jwksBytes []byte, err error) {
+ if clusterName == "" {
+ return "", nil, errors.New("eks: GenerateClusterOIDCKeypair empty cluster name")
+ }
+ if len(masterKey) == 0 {
+ return "", nil, errors.New("eks: GenerateClusterOIDCKeypair empty master key")
+ }
+
+ priv, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
+ if err != nil {
+ return "", nil, fmt.Errorf("generate ECDSA-P256 key: %w", err)
+ }
+
+ pkcs8, err := x509.MarshalPKCS8PrivateKey(priv)
+ if err != nil {
+ return "", nil, fmt.Errorf("marshal pkcs8 private key: %w", err)
+ }
+ pemBlock := pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: pkcs8})
+
+ ciphertext, err := handlers_iam.EncryptSecret(string(pemBlock), masterKey)
+ if err != nil {
+ return "", nil, fmt.Errorf("encrypt OIDC private key: %w", err)
+ }
+ if _, err := kv.Put(OIDCSigningKeyKey(clusterName), []byte(ciphertext)); err != nil {
+ return "", nil, fmt.Errorf("kv put %s: %w", OIDCSigningKeyKey(clusterName), err)
+ }
+
+ jwksBytes, err = marshalJWKS(&priv.PublicKey)
+ if err != nil {
+ return "", nil, err
+ }
+ if _, err := kv.Put(OIDCJWKSKey(clusterName), jwksBytes); err != nil {
+ return "", nil, fmt.Errorf("kv put %s: %w", OIDCJWKSKey(clusterName), err)
+ }
+ return string(pemBlock), jwksBytes, nil
+}
+
+// LoadClusterOIDCPrivateKey reads the encrypted PEM from KV, decrypts with
+// masterKey, parses it to *ecdsa.PrivateKey. Returns ErrClusterNotFound when
+// the key is absent so DeleteCluster idempotency works.
+func LoadClusterOIDCPrivateKey(kv nats.KeyValue, clusterName string, masterKey []byte) (*ecdsa.PrivateKey, error) {
+ if clusterName == "" {
+ return nil, errors.New("eks: LoadClusterOIDCPrivateKey empty cluster name")
+ }
+ if len(masterKey) == 0 {
+ return nil, errors.New("eks: LoadClusterOIDCPrivateKey empty master key")
+ }
+ entry, err := kv.Get(OIDCSigningKeyKey(clusterName))
+ if err != nil {
+ if errors.Is(err, nats.ErrKeyNotFound) {
+ return nil, ErrClusterNotFound
+ }
+ return nil, fmt.Errorf("kv get %s: %w", OIDCSigningKeyKey(clusterName), err)
+ }
+ pemStr, err := handlers_iam.DecryptSecret(string(entry.Value()), masterKey)
+ if err != nil {
+ return nil, fmt.Errorf("decrypt OIDC private key: %w", err)
+ }
+ block, _ := pem.Decode([]byte(pemStr))
+ if block == nil {
+ return nil, errors.New("eks: PEM decode produced no block")
+ }
+ keyAny, err := x509.ParsePKCS8PrivateKey(block.Bytes)
+ if err != nil {
+ return nil, fmt.Errorf("parse pkcs8 private key: %w", err)
+ }
+ priv, ok := keyAny.(*ecdsa.PrivateKey)
+ if !ok {
+ return nil, fmt.Errorf("eks: unexpected key type %T (want *ecdsa.PrivateKey)", keyAny)
+ }
+ if priv.Curve != elliptic.P256() {
+ return nil, fmt.Errorf("eks: unexpected curve %s (want P-256)", priv.Curve.Params().Name)
+ }
+ return priv, nil
+}
+
+// ZeroizeClusterOIDCKey overwrites the encrypted private-key blob with zeros
+// of the same length, then purges the key. DeleteCluster calls this first so
+// a crash after this point leaves no recoverable key material. Purge (not
+// Delete) drops the key's entire revision history — a Delete leaves a tombstone
+// over prior revisions, so the original encrypted PEM would survive in history
+// until age/limit rolls it off. The account bucket is History=1 regardless
+// (see store.go), so this is defense-in-depth against a widened history.
+// Absent key is a no-op — supports idempotent DeleteCluster retries.
+func ZeroizeClusterOIDCKey(kv nats.KeyValue, clusterName string) error {
+ if clusterName == "" {
+ return errors.New("eks: ZeroizeClusterOIDCKey empty cluster name")
+ }
+ key := OIDCSigningKeyKey(clusterName)
+ entry, err := kv.Get(key)
+ if err != nil {
+ if errors.Is(err, nats.ErrKeyNotFound) {
+ return nil
+ }
+ return fmt.Errorf("kv get %s: %w", key, err)
+ }
+ zero := make([]byte, len(entry.Value()))
+ if _, err := kv.Put(key, zero); err != nil {
+ return fmt.Errorf("kv put zero %s: %w", key, err)
+ }
+ if err := kv.Purge(key); err != nil {
+ return fmt.Errorf("kv purge %s: %w", key, err)
+ }
+ return nil
+}
+
+// marshalJWKS builds the single-key JWKS document for an ECDSA P-256
+// public key. kid is the base64url SHA-256 of the SPKI DER.
+func marshalJWKS(pub *ecdsa.PublicKey) ([]byte, error) {
+ pubDER, err := x509.MarshalPKIXPublicKey(pub)
+ if err != nil {
+ return nil, fmt.Errorf("marshal public key DER: %w", err)
+ }
+ kidHash := sha256.Sum256(pubDER)
+ kid := base64.RawURLEncoding.EncodeToString(kidHash[:])
+
+ xb := padCoord(pub.X.Bytes())
+ yb := padCoord(pub.Y.Bytes())
+
+ doc := oidcJWKS{Keys: []oidcJWK{{
+ Kty: "EC",
+ Kid: kid,
+ Use: "sig",
+ Alg: "ES256",
+ Crv: "P-256",
+ X: base64.RawURLEncoding.EncodeToString(xb),
+ Y: base64.RawURLEncoding.EncodeToString(yb),
+ }}}
+ out, err := json.Marshal(&doc)
+ if err != nil {
+ return nil, fmt.Errorf("marshal JWKS: %w", err)
+ }
+ return out, nil
+}
+
+func padCoord(b []byte) []byte {
+ if len(b) >= p256CoordLen {
+ return b
+ }
+ out := make([]byte, p256CoordLen)
+ copy(out[p256CoordLen-len(b):], b)
+ return out
+}
diff --git a/spinifex/handlers/eks/oidc_keypair_test.go b/spinifex/handlers/eks/oidc_keypair_test.go
new file mode 100644
index 000000000..341e612f1
--- /dev/null
+++ b/spinifex/handlers/eks/oidc_keypair_test.go
@@ -0,0 +1,228 @@
+package handlers_eks
+
+import (
+ "crypto/ecdsa"
+ "crypto/elliptic"
+ "crypto/rand"
+ "crypto/x509"
+ "encoding/base64"
+ "encoding/json"
+ "encoding/pem"
+ "errors"
+ "testing"
+
+ "github.com/nats-io/nats.go"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+var testMasterKey = func() []byte {
+ k := make([]byte, 32)
+ if _, err := rand.Read(k); err != nil {
+ panic(err)
+ }
+ return k
+}()
+
+func TestGenerateClusterOIDCKeypair_PersistsJWKSAndEncryptedPrivateKey(t *testing.T) {
+ kv := newClusterStateTestKV(t)
+
+ _, jwksBytes, err := GenerateClusterOIDCKeypair(kv, "alpha", testMasterKey)
+ require.NoError(t, err)
+ require.NotEmpty(t, jwksBytes)
+
+ jwksEntry, err := kv.Get(OIDCJWKSKey("alpha"))
+ require.NoError(t, err)
+ assert.Equal(t, jwksBytes, jwksEntry.Value())
+
+ keyEntry, err := kv.Get(OIDCSigningKeyKey("alpha"))
+ require.NoError(t, err)
+ assert.NotEmpty(t, keyEntry.Value())
+ assert.NotContains(t, string(keyEntry.Value()), "BEGIN PRIVATE KEY",
+ "encrypted blob must not contain a PEM header")
+}
+
+// PublicKeyPEMFromPrivate must yield a PKIX PUBLIC KEY PEM matching the private
+// key's public part — kube-apiserver's --service-account-key-file requires a
+// public key and crash-loops on a private-key PEM.
+func TestPublicKeyPEMFromPrivate_RoundTrips(t *testing.T) {
+ kv := newClusterStateTestKV(t)
+ privPEM, _, err := GenerateClusterOIDCKeypair(kv, "alpha", testMasterKey)
+ require.NoError(t, err)
+
+ pubPEM, err := PublicKeyPEMFromPrivate(privPEM)
+ require.NoError(t, err)
+ require.Contains(t, pubPEM, "BEGIN PUBLIC KEY")
+ assert.NotContains(t, pubPEM, "PRIVATE KEY")
+
+ block, _ := pem.Decode([]byte(pubPEM))
+ require.NotNil(t, block)
+ pub, err := x509.ParsePKIXPublicKey(block.Bytes)
+ require.NoError(t, err)
+ pubEC, ok := pub.(*ecdsa.PublicKey)
+ require.True(t, ok)
+
+ stored, err := LoadClusterOIDCPrivateKey(kv, "alpha", testMasterKey)
+ require.NoError(t, err)
+ assert.True(t, stored.PublicKey.Equal(pubEC), "derived public key must match the private key")
+}
+
+func TestPublicKeyPEMFromPrivate_RejectsGarbage(t *testing.T) {
+ _, err := PublicKeyPEMFromPrivate("not a pem")
+ require.Error(t, err)
+
+ _, err = PublicKeyPEMFromPrivate("-----BEGIN PRIVATE KEY-----\nbm90LWtleQ==\n-----END PRIVATE KEY-----\n")
+ require.Error(t, err)
+}
+
+// The generator returns the plaintext private-key PEM directly so CreateCluster
+// avoids a second KV read + decrypt; it must match the key persisted in KV.
+func TestGenerateClusterOIDCKeypair_ReturnsPrivateKeyPEMMatchingStored(t *testing.T) {
+ kv := newClusterStateTestKV(t)
+
+ privPEM, _, err := GenerateClusterOIDCKeypair(kv, "alpha", testMasterKey)
+ require.NoError(t, err)
+ require.Contains(t, privPEM, "BEGIN PRIVATE KEY", "returned PEM must be plaintext")
+
+ block, _ := pem.Decode([]byte(privPEM))
+ require.NotNil(t, block)
+ returned, err := x509.ParsePKCS8PrivateKey(block.Bytes)
+ require.NoError(t, err)
+ returnedEC, ok := returned.(*ecdsa.PrivateKey)
+ require.True(t, ok)
+
+ stored, err := LoadClusterOIDCPrivateKey(kv, "alpha", testMasterKey)
+ require.NoError(t, err)
+ assert.True(t, stored.Equal(returnedEC), "returned PEM must match the persisted key")
+}
+
+func TestGenerateClusterOIDCKeypair_JWKSShapeIsRFC7517EC_P256(t *testing.T) {
+ kv := newClusterStateTestKV(t)
+
+ _, jwksBytes, err := GenerateClusterOIDCKeypair(kv, "alpha", testMasterKey)
+ require.NoError(t, err)
+
+ var doc oidcJWKS
+ require.NoError(t, json.Unmarshal(jwksBytes, &doc))
+ require.Len(t, doc.Keys, 1)
+
+ k := doc.Keys[0]
+ assert.Equal(t, "EC", k.Kty)
+ assert.Equal(t, "P-256", k.Crv)
+ assert.Equal(t, "ES256", k.Alg)
+ assert.Equal(t, "sig", k.Use)
+ assert.NotEmpty(t, k.Kid)
+
+ xBytes, err := base64.RawURLEncoding.DecodeString(k.X)
+ require.NoError(t, err)
+ assert.Len(t, xBytes, p256CoordLen, "JWK x must be 32-byte SEC1 coordinate")
+ yBytes, err := base64.RawURLEncoding.DecodeString(k.Y)
+ require.NoError(t, err)
+ assert.Len(t, yBytes, p256CoordLen, "JWK y must be 32-byte SEC1 coordinate")
+}
+
+func TestGenerateClusterOIDCKeypair_TwoCallsProduceDistinctKeys(t *testing.T) {
+ kv := newClusterStateTestKV(t)
+
+ _, a, err := GenerateClusterOIDCKeypair(kv, "alpha", testMasterKey)
+ require.NoError(t, err)
+ _, b, err := GenerateClusterOIDCKeypair(kv, "beta", testMasterKey)
+ require.NoError(t, err)
+ assert.NotEqual(t, a, b, "JWKS for distinct clusters must differ")
+}
+
+func TestGenerateClusterOIDCKeypair_EmptyArgsRejected(t *testing.T) {
+ kv := newClusterStateTestKV(t)
+
+ _, _, err := GenerateClusterOIDCKeypair(kv, "", testMasterKey)
+ require.Error(t, err)
+ _, _, err = GenerateClusterOIDCKeypair(kv, "alpha", nil)
+ require.Error(t, err)
+}
+
+func TestLoadClusterOIDCPrivateKey_RoundTripMatchesJWKSPublic(t *testing.T) {
+ kv := newClusterStateTestKV(t)
+
+ _, jwksBytes, err := GenerateClusterOIDCKeypair(kv, "alpha", testMasterKey)
+ require.NoError(t, err)
+
+ priv, err := LoadClusterOIDCPrivateKey(kv, "alpha", testMasterKey)
+ require.NoError(t, err)
+ require.NotNil(t, priv)
+ assert.Equal(t, elliptic.P256(), priv.Curve)
+
+ var doc oidcJWKS
+ require.NoError(t, json.Unmarshal(jwksBytes, &doc))
+ require.Len(t, doc.Keys, 1)
+
+ xExpected, err := base64.RawURLEncoding.DecodeString(doc.Keys[0].X)
+ require.NoError(t, err)
+ yExpected, err := base64.RawURLEncoding.DecodeString(doc.Keys[0].Y)
+ require.NoError(t, err)
+
+ assert.Equal(t, padCoord(priv.X.Bytes()), xExpected)
+ assert.Equal(t, padCoord(priv.Y.Bytes()), yExpected)
+}
+
+func TestLoadClusterOIDCPrivateKey_WrongMasterKeyFails(t *testing.T) {
+ kv := newClusterStateTestKV(t)
+ _, _, err := GenerateClusterOIDCKeypair(kv, "alpha", testMasterKey)
+ require.NoError(t, err)
+
+ other := make([]byte, 32)
+ other[0] = 0xff
+ _, err = LoadClusterOIDCPrivateKey(kv, "alpha", other)
+ require.Error(t, err)
+}
+
+func TestLoadClusterOIDCPrivateKey_MissingReturnsErrClusterNotFound(t *testing.T) {
+ kv := newClusterStateTestKV(t)
+ _, err := LoadClusterOIDCPrivateKey(kv, "ghost", testMasterKey)
+ require.ErrorIs(t, err, ErrClusterNotFound)
+}
+
+func TestZeroizeClusterOIDCKey_DeletesKeyAndLeavesSiblingsIntact(t *testing.T) {
+ kv := newClusterStateTestKV(t)
+ _, _, err := GenerateClusterOIDCKeypair(kv, "alpha", testMasterKey)
+ require.NoError(t, err)
+
+ require.NoError(t, ZeroizeClusterOIDCKey(kv, "alpha"))
+
+ _, err = kv.Get(OIDCSigningKeyKey("alpha"))
+ assert.True(t, errors.Is(err, nats.ErrKeyNotFound))
+
+ jwks, err := kv.Get(OIDCJWKSKey("alpha"))
+ require.NoError(t, err, "JWKS must survive zeroize (it carries no secret material)")
+ assert.NotEmpty(t, jwks.Value())
+}
+
+func TestZeroizeClusterOIDCKey_MissingIsNoop(t *testing.T) {
+ kv := newClusterStateTestKV(t)
+ require.NoError(t, ZeroizeClusterOIDCKey(kv, "ghost"))
+}
+
+func TestZeroizeClusterOIDCKey_EmptyNameRejected(t *testing.T) {
+ kv := newClusterStateTestKV(t)
+ require.Error(t, ZeroizeClusterOIDCKey(kv, ""))
+}
+
+func TestMarshalJWKS_DeterministicForSameKey(t *testing.T) {
+ priv, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
+ require.NoError(t, err)
+
+ a, err := marshalJWKS(&priv.PublicKey)
+ require.NoError(t, err)
+ b, err := marshalJWKS(&priv.PublicKey)
+ require.NoError(t, err)
+ assert.Equal(t, a, b)
+}
+
+func TestPadCoord_LeftPadsShortBigIntBytes(t *testing.T) {
+ got := padCoord([]byte{0x01, 0x02})
+ require.Len(t, got, p256CoordLen)
+ assert.Equal(t, byte(0x01), got[p256CoordLen-2])
+ assert.Equal(t, byte(0x02), got[p256CoordLen-1])
+ for _, b := range got[:p256CoordLen-2] {
+ assert.Zero(t, b)
+ }
+}
diff --git a/spinifex/handlers/eks/reconciler_registry.go b/spinifex/handlers/eks/reconciler_registry.go
new file mode 100644
index 000000000..5fc72d5a0
--- /dev/null
+++ b/spinifex/handlers/eks/reconciler_registry.go
@@ -0,0 +1,178 @@
+package handlers_eks
+
+import (
+ "context"
+ "errors"
+ "log/slog"
+ "sync"
+
+ "github.com/nats-io/nats.go"
+)
+
+// ErrLeaseHeld signals that a reconciler was not spawned because another node
+// holds the leader lease. Spawn treats it as benign: it drops the registry
+// entry (rather than recording a phantom holder) so a later Spawn re-attempts
+// once the holder's lease TTL lapses.
+var ErrLeaseHeld = errors.New("eks: reconciler lease held by another node")
+
+// ReconcilerRegistry tracks active per-cluster reconciler goroutines so the
+// daemon can spawn, stop, and graceful-shutdown them in unison. One entry per
+// (accountID, clusterName) key.
+type ReconcilerRegistry struct {
+ mu sync.Mutex
+ holders map[string]*reconcilerHandle
+}
+
+type reconcilerHandle struct {
+ cancel context.CancelFunc
+ release func()
+ done chan struct{}
+}
+
+// NewReconcilerRegistry returns an empty registry.
+func NewReconcilerRegistry() *ReconcilerRegistry {
+ return &ReconcilerRegistry{holders: map[string]*reconcilerHandle{}}
+}
+
+// SpawnReconcilerFn is the per-cluster goroutine starter the registry uses.
+// Tests inject a fake; production wires a real ClusterReconciler under the
+// hood (acquires lease, then loops via Run).
+type SpawnReconcilerFn func(ctx context.Context, accountID, clusterName string) (release func(), err error)
+
+// Spawn launches one reconciler goroutine for (accountID, clusterName). If
+// one is already running for that pair, returns nil without spawning a
+// duplicate. SpawnFn is responsible for acquiring the leader lease + invoking
+// Run; the registry only owns the ctx and the release.
+func (r *ReconcilerRegistry) Spawn(parent context.Context, accountID, clusterName string, spawnFn SpawnReconcilerFn) error {
+ if accountID == "" || clusterName == "" {
+ return errors.New("eks: ReconcilerRegistry.Spawn empty ids")
+ }
+ if spawnFn == nil {
+ return errors.New("eks: ReconcilerRegistry.Spawn nil SpawnFn")
+ }
+ key := registryKey(accountID, clusterName)
+
+ r.mu.Lock()
+ if _, ok := r.holders[key]; ok {
+ r.mu.Unlock()
+ return nil
+ }
+ ctx, cancel := context.WithCancel(parent)
+ done := make(chan struct{})
+ h := &reconcilerHandle{cancel: cancel, done: done}
+ r.holders[key] = h
+ r.mu.Unlock()
+
+ release, err := spawnFn(ctx, accountID, clusterName)
+ if err != nil {
+ r.mu.Lock()
+ delete(r.holders, key)
+ r.mu.Unlock()
+ cancel()
+ close(done)
+ if errors.Is(err, ErrLeaseHeld) {
+ // Lease held elsewhere — not a failure. The entry is already
+ // dropped above, so a later Spawn retries after the holder's TTL.
+ return nil
+ }
+ return err
+ }
+
+ r.mu.Lock()
+ h.release = release
+ r.mu.Unlock()
+
+ go func() {
+ <-ctx.Done()
+ r.mu.Lock()
+ rel := h.release
+ r.mu.Unlock()
+ if rel != nil {
+ rel()
+ }
+ close(done)
+ }()
+
+ slog.Info("ReconcilerRegistry: spawned", "accountID", accountID, "cluster", clusterName)
+ return nil
+}
+
+// Stop cancels a single reconciler. Blocks until the goroutine has exited.
+// No-op if no reconciler is registered for the key.
+func (r *ReconcilerRegistry) Stop(accountID, clusterName string) {
+ key := registryKey(accountID, clusterName)
+ r.mu.Lock()
+ h, ok := r.holders[key]
+ if ok {
+ delete(r.holders, key)
+ }
+ r.mu.Unlock()
+ if !ok {
+ return
+ }
+ h.cancel()
+ <-h.done
+ slog.Info("ReconcilerRegistry: stopped", "accountID", accountID, "cluster", clusterName)
+}
+
+// StopAll cancels every active reconciler and blocks until all goroutines
+// have exited. Called from daemon graceful-shutdown.
+func (r *ReconcilerRegistry) StopAll() {
+ r.mu.Lock()
+ handles := make([]*reconcilerHandle, 0, len(r.holders))
+ for k, h := range r.holders {
+ handles = append(handles, h)
+ delete(r.holders, k)
+ }
+ r.mu.Unlock()
+
+ for _, h := range handles {
+ h.cancel()
+ }
+ for _, h := range handles {
+ <-h.done
+ }
+}
+
+// Has reports whether a reconciler is currently registered for the key.
+func (r *ReconcilerRegistry) Has(accountID, clusterName string) bool {
+ r.mu.Lock()
+ defer r.mu.Unlock()
+ _, ok := r.holders[registryKey(accountID, clusterName)]
+ return ok
+}
+
+func registryKey(accountID, clusterName string) string {
+ return accountID + "/" + clusterName
+}
+
+// RunClusterReconciler is the production SpawnReconcilerFn: it constructs a
+// ClusterReconciler, acquires the leader lease, then drives Run in a
+// goroutine. Returns (release, nil) on lease acquired, or (nil, error) on
+// constructor failure. If another node holds the lease, returns
+// (nil, ErrLeaseHeld) so the registry drops the entry and re-attempts on a
+// later tick instead of recording a phantom holder.
+func RunClusterReconciler(
+ ctx context.Context,
+ leaderKV, acctKV nats.KeyValue,
+ accountID, clusterName, holderID, healthURL string,
+ opts ...ReconcilerOption,
+) (func(), error) {
+ r, err := NewClusterReconciler(leaderKV, acctKV, accountID, clusterName, holderID, healthURL, opts...)
+ if err != nil {
+ return nil, err
+ }
+ release, ok := r.AcquireLease()
+ if !ok {
+ slog.Info("RunClusterReconciler: lease held elsewhere, skipping spawn",
+ "accountID", accountID, "cluster", clusterName)
+ return nil, ErrLeaseHeld
+ }
+ go func() {
+ if runErr := r.Run(ctx); runErr != nil && !errors.Is(runErr, context.Canceled) {
+ slog.Info("RunClusterReconciler: Run exited",
+ "accountID", accountID, "cluster", clusterName, "err", runErr)
+ }
+ }()
+ return release, nil
+}
diff --git a/spinifex/handlers/eks/reconciler_registry_test.go b/spinifex/handlers/eks/reconciler_registry_test.go
new file mode 100644
index 000000000..2494f764f
--- /dev/null
+++ b/spinifex/handlers/eks/reconciler_registry_test.go
@@ -0,0 +1,122 @@
+package handlers_eks
+
+import (
+ "context"
+ "errors"
+ "sync/atomic"
+ "testing"
+ "time"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestReconcilerRegistry_SpawnAndStop(t *testing.T) {
+ reg := NewReconcilerRegistry()
+
+ var (
+ ran atomic.Bool
+ released atomic.Bool
+ )
+ fn := func(ctx context.Context, accountID, clusterName string) (func(), error) {
+ ran.Store(true)
+ go func() { <-ctx.Done() }()
+ return func() { released.Store(true) }, nil
+ }
+
+ require.NoError(t, reg.Spawn(t.Context(), "111122223333", "alpha", fn))
+ assert.True(t, reg.Has("111122223333", "alpha"))
+ assert.True(t, ran.Load())
+
+ reg.Stop("111122223333", "alpha")
+ assert.False(t, reg.Has("111122223333", "alpha"))
+ require.Eventually(t, released.Load, 500*time.Millisecond, 5*time.Millisecond)
+}
+
+func TestReconcilerRegistry_SpawnIdempotent(t *testing.T) {
+ reg := NewReconcilerRegistry()
+ var spawnCalls atomic.Int32
+ fn := func(ctx context.Context, _, _ string) (func(), error) {
+ spawnCalls.Add(1)
+ go func() { <-ctx.Done() }()
+ return func() {}, nil
+ }
+
+ require.NoError(t, reg.Spawn(t.Context(), "111122223333", "alpha", fn))
+ require.NoError(t, reg.Spawn(t.Context(), "111122223333", "alpha", fn))
+
+ assert.EqualValues(t, 1, spawnCalls.Load(), "second Spawn must not re-invoke fn")
+ reg.StopAll()
+}
+
+func TestReconcilerRegistry_SpawnFnErrorRemovesEntry(t *testing.T) {
+ reg := NewReconcilerRegistry()
+ fn := func(_ context.Context, _, _ string) (func(), error) {
+ return nil, errors.New("acquire lease failed")
+ }
+
+ err := reg.Spawn(t.Context(), "111122223333", "alpha", fn)
+ require.Error(t, err)
+ assert.False(t, reg.Has("111122223333", "alpha"), "entry must not linger after spawn failure")
+}
+
+func TestReconcilerRegistry_LeaseHeldDropsEntryAndRetries(t *testing.T) {
+ reg := NewReconcilerRegistry()
+ var spawnCalls atomic.Int32
+ leaseHeld := atomic.Bool{}
+ leaseHeld.Store(true)
+ fn := func(ctx context.Context, _, _ string) (func(), error) {
+ spawnCalls.Add(1)
+ if leaseHeld.Load() {
+ return nil, ErrLeaseHeld
+ }
+ go func() { <-ctx.Done() }()
+ return func() {}, nil
+ }
+
+ // Lease held elsewhere: Spawn is benign (nil), records no phantom holder.
+ require.NoError(t, reg.Spawn(t.Context(), "111122223333", "alpha", fn))
+ assert.False(t, reg.Has("111122223333", "alpha"), "lease-held must not leave a holder")
+
+ // Holder's TTL lapses; a later Spawn re-invokes fn and now takes over —
+ // no daemon restart needed.
+ leaseHeld.Store(false)
+ require.NoError(t, reg.Spawn(t.Context(), "111122223333", "alpha", fn))
+ assert.True(t, reg.Has("111122223333", "alpha"))
+ assert.EqualValues(t, 2, spawnCalls.Load(), "second Spawn must re-attempt after lease-held")
+ reg.StopAll()
+}
+
+func TestReconcilerRegistry_StopAllCancelsEvery(t *testing.T) {
+ reg := NewReconcilerRegistry()
+ var released atomic.Int32
+ fn := func(ctx context.Context, _, _ string) (func(), error) {
+ go func() { <-ctx.Done() }()
+ return func() { released.Add(1) }, nil
+ }
+
+ require.NoError(t, reg.Spawn(t.Context(), "111122223333", "alpha", fn))
+ require.NoError(t, reg.Spawn(t.Context(), "111122223333", "beta", fn))
+ require.NoError(t, reg.Spawn(t.Context(), "444455556666", "gamma", fn))
+
+ reg.StopAll()
+ require.Eventually(t, func() bool { return released.Load() == 3 }, 500*time.Millisecond, 5*time.Millisecond)
+ assert.False(t, reg.Has("111122223333", "alpha"))
+ assert.False(t, reg.Has("111122223333", "beta"))
+ assert.False(t, reg.Has("444455556666", "gamma"))
+}
+
+func TestReconcilerRegistry_StopUnknownKeyNoop(t *testing.T) {
+ reg := NewReconcilerRegistry()
+ reg.Stop("111122223333", "ghost")
+ assert.False(t, reg.Has("111122223333", "ghost"))
+}
+
+func TestReconcilerRegistry_SpawnRejectsBadArgs(t *testing.T) {
+ reg := NewReconcilerRegistry()
+ fn := func(_ context.Context, _, _ string) (func(), error) { return func() {}, nil }
+
+ require.Error(t, reg.Spawn(t.Context(), "", "alpha", fn))
+ require.Error(t, reg.Spawn(t.Context(), "111122223333", "", fn))
+ require.Error(t, reg.Spawn(t.Context(), "111122223333", "alpha", nil))
+}
diff --git a/spinifex/handlers/eks/security_groups.go b/spinifex/handlers/eks/security_groups.go
new file mode 100644
index 000000000..fd0f964a9
--- /dev/null
+++ b/spinifex/handlers/eks/security_groups.go
@@ -0,0 +1,262 @@
+package handlers_eks
+
+import (
+ "errors"
+ "fmt"
+ "log/slog"
+
+ "github.com/aws/aws-sdk-go/aws"
+ "github.com/aws/aws-sdk-go/service/ec2"
+ "github.com/mulgadc/spinifex/spinifex/awserrors"
+ "github.com/mulgadc/spinifex/spinifex/tags"
+)
+
+// sgProvisioner is the subset of handlers_ec2_vpc.VPCService that the cluster
+// SG helpers need. Narrow so tests can fake with hand-rolled recorders without
+// implementing the full VPCService surface.
+type sgProvisioner interface {
+ CreateSecurityGroup(input *ec2.CreateSecurityGroupInput, accountID string) (*ec2.CreateSecurityGroupOutput, error)
+ DescribeSecurityGroups(input *ec2.DescribeSecurityGroupsInput, accountID string) (*ec2.DescribeSecurityGroupsOutput, error)
+ DeleteSecurityGroup(input *ec2.DeleteSecurityGroupInput, accountID string) (*ec2.DeleteSecurityGroupOutput, error)
+ AuthorizeSecurityGroupIngress(input *ec2.AuthorizeSecurityGroupIngressInput, accountID string) (*ec2.AuthorizeSecurityGroupIngressOutput, error)
+}
+
+// clusterEKSClusterTagKey is stamped on every cluster-scoped resource so the
+// DeleteCluster sweep + the UI hide-from-customer filter can group them.
+const clusterEKSClusterTagKey = "spinifex:eks-cluster"
+
+// clusterEKSRoleTagKey distinguishes the control-plane SG from the nodegroup
+// SG when both live in the same VPC.
+const clusterEKSRoleTagKey = "spinifex:eks-role"
+
+// clusterEKSNodegroupTagKey is stamped on worker instances so the nodegroup
+// they belong to is recoverable from EC2 tags alone.
+const clusterEKSNodegroupTagKey = "spinifex:eks-nodegroup"
+
+const (
+ clusterEKSRoleControlPlane = "control-plane"
+ clusterEKSRoleNodegroup = "nodegroup"
+)
+
+// ClusterControlPlaneSGName returns the deterministic SG name used for the
+// cluster's control-plane SG. Stable so Stage-6 DeleteCluster can locate it
+// from clusterName alone.
+func ClusterControlPlaneSGName(clusterName string) string {
+ return fmt.Sprintf("eks-cluster-%s-control-plane-sg", clusterName)
+}
+
+// ClusterNodegroupSGName returns the deterministic SG name used for the
+// cluster's nodegroup SG.
+func ClusterNodegroupSGName(clusterName string) string {
+ return fmt.Sprintf("eks-cluster-%s-nodegroup-sg", clusterName)
+}
+
+// EnsureClusterSGs creates (or returns existing) control-plane + nodegroup
+// security groups in the customer VPC. Both are tagged
+// spinifex:managed-by=eks + spinifex:eks-cluster=. Idempotent: an SG
+// whose group-name + vpc-id match the expected pair is reused without a
+// second create call. The two creates are independent — a failure on the
+// nodegroup SG does not unwind the control-plane SG (DeleteCluster cleans up).
+func EnsureClusterSGs(sgp sgProvisioner, accountID, clusterName, vpcID string) (controlPlaneSGID, nodegroupSGID string, err error) {
+ if clusterName == "" {
+ return "", "", errors.New("eks: EnsureClusterSGs empty cluster name")
+ }
+ if vpcID == "" {
+ return "", "", errors.New("eks: EnsureClusterSGs empty vpc id")
+ }
+
+ controlPlaneSGID, err = ensureClusterSG(sgp, accountID, vpcID, clusterName,
+ ClusterControlPlaneSGName(clusterName),
+ fmt.Sprintf("EKS control-plane SG for cluster %s", clusterName),
+ clusterEKSRoleControlPlane)
+ if err != nil {
+ return "", "", err
+ }
+
+ nodegroupSGID, err = ensureClusterSG(sgp, accountID, vpcID, clusterName,
+ ClusterNodegroupSGName(clusterName),
+ fmt.Sprintf("EKS nodegroup SG for cluster %s", clusterName),
+ clusterEKSRoleNodegroup)
+ if err != nil {
+ return "", "", err
+ }
+
+ return controlPlaneSGID, nodegroupSGID, nil
+}
+
+// DeleteClusterSGs removes both cluster SGs in the customer VPC. Missing SGs
+// are no-ops so DeleteCluster can retry safely. Delete errors are logged and
+// the sweep continues; the first error is returned so the caller surfaces it
+// without halting the broader teardown.
+func DeleteClusterSGs(sgp sgProvisioner, accountID, clusterName, vpcID string) error {
+ if clusterName == "" {
+ return errors.New("eks: DeleteClusterSGs empty cluster name")
+ }
+ if vpcID == "" {
+ return errors.New("eks: DeleteClusterSGs empty vpc id")
+ }
+
+ var firstErr error
+ for _, name := range []string{ClusterControlPlaneSGName(clusterName), ClusterNodegroupSGName(clusterName)} {
+ id, err := lookupSGByName(sgp, accountID, vpcID, name)
+ if err != nil {
+ slog.Warn("DeleteClusterSGs: SG lookup failed", "sg", name, "err", err)
+ if firstErr == nil {
+ firstErr = err
+ }
+ continue
+ }
+ if id == "" {
+ continue
+ }
+ if _, err := sgp.DeleteSecurityGroup(&ec2.DeleteSecurityGroupInput{GroupId: aws.String(id)}, accountID); err != nil {
+ slog.Warn("DeleteClusterSGs: SG delete failed", "sg", id, "name", name, "err", err)
+ if firstErr == nil {
+ firstErr = fmt.Errorf("delete SG %s: %w", id, err)
+ }
+ }
+ }
+ return firstErr
+}
+
+func ensureClusterSG(sgp sgProvisioner, accountID, vpcID, clusterName, name, description, role string) (string, error) {
+ if id, err := lookupSGByName(sgp, accountID, vpcID, name); err != nil {
+ return "", err
+ } else if id != "" {
+ return id, nil
+ }
+
+ out, err := sgp.CreateSecurityGroup(&ec2.CreateSecurityGroupInput{
+ GroupName: aws.String(name),
+ Description: aws.String(description),
+ VpcId: aws.String(vpcID),
+ TagSpecifications: []*ec2.TagSpecification{{
+ ResourceType: aws.String("security-group"),
+ Tags: []*ec2.Tag{
+ {Key: aws.String(tags.ManagedByKey), Value: aws.String(tags.ManagedByEKS)},
+ {Key: aws.String(clusterEKSClusterTagKey), Value: aws.String(clusterName)},
+ {Key: aws.String(clusterEKSRoleTagKey), Value: aws.String(role)},
+ },
+ }},
+ }, accountID)
+ if err != nil {
+ return "", fmt.Errorf("create SG %s: %w", name, err)
+ }
+ if out == nil || out.GroupId == nil || *out.GroupId == "" {
+ return "", fmt.Errorf("eks: CreateSecurityGroup returned empty GroupId for %s", name)
+ }
+ return *out.GroupId, nil
+}
+
+// EnsureNodegroupSGRules authorizes the ingress rules a nodegroup's workers
+// need to join the K3s control plane and form the flannel overlay. All rules
+// are group-to-group (UserIdGroupPairs) so they stay correct as instances come
+// and go. Idempotent: the InvalidPermission.Duplicate error a re-run triggers
+// for an already-authorized rule is treated as success.
+//
+// Rules:
+// - agent → server apiserver/supervisor: 6443/tcp (ngSG → cpSG)
+// - flannel VXLAN: 8472/udp both directions between cpSG and ngSG, and
+// ngSG ↔ ngSG
+// - kubelet: 10250/tcp (cpSG → ngSG)
+// - intra-nodegroup all traffic: ngSG self-reference
+func EnsureNodegroupSGRules(sgp sgProvisioner, accountID, clusterName, cpSGID, ngSGID string) error {
+ if cpSGID == "" || ngSGID == "" {
+ return errors.New("eks: EnsureNodegroupSGRules empty SG id")
+ }
+
+ type rule struct {
+ targetSG string
+ sourceSG string
+ proto string
+ from int64
+ to int64
+ desc string
+ }
+ rules := []rule{
+ {cpSGID, ngSGID, "tcp", 6443, 6443, "EKS agent to apiserver/supervisor"},
+ {cpSGID, ngSGID, "udp", 8472, 8472, "EKS flannel VXLAN node to control-plane"},
+ {ngSGID, cpSGID, "udp", 8472, 8472, "EKS flannel VXLAN control-plane to node"},
+ {ngSGID, ngSGID, "udp", 8472, 8472, "EKS flannel VXLAN node to node"},
+ {ngSGID, cpSGID, "tcp", 10250, 10250, "EKS kubelet control-plane to node"},
+ {ngSGID, ngSGID, "-1", -1, -1, "EKS intra-nodegroup all traffic"},
+ }
+
+ for _, r := range rules {
+ perm := &ec2.IpPermission{
+ IpProtocol: aws.String(r.proto),
+ UserIdGroupPairs: []*ec2.UserIdGroupPair{{
+ GroupId: aws.String(r.sourceSG),
+ Description: aws.String(r.desc),
+ }},
+ }
+ if r.proto != "-1" {
+ perm.FromPort = aws.Int64(r.from)
+ perm.ToPort = aws.Int64(r.to)
+ }
+ _, err := sgp.AuthorizeSecurityGroupIngress(&ec2.AuthorizeSecurityGroupIngressInput{
+ GroupId: aws.String(r.targetSG),
+ IpPermissions: []*ec2.IpPermission{perm},
+ }, accountID)
+ if err != nil {
+ if awserrors.IsErrorCode(err, awserrors.ErrorInvalidPermissionDuplicate) {
+ continue
+ }
+ return fmt.Errorf("authorize %s ingress on %s: %w", r.desc, r.targetSG, err)
+ }
+ }
+ return nil
+}
+
+// EnsureControlPlaneIngress authorizes the ingress the cluster NLB needs to
+// reach the K3s apiserver. The NLB's backing LB VM forwards the front-end
+// listener to the apiserver on k3sAPIServerPort from inside the VPC, so the
+// control-plane SG must admit that port from the VPC CIDR. Public exposure is
+// gated separately at the NLB front-end by publicAccessCidrs; this rule only
+// governs the in-VPC LB→apiserver hop. Idempotent: the
+// InvalidPermission.Duplicate error a re-run triggers is treated as success.
+func EnsureControlPlaneIngress(sgp sgProvisioner, accountID, cpSGID, vpcCIDR string) error {
+ if cpSGID == "" {
+ return errors.New("eks: EnsureControlPlaneIngress empty control-plane SG id")
+ }
+ if vpcCIDR == "" {
+ return errors.New("eks: EnsureControlPlaneIngress empty vpc cidr")
+ }
+
+ perm := &ec2.IpPermission{
+ IpProtocol: aws.String("tcp"),
+ FromPort: aws.Int64(k3sAPIServerPort),
+ ToPort: aws.Int64(k3sAPIServerPort),
+ IpRanges: []*ec2.IpRange{{
+ CidrIp: aws.String(vpcCIDR),
+ Description: aws.String("EKS NLB to apiserver"),
+ }},
+ }
+ _, err := sgp.AuthorizeSecurityGroupIngress(&ec2.AuthorizeSecurityGroupIngressInput{
+ GroupId: aws.String(cpSGID),
+ IpPermissions: []*ec2.IpPermission{perm},
+ }, accountID)
+ if err != nil && !awserrors.IsErrorCode(err, awserrors.ErrorInvalidPermissionDuplicate) {
+ return fmt.Errorf("authorize control-plane apiserver ingress on %s: %w", cpSGID, err)
+ }
+ return nil
+}
+
+func lookupSGByName(sgp sgProvisioner, accountID, vpcID, name string) (string, error) {
+ out, err := sgp.DescribeSecurityGroups(&ec2.DescribeSecurityGroupsInput{
+ Filters: []*ec2.Filter{
+ {Name: aws.String("group-name"), Values: aws.StringSlice([]string{name})},
+ {Name: aws.String("vpc-id"), Values: aws.StringSlice([]string{vpcID})},
+ },
+ }, accountID)
+ if err != nil {
+ return "", fmt.Errorf("describe SG %s: %w", name, err)
+ }
+ for _, g := range out.SecurityGroups {
+ if g == nil || g.GroupId == nil {
+ continue
+ }
+ return *g.GroupId, nil
+ }
+ return "", nil
+}
diff --git a/spinifex/handlers/eks/security_groups_test.go b/spinifex/handlers/eks/security_groups_test.go
new file mode 100644
index 000000000..41ff851f2
--- /dev/null
+++ b/spinifex/handlers/eks/security_groups_test.go
@@ -0,0 +1,257 @@
+package handlers_eks
+
+import (
+ "errors"
+ "testing"
+
+ "github.com/aws/aws-sdk-go/aws"
+ "github.com/aws/aws-sdk-go/service/ec2"
+ "github.com/mulgadc/spinifex/spinifex/awserrors"
+ "github.com/mulgadc/spinifex/spinifex/tags"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+type fakeSGProvisioner struct {
+ createCalls []*ec2.CreateSecurityGroupInput
+ describeCalls []*ec2.DescribeSecurityGroupsInput
+ deleteCalls []*ec2.DeleteSecurityGroupInput
+ authorizeCalls []*ec2.AuthorizeSecurityGroupIngressInput
+
+ // existing maps "name|vpcId" → groupId for DescribeSecurityGroups lookup.
+ existing map[string]string
+
+ // nextCreateID is returned by the next CreateSecurityGroup call. Tests can
+ // pre-seed a queue via createIDs to differentiate the control-plane vs
+ // nodegroup SG IDs.
+ createIDs []string
+
+ createErr error
+ describeErr error
+ deleteErr error
+ authorizeErr error
+}
+
+var _ sgProvisioner = (*fakeSGProvisioner)(nil)
+
+func newFakeSGProvisioner() *fakeSGProvisioner {
+ return &fakeSGProvisioner{existing: map[string]string{}}
+}
+
+func (f *fakeSGProvisioner) CreateSecurityGroup(input *ec2.CreateSecurityGroupInput, _ string) (*ec2.CreateSecurityGroupOutput, error) {
+ f.createCalls = append(f.createCalls, input)
+ if f.createErr != nil {
+ return nil, f.createErr
+ }
+ id := "sg-default"
+ if len(f.createIDs) > 0 {
+ id = f.createIDs[0]
+ f.createIDs = f.createIDs[1:]
+ }
+ if input.GroupName != nil && input.VpcId != nil {
+ f.existing[*input.GroupName+"|"+*input.VpcId] = id
+ }
+ return &ec2.CreateSecurityGroupOutput{GroupId: aws.String(id)}, nil
+}
+
+func (f *fakeSGProvisioner) DescribeSecurityGroups(input *ec2.DescribeSecurityGroupsInput, _ string) (*ec2.DescribeSecurityGroupsOutput, error) {
+ f.describeCalls = append(f.describeCalls, input)
+ if f.describeErr != nil {
+ return nil, f.describeErr
+ }
+ var name, vpc string
+ for _, filt := range input.Filters {
+ if filt == nil || filt.Name == nil || len(filt.Values) == 0 || filt.Values[0] == nil {
+ continue
+ }
+ switch *filt.Name {
+ case "group-name":
+ name = *filt.Values[0]
+ case "vpc-id":
+ vpc = *filt.Values[0]
+ }
+ }
+ id, ok := f.existing[name+"|"+vpc]
+ out := &ec2.DescribeSecurityGroupsOutput{}
+ if ok {
+ out.SecurityGroups = []*ec2.SecurityGroup{{GroupId: aws.String(id), GroupName: aws.String(name), VpcId: aws.String(vpc)}}
+ }
+ return out, nil
+}
+
+func (f *fakeSGProvisioner) DeleteSecurityGroup(input *ec2.DeleteSecurityGroupInput, _ string) (*ec2.DeleteSecurityGroupOutput, error) {
+ f.deleteCalls = append(f.deleteCalls, input)
+ if f.deleteErr != nil {
+ return nil, f.deleteErr
+ }
+ return &ec2.DeleteSecurityGroupOutput{}, nil
+}
+
+func (f *fakeSGProvisioner) AuthorizeSecurityGroupIngress(input *ec2.AuthorizeSecurityGroupIngressInput, _ string) (*ec2.AuthorizeSecurityGroupIngressOutput, error) {
+ f.authorizeCalls = append(f.authorizeCalls, input)
+ if f.authorizeErr != nil {
+ return nil, f.authorizeErr
+ }
+ return &ec2.AuthorizeSecurityGroupIngressOutput{}, nil
+}
+
+func TestEnsureClusterSGs_EmptyInputsRejected(t *testing.T) {
+ sgp := newFakeSGProvisioner()
+
+ _, _, err := EnsureClusterSGs(sgp, "111122223333", "", "vpc-aaa")
+ require.Error(t, err)
+
+ _, _, err = EnsureClusterSGs(sgp, "111122223333", "alpha", "")
+ require.Error(t, err)
+
+ assert.Empty(t, sgp.createCalls)
+ assert.Empty(t, sgp.describeCalls)
+}
+
+func TestEnsureClusterSGs_FreshCreatesBoth(t *testing.T) {
+ sgp := newFakeSGProvisioner()
+ sgp.createIDs = []string{"sg-cp-001", "sg-ng-002"}
+
+ cpID, ngID, err := EnsureClusterSGs(sgp, "111122223333", "alpha", "vpc-aaa")
+ require.NoError(t, err)
+ assert.Equal(t, "sg-cp-001", cpID)
+ assert.Equal(t, "sg-ng-002", ngID)
+
+ require.Len(t, sgp.createCalls, 2)
+
+ assertSGCreateTagged(t, sgp.createCalls[0], "eks-cluster-alpha-control-plane-sg", "vpc-aaa", "alpha", clusterEKSRoleControlPlane)
+ assertSGCreateTagged(t, sgp.createCalls[1], "eks-cluster-alpha-nodegroup-sg", "vpc-aaa", "alpha", clusterEKSRoleNodegroup)
+}
+
+func TestEnsureClusterSGs_IdempotentReusesExisting(t *testing.T) {
+ sgp := newFakeSGProvisioner()
+ sgp.existing["eks-cluster-alpha-control-plane-sg|vpc-aaa"] = "sg-existing-cp"
+ sgp.existing["eks-cluster-alpha-nodegroup-sg|vpc-aaa"] = "sg-existing-ng"
+
+ cpID, ngID, err := EnsureClusterSGs(sgp, "111122223333", "alpha", "vpc-aaa")
+ require.NoError(t, err)
+ assert.Equal(t, "sg-existing-cp", cpID)
+ assert.Equal(t, "sg-existing-ng", ngID)
+ assert.Empty(t, sgp.createCalls, "no create calls expected when SGs already exist")
+}
+
+func TestEnsureClusterSGs_MixedExistenceCreatesMissing(t *testing.T) {
+ sgp := newFakeSGProvisioner()
+ sgp.existing["eks-cluster-alpha-control-plane-sg|vpc-aaa"] = "sg-existing-cp"
+ sgp.createIDs = []string{"sg-new-ng"}
+
+ cpID, ngID, err := EnsureClusterSGs(sgp, "111122223333", "alpha", "vpc-aaa")
+ require.NoError(t, err)
+ assert.Equal(t, "sg-existing-cp", cpID)
+ assert.Equal(t, "sg-new-ng", ngID)
+
+ require.Len(t, sgp.createCalls, 1, "only nodegroup SG should be created")
+ assert.Equal(t, "eks-cluster-alpha-nodegroup-sg", aws.StringValue(sgp.createCalls[0].GroupName))
+}
+
+func TestEnsureClusterSGs_CreateErrorSurfacedFromControlPlane(t *testing.T) {
+ sgp := newFakeSGProvisioner()
+ sgp.createErr = errors.New("vpcd unavailable")
+
+ _, _, err := EnsureClusterSGs(sgp, "111122223333", "alpha", "vpc-aaa")
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), "create SG eks-cluster-alpha-control-plane-sg")
+}
+
+func TestDeleteClusterSGs_DeletesBothExisting(t *testing.T) {
+ sgp := newFakeSGProvisioner()
+ sgp.existing["eks-cluster-alpha-control-plane-sg|vpc-aaa"] = "sg-existing-cp"
+ sgp.existing["eks-cluster-alpha-nodegroup-sg|vpc-aaa"] = "sg-existing-ng"
+
+ err := DeleteClusterSGs(sgp, "111122223333", "alpha", "vpc-aaa")
+ require.NoError(t, err)
+
+ require.Len(t, sgp.deleteCalls, 2)
+ assert.Equal(t, "sg-existing-cp", aws.StringValue(sgp.deleteCalls[0].GroupId))
+ assert.Equal(t, "sg-existing-ng", aws.StringValue(sgp.deleteCalls[1].GroupId))
+}
+
+func TestDeleteClusterSGs_MissingSGsNoOp(t *testing.T) {
+ sgp := newFakeSGProvisioner()
+
+ err := DeleteClusterSGs(sgp, "111122223333", "alpha", "vpc-aaa")
+ require.NoError(t, err)
+ assert.Empty(t, sgp.deleteCalls, "no delete calls expected when SGs already absent")
+}
+
+func TestDeleteClusterSGs_FirstErrorSurfacedSweepContinues(t *testing.T) {
+ sgp := newFakeSGProvisioner()
+ sgp.existing["eks-cluster-alpha-control-plane-sg|vpc-aaa"] = "sg-existing-cp"
+ sgp.existing["eks-cluster-alpha-nodegroup-sg|vpc-aaa"] = "sg-existing-ng"
+ sgp.deleteErr = errors.New("DependencyViolation")
+
+ err := DeleteClusterSGs(sgp, "111122223333", "alpha", "vpc-aaa")
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), "sg-existing-cp")
+ assert.Len(t, sgp.deleteCalls, 2, "both delete calls should be attempted despite first error")
+}
+
+func TestEnsureControlPlaneIngress_AuthorizesAPIServerFromVPCCIDR(t *testing.T) {
+ sgp := newFakeSGProvisioner()
+
+ err := EnsureControlPlaneIngress(sgp, "111122223333", "sg-cp-001", "10.0.0.0/16")
+ require.NoError(t, err)
+
+ require.Len(t, sgp.authorizeCalls, 1)
+ in := sgp.authorizeCalls[0]
+ assert.Equal(t, "sg-cp-001", aws.StringValue(in.GroupId))
+ require.Len(t, in.IpPermissions, 1)
+ perm := in.IpPermissions[0]
+ assert.Equal(t, "tcp", aws.StringValue(perm.IpProtocol))
+ assert.Equal(t, k3sAPIServerPort, aws.Int64Value(perm.FromPort))
+ assert.Equal(t, k3sAPIServerPort, aws.Int64Value(perm.ToPort))
+ require.Len(t, perm.IpRanges, 1)
+ assert.Equal(t, "10.0.0.0/16", aws.StringValue(perm.IpRanges[0].CidrIp))
+}
+
+func TestEnsureControlPlaneIngress_EmptyInputsRejected(t *testing.T) {
+ sgp := newFakeSGProvisioner()
+
+ require.Error(t, EnsureControlPlaneIngress(sgp, "111122223333", "", "10.0.0.0/16"))
+ require.Error(t, EnsureControlPlaneIngress(sgp, "111122223333", "sg-cp-001", ""))
+ assert.Empty(t, sgp.authorizeCalls)
+}
+
+func TestEnsureControlPlaneIngress_DuplicateTolerated(t *testing.T) {
+ sgp := newFakeSGProvisioner()
+ sgp.authorizeErr = errors.New(awserrors.ErrorInvalidPermissionDuplicate)
+
+ err := EnsureControlPlaneIngress(sgp, "111122223333", "sg-cp-001", "10.0.0.0/16")
+ require.NoError(t, err, "a duplicate rule on re-run must be treated as success")
+}
+
+func TestEnsureControlPlaneIngress_AuthorizeErrorSurfaced(t *testing.T) {
+ sgp := newFakeSGProvisioner()
+ sgp.authorizeErr = errors.New("vpcd unavailable")
+
+ err := EnsureControlPlaneIngress(sgp, "111122223333", "sg-cp-001", "10.0.0.0/16")
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), "sg-cp-001")
+}
+
+func assertSGCreateTagged(t *testing.T, in *ec2.CreateSecurityGroupInput, name, vpcID, clusterName, role string) {
+ t.Helper()
+ require.NotNil(t, in)
+ assert.Equal(t, name, aws.StringValue(in.GroupName))
+ assert.Equal(t, vpcID, aws.StringValue(in.VpcId))
+ require.Len(t, in.TagSpecifications, 1)
+ spec := in.TagSpecifications[0]
+ require.NotNil(t, spec)
+ assert.Equal(t, "security-group", aws.StringValue(spec.ResourceType))
+
+ got := map[string]string{}
+ for _, tg := range spec.Tags {
+ if tg == nil || tg.Key == nil || tg.Value == nil {
+ continue
+ }
+ got[*tg.Key] = *tg.Value
+ }
+ assert.Equal(t, tags.ManagedByEKS, got[tags.ManagedByKey])
+ assert.Equal(t, clusterName, got[clusterEKSClusterTagKey])
+ assert.Equal(t, role, got[clusterEKSRoleTagKey])
+}
diff --git a/spinifex/handlers/eks/service.go b/spinifex/handlers/eks/service.go
index 96f793e39..aaf17ede8 100644
--- a/spinifex/handlers/eks/service.go
+++ b/spinifex/handlers/eks/service.go
@@ -11,8 +11,10 @@ import "github.com/aws/aws-sdk-go/service/eks"
// (PascalCase). Signatures use the aws-sdk-go input/output types verbatim so
// downstream callers can pass straight through from generated SDK code.
type EKSService interface {
- // Cluster
- CreateCluster(input *eks.CreateClusterInput, accountID string) (*eks.CreateClusterOutput, error)
+ // Cluster. CreateCluster also takes the caller's resolved IAM principal ARN
+ // (from the X-Principal-ARN header) so it can mint the bootstrap
+ // cluster-creator-admin AccessEntry; "" skips that step.
+ CreateCluster(input *eks.CreateClusterInput, accountID, callerPrincipalARN string) (*eks.CreateClusterOutput, error)
DescribeCluster(input *eks.DescribeClusterInput, accountID string) (*eks.DescribeClusterOutput, error)
ListClusters(input *eks.ListClustersInput, accountID string) (*eks.ListClustersOutput, error)
UpdateClusterConfig(input *eks.UpdateClusterConfigInput, accountID string) (*eks.UpdateClusterConfigOutput, error)
diff --git a/spinifex/handlers/eks/service_impl.go b/spinifex/handlers/eks/service_impl.go
index 1ff9827e6..899463b94 100644
--- a/spinifex/handlers/eks/service_impl.go
+++ b/spinifex/handlers/eks/service_impl.go
@@ -1,35 +1,123 @@
package handlers_eks
import (
+ "context"
"errors"
"fmt"
+ "log/slog"
+ "net"
+ "sort"
+ "strconv"
+ "strings"
+ "sync"
+ "time"
+ "github.com/aws/aws-sdk-go/aws"
+ "github.com/aws/aws-sdk-go/service/ec2"
"github.com/aws/aws-sdk-go/service/eks"
"github.com/mulgadc/spinifex/spinifex/awserrors"
"github.com/mulgadc/spinifex/spinifex/config"
+ "github.com/mulgadc/spinifex/spinifex/tags"
+ "github.com/mulgadc/spinifex/spinifex/utils"
"github.com/nats-io/nats.go"
)
-// EKSServiceImpl is the daemon-side EKSService. All methods currently return
-// NotImplemented; bodies land alongside the K3s control-plane work.
+// SubnetVPCResolver resolves a subnet ID to its parent VPC ID and the VPC's
+// CIDR block. Narrow so EKSServiceImpl can stay free of the wider
+// handlers/ec2/vpc surface; the daemon adapts VPCServiceImpl onto this
+// contract.
+type SubnetVPCResolver interface {
+ GetSubnetVPC(accountID, subnetID string) (vpcID string, err error)
+ GetVPCCIDR(accountID, vpcID string) (cidr string, err error)
+}
+
+// EKSServiceDeps wires every external collaborator EKSServiceImpl needs.
+// The narrow per-area interfaces (sgProvisioner, nlbProvisioner, etc.) are
+// already defined alongside their consumers; the daemon adapts each
+// service's concrete type onto the interface.
+type EKSServiceDeps struct {
+ Config *config.Config
+ NATSConn *nats.Conn
+ MasterKey []byte
+ GatewayBaseURL string
+ Region string
+ HolderID string
+
+ // Gateway broker config handed to the K3s server VM so it can publish its
+ // bootstrap envelopes + state reports via SigV4-signed HTTPS POST to the AWS
+ // gateway (the ELBv2 lb-agent model) instead of dialing core NATS.
+ // SystemGatewayURL is the mgmt-reachable AWSGW endpoint (distinct from
+ // GatewayBaseURL, which is the OIDC issuer host); SystemAccessKey/SecretKey
+ // are the system (Predastore) SigV4 creds; GatewayCACert is the PEM that
+ // signs the gateway server cert.
+ SystemGatewayURL string
+ SystemAccessKey string
+ SystemSecretKey string
+ GatewayCACert string
+
+ VPCSG sgProvisioner
+ VPCK3s k3sVPCProvisioner
+ VPCSubnet SubnetVPCResolver
+ NLB nlbProvisioner
+ Instance k3sInstanceLauncher
+ Image k3sAMIResolver
+ EIP eipProvisioner
+ Worker WorkerLauncher
+
+ // AddonInstaller delivers managed-addon manifests to clusters. Optional:
+ // when nil the service defaults to the KV staging installer
+ // (newStagingInstaller) bound to NATSConn.
+ AddonInstaller AddonInstaller
+}
+
+// WorkerLauncher is the narrow EC2 surface CreateNodegroup needs to launch and
+// terminate nodegroup worker instances. Workers go through the normal
+// customer-owned RunInstances path (no ManagedBy tag, no mgmt NIC) so they are
+// visible in DescribeInstances and reclaimed by TerminateInstances like any
+// other customer EC2. The daemon adapts its concrete instance service onto this.
+// Exported so the daemon can assert *Daemon satisfies it with a compile-time
+// check.
+type WorkerLauncher interface {
+ RunWorkerInstance(input *ec2.RunInstancesInput, accountID string) (*ec2.Reservation, error)
+ TerminateWorkerInstances(instanceIDs []string, accountID string) error
+}
+
+// eipProvisioner is the narrow EIP surface CreateCluster needs to give the
+// hidden K3s control-plane VM an egress-only public IP (the distributed-NAT
+// dev/edge topology has no shared masquerade address). The daemon adapts the
+// concrete EIP service onto this.
+type eipProvisioner interface {
+ AllocateAddress(input *ec2.AllocateAddressInput, accountID string) (*ec2.AllocateAddressOutput, error)
+ ReleaseAddress(input *ec2.ReleaseAddressInput, accountID string) (*ec2.ReleaseAddressOutput, error)
+}
+
+// EKSServiceImpl is the daemon-side EKSService. CreateCluster /
+// DescribeCluster / ListClusters / DeleteCluster have real bodies; every
+// other action is still NotImplemented pending follow-up sprints.
type EKSServiceImpl struct {
- config *config.Config
- nc *nats.Conn
- store *Store
+ deps EKSServiceDeps
leaderKV nats.KeyValue
+ registry *ReconcilerRegistry
+
+ mu sync.Mutex
+ bgCtx context.Context
+ bgCancel context.CancelFunc
}
var _ EKSService = (*EKSServiceImpl)(nil)
-// NewEKSServiceImplWithNATS wires the Store handle and creates the shared
-// spinifex-eks-leader bucket so the leader-lease CAS loop has a ready bucket
-// to CAS against without a chicken-and-egg bootstrap.
-func NewEKSServiceImplWithNATS(cfg *config.Config, nc *nats.Conn) (*EKSServiceImpl, error) {
- store, err := NewStore(nc)
- if err != nil {
- return nil, fmt.Errorf("failed to create EKS store: %w", err)
+const defaultK8sVersion = "1.32"
+
+// NewEKSServiceImpl wires the Store, leader bucket, reconciler registry, and
+// per-cluster background-goroutine context. Validates that MasterKey,
+// GatewayBaseURL, Region, and HolderID are set when called from the live
+// daemon path; the legacy NewEKSServiceImplWithNATS shim leaves them empty
+// and bodies that need them return ServerInternal.
+func NewEKSServiceImpl(deps EKSServiceDeps) (*EKSServiceImpl, error) {
+ if deps.NATSConn == nil {
+ return nil, errors.New("eks: NewEKSServiceImpl nil NATSConn")
}
- js, err := nc.JetStream()
+ js, err := deps.NATSConn.JetStream()
if err != nil {
return nil, fmt.Errorf("failed to get JetStream context: %w", err)
}
@@ -37,28 +125,626 @@ func NewEKSServiceImplWithNATS(cfg *config.Config, nc *nats.Conn) (*EKSServiceIm
if err != nil {
return nil, err
}
+ ctx, cancel := context.WithCancel(context.Background())
return &EKSServiceImpl{
- config: cfg,
- nc: nc,
- store: store,
+ deps: deps,
leaderKV: leaderKV,
+ registry: NewReconcilerRegistry(),
+ bgCtx: ctx,
+ bgCancel: cancel,
}, nil
}
-func notImpl() error { return errors.New(awserrors.ErrorNotImplemented) }
+// NewEKSServiceImplWithNATS is a back-compat shim. Bodies that need
+// orchestration deps return ServerInternal when invoked through this shim;
+// the daemon-handler routing test calls it to verify NATS subject wiring
+// without standing up the full dependency graph.
+func NewEKSServiceImplWithNATS(cfg *config.Config, nc *nats.Conn) (*EKSServiceImpl, error) {
+ return NewEKSServiceImpl(EKSServiceDeps{Config: cfg, NATSConn: nc})
+}
+
+// Shutdown stops the per-cluster reconciler goroutines and cancels every
+// background bootstrap goroutine. Safe to call multiple times.
+func (s *EKSServiceImpl) Shutdown() {
+ s.mu.Lock()
+ cancel := s.bgCancel
+ s.mu.Unlock()
+ if cancel != nil {
+ cancel()
+ }
+ s.registry.StopAll()
+}
+
+// SpawnRegisteredReconcilers enumerates every cluster in every per-account
+// bucket and registers a reconciler goroutine for each cluster in
+// CREATING or ACTIVE state. Called by the daemon on boot so a node restart
+// resumes lifecycle reconcile without waiting for the next CreateCluster.
+func (s *EKSServiceImpl) SpawnRegisteredReconcilers() error {
+ if !s.depsReadyForOrchestration() {
+ slog.Debug("SpawnRegisteredReconcilers: deps not ready, skipping")
+ return nil
+ }
+ js, err := s.deps.NATSConn.JetStream()
+ if err != nil {
+ return fmt.Errorf("jetstream: %w", err)
+ }
+ buckets := js.KeyValueStoreNames()
+ for name := range buckets {
+ if !strings.HasPrefix(name, KVBucketEKSAccountPrefix) {
+ continue
+ }
+ accountID := strings.TrimPrefix(name, KVBucketEKSAccountPrefix)
+ acctKV, err := js.KeyValue(name)
+ if err != nil {
+ slog.Warn("SpawnRegisteredReconcilers: open bucket failed", "bucket", name, "err", err)
+ continue
+ }
+ clusters, err := listClusterNames(acctKV)
+ if err != nil {
+ slog.Warn("SpawnRegisteredReconcilers: list clusters failed", "bucket", name, "err", err)
+ continue
+ }
+ for _, cluster := range clusters {
+ meta, err := GetClusterMeta(acctKV, cluster)
+ if err != nil {
+ continue
+ }
+ if meta.Status != ClusterStatusCreating && meta.Status != ClusterStatusActive {
+ continue
+ }
+ // A restart mid-CREATING lost the bootstrap subscriptions; the K3s
+ // VM publishes its one-shot core-NATS messages once and to nobody.
+ // Re-subscribe to whichever artifacts are still missing so the
+ // cluster can still reach ACTIVE instead of hanging in CREATING.
+ if meta.Status == ClusterStatusCreating {
+ if pending := BootstrapPendingKinds(acctKV, cluster, meta); len(pending) > 0 {
+ slog.Info("SpawnRegisteredReconcilers: resuming bootstrap",
+ "cluster", cluster, "pending", pending)
+ s.spawnBootstrap(accountID, cluster, acctKV, pending)
+ }
+ }
+ s.spawnReconciler(accountID, cluster, meta)
+ }
+ }
+ return nil
+}
+
+func (s *EKSServiceImpl) depsReadyForOrchestration() bool {
+ return len(s.missingOrchestrationDeps()) == 0
+}
+
+// requireOrchestrationDeps returns a client-facing ServiceUnavailable (with the
+// specific unmet deps logged at ERROR) when the daemon is missing orchestration
+// wiring, so the failure is diagnosable instead of a bare ServerInternal.
+func (s *EKSServiceImpl) requireOrchestrationDeps(op string) error {
+ missing := s.missingOrchestrationDeps()
+ if len(missing) == 0 {
+ return nil
+ }
+ slog.Error("EKS orchestration deps not ready", "op", op, "missing", missing)
+ return errors.New(awserrors.ErrorServiceUnavailable)
+}
+
+// missingOrchestrationDeps names the deps required for cluster orchestration
+// that are unset, so a "deps not ready" rejection can log the precise cause
+// instead of an opaque ServerInternal whose only hint is a one-time boot WARN.
+func (s *EKSServiceImpl) missingOrchestrationDeps() []string {
+ var missing []string
+ if s.deps.VPCSG == nil {
+ missing = append(missing, "VPCSG")
+ }
+ if s.deps.VPCK3s == nil {
+ missing = append(missing, "VPCK3s")
+ }
+ if s.deps.VPCSubnet == nil {
+ missing = append(missing, "VPCSubnet")
+ }
+ if s.deps.NLB == nil {
+ missing = append(missing, "NLB")
+ }
+ if s.deps.Instance == nil {
+ missing = append(missing, "Instance")
+ }
+ if s.deps.Image == nil {
+ missing = append(missing, "Image")
+ }
+ if s.deps.EIP == nil {
+ missing = append(missing, "EIP")
+ }
+ if s.deps.Worker == nil {
+ missing = append(missing, "Worker")
+ }
+ if len(s.deps.MasterKey) == 0 {
+ missing = append(missing, "MasterKey")
+ }
+ if s.deps.GatewayBaseURL == "" {
+ missing = append(missing, "GatewayBaseURL")
+ }
+ if s.deps.Region == "" {
+ missing = append(missing, "Region")
+ }
+ if s.deps.HolderID == "" {
+ missing = append(missing, "HolderID")
+ }
+ return missing
+}
// --- Cluster ---
-func (s *EKSServiceImpl) CreateCluster(_ *eks.CreateClusterInput, _ string) (*eks.CreateClusterOutput, error) {
- return nil, notImpl()
+// logCreateErr records the precise cause of a CreateCluster step failure at
+// ERROR. The request surface collapses these wrapped errors to an opaque
+// ServerInternal ("An internal error has occurred... contact AWS re:Post"), so
+// without this the wrapped error never reaches the operator. Returns the error
+// wrapped with the stage for the caller to surface.
+func logCreateErr(name, accountID, stage string, err error) error {
+ slog.Error("CreateCluster: "+stage+" failed", "cluster", name, "accountID", accountID, "err", err)
+ return fmt.Errorf("%s: %w", stage, err)
}
-func (s *EKSServiceImpl) DescribeCluster(_ *eks.DescribeClusterInput, _ string) (*eks.DescribeClusterOutput, error) {
- return nil, notImpl()
+// managedIngressTagKey is the CreateCluster tag that controls K3s' bundled
+// traefik + servicelb (interim in-VPC app exposure). They are ON by default so
+// workloads are reachable before the AWS Load Balancer Controller add-on ships;
+// value "false" (case-insensitive) opts out for AWS parity. TODO: flip the
+// default back off (parity) once the aws-lb-controller add-on lands — see
+// docs/development/feature/eks-dataplane-ingress.md.
+const managedIngressTagKey = "spinifex.io/managed-ingress"
+
+// builtinIngressEnabled reports whether a cluster keeps the K3s built-in ingress
+// stack. Default ON; the tag opts out with "false".
+func builtinIngressEnabled(tags map[string]*string) bool {
+ return !strings.EqualFold(aws.StringValue(tags[managedIngressTagKey]), "false")
}
-func (s *EKSServiceImpl) ListClusters(_ *eks.ListClustersInput, _ string) (*eks.ListClustersOutput, error) {
- return nil, notImpl()
+func (s *EKSServiceImpl) CreateCluster(input *eks.CreateClusterInput, accountID, callerPrincipalARN string) (*eks.CreateClusterOutput, error) {
+ if err := s.requireOrchestrationDeps("CreateCluster"); err != nil {
+ return nil, err
+ }
+ if err := validateCreateClusterInput(input); err != nil {
+ return nil, err
+ }
+ name := aws.StringValue(input.Name)
+ subnetIDs := aws.StringValueSlice(input.ResourcesVpcConfig.SubnetIds)
+
+ js, err := s.deps.NATSConn.JetStream()
+ if err != nil {
+ return nil, logCreateErr(name, accountID, "jetstream", err)
+ }
+ acctKV, err := GetOrCreateAccountBucket(js, accountID)
+ if err != nil {
+ return nil, logCreateErr(name, accountID, "get account bucket", err)
+ }
+
+ if existing, err := GetClusterMeta(acctKV, name); err == nil {
+ if existing.Status != ClusterStatusFailed {
+ slog.Info("CreateCluster: cluster already exists",
+ "name", name, "accountID", accountID, "status", existing.Status)
+ return nil, errors.New(awserrors.ErrorEKSResourceInUse)
+ }
+ // A prior attempt failed. Reclaim whatever it left behind and clear its
+ // state so this create starts clean — makes a retry (including the
+ // SDK's own auto-retry) idempotent instead of masking the original
+ // failure with a spurious "already exists" error. If teardown of the
+ // failed attempt's resources fails, surface that rather than recreating
+ // on top of orphans.
+ slog.Info("CreateCluster: reclaiming FAILED cluster before recreate",
+ "name", name, "accountID", accountID)
+ if perr := s.purgeClusterInfra(accountID, name, existing, acctKV); perr != nil {
+ return nil, logCreateErr(name, accountID, "reclaim failed cluster", perr)
+ }
+ } else if !errors.Is(err, ErrClusterNotFound) {
+ return nil, logCreateErr(name, accountID, "preflight get meta", err)
+ }
+
+ vpcID, err := s.deps.VPCSubnet.GetSubnetVPC(accountID, subnetIDs[0])
+ if err != nil {
+ // Subnet is a caller-supplied parameter — a resolve failure is a client
+ // fault (bad/foreign subnet), not an internal one. Surface it as such
+ // instead of collapsing to a retryable ServerInternal the SDK retries.
+ slog.Error("CreateCluster: resolve subnet VPC failed",
+ "cluster", name, "accountID", accountID, "subnet", subnetIDs[0], "err", err)
+ return nil, errors.New(awserrors.ErrorInvalidParameterValue)
+ }
+
+ region := s.deps.Region
+ arn := fmt.Sprintf("arn:aws:eks:%s:%s:cluster/%s", region, accountID, name)
+
+ publicAccess, privateAccess := endpointAccess(input.ResourcesVpcConfig)
+ publicCidrs := publicAccessCidrs(input.ResourcesVpcConfig, publicAccess)
+
+ meta := &ClusterMeta{
+ Name: name,
+ Arn: arn,
+ Status: ClusterStatusCreating,
+ Version: deref(input.Version, defaultK8sVersion),
+ RoleArn: aws.StringValue(input.RoleArn),
+ ResourcesVpcConfig: &ClusterVpcConfig{
+ SubnetIds: subnetIDs,
+ VpcId: vpcID,
+ EndpointPublicAccess: publicAccess,
+ EndpointPrivateAccess: privateAccess,
+ PublicAccessCidrs: publicCidrs,
+ },
+ BuiltinIngress: builtinIngressEnabled(input.Tags),
+ CreatedAt: time.Now().UTC(),
+ }
+ if err := PutClusterMeta(acctKV, meta); err != nil {
+ return nil, logCreateErr(name, accountID, "persist initial meta", err)
+ }
+
+ cpSG, ngSG, err := EnsureClusterSGs(s.deps.VPCSG, accountID, name, vpcID)
+ if err != nil {
+ s.markFailed(acctKV, name)
+ return nil, logCreateErr(name, accountID, "ensure cluster SGs", err)
+ }
+ meta.ResourcesVpcConfig.SecurityGroupIds = []string{cpSG, ngSG}
+
+ // The NLB's backing LB VM forwards the published endpoint to the apiserver
+ // from inside the VPC, so the control-plane SG must admit that hop or the
+ // NLB target stays unhealthy and the endpoint never serves.
+ vpcCIDR, err := s.deps.VPCSubnet.GetVPCCIDR(accountID, vpcID)
+ if err != nil {
+ s.markFailed(acctKV, name)
+ return nil, logCreateErr(name, accountID, "resolve vpc cidr", err)
+ }
+ if err := EnsureControlPlaneIngress(s.deps.VPCSG, accountID, cpSG, vpcCIDR); err != nil {
+ s.markFailed(acctKV, name)
+ return nil, logCreateErr(name, accountID, "ensure control-plane ingress", err)
+ }
+
+ // Public access ⇒ internet-facing NLB (external-pool front-end IP, reachable
+ // on the LAN/edge network); private-only ⇒ internal NLB (VPC-only).
+ nlb, err := EnsureClusterNLB(s.deps.NLB, accountID, name, subnetIDs, publicAccess, publicCidrs)
+ if err != nil {
+ s.markFailed(acctKV, name)
+ return nil, logCreateErr(name, accountID, "ensure cluster NLB", err)
+ }
+ // Prefer the reachable front-end IP as the kubeconfig endpoint host so the
+ // apiserver serving cert (which SANs this IP) validates with TLS verification
+ // on. Fall back to the synthetic NLB DNS name when no IP was read back.
+ endpointHost := nlb.FrontendIP
+ if endpointHost == "" {
+ endpointHost = nlb.DNSName
+ }
+ meta.Endpoint = "https://" + net.JoinHostPort(endpointHost, strconv.FormatInt(clusterNLBListenPort, 10))
+ meta.EndpointIP = nlb.FrontendIP
+ meta.NLBArn = nlb.LoadBalancerArn
+ meta.NLBTargetGroupArn = nlb.TargetGroupArn
+
+ // Persist the NLB ARNs now, before any further fallible step. Without this
+ // a failure between here and the final PutClusterMeta would leave the NLB +
+ // target group + listener + backing LB VM created but unrecorded, so
+ // DeleteCluster (which keys teardown off the persisted ARNs) could not
+ // reclaim them.
+ if err := PutClusterMeta(acctKV, meta); err != nil {
+ s.markFailed(acctKV, name)
+ return nil, logCreateErr(name, accountID, "persist NLB arns", err)
+ }
+
+ oidcIssuer, err := ClusterOIDCIssuer(s.deps.GatewayBaseURL, region, accountID, name)
+ if err != nil {
+ s.markFailed(acctKV, name)
+ return nil, logCreateErr(name, accountID, "build OIDC issuer", err)
+ }
+ meta.OIDCIssuer = oidcIssuer
+
+ privPEM, _, err := GenerateClusterOIDCKeypair(acctKV, name, s.deps.MasterKey)
+ if err != nil {
+ s.markFailed(acctKV, name)
+ return nil, logCreateErr(name, accountID, "generate OIDC keypair", err)
+ }
+ pubPEM, err := PublicKeyPEMFromPrivate(privPEM)
+ if err != nil {
+ s.markFailed(acctKV, name)
+ return nil, logCreateErr(name, accountID, "derive OIDC public key", err)
+ }
+
+ k3sOut, err := LaunchK3sServerVM(s.deps.VPCK3s, s.deps.Instance, s.deps.Image, K3sServerInput{
+ AccountID: accountID,
+ ClusterName: name,
+ Region: region,
+ SubnetID: subnetIDs[0],
+ ControlPlaneSGID: cpSG,
+ NLBDNS: nlb.DNSName,
+ EndpointIP: nlb.FrontendIP,
+ OIDCIssuer: oidcIssuer,
+ OIDCPrivateKeyPEM: privPEM,
+ OIDCPublicKeyPEM: pubPEM,
+ GatewayURL: s.deps.SystemGatewayURL,
+ AccessKey: s.deps.SystemAccessKey,
+ SecretKey: s.deps.SystemSecretKey,
+ GatewayCACert: s.deps.GatewayCACert,
+ BuiltinIngress: meta.BuiltinIngress,
+ })
+ if err != nil {
+ s.markFailed(acctKV, name)
+ if errors.Is(err, ErrEKSServerAMINotFound) {
+ slog.Error("CreateCluster: eks-server AMI not found; cannot launch control plane",
+ "cluster", name, "accountID", accountID, "err", err)
+ return nil, errors.New(awserrors.ErrorServiceUnavailable)
+ }
+ slog.Error("CreateCluster: K3s control-plane VM launch failed",
+ "cluster", name, "accountID", accountID, "err", err)
+ return nil, fmt.Errorf("launch K3s VM: %w", err)
+ }
+ meta.ControlPlaneInstanceID = k3sOut.InstanceID
+ meta.ControlPlaneENIID = k3sOut.ENIID
+ meta.ControlPlaneENIIP = k3sOut.ENIIP
+ meta.ControlPlaneMgmtIP = k3sOut.MgmtIP
+
+ // Egress: the control-plane VM must pull container images. Allocate a
+ // hidden pool address and wire an egress-only SNAT for its /32 (no DNAT —
+ // the VM stays unreachable inbound; the NLB is its only front door).
+ egressIP, egressAllocID, err := s.allocateClusterEgressIP(accountID, name)
+ if err != nil {
+ s.markFailed(acctKV, name)
+ return nil, logCreateErr(name, accountID, "allocate cluster egress IP", err)
+ }
+ meta.EgressEIPAllocationID = egressAllocID
+ meta.EgressEIPPublicIP = egressIP
+ utils.PublishEvent(s.deps.NATSConn, "vpc.add-system-egress", systemEgressEvent{
+ VpcId: vpcID,
+ SubnetId: subnetIDs[0],
+ InstanceIp: k3sOut.ENIIP,
+ ExternalIp: egressIP,
+ })
+
+ // Record the VM + ENI + egress IP before registering the target so a failed
+ // register (or anything after) leaves them recoverable by DeleteCluster.
+ if err := PutClusterMeta(acctKV, meta); err != nil {
+ s.markFailed(acctKV, name)
+ return nil, logCreateErr(name, accountID, "persist control-plane ids", err)
+ }
+
+ if err := RegisterClusterTarget(s.deps.NLB, accountID, nlb.TargetGroupArn, k3sOut.ENIIP); err != nil {
+ s.markFailed(acctKV, name)
+ return nil, logCreateErr(name, accountID, "register NLB target", err)
+ }
+
+ if err := PutClusterMeta(acctKV, meta); err != nil {
+ s.markFailed(acctKV, name)
+ return nil, logCreateErr(name, accountID, "persist final meta", err)
+ }
+
+ // bootstrapClusterCreatorAdminPermissions (default true): grant the caller
+ // system:masters via an AccessEntry so the cluster creator can immediately
+ // authenticate through the token webhook (Q9). Keyed by the caller's exact
+ // principal ARN — the webhook looks the entry up by the same ARN STS returns.
+ if bootstrapCreatorAdmin(input) && callerPrincipalARN != "" {
+ rec := newAccessEntryRecord(region, accountID, name, callerPrincipalARN, "",
+ []string{"system:masters"}, AccessEntryTypeStandard, nil, time.Now().UTC())
+ if err := PutAccessEntryRecord(acctKV, rec); err != nil {
+ s.markFailed(acctKV, name)
+ return nil, fmt.Errorf("seed cluster-creator admin access entry: %w", err)
+ }
+ } else if bootstrapCreatorAdmin(input) {
+ slog.Warn("CreateCluster: bootstrapClusterCreatorAdminPermissions set but caller principal ARN unknown; skipping creator-admin AccessEntry",
+ "cluster", name, "accountID", accountID)
+ }
+
+ s.spawnBootstrap(accountID, name, acctKV, nil)
+ s.spawnReconciler(accountID, name, meta)
+
+ return &eks.CreateClusterOutput{Cluster: clusterMetaToAWS(meta)}, nil
+}
+
+// bootstrapCreatorAdmin reports whether the cluster-creator-admin AccessEntry
+// should be minted. AWS defaults this to true when unspecified.
+func bootstrapCreatorAdmin(input *eks.CreateClusterInput) bool {
+ if input.AccessConfig == nil || input.AccessConfig.BootstrapClusterCreatorAdminPermissions == nil {
+ return true
+ }
+ return *input.AccessConfig.BootstrapClusterCreatorAdminPermissions
+}
+
+// systemEgressEvent is the wire shape for vpc.add-system-egress /
+// vpc.delete-system-egress (mirrors network/subscribers.SystemEgressEvent;
+// kept local to avoid importing the network layer from a handler).
+type systemEgressEvent struct {
+ VpcId string `json:"vpc_id"`
+ SubnetId string `json:"subnet_id"`
+ InstanceIp string `json:"instance_ip"`
+ ExternalIp string `json:"external_ip"`
+}
+
+// allocateClusterEgressIP allocates a hidden pool address for the cluster's
+// control-plane VM egress, tagged ManagedBy=eks so it stays out of customer
+// DescribeAddresses listings.
+func (s *EKSServiceImpl) allocateClusterEgressIP(accountID, name string) (publicIP, allocationID string, err error) {
+ out, err := s.deps.EIP.AllocateAddress(&ec2.AllocateAddressInput{
+ Domain: aws.String("vpc"),
+ TagSpecifications: []*ec2.TagSpecification{{
+ ResourceType: aws.String("elastic-ip"),
+ Tags: []*ec2.Tag{{Key: aws.String(tags.ManagedByKey), Value: aws.String(tags.ManagedByEKS)}},
+ }},
+ }, accountID)
+ if err != nil {
+ return "", "", err
+ }
+ if out == nil || aws.StringValue(out.PublicIp) == "" || aws.StringValue(out.AllocationId) == "" {
+ return "", "", errors.New("eks: AllocateAddress returned incomplete EIP")
+ }
+ return aws.StringValue(out.PublicIp), aws.StringValue(out.AllocationId), nil
+}
+
+func (s *EKSServiceImpl) DescribeCluster(input *eks.DescribeClusterInput, accountID string) (*eks.DescribeClusterOutput, error) {
+ name := aws.StringValue(input.Name)
+ if name == "" {
+ return nil, errors.New(awserrors.ErrorInvalidParameterValue)
+ }
+ js, err := s.deps.NATSConn.JetStream()
+ if err != nil {
+ return nil, fmt.Errorf("jetstream: %w", err)
+ }
+ acctKV, err := GetOrCreateAccountBucket(js, accountID)
+ if err != nil {
+ return nil, fmt.Errorf("get account bucket: %w", err)
+ }
+ meta, err := GetClusterMeta(acctKV, name)
+ if err != nil {
+ if errors.Is(err, ErrClusterNotFound) {
+ return nil, errors.New(awserrors.ErrorEKSResourceNotFound)
+ }
+ return nil, err
+ }
+ return &eks.DescribeClusterOutput{Cluster: clusterMetaToAWS(meta)}, nil
+}
+
+func (s *EKSServiceImpl) ListClusters(input *eks.ListClustersInput, accountID string) (*eks.ListClustersOutput, error) {
+ js, err := s.deps.NATSConn.JetStream()
+ if err != nil {
+ return nil, eksReadUnavailableOr(err, "jetstream")
+ }
+ acctKV, err := GetOrCreateAccountBucket(js, accountID)
+ if err != nil {
+ return nil, eksReadUnavailableOr(err, "get account bucket")
+ }
+ names, err := listClusterNames(acctKV)
+ if err != nil {
+ return nil, eksReadUnavailableOr(err, "list cluster names")
+ }
+ sort.Strings(names)
+
+ maxResults := int(aws.Int64Value(input.MaxResults))
+ if maxResults <= 0 || maxResults > 100 {
+ maxResults = 100
+ }
+ startToken := aws.StringValue(input.NextToken)
+
+ page := make([]string, 0, maxResults)
+ var nextToken string
+ startIdx := 0
+ if startToken != "" {
+ startIdx = sort.SearchStrings(names, startToken)
+ }
+ endIdx := min(startIdx+maxResults, len(names))
+ page = append(page, names[startIdx:endIdx]...)
+ if endIdx < len(names) {
+ nextToken = names[endIdx]
+ }
+
+ out := &eks.ListClustersOutput{Clusters: aws.StringSlice(page)}
+ if nextToken != "" {
+ out.NextToken = aws.String(nextToken)
+ }
+ return out, nil
+}
+
+func (s *EKSServiceImpl) DeleteCluster(input *eks.DeleteClusterInput, accountID string) (*eks.DeleteClusterOutput, error) {
+ if err := s.requireOrchestrationDeps("DeleteCluster"); err != nil {
+ return nil, err
+ }
+ name := aws.StringValue(input.Name)
+ if name == "" {
+ return nil, errors.New(awserrors.ErrorInvalidParameterValue)
+ }
+ js, err := s.deps.NATSConn.JetStream()
+ if err != nil {
+ return nil, fmt.Errorf("jetstream: %w", err)
+ }
+ acctKV, err := GetOrCreateAccountBucket(js, accountID)
+ if err != nil {
+ return nil, fmt.Errorf("get account bucket: %w", err)
+ }
+
+ meta, err := GetClusterMeta(acctKV, name)
+ if err != nil {
+ if errors.Is(err, ErrClusterNotFound) {
+ return nil, errors.New(awserrors.ErrorEKSResourceNotFound)
+ }
+ return nil, err
+ }
+
+ if err := SetClusterStatus(acctKV, name, ClusterStatusDeleting); err != nil {
+ return nil, fmt.Errorf("set DELETING: %w", err)
+ }
+ meta.Status = ClusterStatusDeleting
+
+ if err := s.purgeClusterInfra(accountID, name, meta, acctKV); err != nil {
+ slog.Error("DeleteCluster: teardown incomplete; leaving cluster DELETING for retry",
+ "cluster", name, "err", err)
+ return nil, fmt.Errorf("eks: DeleteCluster %s: %w", name, err)
+ }
+
+ return &eks.DeleteClusterOutput{Cluster: clusterMetaToAWS(meta)}, nil
+}
+
+// purgeClusterInfra tears down a cluster's live AWS resources and erases its
+// per-cluster KV state. Zeroize (a security guarantee — no recoverable key
+// material) plus NLB and VM teardown are blocking: their failures are joined
+// and returned BEFORE the KV sweep, so a billable resource is never orphaned
+// without the meta record that owns its ARNs/IDs. SG cleanup is best-effort
+// (a leaked SG strands no owning record). Shared by DeleteCluster and the
+// FAILED-cluster reclaim path in CreateCluster.
+func (s *EKSServiceImpl) purgeClusterInfra(accountID, name string, meta *ClusterMeta, acctKV nats.KeyValue) error {
+ s.registry.Stop(accountID, name)
+
+ var teardownErrs []error
+
+ if err := ZeroizeClusterOIDCKey(acctKV, name); err != nil {
+ teardownErrs = append(teardownErrs, fmt.Errorf("zeroize OIDC key: %w", err))
+ }
+
+ if meta.NLBArn != "" {
+ // Deregister is best-effort: DeleteClusterNLB tears down the whole NLB
+ // + target group, so a stale target registration cannot leak past it.
+ if meta.NLBTargetGroupArn != "" && meta.ControlPlaneENIIP != "" {
+ if err := DeregisterClusterTarget(s.deps.NLB, accountID, meta.NLBTargetGroupArn, meta.ControlPlaneENIIP); err != nil {
+ slog.Warn("purgeClusterInfra: deregister NLB target failed", "cluster", name, "err", err)
+ }
+ }
+ if err := DeleteClusterNLB(s.deps.NLB, accountID, name); err != nil {
+ teardownErrs = append(teardownErrs, fmt.Errorf("delete NLB: %w", err))
+ }
+ }
+
+ if meta.ControlPlaneInstanceID != "" || meta.ControlPlaneENIID != "" {
+ if err := TerminateK3sServerVM(s.deps.VPCK3s, s.deps.Instance,
+ accountID, meta.ControlPlaneInstanceID, meta.ControlPlaneENIID); err != nil {
+ teardownErrs = append(teardownErrs, fmt.Errorf("terminate K3s VM: %w", err))
+ }
+ }
+
+ // Egress: drop the OVN reroute+snat (fire-and-forget event, prune-safe) and
+ // release the hidden pool address. ReleaseAddress is blocking — the EIP is
+ // billable and its allocation ID lives in the meta we are about to erase, so
+ // a release failure must not let the KV sweep orphan it.
+ if meta.EgressEIPPublicIP != "" || meta.EgressEIPAllocationID != "" {
+ if meta.ResourcesVpcConfig != nil && meta.ResourcesVpcConfig.VpcId != "" && meta.ControlPlaneENIIP != "" {
+ subnetID := ""
+ if len(meta.ResourcesVpcConfig.SubnetIds) > 0 {
+ subnetID = meta.ResourcesVpcConfig.SubnetIds[0]
+ }
+ utils.PublishEvent(s.deps.NATSConn, "vpc.delete-system-egress", systemEgressEvent{
+ VpcId: meta.ResourcesVpcConfig.VpcId,
+ SubnetId: subnetID,
+ InstanceIp: meta.ControlPlaneENIIP,
+ ExternalIp: meta.EgressEIPPublicIP,
+ })
+ }
+ if meta.EgressEIPAllocationID != "" {
+ if _, err := s.deps.EIP.ReleaseAddress(&ec2.ReleaseAddressInput{
+ AllocationId: aws.String(meta.EgressEIPAllocationID),
+ }, accountID); err != nil {
+ teardownErrs = append(teardownErrs, fmt.Errorf("release egress EIP: %w", err))
+ }
+ }
+ }
+
+ if len(teardownErrs) > 0 {
+ return errors.Join(teardownErrs...)
+ }
+
+ if meta.ResourcesVpcConfig != nil && meta.ResourcesVpcConfig.VpcId != "" {
+ if err := DeleteClusterSGs(s.deps.VPCSG, accountID, name, meta.ResourcesVpcConfig.VpcId); err != nil {
+ slog.Warn("purgeClusterInfra: DeleteClusterSGs failed", "cluster", name, "err", err)
+ }
+ }
+
+ // Only now, with NLB + VM confirmed gone, erase the meta + per-cluster KV.
+ if err := DeleteClusterPrefix(acctKV, name); err != nil {
+ return fmt.Errorf("delete cluster prefix: %w", err)
+ }
+ return nil
}
func (s *EKSServiceImpl) UpdateClusterConfig(_ *eks.UpdateClusterConfigInput, _ string) (*eks.UpdateClusterConfigOutput, error) {
@@ -69,99 +755,364 @@ func (s *EKSServiceImpl) UpdateClusterVersion(_ *eks.UpdateClusterVersionInput,
return nil, notImpl()
}
-func (s *EKSServiceImpl) DeleteCluster(_ *eks.DeleteClusterInput, _ string) (*eks.DeleteClusterOutput, error) {
- return nil, notImpl()
-}
-
// --- Nodegroup ---
-func (s *EKSServiceImpl) CreateNodegroup(_ *eks.CreateNodegroupInput, _ string) (*eks.CreateNodegroupOutput, error) {
- return nil, notImpl()
+func (s *EKSServiceImpl) CreateNodegroup(input *eks.CreateNodegroupInput, accountID string) (*eks.CreateNodegroupOutput, error) {
+ if err := s.requireOrchestrationDeps("CreateNodegroup"); err != nil {
+ return nil, err
+ }
+ acctKV, err := s.nodegroupAcctKV(accountID)
+ if err != nil {
+ return nil, err
+ }
+ return s.createNodegroup(acctKV, input, accountID)
}
-func (s *EKSServiceImpl) DescribeNodegroup(_ *eks.DescribeNodegroupInput, _ string) (*eks.DescribeNodegroupOutput, error) {
- return nil, notImpl()
+func (s *EKSServiceImpl) DescribeNodegroup(input *eks.DescribeNodegroupInput, accountID string) (*eks.DescribeNodegroupOutput, error) {
+ if input == nil {
+ return nil, errors.New(awserrors.ErrorInvalidParameterValue)
+ }
+ acctKV, err := s.nodegroupAcctKV(accountID)
+ if err != nil {
+ return nil, err
+ }
+ return s.describeNodegroup(acctKV, input)
}
-func (s *EKSServiceImpl) ListNodegroups(_ *eks.ListNodegroupsInput, _ string) (*eks.ListNodegroupsOutput, error) {
- return nil, notImpl()
+func (s *EKSServiceImpl) ListNodegroups(input *eks.ListNodegroupsInput, accountID string) (*eks.ListNodegroupsOutput, error) {
+ if input == nil {
+ return nil, errors.New(awserrors.ErrorInvalidParameterValue)
+ }
+ acctKV, err := s.nodegroupAcctKV(accountID)
+ if err != nil {
+ return nil, err
+ }
+ return s.listNodegroups(acctKV, input)
}
-func (s *EKSServiceImpl) UpdateNodegroupConfig(_ *eks.UpdateNodegroupConfigInput, _ string) (*eks.UpdateNodegroupConfigOutput, error) {
- return nil, notImpl()
+func (s *EKSServiceImpl) UpdateNodegroupConfig(input *eks.UpdateNodegroupConfigInput, accountID string) (*eks.UpdateNodegroupConfigOutput, error) {
+ if err := s.requireOrchestrationDeps("UpdateNodegroupConfig"); err != nil {
+ return nil, err
+ }
+ acctKV, err := s.nodegroupAcctKV(accountID)
+ if err != nil {
+ return nil, err
+ }
+ return s.updateNodegroupConfig(acctKV, input, accountID)
}
-func (s *EKSServiceImpl) UpdateNodegroupVersion(_ *eks.UpdateNodegroupVersionInput, _ string) (*eks.UpdateNodegroupVersionOutput, error) {
- return nil, notImpl()
+func (s *EKSServiceImpl) UpdateNodegroupVersion(input *eks.UpdateNodegroupVersionInput, accountID string) (*eks.UpdateNodegroupVersionOutput, error) {
+ return s.updateNodegroupVersion(input)
}
-func (s *EKSServiceImpl) DeleteNodegroup(_ *eks.DeleteNodegroupInput, _ string) (*eks.DeleteNodegroupOutput, error) {
- return nil, notImpl()
+func (s *EKSServiceImpl) DeleteNodegroup(input *eks.DeleteNodegroupInput, accountID string) (*eks.DeleteNodegroupOutput, error) {
+ if err := s.requireOrchestrationDeps("DeleteNodegroup"); err != nil {
+ return nil, err
+ }
+ acctKV, err := s.nodegroupAcctKV(accountID)
+ if err != nil {
+ return nil, err
+ }
+ return s.deleteNodegroup(acctKV, input, accountID)
}
// --- AccessEntry + AccessPolicy ---
-func (s *EKSServiceImpl) CreateAccessEntry(_ *eks.CreateAccessEntryInput, _ string) (*eks.CreateAccessEntryOutput, error) {
- return nil, notImpl()
+// acctKVForCluster opens the per-account bucket and verifies the cluster exists,
+// translating absence to the AWS ResourceNotFoundException shape. The
+// AccessEntry handlers all need both.
+func (s *EKSServiceImpl) acctKVForCluster(accountID, cluster string) (nats.KeyValue, error) {
+ if cluster == "" {
+ return nil, errors.New(awserrors.ErrorInvalidParameterValue)
+ }
+ js, err := s.deps.NATSConn.JetStream()
+ if err != nil {
+ return nil, fmt.Errorf("jetstream: %w", err)
+ }
+ acctKV, err := GetOrCreateAccountBucket(js, accountID)
+ if err != nil {
+ return nil, fmt.Errorf("get account bucket: %w", err)
+ }
+ if _, err := GetClusterMeta(acctKV, cluster); err != nil {
+ if errors.Is(err, ErrClusterNotFound) {
+ return nil, errors.New(awserrors.ErrorEKSResourceNotFound)
+ }
+ return nil, err
+ }
+ return acctKV, nil
}
-func (s *EKSServiceImpl) DescribeAccessEntry(_ *eks.DescribeAccessEntryInput, _ string) (*eks.DescribeAccessEntryOutput, error) {
- return nil, notImpl()
+func (s *EKSServiceImpl) CreateAccessEntry(input *eks.CreateAccessEntryInput, accountID string) (*eks.CreateAccessEntryOutput, error) {
+ if input == nil {
+ return nil, errors.New(awserrors.ErrorInvalidParameterValue)
+ }
+ cluster := aws.StringValue(input.ClusterName)
+ principalARN := aws.StringValue(input.PrincipalArn)
+ if principalARN == "" {
+ return nil, errors.New(awserrors.ErrorInvalidParameterValue)
+ }
+ entryType := aws.StringValue(input.Type)
+ if entryType == "" {
+ entryType = AccessEntryTypeStandard
+ }
+ if entryType != AccessEntryTypeStandard {
+ // Node-type entries (EC2_LINUX/etc.) need the nodegroup join path; reject
+ // until Sprint 6d wires them.
+ return nil, errors.New(awserrors.ErrorInvalidParameterValue)
+ }
+ acctKV, err := s.acctKVForCluster(accountID, cluster)
+ if err != nil {
+ return nil, err
+ }
+ if _, err := GetAccessEntryRecord(acctKV, cluster, principalARN); err == nil {
+ return nil, errors.New(awserrors.ErrorEKSResourceInUse)
+ } else if !errors.Is(err, ErrAccessEntryNotFound) {
+ return nil, err
+ }
+ rec := newAccessEntryRecord(s.deps.Region, accountID, cluster, principalARN,
+ aws.StringValue(input.Username), aws.StringValueSlice(input.KubernetesGroups),
+ entryType, aws.StringValueMap(input.Tags), time.Now().UTC())
+ if err := PutAccessEntryRecord(acctKV, rec); err != nil {
+ return nil, err
+ }
+ return &eks.CreateAccessEntryOutput{AccessEntry: accessEntryRecordToAWS(rec)}, nil
}
-func (s *EKSServiceImpl) ListAccessEntries(_ *eks.ListAccessEntriesInput, _ string) (*eks.ListAccessEntriesOutput, error) {
- return nil, notImpl()
+func (s *EKSServiceImpl) DescribeAccessEntry(input *eks.DescribeAccessEntryInput, accountID string) (*eks.DescribeAccessEntryOutput, error) {
+ if input == nil {
+ return nil, errors.New(awserrors.ErrorInvalidParameterValue)
+ }
+ cluster := aws.StringValue(input.ClusterName)
+ principalARN := aws.StringValue(input.PrincipalArn)
+ acctKV, err := s.acctKVForCluster(accountID, cluster)
+ if err != nil {
+ return nil, err
+ }
+ rec, err := GetAccessEntryRecord(acctKV, cluster, principalARN)
+ if err != nil {
+ if errors.Is(err, ErrAccessEntryNotFound) {
+ return nil, errors.New(awserrors.ErrorEKSResourceNotFound)
+ }
+ return nil, err
+ }
+ return &eks.DescribeAccessEntryOutput{AccessEntry: accessEntryRecordToAWS(rec)}, nil
}
-func (s *EKSServiceImpl) UpdateAccessEntry(_ *eks.UpdateAccessEntryInput, _ string) (*eks.UpdateAccessEntryOutput, error) {
- return nil, notImpl()
+func (s *EKSServiceImpl) ListAccessEntries(input *eks.ListAccessEntriesInput, accountID string) (*eks.ListAccessEntriesOutput, error) {
+ if input == nil {
+ return nil, errors.New(awserrors.ErrorInvalidParameterValue)
+ }
+ cluster := aws.StringValue(input.ClusterName)
+ acctKV, err := s.acctKVForCluster(accountID, cluster)
+ if err != nil {
+ return nil, err
+ }
+ recs, err := ListAccessEntryRecords(acctKV, cluster)
+ if err != nil {
+ return nil, err
+ }
+ filter := aws.StringValue(input.AssociatedPolicyArn)
+ arns := make([]string, 0, len(recs))
+ for _, rec := range recs {
+ if filter != "" && !hasAssociatedPolicy(rec, filter) {
+ continue
+ }
+ arns = append(arns, rec.PrincipalARN)
+ }
+ return &eks.ListAccessEntriesOutput{AccessEntries: aws.StringSlice(arns)}, nil
}
-func (s *EKSServiceImpl) DeleteAccessEntry(_ *eks.DeleteAccessEntryInput, _ string) (*eks.DeleteAccessEntryOutput, error) {
- return nil, notImpl()
+func (s *EKSServiceImpl) UpdateAccessEntry(input *eks.UpdateAccessEntryInput, accountID string) (*eks.UpdateAccessEntryOutput, error) {
+ if input == nil {
+ return nil, errors.New(awserrors.ErrorInvalidParameterValue)
+ }
+ cluster := aws.StringValue(input.ClusterName)
+ principalARN := aws.StringValue(input.PrincipalArn)
+ acctKV, err := s.acctKVForCluster(accountID, cluster)
+ if err != nil {
+ return nil, err
+ }
+ now := time.Now().UTC()
+ rec, err := casUpdateAccessEntry(acctKV, cluster, principalARN, func(r *AccessEntryRecord) bool {
+ if input.KubernetesGroups != nil {
+ r.KubernetesGroups = aws.StringValueSlice(input.KubernetesGroups)
+ }
+ if u := aws.StringValue(input.Username); u != "" {
+ r.KubernetesUsername = u
+ }
+ r.ModifiedAt = now
+ return true
+ })
+ if err != nil {
+ if errors.Is(err, ErrAccessEntryNotFound) {
+ return nil, errors.New(awserrors.ErrorEKSResourceNotFound)
+ }
+ return nil, err
+ }
+ return &eks.UpdateAccessEntryOutput{AccessEntry: accessEntryRecordToAWS(rec)}, nil
}
-func (s *EKSServiceImpl) AssociateAccessPolicy(_ *eks.AssociateAccessPolicyInput, _ string) (*eks.AssociateAccessPolicyOutput, error) {
- return nil, notImpl()
+func (s *EKSServiceImpl) DeleteAccessEntry(input *eks.DeleteAccessEntryInput, accountID string) (*eks.DeleteAccessEntryOutput, error) {
+ if input == nil {
+ return nil, errors.New(awserrors.ErrorInvalidParameterValue)
+ }
+ cluster := aws.StringValue(input.ClusterName)
+ principalARN := aws.StringValue(input.PrincipalArn)
+ acctKV, err := s.acctKVForCluster(accountID, cluster)
+ if err != nil {
+ return nil, err
+ }
+ if err := DeleteAccessEntryRecord(acctKV, cluster, principalARN); err != nil {
+ if errors.Is(err, ErrAccessEntryNotFound) {
+ return nil, errors.New(awserrors.ErrorEKSResourceNotFound)
+ }
+ return nil, err
+ }
+ return &eks.DeleteAccessEntryOutput{}, nil
}
-func (s *EKSServiceImpl) DisassociateAccessPolicy(_ *eks.DisassociateAccessPolicyInput, _ string) (*eks.DisassociateAccessPolicyOutput, error) {
- return nil, notImpl()
+func (s *EKSServiceImpl) AssociateAccessPolicy(input *eks.AssociateAccessPolicyInput, accountID string) (*eks.AssociateAccessPolicyOutput, error) {
+ if input == nil {
+ return nil, errors.New(awserrors.ErrorInvalidParameterValue)
+ }
+ cluster := aws.StringValue(input.ClusterName)
+ principalARN := aws.StringValue(input.PrincipalArn)
+ policyARN := aws.StringValue(input.PolicyArn)
+ if _, ok := supportedAccessPolicies[policyARN]; !ok {
+ return nil, errors.New(awserrors.ErrorInvalidParameterValue)
+ }
+ scope, err := validateAccessScope(input.AccessScope)
+ if err != nil {
+ return nil, errors.New(awserrors.ErrorInvalidParameterValue)
+ }
+ acctKV, err := s.acctKVForCluster(accountID, cluster)
+ if err != nil {
+ return nil, err
+ }
+ now := time.Now().UTC()
+ var assoc AssociatedAccessPolicy
+ _, err = casUpdateAccessEntry(acctKV, cluster, principalARN, func(r *AccessEntryRecord) bool {
+ assoc = AssociatedAccessPolicy{PolicyARN: policyARN, AccessScope: scope, AssociatedAt: now, ModifiedAt: now}
+ for i := range r.AssociatedPolicies {
+ if r.AssociatedPolicies[i].PolicyARN == policyARN {
+ assoc.AssociatedAt = r.AssociatedPolicies[i].AssociatedAt
+ r.AssociatedPolicies[i] = assoc
+ r.ModifiedAt = now
+ return true
+ }
+ }
+ r.AssociatedPolicies = append(r.AssociatedPolicies, assoc)
+ r.ModifiedAt = now
+ return true
+ })
+ if err != nil {
+ if errors.Is(err, ErrAccessEntryNotFound) {
+ return nil, errors.New(awserrors.ErrorEKSResourceNotFound)
+ }
+ return nil, err
+ }
+ return &eks.AssociateAccessPolicyOutput{
+ ClusterName: aws.String(cluster),
+ PrincipalArn: aws.String(principalARN),
+ AssociatedAccessPolicy: associatedPolicyToAWS(assoc),
+ }, nil
}
-func (s *EKSServiceImpl) ListAssociatedAccessPolicies(_ *eks.ListAssociatedAccessPoliciesInput, _ string) (*eks.ListAssociatedAccessPoliciesOutput, error) {
- return nil, notImpl()
+func (s *EKSServiceImpl) DisassociateAccessPolicy(input *eks.DisassociateAccessPolicyInput, accountID string) (*eks.DisassociateAccessPolicyOutput, error) {
+ if input == nil {
+ return nil, errors.New(awserrors.ErrorInvalidParameterValue)
+ }
+ cluster := aws.StringValue(input.ClusterName)
+ principalARN := aws.StringValue(input.PrincipalArn)
+ policyARN := aws.StringValue(input.PolicyArn)
+ acctKV, err := s.acctKVForCluster(accountID, cluster)
+ if err != nil {
+ return nil, err
+ }
+ now := time.Now().UTC()
+ _, err = casUpdateAccessEntry(acctKV, cluster, principalARN, func(r *AccessEntryRecord) bool {
+ for i := range r.AssociatedPolicies {
+ if r.AssociatedPolicies[i].PolicyARN == policyARN {
+ r.AssociatedPolicies = append(r.AssociatedPolicies[:i], r.AssociatedPolicies[i+1:]...)
+ r.ModifiedAt = now
+ return true
+ }
+ }
+ return false
+ })
+ if err != nil {
+ if errors.Is(err, ErrAccessEntryNotFound) {
+ return nil, errors.New(awserrors.ErrorEKSResourceNotFound)
+ }
+ return nil, err
+ }
+ return &eks.DisassociateAccessPolicyOutput{}, nil
+}
+
+func (s *EKSServiceImpl) ListAssociatedAccessPolicies(input *eks.ListAssociatedAccessPoliciesInput, accountID string) (*eks.ListAssociatedAccessPoliciesOutput, error) {
+ if input == nil {
+ return nil, errors.New(awserrors.ErrorInvalidParameterValue)
+ }
+ cluster := aws.StringValue(input.ClusterName)
+ principalARN := aws.StringValue(input.PrincipalArn)
+ acctKV, err := s.acctKVForCluster(accountID, cluster)
+ if err != nil {
+ return nil, err
+ }
+ rec, err := GetAccessEntryRecord(acctKV, cluster, principalARN)
+ if err != nil {
+ if errors.Is(err, ErrAccessEntryNotFound) {
+ return nil, errors.New(awserrors.ErrorEKSResourceNotFound)
+ }
+ return nil, err
+ }
+ policies := make([]*eks.AssociatedAccessPolicy, 0, len(rec.AssociatedPolicies))
+ for _, p := range rec.AssociatedPolicies {
+ policies = append(policies, associatedPolicyToAWS(p))
+ }
+ return &eks.ListAssociatedAccessPoliciesOutput{
+ ClusterName: aws.String(cluster),
+ PrincipalArn: aws.String(principalARN),
+ AssociatedAccessPolicies: policies,
+ }, nil
}
func (s *EKSServiceImpl) ListAccessPolicies(_ *eks.ListAccessPoliciesInput, _ string) (*eks.ListAccessPoliciesOutput, error) {
- return nil, notImpl()
+ arns := make([]string, 0, len(supportedAccessPolicies))
+ for arn := range supportedAccessPolicies {
+ arns = append(arns, arn)
+ }
+ sort.Strings(arns)
+ policies := make([]*eks.AccessPolicy, 0, len(arns))
+ for _, arn := range arns {
+ policies = append(policies, &eks.AccessPolicy{
+ Arn: aws.String(arn),
+ Name: aws.String(accessPolicyName(arn)),
+ })
+ }
+ return &eks.ListAccessPoliciesOutput{AccessPolicies: policies}, nil
}
-// --- Addons ---
-
-func (s *EKSServiceImpl) ListAddons(_ *eks.ListAddonsInput, _ string) (*eks.ListAddonsOutput, error) {
- return nil, notImpl()
+// hasAssociatedPolicy reports whether the entry has the given policy ARN bound.
+func hasAssociatedPolicy(rec *AccessEntryRecord, policyARN string) bool {
+ for _, p := range rec.AssociatedPolicies {
+ if p.PolicyARN == policyARN {
+ return true
+ }
+ }
+ return false
}
-func (s *EKSServiceImpl) DescribeAddonVersions(_ *eks.DescribeAddonVersionsInput, _ string) (*eks.DescribeAddonVersionsOutput, error) {
- return nil, notImpl()
+// accessPolicyName extracts the policy short name from its ARN
+// (…/cluster-access-policy/AmazonEKSViewPolicy → AmazonEKSViewPolicy).
+func accessPolicyName(arn string) string {
+ if i := strings.LastIndex(arn, "/"); i >= 0 {
+ return arn[i+1:]
+ }
+ return arn
}
-func (s *EKSServiceImpl) CreateAddon(_ *eks.CreateAddonInput, _ string) (*eks.CreateAddonOutput, error) {
- return nil, notImpl()
-}
-
-func (s *EKSServiceImpl) DeleteAddon(_ *eks.DeleteAddonInput, _ string) (*eks.DeleteAddonOutput, error) {
- return nil, notImpl()
-}
-
-func (s *EKSServiceImpl) DescribeAddon(_ *eks.DescribeAddonInput, _ string) (*eks.DescribeAddonOutput, error) {
- return nil, notImpl()
-}
-
-func (s *EKSServiceImpl) UpdateAddon(_ *eks.UpdateAddonInput, _ string) (*eks.UpdateAddonOutput, error) {
- return nil, notImpl()
-}
+// --- Addons (see addons.go) ---
// --- OIDC identity-provider configs ---
@@ -194,3 +1145,228 @@ func (s *EKSServiceImpl) UntagResource(_ *eks.UntagResourceInput, _ string) (*ek
func (s *EKSServiceImpl) ListTagsForResource(_ *eks.ListTagsForResourceInput, _ string) (*eks.ListTagsForResourceOutput, error) {
return nil, notImpl()
}
+
+// --- helpers ---
+
+func notImpl() error { return errors.New(awserrors.ErrorNotImplemented) }
+
+func validateCreateClusterInput(input *eks.CreateClusterInput) error {
+ if input == nil || input.Name == nil || *input.Name == "" {
+ return errors.New(awserrors.ErrorInvalidParameterValue)
+ }
+ if input.RoleArn == nil || !strings.HasPrefix(*input.RoleArn, "arn:aws:iam:") {
+ return errors.New(awserrors.ErrorInvalidParameterValue)
+ }
+ if input.ResourcesVpcConfig == nil || len(input.ResourcesVpcConfig.SubnetIds) == 0 {
+ return errors.New(awserrors.ErrorInvalidParameterValue)
+ }
+ for _, sn := range input.ResourcesVpcConfig.SubnetIds {
+ if sn == nil || *sn == "" {
+ return errors.New(awserrors.ErrorInvalidParameterValue)
+ }
+ }
+ if input.AccessConfig != nil && input.AccessConfig.AuthenticationMode != nil {
+ mode := *input.AccessConfig.AuthenticationMode
+ if mode != "" && mode != eks.AuthenticationModeApi {
+ return errors.New(awserrors.ErrorInvalidParameter)
+ }
+ }
+ // AWS rejects disabling both public and private endpoint access — the
+ // control plane would be unreachable.
+ if v := input.ResourcesVpcConfig; v != nil &&
+ v.EndpointPublicAccess != nil && !*v.EndpointPublicAccess &&
+ v.EndpointPrivateAccess != nil && !*v.EndpointPrivateAccess {
+ return errors.New(awserrors.ErrorInvalidParameterValue)
+ }
+ return nil
+}
+
+// defaultPublicAccessCidr is the AWS-default allowed source range for a public
+// cluster endpoint when the caller specifies none.
+const defaultPublicAccessCidr = "0.0.0.0/0"
+
+// endpointAccess resolves apiserver endpoint exposure from the request,
+// applying AWS defaults (public on, private off) to omitted fields.
+func endpointAccess(vpc *eks.VpcConfigRequest) (public, private bool) {
+ public, private = true, false
+ if vpc == nil {
+ return public, private
+ }
+ if vpc.EndpointPublicAccess != nil {
+ public = *vpc.EndpointPublicAccess
+ }
+ if vpc.EndpointPrivateAccess != nil {
+ private = *vpc.EndpointPrivateAccess
+ }
+ return public, private
+}
+
+// publicAccessCidrs returns the allowed source ranges for a public endpoint,
+// defaulting to 0.0.0.0/0 (AWS parity) when public access is on and none given.
+// Nil for a private-only endpoint.
+func publicAccessCidrs(vpc *eks.VpcConfigRequest, public bool) []string {
+ if !public {
+ return nil
+ }
+ if vpc != nil && len(vpc.PublicAccessCidrs) > 0 {
+ return aws.StringValueSlice(vpc.PublicAccessCidrs)
+ }
+ return []string{defaultPublicAccessCidr}
+}
+
+func (s *EKSServiceImpl) markFailed(kv nats.KeyValue, name string) {
+ if err := SetClusterStatus(kv, name, ClusterStatusFailed); err != nil {
+ slog.Warn("CreateCluster: SetClusterStatus(FAILED) failed", "cluster", name, "err", err)
+ }
+}
+
+// spawnBootstrap launches the one-shot NATS bootstrap subscriber for a cluster.
+// kinds nil means a fresh CreateCluster (wait on all four subjects); a non-nil
+// subset is a daemon-restart resume that waits only on the missing artifacts.
+func (s *EKSServiceImpl) spawnBootstrap(accountID, clusterName string, kv nats.KeyValue, kinds []string) {
+ boot, err := NewNATSBootstrap(s.deps.NATSConn, kv, s.deps.MasterKey, accountID, clusterName)
+ if err != nil {
+ slog.Error("spawnBootstrap: NewNATSBootstrap failed", "cluster", clusterName, "err", err)
+ return
+ }
+ go func() {
+ err := boot.RunForKinds(s.bgCtx, kinds)
+ if err == nil || errors.Is(err, context.Canceled) {
+ return
+ }
+ // Bootstrap died without collecting all requested artifacts. Without
+ // this the cluster sits in CREATING forever — fail it so
+ // DescribeCluster surfaces the cause and DeleteCluster can reclaim the
+ // resources.
+ slog.Warn("NATSBootstrap exited; marking cluster FAILED", "cluster", clusterName, "err", err)
+ if markErr := MarkClusterFailed(kv, clusterName, "bootstrap failed: "+err.Error()); markErr != nil &&
+ !errors.Is(markErr, ErrClusterNotFound) {
+ slog.Error("spawnBootstrap: MarkClusterFailed", "cluster", clusterName, "err", markErr)
+ }
+ }()
+}
+
+func (s *EKSServiceImpl) spawnReconciler(accountID, clusterName string, _ *ClusterMeta) {
+ js, err := s.deps.NATSConn.JetStream()
+ if err != nil {
+ slog.Error("spawnReconciler: jetstream", "err", err)
+ return
+ }
+ acctKV, err := GetOrCreateAccountBucket(js, accountID)
+ if err != nil {
+ slog.Error("spawnReconciler: account bucket", "err", err)
+ return
+ }
+ // Health gates on the control plane's NATS self-report, not an HTTP probe:
+ // k3s binds the apiserver to the VPC node-ip, unreachable from the host. The
+ // CP publishes {healthz,node_count} on the mgmt bus the daemon already shares.
+ stateSubject := StateSubject(accountID, clusterName)
+ spawn := func(ctx context.Context, _, _ string) (func(), error) {
+ return RunClusterReconciler(ctx, s.leaderKV, acctKV, accountID, clusterName, s.deps.HolderID, "",
+ WithStateSource(s.deps.NATSConn, stateSubject))
+ }
+ if err := s.registry.Spawn(s.bgCtx, accountID, clusterName, spawn); err != nil {
+ slog.Error("spawnReconciler: registry spawn", "cluster", clusterName, "err", err)
+ }
+}
+
+func clusterMetaToAWS(meta *ClusterMeta) *eks.Cluster {
+ if meta == nil {
+ return nil
+ }
+ out := &eks.Cluster{
+ Name: aws.String(meta.Name),
+ Arn: aws.String(meta.Arn),
+ Status: aws.String(string(meta.Status)),
+ Version: aws.String(meta.Version),
+ RoleArn: aws.String(meta.RoleArn),
+ CreatedAt: aws.Time(meta.CreatedAt),
+ }
+ if meta.Endpoint != "" {
+ out.Endpoint = aws.String(meta.Endpoint)
+ }
+ if meta.OIDCIssuer != "" {
+ out.Identity = &eks.Identity{Oidc: &eks.OIDC{Issuer: aws.String(meta.OIDCIssuer)}}
+ }
+ if meta.CertificateAuthorityB64 != "" {
+ out.CertificateAuthority = &eks.Certificate{Data: aws.String(meta.CertificateAuthorityB64)}
+ }
+ if meta.ResourcesVpcConfig != nil {
+ out.ResourcesVpcConfig = &eks.VpcConfigResponse{
+ SubnetIds: aws.StringSlice(meta.ResourcesVpcConfig.SubnetIds),
+ SecurityGroupIds: aws.StringSlice(meta.ResourcesVpcConfig.SecurityGroupIds),
+ VpcId: aws.String(meta.ResourcesVpcConfig.VpcId),
+ EndpointPublicAccess: aws.Bool(meta.ResourcesVpcConfig.EndpointPublicAccess),
+ EndpointPrivateAccess: aws.Bool(meta.ResourcesVpcConfig.EndpointPrivateAccess),
+ PublicAccessCidrs: aws.StringSlice(meta.ResourcesVpcConfig.PublicAccessCidrs),
+ }
+ }
+ if meta.HealthIssue != "" {
+ out.Health = &eks.ClusterHealth{
+ Issues: []*eks.ClusterIssue{{
+ Code: aws.String(eks.ClusterIssueCodeClusterUnreachable),
+ Message: aws.String(meta.HealthIssue),
+ }},
+ }
+ }
+ return out
+}
+
+// natsTransient reports whether err is a transient NATS/JetStream condition
+// rather than a genuine fault. These occur in the post-restart window before
+// the KV stream's Raft group has elected a leader: JetStream requests get no
+// responder, time out, or the stream is briefly unreachable.
+func natsTransient(err error) bool {
+ return err != nil && (errors.Is(err, nats.ErrNoResponders) ||
+ errors.Is(err, nats.ErrTimeout) ||
+ errors.Is(err, nats.ErrNoStreamResponse) ||
+ errors.Is(err, nats.ErrConnectionClosed))
+}
+
+// eksReadUnavailableOr maps a transient NATS/JetStream failure to a retryable
+// ServiceUnavailable (503) so AWS clients back off and retry, instead of the
+// daemon sanitizing an unrecognised wrapped error to a misleading 500
+// InternalError. Non-transient errors pass through wrapped so real faults stay
+// visible.
+func eksReadUnavailableOr(err error, op string) error {
+ if natsTransient(err) {
+ slog.Warn("EKS read temporarily unavailable", "op", op, "err", err)
+ return errors.New(awserrors.ErrorServiceUnavailable)
+ }
+ return fmt.Errorf("%s: %w", op, err)
+}
+
+func listClusterNames(kv nats.KeyValue) ([]string, error) {
+ keys, err := kv.Keys()
+ if err != nil {
+ if errors.Is(err, nats.ErrNoKeysFound) {
+ return nil, nil
+ }
+ return nil, fmt.Errorf("kv keys: %w", err)
+ }
+ suffix := "/meta"
+ names := make([]string, 0, len(keys))
+ seen := make(map[string]struct{}, len(keys))
+ for _, k := range keys {
+ if !strings.HasPrefix(k, "clusters/") || !strings.HasSuffix(k, suffix) {
+ continue
+ }
+ name := strings.TrimSuffix(strings.TrimPrefix(k, "clusters/"), suffix)
+ if name == "" || strings.Contains(name, "/") {
+ continue
+ }
+ if _, ok := seen[name]; ok {
+ continue
+ }
+ seen[name] = struct{}{}
+ names = append(names, name)
+ }
+ return names, nil
+}
+
+func deref(p *string, fallback string) string {
+ if p == nil || *p == "" {
+ return fallback
+ }
+ return *p
+}
diff --git a/spinifex/handlers/eks/service_impl_test.go b/spinifex/handlers/eks/service_impl_test.go
index fce46a0a8..963884c51 100644
--- a/spinifex/handlers/eks/service_impl_test.go
+++ b/spinifex/handlers/eks/service_impl_test.go
@@ -1,12 +1,15 @@
package handlers_eks
import (
+ "errors"
"testing"
"github.com/aws/aws-sdk-go/aws"
"github.com/aws/aws-sdk-go/service/eks"
"github.com/mulgadc/spinifex/spinifex/awserrors"
"github.com/mulgadc/spinifex/spinifex/testutil"
+ "github.com/nats-io/nats.go"
+ "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
@@ -18,17 +21,27 @@ func setupTestService(t *testing.T) *EKSServiceImpl {
return svc
}
-func TestEKSServiceImpl_ClusterMethodsReturnNotImplemented(t *testing.T) {
+// TestEKSServiceImpl_ClusterLifecycleShimMode covers the four lifecycle
+// methods when the service is constructed via NewEKSServiceImplWithNATS
+// (the shim path the daemon-handler routing test uses): orchestration deps
+// are absent so CreateCluster/DeleteCluster short-circuit to ServiceUnavailable
+// (the missing deps are logged at ERROR), DescribeCluster hits an empty
+// per-account bucket and surfaces ResourceNotFoundException, and ListClusters
+// returns an empty list. The UpdateClusterConfig + UpdateClusterVersion paths
+// stay NotImplemented.
+func TestEKSServiceImpl_ClusterLifecycleShimMode(t *testing.T) {
svc := setupTestService(t)
- _, err := svc.CreateCluster(&eks.CreateClusterInput{Name: aws.String("c1")}, testAccountID)
- require.EqualError(t, err, awserrors.ErrorNotImplemented)
+ _, err := svc.CreateCluster(&eks.CreateClusterInput{Name: aws.String("c1")}, testAccountID, "")
+ require.EqualError(t, err, awserrors.ErrorServiceUnavailable)
_, err = svc.DescribeCluster(&eks.DescribeClusterInput{Name: aws.String("c1")}, testAccountID)
- require.EqualError(t, err, awserrors.ErrorNotImplemented)
+ require.EqualError(t, err, awserrors.ErrorEKSResourceNotFound)
- _, err = svc.ListClusters(&eks.ListClustersInput{}, testAccountID)
- require.EqualError(t, err, awserrors.ErrorNotImplemented)
+ out, err := svc.ListClusters(&eks.ListClustersInput{}, testAccountID)
+ require.NoError(t, err)
+ require.NotNil(t, out)
+ require.Empty(t, out.Clusters)
_, err = svc.UpdateClusterConfig(&eks.UpdateClusterConfigInput{Name: aws.String("c1")}, testAccountID)
require.EqualError(t, err, awserrors.ErrorNotImplemented)
@@ -37,82 +50,277 @@ func TestEKSServiceImpl_ClusterMethodsReturnNotImplemented(t *testing.T) {
require.EqualError(t, err, awserrors.ErrorNotImplemented)
_, err = svc.DeleteCluster(&eks.DeleteClusterInput{Name: aws.String("c1")}, testAccountID)
- require.EqualError(t, err, awserrors.ErrorNotImplemented)
+ require.EqualError(t, err, awserrors.ErrorServiceUnavailable)
}
-func TestEKSServiceImpl_NodegroupMethodsReturnNotImplemented(t *testing.T) {
+// EKS only supports the API authentication mode here; CONFIG_MAP (and the
+// API_AND_CONFIG_MAP hybrid) must be rejected with InvalidParameterException.
+func TestValidateCreateClusterInput_RejectsConfigMapAuthMode(t *testing.T) {
+ in := createInput("alpha")
+ in.AccessConfig = &eks.CreateAccessConfigRequest{
+ AuthenticationMode: aws.String(eks.AuthenticationModeConfigMap),
+ }
+ err := validateCreateClusterInput(in)
+ require.EqualError(t, err, awserrors.ErrorInvalidParameter)
+}
+
+// The API_AND_CONFIG_MAP hybrid still enables the unsupported aws-auth ConfigMap
+// path, so it must be rejected the same as plain CONFIG_MAP — the sibling test
+// only covers the CONFIG_MAP value.
+func TestValidateCreateClusterInput_RejectsAPIAndConfigMapAuthMode(t *testing.T) {
+ in := createInput("alpha")
+ in.AccessConfig = &eks.CreateAccessConfigRequest{
+ AuthenticationMode: aws.String(eks.AuthenticationModeApiAndConfigMap),
+ }
+ require.EqualError(t, validateCreateClusterInput(in), awserrors.ErrorInvalidParameter)
+}
+
+func TestValidateCreateClusterInput_AcceptsAPIAuthMode(t *testing.T) {
+ in := createInput("alpha")
+ in.AccessConfig = &eks.CreateAccessConfigRequest{
+ AuthenticationMode: aws.String(eks.AuthenticationModeApi),
+ }
+ require.NoError(t, validateCreateClusterInput(in))
+}
+
+// A create request with no subnets is malformed and must be rejected before any
+// orchestration work (InvalidParameterValue → 400).
+func TestValidateCreateClusterInput_RejectsMissingSubnetIds(t *testing.T) {
+ in := createInput("alpha")
+ in.ResourcesVpcConfig = &eks.VpcConfigRequest{}
+ require.EqualError(t, validateCreateClusterInput(in), awserrors.ErrorInvalidParameterValue)
+
+ in.ResourcesVpcConfig = nil
+ require.EqualError(t, validateCreateClusterInput(in), awserrors.ErrorInvalidParameterValue)
+}
+
+// DescribeCluster on an absent cluster must reach the KV lookup (full deps
+// wired, unlike the shim short-circuit) and surface ResourceNotFoundException.
+func TestDescribeCluster_NotFoundWithFullDeps(t *testing.T) {
+ f := newEKSServiceFixture(t)
+
+ _, err := f.svc.DescribeCluster(&eks.DescribeClusterInput{Name: aws.String("ghost")}, testAccountID)
+ require.EqualError(t, err, awserrors.ErrorEKSResourceNotFound)
+}
+
+// DeleteCluster on an absent cluster must reach the KV lookup with full deps
+// wired (the shim path short-circuits to ServiceUnavailable before the meta
+// read) and surface ResourceNotFoundException, not a teardown of nothing.
+func TestDeleteCluster_NotFoundWithFullDeps(t *testing.T) {
+ f := newEKSServiceFixture(t)
+
+ _, err := f.svc.DeleteCluster(deleteInput("ghost"), testAccountID)
+ require.EqualError(t, err, awserrors.ErrorEKSResourceNotFound)
+}
+
+// Security guarantee: the OIDC signing key must be zeroized BEFORE infra
+// teardown, so a teardown failure (which leaves the meta for retry) can never
+// leave recoverable key material behind. Force VM terminate to fail and assert
+// the key is already gone while the meta survives.
+func TestDeleteCluster_ZeroizesOIDCKeyBeforeTeardown(t *testing.T) {
+ f := newDeleteClusterFixture(t, "alpha")
+ f.inst.terminateErr = errors.New("hypervisor unreachable")
+
+ _, err := f.svc.DeleteCluster(deleteInput("alpha"), testAccountID)
+ require.Error(t, err, "teardown failure must surface")
+
+ _, getErr := f.kv.Get(OIDCSigningKeyKey("alpha"))
+ assert.ErrorIs(t, getErr, nats.ErrKeyNotFound, "OIDC key must be zeroized before teardown, even when teardown fails")
+
+ meta, metaErr := GetClusterMeta(f.kv, "alpha")
+ require.NoError(t, metaErr, "meta must survive a failed teardown for retry")
+ assert.Equal(t, ClusterStatusDeleting, meta.Status)
+}
+
+// In shim mode (orchestration deps absent) the mutating nodegroup methods
+// short-circuit to ServiceUnavailable, the read methods reach an empty
+// per-account bucket and surface ResourceNotFoundException, and
+// UpdateNodegroupVersion stays NotImplemented (v1 doesn't do AMI upgrades).
+func TestEKSServiceImpl_NodegroupMethodsShimMode(t *testing.T) {
svc := setupTestService(t)
_, err := svc.CreateNodegroup(&eks.CreateNodegroupInput{ClusterName: aws.String("c1"), NodegroupName: aws.String("ng1")}, testAccountID)
- require.EqualError(t, err, awserrors.ErrorNotImplemented)
+ require.EqualError(t, err, awserrors.ErrorServiceUnavailable)
_, err = svc.DescribeNodegroup(&eks.DescribeNodegroupInput{ClusterName: aws.String("c1"), NodegroupName: aws.String("ng1")}, testAccountID)
- require.EqualError(t, err, awserrors.ErrorNotImplemented)
+ require.EqualError(t, err, awserrors.ErrorEKSResourceNotFound)
_, err = svc.ListNodegroups(&eks.ListNodegroupsInput{ClusterName: aws.String("c1")}, testAccountID)
- require.EqualError(t, err, awserrors.ErrorNotImplemented)
+ require.EqualError(t, err, awserrors.ErrorEKSResourceNotFound)
_, err = svc.UpdateNodegroupConfig(&eks.UpdateNodegroupConfigInput{ClusterName: aws.String("c1"), NodegroupName: aws.String("ng1")}, testAccountID)
- require.EqualError(t, err, awserrors.ErrorNotImplemented)
+ require.EqualError(t, err, awserrors.ErrorServiceUnavailable)
_, err = svc.UpdateNodegroupVersion(&eks.UpdateNodegroupVersionInput{ClusterName: aws.String("c1"), NodegroupName: aws.String("ng1")}, testAccountID)
require.EqualError(t, err, awserrors.ErrorNotImplemented)
_, err = svc.DeleteNodegroup(&eks.DeleteNodegroupInput{ClusterName: aws.String("c1"), NodegroupName: aws.String("ng1")}, testAccountID)
- require.EqualError(t, err, awserrors.ErrorNotImplemented)
+ require.EqualError(t, err, awserrors.ErrorServiceUnavailable)
}
-func TestEKSServiceImpl_AccessMethodsReturnNotImplemented(t *testing.T) {
- svc := setupTestService(t)
-
- _, err := svc.CreateAccessEntry(&eks.CreateAccessEntryInput{ClusterName: aws.String("c1"), PrincipalArn: aws.String("arn:aws:iam::111122223333:user/dev")}, testAccountID)
- require.EqualError(t, err, awserrors.ErrorNotImplemented)
-
- _, err = svc.DescribeAccessEntry(&eks.DescribeAccessEntryInput{ClusterName: aws.String("c1")}, testAccountID)
- require.EqualError(t, err, awserrors.ErrorNotImplemented)
-
- _, err = svc.ListAccessEntries(&eks.ListAccessEntriesInput{ClusterName: aws.String("c1")}, testAccountID)
- require.EqualError(t, err, awserrors.ErrorNotImplemented)
-
- _, err = svc.UpdateAccessEntry(&eks.UpdateAccessEntryInput{ClusterName: aws.String("c1")}, testAccountID)
- require.EqualError(t, err, awserrors.ErrorNotImplemented)
-
- _, err = svc.DeleteAccessEntry(&eks.DeleteAccessEntryInput{ClusterName: aws.String("c1")}, testAccountID)
- require.EqualError(t, err, awserrors.ErrorNotImplemented)
-
- _, err = svc.AssociateAccessPolicy(&eks.AssociateAccessPolicyInput{ClusterName: aws.String("c1")}, testAccountID)
- require.EqualError(t, err, awserrors.ErrorNotImplemented)
-
- _, err = svc.DisassociateAccessPolicy(&eks.DisassociateAccessPolicyInput{ClusterName: aws.String("c1")}, testAccountID)
- require.EqualError(t, err, awserrors.ErrorNotImplemented)
-
- _, err = svc.ListAssociatedAccessPolicies(&eks.ListAssociatedAccessPoliciesInput{ClusterName: aws.String("c1")}, testAccountID)
- require.EqualError(t, err, awserrors.ErrorNotImplemented)
-
- _, err = svc.ListAccessPolicies(&eks.ListAccessPoliciesInput{}, testAccountID)
- require.EqualError(t, err, awserrors.ErrorNotImplemented)
+// seedTestCluster lays down a minimal ACTIVE cluster meta in the per-account
+// bucket so the AccessEntry handlers (which gate on cluster existence) can run.
+func seedTestCluster(t *testing.T, svc *EKSServiceImpl, cluster string) {
+ t.Helper()
+ js, err := svc.deps.NATSConn.JetStream()
+ require.NoError(t, err)
+ kv, err := GetOrCreateAccountBucket(js, testAccountID)
+ require.NoError(t, err)
+ require.NoError(t, PutClusterMeta(kv, &ClusterMeta{Name: cluster, Status: ClusterStatusActive}))
}
-func TestEKSServiceImpl_AddonsMethodsReturnNotImplemented(t *testing.T) {
+const testPrincipalARN = "arn:aws:iam::111122223333:role/dev"
+
+func TestAccessEntry_UnknownClusterIsNotFound(t *testing.T) {
svc := setupTestService(t)
+ _, err := svc.CreateAccessEntry(&eks.CreateAccessEntryInput{
+ ClusterName: aws.String("missing"), PrincipalArn: aws.String(testPrincipalARN),
+ }, testAccountID)
+ require.EqualError(t, err, awserrors.ErrorEKSResourceNotFound)
+}
- _, err := svc.ListAddons(&eks.ListAddonsInput{ClusterName: aws.String("c1")}, testAccountID)
- require.EqualError(t, err, awserrors.ErrorNotImplemented)
+func TestAccessEntry_CreateDescribeListDelete(t *testing.T) {
+ svc := setupTestService(t)
+ seedTestCluster(t, svc, "c1")
- _, err = svc.DescribeAddonVersions(&eks.DescribeAddonVersionsInput{}, testAccountID)
- require.EqualError(t, err, awserrors.ErrorNotImplemented)
+ out, err := svc.CreateAccessEntry(&eks.CreateAccessEntryInput{
+ ClusterName: aws.String("c1"),
+ PrincipalArn: aws.String(testPrincipalARN),
+ KubernetesGroups: aws.StringSlice([]string{"system:masters"}),
+ }, testAccountID)
+ require.NoError(t, err)
+ require.NotNil(t, out.AccessEntry)
+ // Username defaults to the principal ARN; type defaults to STANDARD.
+ assert.Equal(t, testPrincipalARN, aws.StringValue(out.AccessEntry.Username))
+ assert.Equal(t, AccessEntryTypeStandard, aws.StringValue(out.AccessEntry.Type))
+ assert.Contains(t, aws.StringValue(out.AccessEntry.AccessEntryArn), ":access-entry/c1/")
- _, err = svc.CreateAddon(&eks.CreateAddonInput{ClusterName: aws.String("c1"), AddonName: aws.String("vpc-cni")}, testAccountID)
- require.EqualError(t, err, awserrors.ErrorNotImplemented)
+ // Duplicate Create → ResourceInUseException.
+ _, err = svc.CreateAccessEntry(&eks.CreateAccessEntryInput{
+ ClusterName: aws.String("c1"), PrincipalArn: aws.String(testPrincipalARN),
+ }, testAccountID)
+ require.EqualError(t, err, awserrors.ErrorEKSResourceInUse)
- _, err = svc.DeleteAddon(&eks.DeleteAddonInput{ClusterName: aws.String("c1"), AddonName: aws.String("vpc-cni")}, testAccountID)
- require.EqualError(t, err, awserrors.ErrorNotImplemented)
+ desc, err := svc.DescribeAccessEntry(&eks.DescribeAccessEntryInput{
+ ClusterName: aws.String("c1"), PrincipalArn: aws.String(testPrincipalARN),
+ }, testAccountID)
+ require.NoError(t, err)
+ assert.Equal(t, []string{"system:masters"}, aws.StringValueSlice(desc.AccessEntry.KubernetesGroups))
- _, err = svc.DescribeAddon(&eks.DescribeAddonInput{ClusterName: aws.String("c1"), AddonName: aws.String("vpc-cni")}, testAccountID)
- require.EqualError(t, err, awserrors.ErrorNotImplemented)
+ list, err := svc.ListAccessEntries(&eks.ListAccessEntriesInput{ClusterName: aws.String("c1")}, testAccountID)
+ require.NoError(t, err)
+ assert.Equal(t, []string{testPrincipalARN}, aws.StringValueSlice(list.AccessEntries))
- _, err = svc.UpdateAddon(&eks.UpdateAddonInput{ClusterName: aws.String("c1"), AddonName: aws.String("vpc-cni")}, testAccountID)
- require.EqualError(t, err, awserrors.ErrorNotImplemented)
+ _, err = svc.DeleteAccessEntry(&eks.DeleteAccessEntryInput{
+ ClusterName: aws.String("c1"), PrincipalArn: aws.String(testPrincipalARN),
+ }, testAccountID)
+ require.NoError(t, err)
+
+ _, err = svc.DescribeAccessEntry(&eks.DescribeAccessEntryInput{
+ ClusterName: aws.String("c1"), PrincipalArn: aws.String(testPrincipalARN),
+ }, testAccountID)
+ require.EqualError(t, err, awserrors.ErrorEKSResourceNotFound)
+}
+
+func TestAccessEntry_RejectsNodeType(t *testing.T) {
+ svc := setupTestService(t)
+ seedTestCluster(t, svc, "c1")
+ _, err := svc.CreateAccessEntry(&eks.CreateAccessEntryInput{
+ ClusterName: aws.String("c1"), PrincipalArn: aws.String(testPrincipalARN), Type: aws.String("EC2_LINUX"),
+ }, testAccountID)
+ require.EqualError(t, err, awserrors.ErrorInvalidParameterValue)
+}
+
+func TestAccessEntry_DescribeDeleteMissingIsNotFound(t *testing.T) {
+ svc := setupTestService(t)
+ seedTestCluster(t, svc, "c1")
+ _, err := svc.DescribeAccessEntry(&eks.DescribeAccessEntryInput{
+ ClusterName: aws.String("c1"), PrincipalArn: aws.String(testPrincipalARN),
+ }, testAccountID)
+ require.EqualError(t, err, awserrors.ErrorEKSResourceNotFound)
+ _, err = svc.DeleteAccessEntry(&eks.DeleteAccessEntryInput{
+ ClusterName: aws.String("c1"), PrincipalArn: aws.String(testPrincipalARN),
+ }, testAccountID)
+ require.EqualError(t, err, awserrors.ErrorEKSResourceNotFound)
+}
+
+func TestAccessPolicy_AssociateListDisassociate(t *testing.T) {
+ svc := setupTestService(t)
+ seedTestCluster(t, svc, "c1")
+ _, err := svc.CreateAccessEntry(&eks.CreateAccessEntryInput{
+ ClusterName: aws.String("c1"), PrincipalArn: aws.String(testPrincipalARN),
+ }, testAccountID)
+ require.NoError(t, err)
+
+ const viewPolicy = "arn:aws:eks::aws:cluster-access-policy/AmazonEKSViewPolicy"
+ assoc, err := svc.AssociateAccessPolicy(&eks.AssociateAccessPolicyInput{
+ ClusterName: aws.String("c1"),
+ PrincipalArn: aws.String(testPrincipalARN),
+ PolicyArn: aws.String(viewPolicy),
+ AccessScope: &eks.AccessScope{Type: aws.String("cluster")},
+ }, testAccountID)
+ require.NoError(t, err)
+ assert.Equal(t, viewPolicy, aws.StringValue(assoc.AssociatedAccessPolicy.PolicyArn))
+
+ listed, err := svc.ListAssociatedAccessPolicies(&eks.ListAssociatedAccessPoliciesInput{
+ ClusterName: aws.String("c1"), PrincipalArn: aws.String(testPrincipalARN),
+ }, testAccountID)
+ require.NoError(t, err)
+ require.Len(t, listed.AssociatedAccessPolicies, 1)
+
+ // Entry now discoverable by the associated-policy filter.
+ filtered, err := svc.ListAccessEntries(&eks.ListAccessEntriesInput{
+ ClusterName: aws.String("c1"), AssociatedPolicyArn: aws.String(viewPolicy),
+ }, testAccountID)
+ require.NoError(t, err)
+ assert.Equal(t, []string{testPrincipalARN}, aws.StringValueSlice(filtered.AccessEntries))
+
+ _, err = svc.DisassociateAccessPolicy(&eks.DisassociateAccessPolicyInput{
+ ClusterName: aws.String("c1"), PrincipalArn: aws.String(testPrincipalARN), PolicyArn: aws.String(viewPolicy),
+ }, testAccountID)
+ require.NoError(t, err)
+ listed, err = svc.ListAssociatedAccessPolicies(&eks.ListAssociatedAccessPoliciesInput{
+ ClusterName: aws.String("c1"), PrincipalArn: aws.String(testPrincipalARN),
+ }, testAccountID)
+ require.NoError(t, err)
+ assert.Empty(t, listed.AssociatedAccessPolicies)
+}
+
+func TestAccessPolicy_AssociateRejectsUnsupportedPolicyAndScope(t *testing.T) {
+ svc := setupTestService(t)
+ seedTestCluster(t, svc, "c1")
+ _, err := svc.CreateAccessEntry(&eks.CreateAccessEntryInput{
+ ClusterName: aws.String("c1"), PrincipalArn: aws.String(testPrincipalARN),
+ }, testAccountID)
+ require.NoError(t, err)
+
+ // Unsupported policy ARN.
+ _, err = svc.AssociateAccessPolicy(&eks.AssociateAccessPolicyInput{
+ ClusterName: aws.String("c1"), PrincipalArn: aws.String(testPrincipalARN),
+ PolicyArn: aws.String("arn:aws:eks::aws:cluster-access-policy/MadeUpPolicy"),
+ AccessScope: &eks.AccessScope{Type: aws.String("cluster")},
+ }, testAccountID)
+ require.EqualError(t, err, awserrors.ErrorInvalidParameterValue)
+
+ // namespace scope without namespaces.
+ _, err = svc.AssociateAccessPolicy(&eks.AssociateAccessPolicyInput{
+ ClusterName: aws.String("c1"), PrincipalArn: aws.String(testPrincipalARN),
+ PolicyArn: aws.String("arn:aws:eks::aws:cluster-access-policy/AmazonEKSViewPolicy"),
+ AccessScope: &eks.AccessScope{Type: aws.String("namespace")},
+ }, testAccountID)
+ require.EqualError(t, err, awserrors.ErrorInvalidParameterValue)
+}
+
+func TestListAccessPolicies_ReturnsSupportedCatalogue(t *testing.T) {
+ svc := setupTestService(t)
+ out, err := svc.ListAccessPolicies(&eks.ListAccessPoliciesInput{}, testAccountID)
+ require.NoError(t, err)
+ require.Len(t, out.AccessPolicies, len(supportedAccessPolicies))
+ for _, p := range out.AccessPolicies {
+ _, ok := supportedAccessPolicies[aws.StringValue(p.Arn)]
+ assert.True(t, ok, "unexpected policy %s", aws.StringValue(p.Arn))
+ assert.NotEmpty(t, aws.StringValue(p.Name))
+ }
}
func TestEKSServiceImpl_OIDCMethodsReturnNotImplemented(t *testing.T) {
diff --git a/spinifex/handlers/eks/service_nats.go b/spinifex/handlers/eks/service_nats.go
index e82194731..89534411a 100644
--- a/spinifex/handlers/eks/service_nats.go
+++ b/spinifex/handlers/eks/service_nats.go
@@ -25,8 +25,9 @@ func NewNATSEKSService(conn *nats.Conn) EKSService {
// --- Cluster ---
-func (s *NATSEKSService) CreateCluster(input *eks.CreateClusterInput, accountID string) (*eks.CreateClusterOutput, error) {
- return utils.NATSRequest[eks.CreateClusterOutput](s.natsConn, "eks.CreateCluster", input, defaultTimeout, accountID)
+func (s *NATSEKSService) CreateCluster(input *eks.CreateClusterInput, accountID, callerPrincipalARN string) (*eks.CreateClusterOutput, error) {
+ return utils.NATSRequest[eks.CreateClusterOutput](s.natsConn, "eks.CreateCluster", input, defaultTimeout, accountID,
+ utils.NATSHeader{Key: utils.PrincipalARNHeader, Value: callerPrincipalARN})
}
func (s *NATSEKSService) DescribeCluster(input *eks.DescribeClusterInput, accountID string) (*eks.DescribeClusterOutput, error) {
diff --git a/spinifex/handlers/eks/spawn_registered_reconcilers_test.go b/spinifex/handlers/eks/spawn_registered_reconcilers_test.go
new file mode 100644
index 000000000..63ebc71fa
--- /dev/null
+++ b/spinifex/handlers/eks/spawn_registered_reconcilers_test.go
@@ -0,0 +1,44 @@
+package handlers_eks
+
+import (
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+// TestSpawnRegisteredReconcilers_ResumesNonTerminal models a daemon restart:
+// every cluster still in CREATING or ACTIVE must get a reconciler re-registered
+// so lifecycle reconcile resumes without waiting for the next CreateCluster,
+// while terminal (FAILED/DELETING) clusters stay untouched.
+func TestSpawnRegisteredReconcilers_ResumesNonTerminal(t *testing.T) {
+ f := newEKSServiceFixture(t)
+
+ active := sampleClusterMeta("alpha")
+ active.Status = ClusterStatusActive
+ require.NoError(t, PutClusterMeta(f.kv, active))
+
+ // CREATING resumes both the bootstrap re-subscribe and the reconciler.
+ creating := sampleClusterMeta("beta")
+ creating.Status = ClusterStatusCreating
+ require.NoError(t, PutClusterMeta(f.kv, creating))
+
+ failed := sampleClusterMeta("zeta")
+ failed.Status = ClusterStatusFailed
+ require.NoError(t, PutClusterMeta(f.kv, failed))
+
+ require.NoError(t, f.svc.SpawnRegisteredReconcilers())
+
+ assert.True(t, f.svc.registry.Has(testAccountID, "alpha"), "ACTIVE cluster must resume a reconciler")
+ assert.True(t, f.svc.registry.Has(testAccountID, "beta"), "CREATING cluster must resume a reconciler")
+ assert.False(t, f.svc.registry.Has(testAccountID, "zeta"), "FAILED cluster must not be resumed")
+}
+
+// TestSpawnRegisteredReconcilers_DepsNotReadyNoops confirms the boot scan is a
+// safe no-op when orchestration deps are absent (the shim construction path),
+// so it never panics or registers phantom holders on a half-wired daemon.
+func TestSpawnRegisteredReconcilers_DepsNotReadyNoops(t *testing.T) {
+ svc := setupTestService(t)
+ require.NoError(t, svc.SpawnRegisteredReconcilers())
+ assert.False(t, svc.registry.Has(testAccountID, "alpha"))
+}
diff --git a/spinifex/handlers/eks/state_report.go b/spinifex/handlers/eks/state_report.go
new file mode 100644
index 000000000..ccaa91c73
--- /dev/null
+++ b/spinifex/handlers/eks/state_report.go
@@ -0,0 +1,37 @@
+package handlers_eks
+
+import (
+ "encoding/json"
+ "fmt"
+)
+
+// StateSubject returns the NATS subject the control-plane VM publishes its
+// periodic health self-report to: "eks.state.{accountID}.{clusterName}.server".
+// The CP VM is on the management NATS bus, so this report reaches the host-side
+// reconciler without any route into the VPC overlay — unlike an HTTP /healthz
+// probe to the apiserver, which k3s binds to the unreachable VPC node-ip.
+func StateSubject(accountID, clusterName string) string {
+ return fmt.Sprintf("eks.state.%s.%s.server", accountID, clusterName)
+}
+
+// ServerStateReport is the JSON payload of a control-plane state report, emitted
+// by scripts/images/eks-node/mulga-eks-state-report.sh. Healthz mirrors the
+// apiserver's /healthz body ("ok" when serving); NodeCount is the number of
+// nodes the apiserver reports (server + joined agents); TS is the publish time
+// in unix seconds, used to reject stale reports from a CP that stopped emitting.
+type ServerStateReport struct {
+ Healthz string `json:"healthz"`
+ NodeCount int `json:"node_count"`
+ TS int64 `json:"ts"`
+}
+
+// Healthy reports whether the apiserver was serving at publish time.
+func (s ServerStateReport) Healthy() bool { return s.Healthz == "ok" }
+
+func unmarshalServerStateReport(data []byte) (ServerStateReport, error) {
+ var r ServerStateReport
+ if err := json.Unmarshal(data, &r); err != nil {
+ return ServerStateReport{}, fmt.Errorf("eks: unmarshal server state report: %w", err)
+ }
+ return r, nil
+}
diff --git a/spinifex/handlers/eks/store.go b/spinifex/handlers/eks/store.go
index b67aad680..937b259bc 100644
--- a/spinifex/handlers/eks/store.go
+++ b/spinifex/handlers/eks/store.go
@@ -1,6 +1,8 @@
package handlers_eks
import (
+ "crypto/sha256"
+ "encoding/hex"
"errors"
"fmt"
"log/slog"
@@ -24,6 +26,13 @@ const (
KVBucketEKSAccountPrefix = "eks-account-"
KVBucketEKSAccountVersion = 1
+ // KVBucketEKSAccountHistory pins the per-account bucket to one revision per
+ // key. This MUST stay 1: ZeroizeClusterOIDCKey relies on Purge dropping the
+ // key's whole history so no prior revision of the encrypted OIDC signing key
+ // survives a DeleteCluster. Keep this decoupled from the schema version so a
+ // future version bump can't silently widen history and resurrect ciphertext.
+ KVBucketEKSAccountHistory = 1
+
KVBucketEKSLeader = "spinifex-eks-leader"
KVBucketEKSLeaderVersion = 1
KVBucketEKSLeaderTTL = 60 * time.Second
@@ -47,14 +56,34 @@ func ClusterMetaKey(name string) string {
return fmt.Sprintf("clusters/%s/meta", name)
}
+// NodegroupsPrefix returns the KV key prefix under which all of a cluster's
+// nodegroup records live. Used by ListNodegroups to enumerate.
+func NodegroupsPrefix(cluster string) string {
+ return fmt.Sprintf("clusters/%s/nodegroups/", cluster)
+}
+
// NodegroupKey returns the KV key for a nodegroup record under a cluster.
func NodegroupKey(cluster, ng string) string {
- return fmt.Sprintf("clusters/%s/nodegroups/%s", cluster, ng)
+ return NodegroupsPrefix(cluster) + ng
+}
+
+// AccessEntriesPrefix returns the KV key prefix under which all of a cluster's
+// AccessEntry records live. Used by ListAccessEntries to enumerate.
+func AccessEntriesPrefix(cluster string) string {
+ return fmt.Sprintf("clusters/%s/access-entries/", cluster)
}
// AccessEntryKey returns the KV key for an AccessEntry record under a cluster.
+// The principal ARN is hashed because IAM ARNs contain ':' which is not a legal
+// NATS JetStream KV key character; the record itself carries the plaintext ARN.
func AccessEntryKey(cluster, principalARN string) string {
- return fmt.Sprintf("clusters/%s/access-entries/%s", cluster, principalARN)
+ return AccessEntriesPrefix(cluster) + PrincipalARNHash(principalARN)
+}
+
+// PrincipalARNHash maps an IAM principal ARN to a KV-key-safe token.
+func PrincipalARNHash(principalARN string) string {
+ sum := sha256.Sum256([]byte(principalARN))
+ return hex.EncodeToString(sum[:])
}
// OIDCProviderKey returns the KV key for a registered OIDC provider config
@@ -74,6 +103,15 @@ func OIDCJWKSKey(cluster string) string {
return fmt.Sprintf("clusters/%s/oidc-jwks.json", cluster)
}
+// OIDCJWKSVerifiedKey returns the KV key marking that the K3s-VM-published JWKS
+// passed the controller cross-check (kid + kty match the controller-generated
+// keypair). The reconciler gates the ACTIVE transition on this marker, NOT on
+// OIDCJWKSKey — the controller pre-seeds OIDCJWKSKey at create time, so its
+// presence proves nothing about the running cluster's actual signing key.
+func OIDCJWKSVerifiedKey(cluster string) string {
+ return fmt.Sprintf("clusters/%s/oidc-jwks-verified", cluster)
+}
+
// AdminKubeconfigKey returns the KV key for a cluster's encrypted admin
// kubeconfig (used by the spinifex-side nodegroup reconciler).
func AdminKubeconfigKey(cluster string) string {
@@ -91,6 +129,23 @@ func EventKey(cluster, ts string) string {
return fmt.Sprintf("clusters/%s/events/%s", cluster, ts)
}
+// AddonsPrefix returns the KV key prefix under which all of a cluster's managed
+// add-on records live. Used by ListAddons to enumerate.
+func AddonsPrefix(cluster string) string {
+ return fmt.Sprintf("clusters/%s/addons/", cluster)
+}
+
+// AddonKey returns the KV key for a managed add-on record under a cluster.
+func AddonKey(cluster, addon string) string {
+ return AddonsPrefix(cluster) + addon
+}
+
+// AddonManifestKey returns the KV key under which the rendered manifest for a
+// managed add-on is staged for the VM-side delivery transport to consume.
+func AddonManifestKey(cluster, addon string) string {
+ return AddonsPrefix(cluster) + addon + "/manifest"
+}
+
// Store is the per-daemon EKS KV handle. Per-account and leader buckets are
// accessed via the package-level factories below.
type Store struct {
@@ -118,7 +173,7 @@ func AccountBucketName(accountID string) string {
// accountID return the existing handle.
func GetOrCreateAccountBucket(js nats.JetStreamContext, accountID string) (nats.KeyValue, error) {
bucket := AccountBucketName(accountID)
- kv, err := utils.GetOrCreateKVBucket(js, bucket, KVBucketEKSAccountVersion)
+ kv, err := utils.GetOrCreateKVBucket(js, bucket, KVBucketEKSAccountHistory)
if err != nil {
return nil, fmt.Errorf("failed to create EKS per-account KV bucket %s: %w", bucket, err)
}
diff --git a/spinifex/handlers/eks/store_test.go b/spinifex/handlers/eks/store_test.go
index 64879eb2e..8432a6a88 100644
--- a/spinifex/handlers/eks/store_test.go
+++ b/spinifex/handlers/eks/store_test.go
@@ -47,7 +47,7 @@ func TestKeyPaths_MatchQ2Spec(t *testing.T) {
}{
{"ClusterMetaKey", ClusterMetaKey("alpha"), "clusters/alpha/meta"},
{"NodegroupKey", NodegroupKey("alpha", "ng-1"), "clusters/alpha/nodegroups/ng-1"},
- {"AccessEntryKey", AccessEntryKey("alpha", "arn:aws:iam::111122223333:user/dev"), "clusters/alpha/access-entries/arn:aws:iam::111122223333:user/dev"},
+ {"AccessEntryKey", AccessEntryKey("alpha", "arn:aws:iam::111122223333:user/dev"), "clusters/alpha/access-entries/" + PrincipalARNHash("arn:aws:iam::111122223333:user/dev")},
{"OIDCProviderKey", OIDCProviderKey("alpha", "abc123"), "clusters/alpha/oidc-providers/abc123"},
{"OIDCSigningKeyKey", OIDCSigningKeyKey("alpha"), "clusters/alpha/oidc-signing-key.pem.enc"},
{"OIDCJWKSKey", OIDCJWKSKey("alpha"), "clusters/alpha/oidc-jwks.json"},
diff --git a/spinifex/handlers/eks/token_review.go b/spinifex/handlers/eks/token_review.go
new file mode 100644
index 000000000..748b76a3b
--- /dev/null
+++ b/spinifex/handlers/eks/token_review.go
@@ -0,0 +1,92 @@
+package handlers_eks
+
+import (
+ "fmt"
+ "log/slog"
+ "time"
+
+ "github.com/mulgadc/spinifex/spinifex/utils"
+ "github.com/nats-io/nats.go"
+)
+
+// WebhookTokenReviewResult is the gateway's reply to the eks-token-webhook's
+// TokenReview relay: the resolved K8s identity for an `aws eks get-token`
+// bearer token, or Authenticated=false. The webhook maps it into the
+// authentication.k8s.io/v1 TokenReview the apiserver expects. The locationName
+// tags let the gateway's restjson marshaler (jsonutil) emit the same keys the
+// json tags name, so the webhook decodes the body with encoding/json.
+type WebhookTokenReviewResult struct {
+ Authenticated bool `json:"authenticated" locationName:"authenticated"`
+ Username string `json:"username,omitempty" locationName:"username"`
+ UID string `json:"uid,omitempty" locationName:"uid"`
+ Groups []string `json:"groups,omitempty" locationName:"groups"`
+}
+
+// Authenticate is the pure TokenReview decision core: decode token → STS verify
+// → AccessEntry lookup → identity. Any failure yields Authenticated=false (never
+// an error) so a forged/unknown token is a clean K8s 401, not a webhook outage.
+// verify and lookup are injected so the logic is unit-testable without NATS.
+func Authenticate(
+ token string,
+ verify func(presignedURL string) (*TokenVerifyResponse, error),
+ lookup func(principalARN string) (*AccessEntryRecord, error),
+) WebhookTokenReviewResult {
+ presignedURL, err := DecodeGetToken(token)
+ if err != nil {
+ return WebhookTokenReviewResult{Authenticated: false}
+ }
+ ident, err := verify(presignedURL)
+ if err != nil {
+ slog.Debug("token verify rejected", "err", err)
+ return WebhookTokenReviewResult{Authenticated: false}
+ }
+ rec, err := lookup(ident.ARN)
+ if err != nil {
+ // No AccessEntry for an otherwise-valid IAM principal: authenticated to
+ // AWS, but not granted any K8s identity on this cluster.
+ slog.Debug("no access entry for principal", "arn", ident.ARN, "err", err)
+ return WebhookTokenReviewResult{Authenticated: false}
+ }
+ uid := ident.UserID
+ if uid == "" {
+ uid = ident.ARN
+ }
+ return WebhookTokenReviewResult{
+ Authenticated: true,
+ Username: rec.KubernetesUsername,
+ UID: uid,
+ Groups: rec.KubernetesGroups,
+ }
+}
+
+// ResolveTokenReview runs the TokenReview decision host-side, wiring the real
+// STS verify (NATS request to the awsgw-hosted verify subject) and AccessEntry
+// KV read into Authenticate. The eks-token-webhook reaches this through the
+// gateway broker (POST /clusters/{name}/token-review) so the control-plane VM
+// never speaks NATS. Returns an error only for a genuine infrastructure fault
+// (no JetStream / missing account bucket); a forged token resolves to an
+// Authenticated=false result, not an error.
+func ResolveTokenReview(nc *nats.Conn, accountID, clusterName, token string, verifyTimeout time.Duration) (WebhookTokenReviewResult, error) {
+ if nc == nil {
+ return WebhookTokenReviewResult{}, fmt.Errorf("eks: ResolveTokenReview nil nats conn")
+ }
+ js, err := nc.JetStream()
+ if err != nil {
+ return WebhookTokenReviewResult{}, fmt.Errorf("eks: jetstream: %w", err)
+ }
+ kv, err := js.KeyValue(AccountBucketName(accountID))
+ if err != nil {
+ return WebhookTokenReviewResult{}, fmt.Errorf("eks: open account KV %s: %w", accountID, err)
+ }
+
+ verify := func(presignedURL string) (*TokenVerifyResponse, error) {
+ return utils.NATSRequest[TokenVerifyResponse](
+ nc, TokenVerifySubject,
+ TokenVerifyRequest{PresignedURL: presignedURL, ClusterName: clusterName},
+ verifyTimeout, "")
+ }
+ lookup := func(principalARN string) (*AccessEntryRecord, error) {
+ return GetAccessEntryRecord(kv, clusterName, principalARN)
+ }
+ return Authenticate(token, verify, lookup), nil
+}
diff --git a/spinifex/handlers/eks/token_review_test.go b/spinifex/handlers/eks/token_review_test.go
new file mode 100644
index 000000000..4d29d0e02
--- /dev/null
+++ b/spinifex/handlers/eks/token_review_test.go
@@ -0,0 +1,156 @@
+package handlers_eks
+
+import (
+ "encoding/base64"
+ "encoding/json"
+ "errors"
+ "testing"
+ "time"
+
+ "github.com/mulgadc/spinifex/spinifex/testutil"
+ "github.com/nats-io/nats.go"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func validToken(url string) string {
+ return "k8s-aws-v1." + base64.RawURLEncoding.EncodeToString([]byte(url))
+}
+
+const testARN = "arn:aws:iam::111122223333:role/admin"
+
+func okVerify(_ string) (*TokenVerifyResponse, error) {
+ return &TokenVerifyResponse{
+ AccountID: "111122223333",
+ ARN: testARN,
+ UserID: "AROAEXAMPLE:session",
+ PrincipalType: "AssumedRole",
+ }, nil
+}
+
+func TestAuthenticate_GrantsWhenEntryExists(t *testing.T) {
+ lookup := func(arn string) (*AccessEntryRecord, error) {
+ assert.Equal(t, testARN, arn)
+ return &AccessEntryRecord{
+ KubernetesUsername: testARN,
+ KubernetesGroups: []string{"system:masters"},
+ }, nil
+ }
+
+ res := Authenticate(validToken("https://sts.amazonaws.com/?Action=GetCallerIdentity"), okVerify, lookup)
+
+ require.True(t, res.Authenticated)
+ assert.Equal(t, testARN, res.Username)
+ assert.Equal(t, "AROAEXAMPLE:session", res.UID)
+ assert.Equal(t, []string{"system:masters"}, res.Groups)
+}
+
+func TestAuthenticate_DeniesMalformedToken(t *testing.T) {
+ called := false
+ verify := func(string) (*TokenVerifyResponse, error) { called = true; return nil, nil }
+ lookup := func(string) (*AccessEntryRecord, error) { return nil, nil }
+
+ res := Authenticate("not-a-k8s-aws-token", verify, lookup)
+
+ assert.False(t, res.Authenticated)
+ assert.False(t, called, "must not call STS for a token that fails to decode")
+}
+
+func TestAuthenticate_DeniesWhenVerifyFails(t *testing.T) {
+ verify := func(string) (*TokenVerifyResponse, error) {
+ return nil, errors.New("signature mismatch")
+ }
+ lookup := func(string) (*AccessEntryRecord, error) {
+ t.Fatal("lookup must not run when verify fails")
+ return nil, nil
+ }
+
+ res := Authenticate(validToken("https://sts/?x=1"), verify, lookup)
+ assert.False(t, res.Authenticated)
+}
+
+func TestAuthenticate_DeniesWhenNoAccessEntry(t *testing.T) {
+ lookup := func(string) (*AccessEntryRecord, error) {
+ return nil, ErrAccessEntryNotFound
+ }
+
+ res := Authenticate(validToken("https://sts/?x=1"), okVerify, lookup)
+ assert.False(t, res.Authenticated)
+ assert.Empty(t, res.Username)
+}
+
+func TestAuthenticate_FallsBackUIDToARN(t *testing.T) {
+ verify := func(string) (*TokenVerifyResponse, error) {
+ return &TokenVerifyResponse{ARN: testARN}, nil // no UserID
+ }
+ lookup := func(string) (*AccessEntryRecord, error) {
+ return &AccessEntryRecord{KubernetesUsername: testARN, KubernetesGroups: []string{"system:masters"}}, nil
+ }
+
+ res := Authenticate(validToken("https://sts/?x=1"), verify, lookup)
+ require.True(t, res.Authenticated)
+ assert.Equal(t, testARN, res.UID)
+}
+
+func TestResolveTokenReview_NilConn(t *testing.T) {
+ _, err := ResolveTokenReview(nil, "111122223333", "alpha", "tok", time.Second)
+ require.Error(t, err)
+}
+
+// A genuine infra fault (account bucket never created) is an error, not a
+// silent Authenticated=false — the webhook turns it into a retryable 5xx.
+func TestResolveTokenReview_MissingBucketErrors(t *testing.T) {
+ _, nc, _ := testutil.StartTestJetStream(t)
+ _, err := ResolveTokenReview(nc, "111122223333", "alpha", validToken("https://sts/?x=1"), time.Second)
+ require.Error(t, err)
+}
+
+func TestResolveTokenReview_AuthenticatesViaVerifyAndKV(t *testing.T) {
+ _, nc, js := testutil.StartTestJetStream(t)
+ kv := testutil.SeedKV(t, js, AccountBucketName("111122223333"), nil)
+ require.NoError(t, PutAccessEntryRecord(kv, &AccessEntryRecord{
+ ClusterName: "alpha",
+ PrincipalARN: testARN,
+ KubernetesUsername: testARN,
+ KubernetesGroups: []string{"system:masters"},
+ Type: AccessEntryTypeStandard,
+ }))
+
+ // Stand in for the awsgw-hosted STS verify responder.
+ sub, err := nc.Subscribe(TokenVerifySubject, func(m *nats.Msg) {
+ resp, _ := json.Marshal(TokenVerifyResponse{
+ AccountID: "111122223333",
+ ARN: testARN,
+ UserID: "AROAEXAMPLE:session",
+ PrincipalType: "AssumedRole",
+ })
+ _ = m.Respond(resp)
+ })
+ require.NoError(t, err)
+ t.Cleanup(func() { _ = sub.Unsubscribe() })
+
+ res, err := ResolveTokenReview(nc, "111122223333", "alpha", validToken("https://sts/?Action=GetCallerIdentity"), 2*time.Second)
+ require.NoError(t, err)
+ require.True(t, res.Authenticated)
+ assert.Equal(t, testARN, res.Username)
+ assert.Equal(t, "AROAEXAMPLE:session", res.UID)
+ assert.Equal(t, []string{"system:masters"}, res.Groups)
+}
+
+// A valid IAM principal with no AccessEntry resolves to Authenticated=false
+// (a clean 401), not an error.
+func TestResolveTokenReview_NoAccessEntryDenies(t *testing.T) {
+ _, nc, js := testutil.StartTestJetStream(t)
+ testutil.SeedKV(t, js, AccountBucketName("111122223333"), nil)
+
+ sub, err := nc.Subscribe(TokenVerifySubject, func(m *nats.Msg) {
+ resp, _ := json.Marshal(TokenVerifyResponse{AccountID: "111122223333", ARN: testARN})
+ _ = m.Respond(resp)
+ })
+ require.NoError(t, err)
+ t.Cleanup(func() { _ = sub.Unsubscribe() })
+
+ res, err := ResolveTokenReview(nc, "111122223333", "alpha", validToken("https://sts/?x=1"), 2*time.Second)
+ require.NoError(t, err)
+ assert.False(t, res.Authenticated)
+}
diff --git a/spinifex/handlers/eks/token_webhook.go b/spinifex/handlers/eks/token_webhook.go
new file mode 100644
index 000000000..8f4e483e0
--- /dev/null
+++ b/spinifex/handlers/eks/token_webhook.go
@@ -0,0 +1,58 @@
+package handlers_eks
+
+import (
+ "encoding/base64"
+ "errors"
+ "strings"
+)
+
+// TokenVerifySubject is the NATS request subject the EKS token webhook calls to
+// resolve an `aws eks get-token` presigned URL into a caller principal. It is
+// served by the awsgw service (which hosts the in-process STS service) because
+// STS is gateway-local and not otherwise exposed on NATS.
+const TokenVerifySubject = "eks.VerifyToken" //nolint:gosec // NATS subject name, not a credential
+
+// getTokenV1Prefix is the scheme prefix `aws eks get-token` prepends to the
+// base64url-encoded presigned STS GetCallerIdentity URL.
+const getTokenV1Prefix = "k8s-aws-v1." //nolint:gosec // token scheme prefix, not a credential
+
+// ErrMalformedToken is returned by DecodeGetToken when the bearer token is not
+// a well-formed `k8s-aws-v1.` value.
+var ErrMalformedToken = errors.New("eks: malformed k8s-aws-v1 token")
+
+// TokenVerifyRequest is the NATS request payload sent to TokenVerifySubject.
+// ClusterName is the anti-replay binding (Q10): the webhook passes its own
+// cluster name and STS rejects a URL signed for any other x-k8s-aws-id.
+type TokenVerifyRequest struct {
+ PresignedURL string `json:"presignedURL"`
+ ClusterName string `json:"clusterName"`
+}
+
+// TokenVerifyResponse is the NATS reply: the resolved caller principal. It
+// mirrors the fields of handlers_sts.PresignedCallerIdentity the webhook needs
+// to key its AccessEntry lookup.
+type TokenVerifyResponse struct {
+ AccountID string `json:"accountID"`
+ ARN string `json:"arn"`
+ UserID string `json:"userID"`
+ PrincipalType string `json:"principalType"`
+}
+
+// DecodeGetToken extracts the presigned STS GetCallerIdentity URL from a
+// `k8s-aws-v1.` bearer token. The encoding is base64url without
+// padding, matching the aws-iam-authenticator / aws-cli get-token output.
+func DecodeGetToken(token string) (string, error) {
+ token = strings.TrimSpace(token)
+ if !strings.HasPrefix(token, getTokenV1Prefix) {
+ return "", ErrMalformedToken
+ }
+ encoded := strings.TrimPrefix(token, getTokenV1Prefix)
+ raw, err := base64.RawURLEncoding.DecodeString(encoded)
+ if err != nil {
+ return "", ErrMalformedToken
+ }
+ if len(raw) == 0 {
+ return "", ErrMalformedToken
+ }
+ return string(raw), nil
+}
diff --git a/spinifex/handlers/eks/token_webhook_test.go b/spinifex/handlers/eks/token_webhook_test.go
new file mode 100644
index 000000000..69f89c1a8
--- /dev/null
+++ b/spinifex/handlers/eks/token_webhook_test.go
@@ -0,0 +1,44 @@
+package handlers_eks
+
+import (
+ "encoding/base64"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestDecodeGetToken_RoundTrips(t *testing.T) {
+ url := "https://sts.amazonaws.com/?Action=GetCallerIdentity&Version=2011-06-15&X-Amz-Algorithm=AWS4-HMAC-SHA256"
+ token := getTokenV1Prefix + base64.RawURLEncoding.EncodeToString([]byte(url))
+
+ got, err := DecodeGetToken(token)
+ require.NoError(t, err)
+ assert.Equal(t, url, got)
+}
+
+func TestDecodeGetToken_TrimsWhitespace(t *testing.T) {
+ url := "https://sts.amazonaws.com/?Action=GetCallerIdentity"
+ token := " " + getTokenV1Prefix + base64.RawURLEncoding.EncodeToString([]byte(url)) + "\n"
+
+ got, err := DecodeGetToken(token)
+ require.NoError(t, err)
+ assert.Equal(t, url, got)
+}
+
+func TestDecodeGetToken_RejectsMalformed(t *testing.T) {
+ cases := map[string]string{
+ "missing prefix": base64.RawURLEncoding.EncodeToString([]byte("https://sts")),
+ "empty": "",
+ "prefix only": getTokenV1Prefix,
+ "bad base64": getTokenV1Prefix + "!!!not-base64!!!",
+ "padded base64": getTokenV1Prefix + base64.URLEncoding.EncodeToString([]byte("https://sts.amazonaws.com/?x=12")),
+ "wrong scheme": "k8s-aws-v2." + base64.RawURLEncoding.EncodeToString([]byte("https://sts")),
+ }
+ for name, tok := range cases {
+ t.Run(name, func(t *testing.T) {
+ _, err := DecodeGetToken(tok)
+ assert.ErrorIs(t, err, ErrMalformedToken)
+ })
+ }
+}
diff --git a/spinifex/handlers/eks/types.go b/spinifex/handlers/eks/types.go
index c42bf0997..cf17dd393 100644
--- a/spinifex/handlers/eks/types.go
+++ b/spinifex/handlers/eks/types.go
@@ -27,25 +27,61 @@ type ClusterRecord struct {
}
// NodegroupRecord is the persisted-state envelope for an EKS managed
-// nodegroup.
+// nodegroup. It is the source of truth for Describe/ListNodegroups and tracks
+// the worker EC2 instance IDs the nodegroup owns so Update/DeleteNodegroup can
+// scale and tear them down.
type NodegroupRecord struct {
- ClusterName string `json:"clusterName"`
- Name string `json:"name"`
- Status string `json:"status"`
- DesiredSize int64 `json:"desiredSize"`
- InstanceTypes []string `json:"instanceTypes,omitempty"`
- CreatedAt time.Time `json:"createdAt"`
+ ClusterName string `json:"clusterName"`
+ Name string `json:"name"`
+ Arn string `json:"arn"`
+ Status string `json:"status"`
+ StatusReason string `json:"statusReason,omitempty"`
+ Subnets []string `json:"subnets,omitempty"`
+ InstanceTypes []string `json:"instanceTypes,omitempty"`
+ AMIType string `json:"amiType,omitempty"`
+ DiskSize int64 `json:"diskSize,omitempty"`
+ ScalingMin int64 `json:"scalingMin"`
+ ScalingMax int64 `json:"scalingMax"`
+ ScalingDesired int64 `json:"scalingDesired"`
+ Version string `json:"version,omitempty"`
+ NodeRole string `json:"nodeRole,omitempty"`
+ Labels map[string]string `json:"labels,omitempty"`
+ InstanceIDs []string `json:"instanceIds,omitempty"`
+ Health string `json:"health,omitempty"`
+ CreatedAt time.Time `json:"createdAt"`
+ ModifiedAt time.Time `json:"modifiedAt"`
}
// AccessEntryRecord is the persisted-state envelope for an EKS API-mode
// AccessEntry (Q9).
type AccessEntryRecord struct {
- ClusterName string `json:"clusterName"`
- PrincipalARN string `json:"principalARN"`
- KubernetesUsername string `json:"kubernetesUsername"`
- KubernetesGroups []string `json:"kubernetesGroups,omitempty"`
- Type string `json:"type"`
- CreatedAt time.Time `json:"createdAt"`
+ ARN string `json:"arn"`
+ ClusterName string `json:"clusterName"`
+ PrincipalARN string `json:"principalARN"`
+ KubernetesUsername string `json:"kubernetesUsername"`
+ KubernetesGroups []string `json:"kubernetesGroups,omitempty"`
+ Type string `json:"type"`
+ Tags map[string]string `json:"tags,omitempty"`
+ AssociatedPolicies []AssociatedAccessPolicy `json:"associatedPolicies,omitempty"`
+ CreatedAt time.Time `json:"createdAt"`
+ ModifiedAt time.Time `json:"modifiedAt"`
+}
+
+// AssociatedAccessPolicy is a managed access policy bound to an AccessEntry
+// with a cluster- or namespace-scoped grant (Q9).
+type AssociatedAccessPolicy struct {
+ PolicyARN string `json:"policyARN"`
+ AccessScope AccessScope `json:"accessScope"`
+ AssociatedAt time.Time `json:"associatedAt"`
+ ModifiedAt time.Time `json:"modifiedAt"`
+}
+
+// AccessScope restricts an associated access policy to the whole cluster or a
+// set of namespaces. Type is "cluster" or "namespace"; Namespaces is required
+// (and only meaningful) when Type is "namespace".
+type AccessScope struct {
+ Type string `json:"type"`
+ Namespaces []string `json:"namespaces,omitempty"`
}
// OIDCProviderConfigRecord captures the minimum needed to register an OIDC
diff --git a/spinifex/handlers/elbv2/agent_types.go b/spinifex/handlers/elbv2/agent_types.go
index 16a39c39f..bc4fd6fbb 100644
--- a/spinifex/handlers/elbv2/agent_types.go
+++ b/spinifex/handlers/elbv2/agent_types.go
@@ -32,10 +32,38 @@ type GetLBConfigInput struct {
LBID *string `locationName:"LBID" type:"string"`
}
-// GetLBConfigOutput returns the pre-computed HAProxy config and its hash.
+// GetLBConfigOutput returns the pre-computed data-plane config and its hash,
+// plus any TLS certificate files the config references. Engine selects the
+// data plane the agent runs: "haproxy" (ALB) or "nginx" (NLB). HealthTargets
+// is populated for NLBs only: nginx `stream` has no active upstream probing,
+// so the agent probes these and reports the results via heartbeat.
type GetLBConfigOutput struct {
- ConfigText *string `type:"string"`
- ConfigHash *string `type:"string"`
+ ConfigText *string `type:"string"`
+ ConfigHash *string `type:"string"`
+ CertFiles []*CertFile `locationName:"CertFiles" type:"list"`
+ Engine *string `type:"string"`
+ HealthTargets []*HealthTarget `locationName:"HealthTargets" type:"list"`
+}
+
+// HealthTarget is one backend the nginx agent must actively health-check.
+// ServerName is daemon-computed (sanitizeName("srv", target.Id)) so it matches
+// the name the health checker keys on — the agent echoes it back verbatim and
+// needs no ELBv2 naming logic. Address is the ip:port to probe; Protocol is
+// the target group's health-check protocol (TCP/HTTP/HTTPS); Path is the
+// HTTP(S) health-check path.
+type HealthTarget struct {
+ ServerName *string `locationName:"ServerName" type:"string"`
+ Address *string `locationName:"Address" type:"string"`
+ Protocol *string `locationName:"Protocol" type:"string"`
+ Path *string `locationName:"Path" type:"string"`
+}
+
+// CertFile is one TLS certificate PEM delivered to the LB agent. Path is the
+// absolute destination (under lbagent.CertDir) the agent writes 0600 before
+// reload; PEM is the combined cert+chain+key material.
+type CertFile struct {
+ Path *string `locationName:"Path" type:"string"`
+ PEM *string `locationName:"PEM" type:"string"`
}
// toHealthReport converts the heartbeat input's server list to the lbagent
diff --git a/spinifex/handlers/elbv2/haproxy.go b/spinifex/handlers/elbv2/haproxy.go
index 92c487c82..8e24fd5c8 100644
--- a/spinifex/handlers/elbv2/haproxy.go
+++ b/spinifex/handlers/elbv2/haproxy.go
@@ -6,11 +6,14 @@ import (
"os"
"os/exec"
"path/filepath"
+ "regexp"
"strconv"
"strings"
"sync"
"syscall"
"text/template"
+
+ "github.com/mulgadc/spinifex/spinifex/lbagent"
)
// Every interpolated string field in the templates below MUST be regex-validated
@@ -33,7 +36,7 @@ defaults
timeout server 60s
{{range .Frontends}}
frontend {{.Name}}
- bind *:{{.Port}}
+ bind *:{{.Port}}{{if .CertPath}} ssl crt {{.CertPath}}{{end}}
{{- range .Rules}}
{{- range .ACLs}}
acl {{.Name}} {{.Expr}}
@@ -44,58 +47,32 @@ frontend {{.Name}}
{{end}}
{{range .Backends}}
backend {{.Name}}
+{{- if .Redirect}}
+ http-request redirect {{if .Redirect.Scheme}}scheme {{.Redirect.Scheme}}{{else}}location {{.Redirect.Location}}{{end}} code {{.Redirect.Code}}
+{{- else if .FixedResponse}}
+ http-request return status {{.FixedResponse.StatusCode}}{{if .FixedResponse.ContentType}} content-type "{{.FixedResponse.ContentType}}"{{end}}{{if .FixedResponse.Body}} string "{{.FixedResponse.Body}}"{{end}}
+{{- else}}
balance roundrobin{{if .HealthCheckPath}}
option httpchk GET {{.HealthCheckPath}}
http-check expect status {{.HealthCheckMatcher}}{{end}}
{{- range .Servers}}
server {{.Name}} {{.Addr}}:{{.Port}} check inter {{.CheckInterval}}s fall {{.Fall}} rise {{.Rise}}
{{- end}}
-{{end}}
-`
-
-const haproxyTCPConfigTemplate = `# Auto-generated by spinifex ELBv2 (NLB) — do not edit
-global
- log stdout format short daemon
- stats socket {{.SocketPath}} mode 660 level admin
-
-defaults
- mode tcp
- log global
- option tcplog
- timeout connect 5s
- timeout client 60s
- timeout server 60s
-{{range .Frontends}}
-frontend {{.Name}}
- bind *:{{.Port}}
- default_backend {{.DefaultBackend}}
-{{end}}
-{{range .Backends}}
-backend {{.Name}}
- balance roundrobin
-{{- if .TCPCheck}}
- option tcp-check
-{{- end}}
-{{- if .HTTPCheck}}
- option httpchk GET {{.HealthCheckPath}}
- http-check expect status {{.HealthCheckMatcher}}
-{{- end}}
-{{- range .Servers}}
- server {{.Name}} {{.Addr}}:{{.Port}} check inter {{.CheckInterval}}s fall {{.Fall}} rise {{.Rise}}
{{- end}}
{{end}}
`
-var (
- haproxyHTTPTmpl = template.Must(template.New("haproxy").Parse(haproxyConfigTemplate))
- haproxyTCPTmpl = template.Must(template.New("haproxy-tcp").Parse(haproxyTCPConfigTemplate))
-)
+var haproxyHTTPTmpl = template.Must(template.New("haproxy").Parse(haproxyConfigTemplate))
// HAProxyConfig holds the full configuration for rendering the HAProxy template.
type HAProxyConfig struct {
SocketPath string
Frontends []HAProxyFrontend
Backends []HAProxyBackend
+ // CertFiles maps each absolute `ssl crt` path referenced by a secure
+ // frontend to its combined PEM (cert+chain+key). Not rendered into the
+ // template — carried out to the caller for delivery to the LB agent.
+ CertFiles map[string]string
}
// HAProxyFrontend represents a listener frontend.
@@ -103,6 +80,8 @@ type HAProxyFrontend struct {
Name string
BindAddr string
Port int64
+ Protocol string // listener protocol (HTTP/HTTPS/TCP/TLS); drives TLS termination
+ CertPath string // combined-PEM path for `ssl crt`; empty for non-secure frontends
DefaultBackend string
Rules []HAProxyRule
}
@@ -123,14 +102,33 @@ type HAProxyACL struct {
Expr string
}
-// HAProxyBackend represents a target group backend.
+// HAProxyBackend represents a target group backend, or a synthetic backend that
+// emits a fixed response or a redirect (when FixedResponse / Redirect is set,
+// the server fields are unused).
type HAProxyBackend struct {
Name string
HealthCheckPath string
HealthCheckMatcher string
- TCPCheck bool
- HTTPCheck bool
Servers []HAProxyServer
+ FixedResponse *HAProxyFixedResponse
+ Redirect *HAProxyRedirect
+}
+
+// HAProxyFixedResponse renders an `http-request return` directive for a
+// fixed-response listener default action. All fields are validated before use.
+type HAProxyFixedResponse struct {
+ StatusCode string
+ ContentType string
+ Body string
+}
+
+// HAProxyRedirect renders an `http-request redirect` directive. Exactly one of
+// Scheme (the scheme-only fast path, e.g. HTTP→HTTPS) or Location (a full
+// rebuilt URL with HAProxy log-format fetches) is set.
+type HAProxyRedirect struct {
+ Scheme string
+ Location string
+ Code string
}
// HAProxyServer represents a single target server.
@@ -145,48 +143,47 @@ type HAProxyServer struct {
// GenerateHAProxyConfig builds an HAProxy config string from the LB, its
// listeners, target groups, and per-listener rules. For NLBs it generates a
-// TCP-mode config and ignores rules (NLB has no L7 routing).
+// TCP-mode config and ignores rules (NLB has no L7 routing). TLS termination
+// is off (no certificates resolved) — use GenerateHAProxyConfigWithCerts for
+// HTTPS listeners.
func GenerateHAProxyConfig(lb *LoadBalancerRecord, listeners []*ListenerRecord, tgByArn map[string]*TargetGroupRecord, rulesByListener map[string][]*RuleRecord, bindAddr string) (string, error) {
+ config, _, err := GenerateHAProxyConfigWithCerts(lb, listeners, tgByArn, rulesByListener, bindAddr, nil)
+ return config, err
+}
+
+// GenerateHAProxyConfigWithCerts is GenerateHAProxyConfig plus TLS termination.
+// certPEMByArn maps each listener certificate ARN to its combined PEM; secure
+// frontends render `ssl crt ` and the returned certFiles map carries each
+// path → PEM for delivery to the LB agent. A secure listener whose default cert
+// ARN is absent from certPEMByArn renders without `ssl crt` (HAProxy would fail
+// to bind, surfacing the missing material).
+func GenerateHAProxyConfigWithCerts(lb *LoadBalancerRecord, listeners []*ListenerRecord, tgByArn map[string]*TargetGroupRecord, rulesByListener map[string][]*RuleRecord, bindAddr string, certPEMByArn map[string]string) (string, map[string]string, error) {
+ // NLBs (L4) render an nginx `stream` config — HAProxy load-balances no UDP.
if lb.Type == LoadBalancerTypeNetwork {
- return generateNLBConfig(lb, listeners, tgByArn, bindAddr)
+ return generateNLBStreamConfig(lb, listeners, tgByArn, bindAddr, certPEMByArn)
}
- return generateALBConfig(lb, listeners, tgByArn, rulesByListener, bindAddr)
+ return generateALBConfig(lb, listeners, tgByArn, rulesByListener, bindAddr, certPEMByArn)
}
// generateALBConfig builds an HTTP-mode HAProxy config for ALBs.
-func generateALBConfig(lb *LoadBalancerRecord, listeners []*ListenerRecord, tgByArn map[string]*TargetGroupRecord, rulesByListener map[string][]*RuleRecord, bindAddr string) (string, error) {
- cfg, err := buildHAProxyConfig(lb, listeners, tgByArn, rulesByListener, bindAddr, false)
+func generateALBConfig(lb *LoadBalancerRecord, listeners []*ListenerRecord, tgByArn map[string]*TargetGroupRecord, rulesByListener map[string][]*RuleRecord, bindAddr string, certPEMByArn map[string]string) (string, map[string]string, error) {
+ cfg, err := buildHAProxyConfig(lb, listeners, tgByArn, rulesByListener, bindAddr, certPEMByArn)
if err != nil {
- return "", err
+ return "", nil, err
}
var buf strings.Builder
if err := haproxyHTTPTmpl.Execute(&buf, cfg); err != nil {
- return "", fmt.Errorf("execute haproxy template: %w", err)
+ return "", nil, fmt.Errorf("execute haproxy template: %w", err)
}
- return buf.String(), nil
+ return buf.String(), cfg.CertFiles, nil
}
-// generateNLBConfig builds a TCP-mode HAProxy config for NLBs.
-func generateNLBConfig(lb *LoadBalancerRecord, listeners []*ListenerRecord, tgByArn map[string]*TargetGroupRecord, bindAddr string) (string, error) {
- cfg, err := buildHAProxyConfig(lb, listeners, tgByArn, nil, bindAddr, true)
- if err != nil {
- return "", err
- }
-
- var buf strings.Builder
- if err := haproxyTCPTmpl.Execute(&buf, cfg); err != nil {
- return "", fmt.Errorf("execute haproxy tcp template: %w", err)
- }
-
- return buf.String(), nil
-}
-
-// buildHAProxyConfig constructs the HAProxyConfig struct shared by ALB and NLB
-// generation. For ALBs, rulesByListener provides per-listener rules sorted by
-// priority; nil/empty for NLBs.
-func buildHAProxyConfig(lb *LoadBalancerRecord, listeners []*ListenerRecord, tgByArn map[string]*TargetGroupRecord, rulesByListener map[string][]*RuleRecord, bindAddr string, isTCP bool) (HAProxyConfig, error) {
+// buildHAProxyConfig constructs the HAProxyConfig struct for an ALB (L7).
+// rulesByListener provides per-listener rules sorted by priority. NLBs use the
+// nginx data plane (generateNLBStreamConfig), not this builder.
+func buildHAProxyConfig(lb *LoadBalancerRecord, listeners []*ListenerRecord, tgByArn map[string]*TargetGroupRecord, rulesByListener map[string][]*RuleRecord, bindAddr string, certPEMByArn map[string]string) (HAProxyConfig, error) {
socketDir := filepath.Join(os.TempDir(), "spinifex-haproxy")
// Use "lb" prefix for both ALB and NLB — must match the agent's socket path.
socketPath := filepath.Join(socketDir, fmt.Sprintf("lb-%s.sock", lb.LoadBalancerID))
@@ -206,19 +203,10 @@ func buildHAProxyConfig(lb *LoadBalancerRecord, listeners []*ListenerRecord, tgB
return
}
- backend := HAProxyBackend{Name: backendName}
- if isTCP {
- switch tg.HealthCheck.Protocol {
- case ProtocolHTTP, ProtocolHTTPS:
- backend.HTTPCheck = true
- backend.HealthCheckPath = tg.HealthCheck.Path
- backend.HealthCheckMatcher = tg.HealthCheck.Matcher
- default:
- backend.TCPCheck = true
- }
- } else {
- backend.HealthCheckPath = tg.HealthCheck.Path
- backend.HealthCheckMatcher = tg.HealthCheck.Matcher
+ backend := HAProxyBackend{
+ Name: backendName,
+ HealthCheckPath: tg.HealthCheck.Path,
+ HealthCheckMatcher: tg.HealthCheck.Matcher,
}
for _, target := range tg.Targets {
@@ -238,53 +226,176 @@ func buildHAProxyConfig(lb *LoadBalancerRecord, listeners []*ListenerRecord, tgB
backendAdded[backendName] = struct{}{}
}
+ addFixedResponseBackend := func(name string, fr *FixedResponseAction) {
+ if _, ok := backendAdded[name]; ok {
+ return
+ }
+ hf := &HAProxyFixedResponse{StatusCode: "503"}
+ if fr != nil {
+ if validHTTPStatusCode(fr.StatusCode) {
+ hf.StatusCode = fr.StatusCode
+ }
+ if validContentType(fr.ContentType) {
+ hf.ContentType = fr.ContentType
+ }
+ if validFixedResponseBody(fr.MessageBody) {
+ hf.Body = fr.MessageBody
+ }
+ }
+ cfg.Backends = append(cfg.Backends, HAProxyBackend{Name: name, FixedResponse: hf})
+ backendAdded[name] = struct{}{}
+ }
+
+ addRedirectBackend := func(name string, rd *HAProxyRedirect) {
+ if _, ok := backendAdded[name]; ok {
+ return
+ }
+ cfg.Backends = append(cfg.Backends, HAProxyBackend{Name: name, Redirect: rd})
+ backendAdded[name] = struct{}{}
+ }
+
for _, l := range listeners {
if len(l.DefaultActions) == 0 {
continue
}
- tgArn := l.DefaultActions[0].TargetGroupArn
- defaultBackendName := sanitizeName("bk", tgArn)
+ da := l.DefaultActions[0]
+ isForwardDefault := da.Type == ActionTypeForward && da.TargetGroupArn != ""
+
+ var defaultBackendName string
+ if isForwardDefault {
+ defaultBackendName = sanitizeName("bk", da.TargetGroupArn)
+ } else {
+ // redirect / fixed-response (or any non-forward default, e.g. the
+ // shared-ingress 404): synthesize a backend so HAProxy has a valid
+ // default_backend instead of a dangling reference.
+ defaultBackendName = sanitizeName("bkdefault", l.ListenerArn)
+ }
frontend := HAProxyFrontend{
Name: sanitizeName("ft", l.ListenerArn),
BindAddr: bindAddr,
Port: l.Port,
+ Protocol: l.Protocol,
DefaultBackend: defaultBackendName,
}
- if !isTCP {
- rules := rulesByListener[l.ListenerArn]
- for _, r := range rules {
- rule, err := buildHAProxyRule(r)
- if err != nil {
- return HAProxyConfig{}, err
- }
- frontend.Rules = append(frontend.Rules, rule)
- for _, a := range r.Actions {
- if a.Type == ActionTypeForward && a.TargetGroupArn != "" {
- addBackend(a.TargetGroupArn)
- }
+ // Secure listeners terminate TLS: resolve the default cert to a stable
+ // per-listener PEM path and stage it for delivery. Without resolved
+ // material the frontend renders plain (HAProxy then fails to bind,
+ // surfacing the gap rather than silently serving cleartext).
+ if protocolRequiresCert(l.Protocol) {
+ if pem, path := resolveFrontendCert(lb, l, certPEMByArn); pem != "" {
+ frontend.CertPath = path
+ if cfg.CertFiles == nil {
+ cfg.CertFiles = make(map[string]string)
}
+ cfg.CertFiles[path] = pem
}
}
+ for _, r := range rulesByListener[l.ListenerArn] {
+ backendName, err := registerRuleBackend(r, l, addBackend, addFixedResponseBackend, addRedirectBackend)
+ if err != nil {
+ return HAProxyConfig{}, err
+ }
+ rule, err := buildHAProxyRule(r, backendName)
+ if err != nil {
+ return HAProxyConfig{}, err
+ }
+ frontend.Rules = append(frontend.Rules, rule)
+ }
+
cfg.Frontends = append(cfg.Frontends, frontend)
- addBackend(tgArn)
+ switch {
+ case isForwardDefault:
+ addBackend(da.TargetGroupArn)
+ case da.Type == ActionTypeRedirect:
+ rd, err := buildRedirect(da.Redirect, l)
+ if err != nil {
+ return HAProxyConfig{}, err
+ }
+ addRedirectBackend(defaultBackendName, rd)
+ default:
+ addFixedResponseBackend(defaultBackendName, da.FixedResponse)
+ }
}
return cfg, nil
}
-// buildHAProxyRule converts a RuleRecord into the rendered ACL+use_backend
-// shape. Returns an error if any condition value fails the defence-in-depth
-// re-validation (HAProxy meta-character rejection).
-func buildHAProxyRule(r *RuleRecord) (HAProxyRule, error) {
- if len(r.Actions) == 0 || r.Actions[0].Type != ActionTypeForward {
- return HAProxyRule{}, fmt.Errorf("rule %s has no forward action", r.RuleID)
- }
+// frontendCertPath returns the stable absolute path of a listener's combined
+// PEM file under the agent's cert dir. Daemon-rendered and agent-written paths
+// must match exactly.
+func frontendCertPath(lb *LoadBalancerRecord, l *ListenerRecord) string {
+ return filepath.Join(lbagent.CertDir, fmt.Sprintf("lb-%s-%s.pem", lb.LoadBalancerID, l.ListenerID))
+}
- rule := HAProxyRule{Backend: sanitizeName("bk", r.Actions[0].TargetGroupArn)}
+// resolveFrontendCert picks a listener's default certificate, resolves its PEM
+// from certPEMByArn, and returns (pem, path). Returns ("", "") when the listener
+// has no certificate or its default ARN is unresolved.
+func resolveFrontendCert(lb *LoadBalancerRecord, l *ListenerRecord, certPEMByArn map[string]string) (pem, path string) {
+ if len(l.Certificates) == 0 || certPEMByArn == nil {
+ return "", ""
+ }
+ arn := l.Certificates[0].CertificateArn
+ for _, c := range l.Certificates {
+ if c.IsDefault {
+ arn = c.CertificateArn
+ break
+ }
+ }
+ pem, ok := certPEMByArn[arn]
+ if !ok || pem == "" {
+ return "", ""
+ }
+ return pem, frontendCertPath(lb, l)
+}
+
+// registerRuleBackend resolves the HAProxy backend a rule's single action
+// routes to, registering a synthetic backend for redirect / fixed-response
+// rule actions, and returns its name.
+func registerRuleBackend(
+ r *RuleRecord,
+ l *ListenerRecord,
+ addBackend func(string),
+ addFixedResponseBackend func(string, *FixedResponseAction),
+ addRedirectBackend func(string, *HAProxyRedirect),
+) (string, error) {
+ if len(r.Actions) == 0 {
+ return "", fmt.Errorf("rule %s has no action", r.RuleID)
+ }
+ a := r.Actions[0]
+ switch a.Type {
+ case ActionTypeForward:
+ if a.TargetGroupArn == "" {
+ return "", fmt.Errorf("rule %s forward action has no target group", r.RuleID)
+ }
+ addBackend(a.TargetGroupArn)
+ return sanitizeName("bk", a.TargetGroupArn), nil
+ case ActionTypeFixedResponse:
+ name := sanitizeName("bkrule", r.RuleID)
+ addFixedResponseBackend(name, a.FixedResponse)
+ return name, nil
+ case ActionTypeRedirect:
+ rd, err := buildRedirect(a.Redirect, l)
+ if err != nil {
+ return "", err
+ }
+ name := sanitizeName("bkrule", r.RuleID)
+ addRedirectBackend(name, rd)
+ return name, nil
+ default:
+ return "", fmt.Errorf("rule %s has unsupported action %q", r.RuleID, a.Type)
+ }
+}
+
+// buildHAProxyRule converts a RuleRecord's conditions into the rendered
+// ACL+use_backend shape, routing to backendName (already resolved/registered
+// by registerRuleBackend). Returns an error if any condition value fails the
+// defence-in-depth re-validation (HAProxy meta-character rejection).
+func buildHAProxyRule(r *RuleRecord, backendName string) (HAProxyRule, error) {
+ rule := HAProxyRule{Backend: backendName}
for ci, c := range r.Conditions {
aclName := fmt.Sprintf("r%s_c%d", sanitizeACLToken(r.RuleID), ci)
@@ -395,6 +506,155 @@ func wildcardMatch(v string) (flag, literal string) {
}
}
+// contentTypeRegex matches a conservative MIME type (type/subtype) for the
+// fixed-response content-type header rendered into the HAProxy config.
+var contentTypeRegex = regexp.MustCompile(`^[a-zA-Z0-9!#$&^_.+-]+/[a-zA-Z0-9!#$&^_.+-]+$`)
+
+// validHTTPStatusCode accepts a 3-digit 2xx–5xx HTTP status.
+func validHTTPStatusCode(s string) bool {
+ if len(s) != 3 || s[0] < '2' || s[0] > '5' {
+ return false
+ }
+ for _, r := range s {
+ if r < '0' || r > '9' {
+ return false
+ }
+ }
+ return true
+}
+
+// validContentType accepts a conservative type/subtype MIME string.
+func validContentType(s string) bool {
+ return contentTypeRegex.MatchString(s)
+}
+
+// validFixedResponseBody rejects bytes that would terminate the quoted HAProxy
+// string or inject a directive. Spaces are allowed (the body renders inside
+// double quotes).
+func validFixedResponseBody(s string) bool {
+ if s == "" {
+ return false
+ }
+ for _, r := range s {
+ if r == '\n' || r == '\r' || r == '"' || r == '\\' || r == '#' {
+ return false
+ }
+ }
+ return true
+}
+
+// buildRedirect translates a stored RedirectAction into an HAProxy redirect
+// directive in the context of listener l (which resolves the `#{protocol}` and
+// `#{port}` placeholders). The common scheme-only redirect (e.g. HTTP→HTTPS
+// with every other field left at its AWS default) renders as `redirect scheme`
+// so HAProxy preserves host/path/query; anything else rebuilds a `location`.
+func buildRedirect(rd *RedirectAction, l *ListenerRecord) (*HAProxyRedirect, error) {
+ if rd == nil {
+ return nil, fmt.Errorf("redirect action missing config")
+ }
+
+ var code string
+ switch rd.StatusCode {
+ case "HTTP_301":
+ code = "301"
+ case "HTTP_302":
+ code = "302"
+ default:
+ return nil, fmt.Errorf("redirect has invalid status code %q", rd.StatusCode)
+ }
+
+ for _, f := range []string{rd.Protocol, rd.Host, rd.Port, rd.Path, rd.Query} {
+ if !validRedirectField(f) {
+ return nil, fmt.Errorf("redirect field contains forbidden characters")
+ }
+ }
+
+ defaultHost := rd.Host == "" || rd.Host == "#{host}"
+ defaultPort := rd.Port == "" || rd.Port == "#{port}"
+ defaultPath := rd.Path == "" || rd.Path == "#{path}" || rd.Path == "/#{path}"
+ defaultQuery := rd.Query == "" || rd.Query == "#{query}"
+ explicitScheme := rd.Protocol == ProtocolHTTP || rd.Protocol == ProtocolHTTPS
+
+ if explicitScheme && defaultHost && defaultPort && defaultPath && defaultQuery {
+ return &HAProxyRedirect{Scheme: strings.ToLower(rd.Protocol), Code: code}, nil
+ }
+
+ scheme := redirectScheme(rd.Protocol, l)
+ host := redirectPlaceholders(orDefault(rd.Host, "#{host}"), l)
+
+ port := ""
+ if rd.Port != "" && rd.Port != "#{port}" {
+ port = ":" + redirectPlaceholders(rd.Port, l)
+ }
+
+ path := "%[path]"
+ if !defaultPath {
+ path = redirectPlaceholders(rd.Path, l)
+ }
+
+ query := ""
+ if !defaultQuery {
+ query = "?" + redirectPlaceholders(rd.Query, l)
+ } else if rd.Query == "#{query}" {
+ query = "?%[query]"
+ }
+
+ return &HAProxyRedirect{Location: scheme + "://" + host + port + path + query, Code: code}, nil
+}
+
+func orDefault(s, def string) string {
+ if s == "" {
+ return def
+ }
+ return s
+}
+
+// redirectScheme resolves the redirect scheme literal, mapping the `#{protocol}`
+// placeholder (or an empty protocol) to the listener's own protocol.
+func redirectScheme(proto string, l *ListenerRecord) string {
+ switch proto {
+ case ProtocolHTTP, ProtocolHTTPS:
+ return strings.ToLower(proto)
+ default:
+ return strings.ToLower(l.Protocol)
+ }
+}
+
+// redirectPlaceholders substitutes the AWS redirect placeholders with HAProxy
+// log-format fetches. `#{protocol}` and `#{port}` resolve from the listener.
+func redirectPlaceholders(s string, l *ListenerRecord) string {
+ r := strings.NewReplacer(
+ "#{protocol}", strings.ToLower(l.Protocol),
+ "#{host}", "%[req.hdr(host)]",
+ "#{port}", strconv.FormatInt(l.Port, 10),
+ "#{path}", "%[path]",
+ "#{query}", "%[query]",
+ )
+ return r.Replace(s)
+}
+
+// validRedirectField rejects bytes that would break the HAProxy redirect
+// directive. The known `#{...}` placeholders are stripped first, so any
+// remaining `#`, `%`, bracket, quote, backslash, whitespace, or control byte
+// (which would indicate an unknown placeholder or an injection attempt) fails.
+func validRedirectField(s string) bool {
+ known := []string{"#{protocol}", "#{host}", "#{port}", "#{path}", "#{query}"}
+ tmp := s
+ for _, k := range known {
+ tmp = strings.ReplaceAll(tmp, k, "")
+ }
+ for _, r := range tmp {
+ if r < 0x20 || r == 0x7f {
+ return false
+ }
+ switch r {
+ case ' ', '"', '#', '%', '[', ']', '\\':
+ return false
+ }
+ }
+ return true
+}
+
// validateRenderSafe rejects bytes that could break ACL parsing or inject new
// directives. Mirrors the write-time validators in service_impl_rules.go;
// duplicated here so a future migration that bypasses the API cannot leak a
diff --git a/spinifex/handlers/elbv2/haproxy_certs_test.go b/spinifex/handlers/elbv2/haproxy_certs_test.go
new file mode 100644
index 000000000..f6575c600
--- /dev/null
+++ b/spinifex/handlers/elbv2/haproxy_certs_test.go
@@ -0,0 +1,221 @@
+package handlers_elbv2
+
+import (
+ "encoding/xml"
+ "path/filepath"
+ "testing"
+
+ "github.com/aws/aws-sdk-go/aws"
+ "github.com/mulgadc/spinifex/spinifex/awserrors"
+ handlers_acm "github.com/mulgadc/spinifex/spinifex/handlers/acm"
+ "github.com/mulgadc/spinifex/spinifex/lbagent"
+ "github.com/mulgadc/spinifex/spinifex/utils"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+const certTestArn = "arn:aws:acm:ap-southeast-2:123456789012:certificate/cccc-3333"
+
+func httpsListener() *ListenerRecord {
+ return &ListenerRecord{
+ ListenerArn: "arn:aws:elasticloadbalancing:us-east-1:123:listener/app/my-alb/lb-abc123/lst-https",
+ ListenerID: "lst-https",
+ Protocol: ProtocolHTTPS,
+ Port: 443,
+ Certificates: []ListenerCertificate{{CertificateArn: certTestArn, IsDefault: true}},
+ SslPolicy: DefaultSslPolicy,
+ DefaultActions: []ListenerAction{{Type: ActionTypeFixedResponse, FixedResponse: &FixedResponseAction{StatusCode: "200"}}},
+ LoadBalancerArn: "arn:lb",
+ }
+}
+
+func TestGenerateHAProxyConfigWithCerts_HTTPSRendersSslCrt(t *testing.T) {
+ lb := &LoadBalancerRecord{LoadBalancerID: "lb-abc123", Name: "my-alb"}
+ listeners := []*ListenerRecord{httpsListener()}
+ certPEM := "CERTPEM\nKEYPEM\n"
+
+ config, certFiles, err := GenerateHAProxyConfigWithCerts(
+ lb, listeners, nil, nil, "0.0.0.0",
+ map[string]string{certTestArn: certPEM},
+ )
+ require.NoError(t, err)
+
+ wantPath := filepath.Join(lbagent.CertDir, "lb-lb-abc123-lst-https.pem")
+ assert.Contains(t, config, "bind *:443 ssl crt "+wantPath)
+ require.Len(t, certFiles, 1)
+ assert.Equal(t, certPEM, certFiles[wantPath])
+}
+
+func TestGenerateHAProxyConfigWithCerts_HTTPHasNoSslCrt(t *testing.T) {
+ lb := &LoadBalancerRecord{LoadBalancerID: "lb-abc123", Name: "my-alb"}
+ listeners := []*ListenerRecord{{
+ ListenerArn: "arn:aws:elasticloadbalancing:us-east-1:123:listener/app/my-alb/lb-abc123/lst-http",
+ ListenerID: "lst-http",
+ Protocol: ProtocolHTTP,
+ Port: 80,
+ DefaultActions: []ListenerAction{{Type: ActionTypeFixedResponse, FixedResponse: &FixedResponseAction{StatusCode: "200"}}},
+ }}
+
+ config, certFiles, err := GenerateHAProxyConfigWithCerts(lb, listeners, nil, nil, "0.0.0.0", nil)
+ require.NoError(t, err)
+ assert.Contains(t, config, "bind *:80")
+ assert.NotContains(t, config, "ssl crt")
+ assert.Empty(t, certFiles)
+}
+
+// A secure listener whose cert ARN is unresolved renders without ssl crt — the
+// gap surfaces at HAProxy bind time rather than silently serving cleartext.
+func TestGenerateHAProxyConfigWithCerts_MissingCertRendersPlain(t *testing.T) {
+ lb := &LoadBalancerRecord{LoadBalancerID: "lb-abc123", Name: "my-alb"}
+ config, certFiles, err := GenerateHAProxyConfigWithCerts(
+ lb, []*ListenerRecord{httpsListener()}, nil, nil, "0.0.0.0", nil,
+ )
+ require.NoError(t, err)
+ assert.NotContains(t, config, "ssl crt")
+ assert.Empty(t, certFiles)
+}
+
+func TestGenerateHAProxyConfig_BackwardCompatNoTLS(t *testing.T) {
+ lb := &LoadBalancerRecord{LoadBalancerID: "lb-abc123", Name: "my-alb"}
+ config, err := GenerateHAProxyConfig(lb, []*ListenerRecord{httpsListener()}, nil, nil, "0.0.0.0")
+ require.NoError(t, err)
+ assert.NotContains(t, config, "ssl crt", "legacy entrypoint resolves no certs")
+}
+
+func TestConfigCertHash_ChangesOnRotation(t *testing.T) {
+ const cfg = "frontend ft\n bind *:443 ssl crt /etc/haproxy/certs/x.pem\n"
+ path := "/etc/haproxy/certs/x.pem"
+
+ base := configCertHash(cfg, map[string]string{path: "PEM-A"})
+ rotated := configCertHash(cfg, map[string]string{path: "PEM-B"})
+ same := configCertHash(cfg, map[string]string{path: "PEM-A"})
+
+ assert.NotEqual(t, base, rotated, "rotating cert content must change the hash")
+ assert.Equal(t, base, same, "identical inputs hash identically")
+
+ // Config-only change also moves the hash.
+ assert.NotEqual(t, base, configCertHash(cfg+"\n", map[string]string{path: "PEM-A"}))
+}
+
+func putTestCert(t *testing.T, svc *ELBv2ServiceImpl, arn, account, leaf, chain, key string) {
+ t.Helper()
+ require.NoError(t, svc.acmStore.PutCert(&handlers_acm.CertRecord{
+ CertificateArn: arn,
+ AccountID: account,
+ Certificate: leaf,
+ CertificateChain: chain,
+ PrivateKey: key,
+ DomainName: "example.com",
+ }))
+}
+
+func TestResolveCertPEM_ConcatOrderAndOwnership(t *testing.T) {
+ svc := setupTestService(t)
+ require.NotNil(t, svc.acmStore)
+ putTestCert(t, svc, certTestArn, testAccountID, "LEAF", "CHAIN", "KEY")
+
+ pem, err := svc.resolveCertPEM(certTestArn, testAccountID)
+ require.NoError(t, err)
+ assert.Equal(t, "LEAF\nCHAIN\nKEY\n", pem)
+
+ // Wrong account → treated as not found.
+ _, err = svc.resolveCertPEM(certTestArn, "999999999999")
+ require.Error(t, err)
+
+ // Unknown ARN → not found.
+ _, err = svc.resolveCertPEM("arn:aws:acm:ap-southeast-2:123456789012:certificate/missing", testAccountID)
+ require.Error(t, err)
+}
+
+func TestValidateListenerCerts_RejectsUnknownAndWrongAccount(t *testing.T) {
+ svc := setupTestService(t)
+ putTestCert(t, svc, certTestArn, testAccountID, "LEAF", "", "KEY")
+
+ // Known, owned cert → accepted.
+ require.NoError(t, svc.validateListenerCerts(
+ []ListenerCertificate{{CertificateArn: certTestArn}}, testAccountID))
+
+ // Unknown ARN → CertificateNotFound, rejected at the API boundary.
+ err := svc.validateListenerCerts(
+ []ListenerCertificate{{CertificateArn: "arn:aws:acm:ap-southeast-2:123456789012:certificate/missing"}}, testAccountID)
+ require.EqualError(t, err, awserrors.ErrorELBv2CertificateNotFound)
+
+ // Cert owned by another account → not found.
+ err = svc.validateListenerCerts(
+ []ListenerCertificate{{CertificateArn: certTestArn}}, "999999999999")
+ require.EqualError(t, err, awserrors.ErrorELBv2CertificateNotFound)
+
+ // No ACM store (degraded) → validation skipped, never blocks.
+ svc.acmStore = nil
+ require.NoError(t, svc.validateListenerCerts(
+ []ListenerCertificate{{CertificateArn: certTestArn}}, testAccountID))
+}
+
+func TestResolveCertPEM_NoChain(t *testing.T) {
+ svc := setupTestService(t)
+ putTestCert(t, svc, certTestArn, testAccountID, "LEAF", "", "KEY")
+
+ pem, err := svc.resolveCertPEM(certTestArn, testAccountID)
+ require.NoError(t, err)
+ assert.Equal(t, "LEAF\nKEY\n", pem)
+}
+
+func TestResolveListenerCerts_DedupAndResolve(t *testing.T) {
+ svc := setupTestService(t)
+ putTestCert(t, svc, certTestArn, testAccountID, "LEAF", "", "KEY")
+
+ // Two listeners share the same cert ARN — resolved once.
+ got, err := svc.resolveListenerCerts([]*ListenerRecord{httpsListener(), httpsListener()}, testAccountID)
+ require.NoError(t, err)
+ require.Len(t, got, 1)
+ assert.Equal(t, "LEAF\nKEY\n", got[certTestArn])
+
+ // HTTP-only listeners → nil map, no error.
+ got, err = svc.resolveListenerCerts([]*ListenerRecord{{Protocol: ProtocolHTTP}}, testAccountID)
+ require.NoError(t, err)
+ assert.Nil(t, got)
+}
+
+func TestGetLBConfig_DeliversCertFiles(t *testing.T) {
+ svc := setupTestService(t)
+ rec := &LoadBalancerRecord{
+ LoadBalancerID: "lb-deliver1",
+ AccountID: testAccountID,
+ ConfigText: "frontend ft\n bind *:443 ssl crt /etc/haproxy/certs/lb-deliver1-lst.pem\n",
+ ConfigHash: "hash123",
+ CertFiles: map[string]string{"/etc/haproxy/certs/lb-deliver1-lst.pem": "LEAF\nKEY\n"},
+ }
+ require.NoError(t, svc.store.PutLoadBalancer(rec))
+
+ out, err := svc.GetLBConfig(&GetLBConfigInput{LBID: aws.String("lb-deliver1")}, testAccountID)
+ require.NoError(t, err)
+ require.Len(t, out.CertFiles, 1)
+ assert.Equal(t, "/etc/haproxy/certs/lb-deliver1-lst.pem", *out.CertFiles[0].Path)
+ assert.Equal(t, "LEAF\nKEY\n", *out.CertFiles[0].PEM)
+
+ // The gateway marshals this output to XML; confirm the member shape the
+ // lb-agent parses (GetLBConfigResult>CertFiles>member>{Path,PEM}).
+ payload := utils.GenerateIAMXMLPayload("GetLBConfig", *out)
+ xmlBytes, err := utils.MarshalToXML(payload)
+ require.NoError(t, err)
+ var parsed struct {
+ Members []struct {
+ Path string `xml:"Path"`
+ PEM string `xml:"PEM"`
+ } `xml:"GetLBConfigResult>CertFiles>member"`
+ }
+ require.NoError(t, xml.Unmarshal(xmlBytes, &parsed))
+ require.Len(t, parsed.Members, 1)
+ assert.Equal(t, "/etc/haproxy/certs/lb-deliver1-lst.pem", parsed.Members[0].Path)
+ assert.Equal(t, "LEAF\nKEY\n", parsed.Members[0].PEM)
+}
+
+func TestCertFilesToSDK_SortedAndNilSafe(t *testing.T) {
+ assert.Nil(t, certFilesToSDK(nil))
+
+ out := certFilesToSDK(map[string]string{"/b.pem": "B", "/a.pem": "A"})
+ require.Len(t, out, 2)
+ assert.Equal(t, "/a.pem", *out[0].Path)
+ assert.Equal(t, "A", *out[0].PEM)
+ assert.Equal(t, "/b.pem", *out[1].Path)
+}
diff --git a/spinifex/handlers/elbv2/haproxy_test.go b/spinifex/handlers/elbv2/haproxy_test.go
index 4adaed7b4..f52e68303 100644
--- a/spinifex/handlers/elbv2/haproxy_test.go
+++ b/spinifex/handlers/elbv2/haproxy_test.go
@@ -163,6 +163,244 @@ func TestGenerateHAProxyConfig_SharedTargetGroup(t *testing.T) {
assert.Equal(t, 1, strings.Count(config, "balance roundrobin"))
}
+// TestGenerateHAProxyConfig_FixedResponseDefault mirrors the shared-ingress
+// shape: a listener whose default action is fixed-response (no target group),
+// with host-header rules forwarding to per-app target groups. The generator
+// must synthesize a default backend instead of emitting a dangling
+// `default_backend bk_`, which makes HAProxy fail to start.
+func TestGenerateHAProxyConfig_FixedResponseDefault(t *testing.T) {
+ lb := &LoadBalancerRecord{LoadBalancerID: "lb-ingress"}
+ listenerArn := "arn:aws:elasticloadbalancing:ap-southeast-2:1:listener/app/wd-ingress/lb-ingress/lst-616760baf6a3031c3"
+ appTG := "arn:aws:elasticloadbalancing:ap-southeast-2:1:targetgroup/wd-identity/tg-app1"
+
+ listeners := []*ListenerRecord{
+ {
+ ListenerArn: listenerArn,
+ Port: 80,
+ Protocol: ProtocolHTTP,
+ DefaultActions: []ListenerAction{
+ {Type: ActionTypeFixedResponse, FixedResponse: &FixedResponseAction{
+ StatusCode: "404",
+ ContentType: "text/plain",
+ MessageBody: "no route",
+ }},
+ },
+ },
+ }
+
+ rulesByListener := map[string][]*RuleRecord{
+ listenerArn: {
+ {
+ RuleID: "rule1",
+ ListenerArn: listenerArn,
+ Priority: 1,
+ Conditions: []RuleCondition{{Field: RuleFieldHostHeader, Values: []string{"identity.toc.spinifex.local"}}},
+ Actions: []ListenerAction{{Type: ActionTypeForward, TargetGroupArn: appTG}},
+ },
+ },
+ }
+
+ tgByArn := map[string]*TargetGroupRecord{
+ appTG: {
+ TargetGroupArn: appTG,
+ HealthCheck: DefaultHealthCheck(),
+ Targets: []Target{{Id: "i-app1", Port: 8080, PrivateIP: "10.42.0.9", HealthState: TargetHealthHealthy}},
+ },
+ }
+
+ config, err := GenerateHAProxyConfig(lb, listeners, tgByArn, rulesByListener, "10.42.0.13")
+ require.NoError(t, err)
+
+ // No dangling default backend.
+ assert.NotContains(t, config, "default_backend bk_\n")
+ // Synthetic default backend named off the listener ARN, returning the fixed response.
+ assert.Contains(t, config, "default_backend bkdefault_lst-616760baf6a3031c3")
+ assert.Contains(t, config, "backend bkdefault_lst-616760baf6a3031c3")
+ assert.Contains(t, config, `http-request return status 404 content-type "text/plain" string "no route"`)
+ // Host-header rule still routes to the app backend.
+ assert.Contains(t, config, "use_backend bk_tg-app1 if")
+ assert.Contains(t, config, "10.42.0.9:8080")
+}
+
+// TestGenerateHAProxyConfig_FixedResponseRejectsUnsafeBody falls back to a bare
+// status when the body or content-type contains injection bytes.
+func TestGenerateHAProxyConfig_FixedResponseRejectsUnsafeBody(t *testing.T) {
+ lb := &LoadBalancerRecord{LoadBalancerID: "lb-evil"}
+ listeners := []*ListenerRecord{
+ {
+ ListenerArn: "arn:lst-evil",
+ Port: 80,
+ DefaultActions: []ListenerAction{
+ {Type: ActionTypeFixedResponse, FixedResponse: &FixedResponseAction{
+ StatusCode: "200",
+ ContentType: "text/plain",
+ MessageBody: "evil\"\n acl x always_true",
+ }},
+ },
+ },
+ }
+
+ config, err := GenerateHAProxyConfig(lb, listeners, nil, nil, "0.0.0.0")
+ require.NoError(t, err)
+
+ assert.Contains(t, config, "http-request return status 200")
+ assert.NotContains(t, config, "always_true")
+ assert.NotContains(t, config, `string "evil`)
+}
+
+// TestGenerateHAProxyConfig_RedirectSchemeDefault renders the canonical
+// HTTP→HTTPS redirect (scheme-only change, all other fields default) as a
+// `redirect scheme https` directive so HAProxy preserves host/path/query.
+func TestGenerateHAProxyConfig_RedirectSchemeDefault(t *testing.T) {
+ lb := &LoadBalancerRecord{LoadBalancerID: "lb-redir"}
+ listenerArn := "arn:aws:elasticloadbalancing:ap-southeast-2:1:listener/app/r/lb-redir/lst-aa"
+ listeners := []*ListenerRecord{
+ {
+ ListenerArn: listenerArn,
+ Port: 80,
+ Protocol: ProtocolHTTP,
+ DefaultActions: []ListenerAction{
+ {Type: ActionTypeRedirect, Redirect: &RedirectAction{
+ Protocol: "HTTPS",
+ Host: "#{host}",
+ Path: "/#{path}",
+ Query: "#{query}",
+ StatusCode: "HTTP_301",
+ }},
+ },
+ },
+ }
+
+ config, err := GenerateHAProxyConfig(lb, listeners, nil, nil, "0.0.0.0")
+ require.NoError(t, err)
+
+ assert.Contains(t, config, "default_backend bkdefault_lst-aa")
+ assert.Contains(t, config, "http-request redirect scheme https code 301")
+ assert.NotContains(t, config, "location")
+}
+
+// TestGenerateHAProxyConfig_RedirectLocation renders a redirect that changes
+// the host as a rebuilt `location` with HAProxy placeholders preserved.
+func TestGenerateHAProxyConfig_RedirectLocation(t *testing.T) {
+ lb := &LoadBalancerRecord{LoadBalancerID: "lb-redir2"}
+ listenerArn := "arn:aws:elasticloadbalancing:ap-southeast-2:1:listener/app/r/lb-redir2/lst-bb"
+ listeners := []*ListenerRecord{
+ {
+ ListenerArn: listenerArn,
+ Port: 80,
+ Protocol: ProtocolHTTP,
+ DefaultActions: []ListenerAction{
+ {Type: ActionTypeRedirect, Redirect: &RedirectAction{
+ Protocol: "HTTPS",
+ Host: "www.example.com",
+ Path: "/#{path}",
+ Query: "#{query}",
+ StatusCode: "HTTP_302",
+ }},
+ },
+ },
+ }
+
+ config, err := GenerateHAProxyConfig(lb, listeners, nil, nil, "0.0.0.0")
+ require.NoError(t, err)
+
+ assert.Contains(t, config, "http-request redirect location https://www.example.com%[path]?%[query] code 302")
+}
+
+// TestGenerateHAProxyConfig_RedirectCustomAll exercises the location form with
+// a placeholder protocol (resolved from the listener), an explicit port, and a
+// custom literal path + query.
+func TestGenerateHAProxyConfig_RedirectCustomAll(t *testing.T) {
+ lb := &LoadBalancerRecord{LoadBalancerID: "lb-redir3"}
+ listenerArn := "arn:aws:elasticloadbalancing:ap-southeast-2:1:listener/app/r/lb-redir3/lst-dd"
+ listeners := []*ListenerRecord{
+ {
+ ListenerArn: listenerArn,
+ Port: 80,
+ Protocol: ProtocolHTTPS,
+ DefaultActions: []ListenerAction{
+ {Type: ActionTypeRedirect, Redirect: &RedirectAction{
+ Protocol: "#{protocol}",
+ Host: "alt.example.com",
+ Port: "8443",
+ Path: "/moved",
+ Query: "ref=1",
+ StatusCode: "HTTP_302",
+ }},
+ },
+ },
+ }
+
+ config, err := GenerateHAProxyConfig(lb, listeners, nil, nil, "0.0.0.0")
+ require.NoError(t, err)
+
+ assert.Contains(t, config, "http-request redirect location https://alt.example.com:8443/moved?ref=1 code 302")
+}
+
+// TestGenerateHAProxyConfig_RedirectRule renders a per-rule redirect action via
+// a synthetic backend reached by use_backend.
+func TestGenerateHAProxyConfig_RedirectRule(t *testing.T) {
+ lb := &LoadBalancerRecord{LoadBalancerID: "lb-rr"}
+ listenerArn := "arn:aws:elasticloadbalancing:ap-southeast-2:1:listener/app/r/lb-rr/lst-cc"
+ appTG := "arn:aws:elasticloadbalancing:ap-southeast-2:1:targetgroup/app/tg-app"
+ listeners := []*ListenerRecord{
+ {
+ ListenerArn: listenerArn,
+ Port: 80,
+ Protocol: ProtocolHTTP,
+ DefaultActions: []ListenerAction{
+ {Type: ActionTypeForward, TargetGroupArn: appTG},
+ },
+ },
+ }
+ rulesByListener := map[string][]*RuleRecord{
+ listenerArn: {
+ {
+ RuleID: "ruleR",
+ ListenerArn: listenerArn,
+ Priority: 1,
+ Conditions: []RuleCondition{{Field: RuleFieldPathPattern, Values: []string{"/old/*"}}},
+ Actions: []ListenerAction{{Type: ActionTypeRedirect, Redirect: &RedirectAction{
+ Protocol: "HTTPS",
+ StatusCode: "HTTP_301",
+ }}},
+ },
+ },
+ }
+ tgByArn := map[string]*TargetGroupRecord{
+ appTG: {TargetGroupArn: appTG, HealthCheck: DefaultHealthCheck()},
+ }
+
+ config, err := GenerateHAProxyConfig(lb, listeners, tgByArn, rulesByListener, "0.0.0.0")
+ require.NoError(t, err)
+
+ assert.Contains(t, config, "use_backend bkrule_ruleR if")
+ assert.Contains(t, config, "backend bkrule_ruleR")
+ assert.Contains(t, config, "http-request redirect scheme https code 301")
+}
+
+// TestGenerateHAProxyConfig_RedirectRejectsInjection fails the render when a
+// redirect field carries HAProxy meta-characters that survived to the store.
+func TestGenerateHAProxyConfig_RedirectRejectsInjection(t *testing.T) {
+ lb := &LoadBalancerRecord{LoadBalancerID: "lb-evil2"}
+ listeners := []*ListenerRecord{
+ {
+ ListenerArn: "arn:lst-evil2",
+ Port: 80,
+ Protocol: ProtocolHTTP,
+ DefaultActions: []ListenerAction{
+ {Type: ActionTypeRedirect, Redirect: &RedirectAction{
+ Host: "evil\"\n acl x always_true",
+ StatusCode: "HTTP_301",
+ }},
+ },
+ },
+ }
+
+ _, err := GenerateHAProxyConfig(lb, listeners, nil, nil, "0.0.0.0")
+ require.Error(t, err)
+}
+
func TestGenerateHAProxyConfig_NoListeners(t *testing.T) {
lb := &LoadBalancerRecord{LoadBalancerID: "lb-empty"}
config, err := GenerateHAProxyConfig(lb, nil, nil, nil, "0.0.0.0")
@@ -347,250 +585,6 @@ func TestHAProxyManager_RemoveConfig_Idempotent(t *testing.T) {
require.NoError(t, err)
}
-// --- NLB TCP Config Generation Tests ---
-
-func TestGenerateNLBConfig_TCPHealthCheck(t *testing.T) {
- lb := &LoadBalancerRecord{
- LoadBalancerID: "lb-nlb001",
- Name: "my-nlb",
- Type: LoadBalancerTypeNetwork,
- }
-
- tgArn := "arn:aws:elasticloadbalancing:us-east-1:123:targetgroup/tcp-tg/tg-tcp001"
- listeners := []*ListenerRecord{
- {
- ListenerArn: "arn:aws:elasticloadbalancing:us-east-1:123:listener/net/my-nlb/lb-nlb001/lst-tcp001",
- Protocol: ProtocolTCP,
- Port: 5432,
- DefaultActions: []ListenerAction{
- {Type: ActionTypeForward, TargetGroupArn: tgArn},
- },
- },
- }
-
- tgByArn := map[string]*TargetGroupRecord{
- tgArn: {
- TargetGroupArn: tgArn,
- Name: "tcp-tg",
- Protocol: ProtocolTCP,
- HealthCheck: HealthCheckConfig{
- Protocol: ProtocolTCP,
- Port: "traffic-port",
- IntervalSeconds: 30,
- TimeoutSeconds: 10,
- HealthyThreshold: 3,
- UnhealthyThreshold: 3,
- },
- Targets: []Target{
- {Id: "i-tcp001", Port: 5432, PrivateIP: "10.0.1.10", HealthState: TargetHealthHealthy},
- {Id: "i-tcp002", Port: 5432, PrivateIP: "10.0.1.11", HealthState: TargetHealthHealthy},
- },
- },
- }
-
- config, err := GenerateHAProxyConfig(lb, listeners, tgByArn, nil, "10.0.1.5")
- require.NoError(t, err)
-
- // Verify TCP mode
- assert.Contains(t, config, "mode tcp")
- assert.Contains(t, config, "option tcplog")
- assert.NotContains(t, config, "mode http")
- assert.NotContains(t, config, "option httplog")
-
- // Verify socket name uses generic lb- prefix (matches agent expectation)
- assert.Contains(t, config, "lb-lb-nlb001.sock")
-
- // Verify TCP health check
- assert.Contains(t, config, "option tcp-check")
- assert.NotContains(t, config, "option httpchk")
-
- // Verify frontend/backend
- assert.Contains(t, config, "bind *:5432")
- assert.Contains(t, config, "10.0.1.10:5432")
- assert.Contains(t, config, "10.0.1.11:5432")
- assert.Contains(t, config, "check inter 30s fall 3 rise 3")
-}
-
-func TestGenerateNLBConfig_HTTPHealthCheckOnTCPTargetGroup(t *testing.T) {
- lb := &LoadBalancerRecord{
- LoadBalancerID: "lb-nlb002",
- Name: "nlb-http-hc",
- Type: LoadBalancerTypeNetwork,
- }
-
- tgArn := "arn:aws:elasticloadbalancing:us-east-1:123:targetgroup/tcp-http-hc/tg-tcp002"
- listeners := []*ListenerRecord{
- {
- ListenerArn: "arn:lst-nlb-http",
- Protocol: ProtocolTCP,
- Port: 8080,
- DefaultActions: []ListenerAction{
- {Type: ActionTypeForward, TargetGroupArn: tgArn},
- },
- },
- }
-
- tgByArn := map[string]*TargetGroupRecord{
- tgArn: {
- TargetGroupArn: tgArn,
- Name: "tcp-http-hc",
- Protocol: ProtocolTCP,
- HealthCheck: HealthCheckConfig{
- Protocol: ProtocolHTTP,
- Port: "traffic-port",
- Path: "/healthz",
- Matcher: "200-299",
- IntervalSeconds: 10,
- TimeoutSeconds: 5,
- HealthyThreshold: 2,
- UnhealthyThreshold: 3,
- },
- Targets: []Target{
- {Id: "i-http-hc1", Port: 8080, PrivateIP: "10.0.2.10", HealthState: TargetHealthHealthy},
- },
- },
- }
-
- config, err := GenerateHAProxyConfig(lb, listeners, tgByArn, nil, "10.0.2.1")
- require.NoError(t, err)
-
- // TCP mode but HTTP health check
- assert.Contains(t, config, "mode tcp")
- assert.Contains(t, config, "option httpchk GET /healthz")
- assert.Contains(t, config, "http-check expect status 200-299")
- assert.NotContains(t, config, "option tcp-check")
-
- assert.Contains(t, config, "10.0.2.10:8080")
- assert.Contains(t, config, "check inter 10s fall 3 rise 2")
-}
-
-func TestGenerateNLBConfig_MixedBackends(t *testing.T) {
- lb := &LoadBalancerRecord{
- LoadBalancerID: "lb-nlb003",
- Name: "nlb-mixed",
- Type: LoadBalancerTypeNetwork,
- }
-
- tgArnTCP := "arn:tg-tcp-mixed"
- tgArnHTTP := "arn:tg-http-mixed"
-
- listeners := []*ListenerRecord{
- {
- ListenerArn: "arn:lst-tcp-mixed",
- Protocol: ProtocolTCP,
- Port: 5432,
- DefaultActions: []ListenerAction{
- {Type: ActionTypeForward, TargetGroupArn: tgArnTCP},
- },
- },
- {
- ListenerArn: "arn:lst-http-mixed",
- Protocol: ProtocolTCP,
- Port: 8080,
- DefaultActions: []ListenerAction{
- {Type: ActionTypeForward, TargetGroupArn: tgArnHTTP},
- },
- },
- }
-
- tgByArn := map[string]*TargetGroupRecord{
- tgArnTCP: {
- Protocol: ProtocolTCP,
- HealthCheck: HealthCheckConfig{
- Protocol: ProtocolTCP,
- IntervalSeconds: 30,
- HealthyThreshold: 3,
- UnhealthyThreshold: 3,
- },
- Targets: []Target{
- {Id: "i-db1", Port: 5432, PrivateIP: "10.0.3.10", HealthState: TargetHealthHealthy},
- },
- },
- tgArnHTTP: {
- Protocol: ProtocolTCP,
- HealthCheck: HealthCheckConfig{
- Protocol: ProtocolHTTP,
- Path: "/ready",
- Matcher: "200",
- IntervalSeconds: 15,
- HealthyThreshold: 2,
- UnhealthyThreshold: 2,
- },
- Targets: []Target{
- {Id: "i-app1", Port: 8080, PrivateIP: "10.0.3.20", HealthState: TargetHealthHealthy},
- },
- },
- }
-
- config, err := GenerateHAProxyConfig(lb, listeners, tgByArn, nil, "10.0.3.1")
- require.NoError(t, err)
-
- // Overall TCP mode
- assert.Contains(t, config, "mode tcp")
-
- // First backend: TCP health check
- assert.Contains(t, config, "option tcp-check")
-
- // Second backend: HTTP health check
- assert.Contains(t, config, "option httpchk GET /ready")
- assert.Contains(t, config, "http-check expect status 200")
-
- // Both servers present
- assert.Contains(t, config, "10.0.3.10:5432")
- assert.Contains(t, config, "10.0.3.20:8080")
-}
-
-func TestGenerateNLBConfig_NoListeners(t *testing.T) {
- lb := &LoadBalancerRecord{
- LoadBalancerID: "lb-nlb-empty",
- Type: LoadBalancerTypeNetwork,
- }
-
- config, err := GenerateHAProxyConfig(lb, nil, nil, nil, "0.0.0.0")
- require.NoError(t, err)
-
- assert.Contains(t, config, "mode tcp")
- assert.Contains(t, config, "option tcplog")
- assert.NotContains(t, config, "frontend")
- assert.NotContains(t, config, "backend")
-}
-
-func TestGenerateNLBConfig_SkipsDrainingTargets(t *testing.T) {
- lb := &LoadBalancerRecord{
- LoadBalancerID: "lb-nlb-drain",
- Type: LoadBalancerTypeNetwork,
- }
-
- tgArn := "arn:tg-nlb-drain"
- listeners := []*ListenerRecord{
- {
- ListenerArn: "arn:lst-nlb-drain",
- Protocol: ProtocolTCP,
- Port: 3306,
- DefaultActions: []ListenerAction{{Type: ActionTypeForward, TargetGroupArn: tgArn}},
- },
- }
-
- tgByArn := map[string]*TargetGroupRecord{
- tgArn: {
- Protocol: ProtocolTCP,
- HealthCheck: DefaultNLBHealthCheck(),
- Targets: []Target{
- {Id: "i-active", Port: 3306, PrivateIP: "10.0.0.1", HealthState: TargetHealthHealthy},
- {Id: "i-draining", Port: 3306, PrivateIP: "10.0.0.2", HealthState: TargetHealthDraining},
- {Id: "i-no-ip", Port: 3306, PrivateIP: "", HealthState: TargetHealthInitial},
- },
- },
- }
-
- config, err := GenerateHAProxyConfig(lb, listeners, tgByArn, nil, "0.0.0.0")
- require.NoError(t, err)
-
- assert.Contains(t, config, "10.0.0.1:3306")
- assert.NotContains(t, config, "10.0.0.2") // draining
- assert.NotContains(t, config, "i-no-ip") // no IP
-}
-
func TestGenerateALBConfig_StillWorks(t *testing.T) {
// Regression: ALB (no Type set) should still generate HTTP-mode config
lb := &LoadBalancerRecord{
diff --git a/spinifex/handlers/elbv2/launcher.go b/spinifex/handlers/elbv2/launcher.go
index 3ca9b6e8b..26d65c184 100644
--- a/spinifex/handlers/elbv2/launcher.go
+++ b/spinifex/handlers/elbv2/launcher.go
@@ -1,112 +1,17 @@
package handlers_elbv2
-// SystemInstanceLauncher is the subset of daemon functionality needed by
-// ELBv2 to launch and terminate system-managed VMs (e.g. ALB VMs).
-// Defined here to avoid a circular import between handlers/elbv2 and daemon.
-//
-// System instances are owned by the system account and hidden from
-// customer DescribeInstances calls.
-type SystemInstanceLauncher interface {
- // LaunchSystemInstance creates and starts a system-managed VM.
- // The VM is assigned to the system account and not visible to customers.
- // The caller provides the instance type, AMI, subnet, user data, and
- // optionally a pre-created ENI to attach.
- //
- // Returns the instance ID and private IP once the VM is running.
- LaunchSystemInstance(input *SystemInstanceInput) (*SystemInstanceOutput, error)
+import "github.com/mulgadc/spinifex/spinifex/handlers/sysinstance"
- // TerminateSystemInstance stops and cleans up a system-managed VM.
- TerminateSystemInstance(instanceID string) error
-}
-
-// SystemInstanceInput describes the direct-boot microVM to launch for an
-// ELBv2 load balancer. There is no AMI or cloud-init path — kernel and
-// initrd come from the bundled spinifex package and per-VM config is
-// delivered via QEMU fw_cfg blobs.
-//
-// JSON tags are explicit because this struct crosses the
-// system.LaunchInstance.* NATS subject root when ELBv2 hands a launch off
-// to the cluster — see daemon/daemon_system_dispatch.go.
-type SystemInstanceInput struct {
- InstanceType string `json:"instance_type"` // e.g. "sys.micro"
- SubnetID string `json:"subnet_id"` // VPC subnet for networking
-
- // ENI fields — the VM always uses a pre-created ENI (the ALB's primary ENI).
- ENIID string `json:"eni_id"`
- ENIMac string `json:"eni_mac"`
- ENIIP string `json:"eni_ip"`
-
- // ExtraENIs lists additional pre-created ENIs that should be attached to
- // the VM alongside the primary ENI. Used for multi-subnet ALBs so the VM
- // has a NIC (and tap on br-int) in every subnet the LB spans.
- ExtraENIs []ExtraENIInput `json:"extra_enis,omitempty"`
-
- // Scheme is the ALB scheme ("internet-facing" or "internal").
- // Internet-facing ALBs get a public IP and NAT rules; internal ALBs do not.
- Scheme string `json:"scheme"`
-
- // AccountID is the owner account of the ALB's ENI. Required so the daemon
- // can look up and update the ENI record (which is keyed by account).
- AccountID string `json:"account_id"`
-
- // HostfwdPorts specifies additional guest ports to forward from the host
- // via the QEMU user-mode dev NIC (dev_networking mode only).
- // Each entry is a guest port (e.g. 80, 443). The host port is auto-assigned.
- HostfwdPorts []int `json:"hostfwd_ports,omitempty"`
-
- // NICs defines the network interfaces for the microVM.
- // Index 0 is the primary VPC ENI, index 1 is the management NIC, index 2+ are extra ENIs.
- NICs []NICConfig `json:"nics,omitempty"`
-
- // LBAgentEnv is a KEY=value blob written to /etc/conf.d/lb-agent inside
- // the guest via fw_cfg.
- LBAgentEnv string `json:"lb_agent_env"`
-
- // CACert holds PEM-encoded CA certificate bytes delivered to the guest via fw_cfg.
- CACert string `json:"ca_cert"`
-}
-
-// ExtraENIInput describes an additional pre-created ENI to attach to a
-// system instance. The primary ENI is still passed via ENIID/ENIMac/ENIIP.
-type ExtraENIInput struct {
- ENIID string `json:"eni_id"`
- ENIMac string `json:"eni_mac"`
- ENIIP string `json:"eni_ip"`
- SubnetID string `json:"subnet_id"`
-}
-
-// NICConfig describes a single network interface for a microVM direct-boot launch.
-// Index 0 is the primary VPC ENI, index 1 is the management NIC, index 2+ are extra ENIs.
-type NICConfig struct {
- MAC string `json:"mac"` // e.g. "02:0a:01:23:45:67"
- CIDR string `json:"cidr"` // e.g. "10.0.1.5/24"
- Gateway string `json:"gateway,omitempty"` // e.g. "10.0.1.1"; empty for mgmt NIC
- IsDefault bool `json:"is_default"` // true for exactly one NIC (primary VPC ENI)
- RouteDst string `json:"route_dst,omitempty"` // specific host route dst, e.g. "10.20.0.5/32"
- RouteVia string `json:"route_via,omitempty"` // next-hop for RouteDst
-}
-
-// RecoveryContext carries the per-VM fields the ELBv2 service needs to
-// reconstruct a SystemInstanceInput during host-reboot recovery, without
-// importing the vm package (which would cycle back through daemon).
-// MgmtMAC / MgmtIP are the values the daemon already allocated for this
-// instance on a prior launch; the service injects them back into NIC[1]
-// rather than re-allocating from the mgmt IPAM.
-type RecoveryContext struct {
- InstanceID string
- InstanceType string
- ENIMac string
- MgmtMAC string
- MgmtIP string
-}
-
-// SystemInstanceOutput contains the result of a successful launch.
-type SystemInstanceOutput struct {
- InstanceID string `json:"instance_id"` // e.g. "i-xxxxx"
- PrivateIP string `json:"private_ip"` // VPC private IP
- PublicIP string `json:"public_ip,omitempty"` // Public IP (only for internet-facing scheme)
-
- // HostfwdMap maps guest port → host port for any forwarded ports.
- // Only populated when dev_networking is enabled and HostfwdPorts were requested.
- HostfwdMap map[int]int `json:"hostfwd_map,omitempty"`
-}
+// The system-instance launch contract now lives in the boot-agnostic
+// handlers/sysinstance package so both ELBv2 and EKS can launch system VMs
+// without an eks→elbv2 dependency. These aliases keep existing ELBv2 source
+// (which builds direct-boot microVM inputs) compiling unchanged. ELBv2 always
+// launches with BootMode=BootDirect (the zero value).
+type (
+ SystemInstanceLauncher = sysinstance.SystemInstanceLauncher
+ SystemInstanceInput = sysinstance.SystemInstanceInput
+ ExtraENIInput = sysinstance.ExtraENIInput
+ NICConfig = sysinstance.NICConfig
+ RecoveryContext = sysinstance.RecoveryContext
+ SystemInstanceOutput = sysinstance.SystemInstanceOutput
+)
diff --git a/spinifex/handlers/elbv2/lifecycle_invariants_test.go b/spinifex/handlers/elbv2/lifecycle_invariants_test.go
new file mode 100644
index 000000000..b2ac8b6e6
--- /dev/null
+++ b/spinifex/handlers/elbv2/lifecycle_invariants_test.go
@@ -0,0 +1,187 @@
+package handlers_elbv2
+
+import (
+ "os"
+ "strings"
+ "testing"
+
+ "github.com/aws/aws-sdk-go/aws"
+ "github.com/aws/aws-sdk-go/service/elbv2"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+// TestRLC1_ELBv2DeleteIdempotentOnAbsent enforces Common Resource Lifecycle
+// Contract rule #1 (idempotent delete) across the ELBv2 surface: deleting an
+// absent resource is success, not a NotFound error. This mirrors AWS ELBv2
+// delete semantics and is required for tofu destroy to converge on retry.
+//
+// Every ELBv2 delete endpoint MUST have a case here. When a new delete
+// endpoint lands, add it; a missing case is an idempotency gap.
+func TestRLC1_ELBv2DeleteIdempotentOnAbsent(t *testing.T) {
+ const arnBase = "arn:aws:elasticloadbalancing:us-east-1:123456789012:"
+
+ cases := []struct {
+ name string
+ call func(svc *ELBv2ServiceImpl) (any, error)
+ }{
+ {"DeleteLoadBalancer", func(svc *ELBv2ServiceImpl) (any, error) {
+ return svc.DeleteLoadBalancer(&elbv2.DeleteLoadBalancerInput{
+ LoadBalancerArn: aws.String(arnBase + "loadbalancer/app/absent/0000000000000000"),
+ }, testAccountID)
+ }},
+ {"DeleteTargetGroup", func(svc *ELBv2ServiceImpl) (any, error) {
+ return svc.DeleteTargetGroup(&elbv2.DeleteTargetGroupInput{
+ TargetGroupArn: aws.String(arnBase + "targetgroup/absent/0000000000000000"),
+ }, testAccountID)
+ }},
+ {"DeleteListener", func(svc *ELBv2ServiceImpl) (any, error) {
+ return svc.DeleteListener(&elbv2.DeleteListenerInput{
+ ListenerArn: aws.String(arnBase + "listener/app/absent/0000000000000000/1111111111111111"),
+ }, testAccountID)
+ }},
+ {"DeleteRule", func(svc *ELBv2ServiceImpl) (any, error) {
+ return svc.DeleteRule(&elbv2.DeleteRuleInput{
+ RuleArn: aws.String(arnBase + "listener-rule/app/absent/0000000000000000/1111111111111111/2222222222222222"),
+ }, testAccountID)
+ }},
+ }
+
+ for _, tc := range cases {
+ t.Run(tc.name, func(t *testing.T) {
+ svc := setupTestService(t)
+ out, err := tc.call(svc)
+ require.NoErrorf(t, err, "%s on an absent resource must return success (RLC rule #1)", tc.name)
+ assert.NotNilf(t, out, "%s must return a non-nil output on absent", tc.name)
+ })
+ }
+}
+
+// TestRLC2_ELBv2NoOrphanAfterDeleteLB enforces ADR-0002 §5 no-orphan
+// completeness: after DeleteLoadBalancer, no listener or rule owned by that LB
+// may survive in the store. Uses two listeners (each with a rule) so a
+// single-listener cascade can't pass by accident.
+func TestRLC2_ELBv2NoOrphanAfterDeleteLB(t *testing.T) {
+ svc := setupTestService(t)
+
+ lbOut, err := svc.CreateLoadBalancer(&elbv2.CreateLoadBalancerInput{Name: aws.String("rlc2-lb")}, testAccountID)
+ require.NoError(t, err)
+ lbArn := lbOut.LoadBalancers[0].LoadBalancerArn
+
+ tgOut, err := svc.CreateTargetGroup(&elbv2.CreateTargetGroupInput{Name: aws.String("rlc2-tg")}, testAccountID)
+ require.NoError(t, err)
+ tgArn := tgOut.TargetGroups[0].TargetGroupArn
+
+ for i, port := range []int64{80, 8080} {
+ lst, err := svc.CreateListener(&elbv2.CreateListenerInput{
+ LoadBalancerArn: lbArn,
+ Protocol: aws.String("HTTP"),
+ Port: aws.Int64(port),
+ DefaultActions: []*elbv2.Action{{Type: aws.String("forward"), TargetGroupArn: tgArn}},
+ }, testAccountID)
+ require.NoError(t, err)
+
+ _, err = svc.CreateRule(&elbv2.CreateRuleInput{
+ ListenerArn: lst.Listeners[0].ListenerArn,
+ Priority: aws.Int64(int64(10 + i)),
+ Conditions: []*elbv2.RuleCondition{{Field: aws.String("path-pattern"), Values: aws.StringSlice([]string{"/api/*"})}},
+ Actions: []*elbv2.Action{{Type: aws.String("forward"), TargetGroupArn: tgArn}},
+ }, testAccountID)
+ require.NoError(t, err)
+ }
+
+ _, err = svc.DeleteLoadBalancer(&elbv2.DeleteLoadBalancerInput{LoadBalancerArn: lbArn}, testAccountID)
+ require.NoError(t, err)
+
+ listeners, err := svc.store.ListListenersByLB(*lbArn)
+ require.NoError(t, err)
+ require.Emptyf(t, listeners, "ADR-0002 §5 no-orphan completeness: no listener owned by a deleted LB may remain")
+
+ rules, err := svc.store.ListRules()
+ require.NoError(t, err)
+ require.Emptyf(t, rules, "ADR-0002 §5 no-orphan completeness: no rule owned by a deleted LB may remain")
+}
+
+// TestRLC3_ELBv2DeleteLBDoesNotBypassListenerCascade enforces ADR-0002 §5 no
+// store-bypass cascade: DeleteLoadBalancer must tear listeners (and their
+// rules) down through the shared deleteListenerCascade helper, never by
+// calling store.DeleteListener directly — a direct store delete orphans the
+// listener's rules. The store field is a concrete type with no injection seam,
+// so the contract is enforced structurally against the source rather than with
+// a runtime spy.
+func TestRLC3_ELBv2DeleteLBDoesNotBypassListenerCascade(t *testing.T) {
+ src, err := os.ReadFile("service_impl.go")
+ require.NoError(t, err)
+
+ body := stripComments(deleteLBFuncBody(t, string(src)))
+ assert.Containsf(t, body, "deleteListenerCascade",
+ "ADR-0002 §5 no store-bypass cascade: DeleteLoadBalancer must route listener teardown through deleteListenerCascade")
+ assert.NotContainsf(t, body, "s.store.DeleteListener(",
+ "ADR-0002 §5 no store-bypass cascade: DeleteLoadBalancer must not call store.DeleteListener directly")
+}
+
+// deleteLBFuncBody returns the source text of the DeleteLoadBalancer method,
+// from its signature up to the start of the next top-level func.
+func deleteLBFuncBody(t *testing.T, src string) string {
+ t.Helper()
+ const sig = "func (s *ELBv2ServiceImpl) DeleteLoadBalancer("
+ start := strings.Index(src, sig)
+ require.GreaterOrEqual(t, start, 0, "DeleteLoadBalancer not found in service_impl.go")
+ rest := src[start+len(sig):]
+ if before, _, found := strings.Cut(rest, "\nfunc "); found {
+ return before
+ }
+ return rest
+}
+
+// stripComments removes // line comments so source-guard assertions match real
+// code, not the prose that documents it.
+func stripComments(src string) string {
+ var b strings.Builder
+ for line := range strings.SplitSeq(src, "\n") {
+ if i := strings.Index(line, "//"); i >= 0 {
+ line = line[:i]
+ }
+ b.WriteString(line)
+ b.WriteByte('\n')
+ }
+ return b.String()
+}
+
+// TestRLC4_ELBv2TGDeletableAfterLBTeardown enforces ADR-0002 §5 TG deletability
+// after LB teardown: create LB+listener+rule→TG, delete the LB, then the target
+// group must delete successfully rather than staying pinned as ResourceInUse by
+// an orphaned rule or stale listener default action.
+func TestRLC4_ELBv2TGDeletableAfterLBTeardown(t *testing.T) {
+ svc := setupTestService(t)
+
+ lbOut, err := svc.CreateLoadBalancer(&elbv2.CreateLoadBalancerInput{Name: aws.String("rlc4-lb")}, testAccountID)
+ require.NoError(t, err)
+ lbArn := lbOut.LoadBalancers[0].LoadBalancerArn
+
+ tgOut, err := svc.CreateTargetGroup(&elbv2.CreateTargetGroupInput{Name: aws.String("rlc4-tg")}, testAccountID)
+ require.NoError(t, err)
+ tgArn := tgOut.TargetGroups[0].TargetGroupArn
+
+ lst, err := svc.CreateListener(&elbv2.CreateListenerInput{
+ LoadBalancerArn: lbArn,
+ Protocol: aws.String("HTTP"),
+ Port: aws.Int64(80),
+ DefaultActions: []*elbv2.Action{{Type: aws.String("forward"), TargetGroupArn: tgArn}},
+ }, testAccountID)
+ require.NoError(t, err)
+
+ _, err = svc.CreateRule(&elbv2.CreateRuleInput{
+ ListenerArn: lst.Listeners[0].ListenerArn,
+ Priority: aws.Int64(10),
+ Conditions: []*elbv2.RuleCondition{{Field: aws.String("path-pattern"), Values: aws.StringSlice([]string{"/api/*"})}},
+ Actions: []*elbv2.Action{{Type: aws.String("forward"), TargetGroupArn: tgArn}},
+ }, testAccountID)
+ require.NoError(t, err)
+
+ _, err = svc.DeleteLoadBalancer(&elbv2.DeleteLoadBalancerInput{LoadBalancerArn: lbArn}, testAccountID)
+ require.NoError(t, err)
+
+ _, err = svc.DeleteTargetGroup(&elbv2.DeleteTargetGroupInput{TargetGroupArn: tgArn}, testAccountID)
+ require.NoErrorf(t, err, "ADR-0002 §5 TG deletability after LB teardown: target group must not stay pinned as ResourceInUse")
+}
diff --git a/spinifex/handlers/elbv2/nginx.go b/spinifex/handlers/elbv2/nginx.go
new file mode 100644
index 000000000..754772e28
--- /dev/null
+++ b/spinifex/handlers/elbv2/nginx.go
@@ -0,0 +1,237 @@
+package handlers_elbv2
+
+import (
+ "fmt"
+ "path/filepath"
+ "sort"
+ "strconv"
+ "strings"
+ "text/template"
+
+ "github.com/mulgadc/spinifex/spinifex/lbagent"
+)
+
+// NLB (L4) data plane. HAProxy load-balances no UDP in any release, so NLB
+// listeners (TCP/UDP/TLS/TCP_UDP) render an nginx `stream` config instead;
+// ALBs stay on HAProxy. See docs/development/feature/nlb-nginx-l4-dataplane.md.
+//
+// Health checks are passive only (max_fails / fail_timeout) — OSS nginx stream
+// has no active upstream probing.
+
+const nginxStreamConfigTemplate = `# Auto-generated by spinifex ELBv2 (NLB) — do not edit
+load_module /usr/lib/nginx/modules/ngx_stream_module.so;
+user root;
+worker_processes auto;
+error_log stderr;
+pid {{.PIDPath}};
+
+events {
+ worker_connections 1024;
+}
+
+stream {
+{{- range .Upstreams}}
+ upstream {{.Name}} {
+{{- range .Servers}}
+ server {{.Addr}}:{{.Port}} max_fails={{.MaxFails}} fail_timeout={{.FailTimeout}}s;
+{{- else}}
+ server 127.0.0.1:1 down;
+{{- end}}
+ }
+{{end}}
+{{- range .Servers}}
+ server {
+ listen {{.Port}}{{if .UDP}} udp{{end}}{{if .SSL}} ssl{{end}};
+{{- if .SSL}}
+ ssl_certificate {{.CertPath}};
+ ssl_certificate_key {{.CertPath}};
+{{- end}}
+{{- if .UDP}}
+ proxy_responses 1;
+ proxy_timeout 10s;
+{{- end}}
+ proxy_pass {{.Upstream}};
+ }
+{{end}}}
+`
+
+var nginxStreamTmpl = template.Must(
+ template.New("nginx-stream").Parse(nginxStreamConfigTemplate),
+)
+
+// nginxConfig is the render model for the NLB nginx `stream` config.
+type nginxConfig struct {
+ PIDPath string
+ Upstreams []nginxUpstream
+ Servers []nginxServer
+}
+
+// nginxUpstream is one target group's backend pool. An empty pool renders a
+// single permanently-down placeholder so nginx (which requires >=1 server)
+// still validates.
+type nginxUpstream struct {
+ Name string
+ Servers []nginxUpstreamServer
+}
+
+type nginxUpstreamServer struct {
+ Addr string
+ Port int64
+ MaxFails int64
+ FailTimeout int64
+}
+
+// nginxServer is one listener's `server` block. TCP_UDP listeners expand to two
+// blocks on the same port (one TCP, one UDP).
+type nginxServer struct {
+ Port int64
+ UDP bool
+ SSL bool
+ CertPath string
+ Upstream string
+}
+
+// engineForType reports the data-plane engine for a load balancer type: nginx
+// for NLBs (L4), HAProxy for everything else (ALB / L7).
+func engineForType(lbType string) string {
+ if lbType == LoadBalancerTypeNetwork {
+ return EngineNginx
+ }
+ return EngineHAProxy
+}
+
+// generateNLBStreamConfig builds an nginx `stream` config for an NLB. Secure
+// (TLS) listeners terminate TLS at nginx and forward plaintext to the target;
+// the returned certFiles map carries each cert path → combined PEM for delivery
+// to the LB agent. bindAddr is unused (nginx listens on all addresses, matching
+// the HAProxy `bind *` behaviour).
+func generateNLBStreamConfig(lb *LoadBalancerRecord, listeners []*ListenerRecord, tgByArn map[string]*TargetGroupRecord, _ string, certPEMByArn map[string]string) (string, map[string]string, error) {
+ cfg := nginxConfig{PIDPath: lbagent.NginxPIDPath}
+ var certFiles map[string]string
+ upstreamAdded := make(map[string]struct{})
+
+ addUpstream := func(tgArn string) string {
+ name := sanitizeName("us", tgArn)
+ if _, ok := upstreamAdded[name]; ok {
+ return name
+ }
+ us := nginxUpstream{Name: name}
+ if tg, ok := tgByArn[tgArn]; ok {
+ for _, t := range tg.Targets {
+ if t.PrivateIP == "" || t.HealthState == TargetHealthDraining {
+ continue
+ }
+ us.Servers = append(us.Servers, nginxUpstreamServer{
+ Addr: t.PrivateIP,
+ Port: t.Port,
+ MaxFails: tg.HealthCheck.UnhealthyThreshold,
+ FailTimeout: tg.HealthCheck.IntervalSeconds,
+ })
+ }
+ }
+ cfg.Upstreams = append(cfg.Upstreams, us)
+ upstreamAdded[name] = struct{}{}
+ return name
+ }
+
+ for _, l := range listeners {
+ if len(l.DefaultActions) == 0 {
+ continue
+ }
+ da := l.DefaultActions[0]
+ // NLB default actions are forward-only (no redirect / fixed-response).
+ if da.Type != ActionTypeForward || da.TargetGroupArn == "" {
+ continue
+ }
+ upstream := addUpstream(da.TargetGroupArn)
+
+ var certPath string
+ if protocolRequiresCert(l.Protocol) {
+ pem, _ := resolveFrontendCert(lb, l, certPEMByArn)
+ if pem != "" {
+ certPath = nginxCertPath(lb, l)
+ if certFiles == nil {
+ certFiles = make(map[string]string)
+ }
+ certFiles[certPath] = pem
+ }
+ }
+
+ switch l.Protocol {
+ case ProtocolUDP:
+ cfg.Servers = append(cfg.Servers, nginxServer{Port: l.Port, UDP: true, Upstream: upstream})
+ case ProtocolTCPUDP:
+ cfg.Servers = append(cfg.Servers,
+ nginxServer{Port: l.Port, Upstream: upstream},
+ nginxServer{Port: l.Port, UDP: true, Upstream: upstream})
+ default: // TCP, TLS
+ cfg.Servers = append(cfg.Servers, nginxServer{
+ Port: l.Port,
+ SSL: certPath != "",
+ CertPath: certPath,
+ Upstream: upstream,
+ })
+ }
+ }
+
+ var buf strings.Builder
+ if err := nginxStreamTmpl.Execute(&buf, cfg); err != nil {
+ return "", nil, fmt.Errorf("execute nginx stream template: %w", err)
+ }
+ return buf.String(), certFiles, nil
+}
+
+// nginxCertPath is the stable absolute PEM path for a TLS listener under the
+// agent's nginx cert dir. Daemon-rendered and agent-written paths must match.
+func nginxCertPath(lb *LoadBalancerRecord, l *ListenerRecord) string {
+ return filepath.Join(lbagent.NginxCertDir, fmt.Sprintf("lb-%s-%s.pem", lb.LoadBalancerID, l.ListenerID))
+}
+
+// buildNLBHealthTargets computes the active health-check targets for an NLB's
+// backends. nginx `stream` has no active upstream probing, so the agent probes
+// these and reports the results via heartbeat. ServerName matches the health
+// checker's key (sanitizeName("srv", target.Id)); the probe port resolves
+// "traffic-port" to the target's port. Sorted by server name for deterministic
+// delivery. The tgByArn map is the same set used to render the upstreams.
+func buildNLBHealthTargets(tgByArn map[string]*TargetGroupRecord) []HealthTargetSpec {
+ var out []HealthTargetSpec
+ for _, tg := range tgByArn {
+ for _, t := range tg.Targets {
+ if t.PrivateIP == "" || t.HealthState == TargetHealthDraining {
+ continue
+ }
+ out = append(out, HealthTargetSpec{
+ ServerName: sanitizeName("srv", t.Id),
+ Address: fmt.Sprintf("%s:%d", t.PrivateIP, nlbHealthCheckPort(tg, t)),
+ Protocol: nlbHealthCheckProtocol(tg),
+ Path: tg.HealthCheck.Path,
+ })
+ }
+ }
+ sort.Slice(out, func(i, j int) bool { return out[i].ServerName < out[j].ServerName })
+ return out
+}
+
+// nlbHealthCheckProtocol returns the target group's health-check protocol,
+// defaulting to the NLB default (TCP) when unset.
+func nlbHealthCheckProtocol(tg *TargetGroupRecord) string {
+ if tg.HealthCheck.Protocol != "" {
+ return tg.HealthCheck.Protocol
+ }
+ return DefaultNLBHealthCheckProtocol
+}
+
+// nlbHealthCheckPort resolves the probe port: an explicit numeric health-check
+// port wins, otherwise "traffic-port" maps to the target's port (or the target
+// group's default port when the target has none).
+func nlbHealthCheckPort(tg *TargetGroupRecord, t Target) int64 {
+ if p := tg.HealthCheck.Port; p != "" && p != DefaultHealthCheckPort {
+ if n, err := strconv.ParseInt(p, 10, 64); err == nil {
+ return n
+ }
+ }
+ if t.Port != 0 {
+ return t.Port
+ }
+ return tg.Port
+}
diff --git a/spinifex/handlers/elbv2/nginx_test.go b/spinifex/handlers/elbv2/nginx_test.go
new file mode 100644
index 000000000..ee4ec13bb
--- /dev/null
+++ b/spinifex/handlers/elbv2/nginx_test.go
@@ -0,0 +1,188 @@
+package handlers_elbv2
+
+import (
+ "strings"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func nlbHealthCheck() HealthCheckConfig {
+ return HealthCheckConfig{
+ Protocol: ProtocolTCP,
+ Port: "traffic-port",
+ IntervalSeconds: 30,
+ TimeoutSeconds: 10,
+ HealthyThreshold: 3,
+ UnhealthyThreshold: 3,
+ }
+}
+
+func tcpListener(arn, listenerID, protocol string, port int64, tgArn string) *ListenerRecord {
+ return &ListenerRecord{
+ ListenerArn: arn,
+ ListenerID: listenerID,
+ Protocol: protocol,
+ Port: port,
+ DefaultActions: []ListenerAction{
+ {Type: ActionTypeForward, TargetGroupArn: tgArn},
+ },
+ }
+}
+
+func nlbTG(arn string, targets ...Target) *TargetGroupRecord {
+ return &TargetGroupRecord{
+ TargetGroupArn: arn,
+ Protocol: ProtocolTCP,
+ HealthCheck: nlbHealthCheck(),
+ Targets: targets,
+ }
+}
+
+func TestGenerateNLBStream_TCP(t *testing.T) {
+ lb := &LoadBalancerRecord{LoadBalancerID: "lb-tcp", Type: LoadBalancerTypeNetwork}
+ tgArn := "arn:aws:elasticloadbalancing:us-east-1:1:targetgroup/db/tg-db"
+ listeners := []*ListenerRecord{tcpListener("arn:lst-tcp", "lst-tcp", ProtocolTCP, 5432, tgArn)}
+ tgByArn := map[string]*TargetGroupRecord{
+ tgArn: nlbTG(tgArn,
+ Target{Id: "i-1", Port: 5432, PrivateIP: "10.0.1.10", HealthState: TargetHealthHealthy},
+ Target{Id: "i-2", Port: 5432, PrivateIP: "10.0.1.11", HealthState: TargetHealthHealthy},
+ ),
+ }
+
+ config, certs, err := GenerateHAProxyConfigWithCerts(lb, listeners, tgByArn, nil, "10.0.1.5", nil)
+ require.NoError(t, err)
+ assert.Empty(t, certs)
+
+ assert.Contains(t, config, "load_module /usr/lib/nginx/modules/ngx_stream_module.so;") // stream is a separate Alpine module
+ assert.Contains(t, config, "stream {")
+ assert.NotContains(t, config, "mode tcp") // not HAProxy
+ assert.Contains(t, config, "listen 5432;")
+ assert.NotContains(t, config, "listen 5432 udp")
+ assert.Contains(t, config, "server 10.0.1.10:5432 max_fails=3 fail_timeout=30s;")
+ assert.Contains(t, config, "server 10.0.1.11:5432 max_fails=3 fail_timeout=30s;")
+ assert.Contains(t, config, "proxy_pass us_tg-db;")
+}
+
+func TestGenerateNLBStream_UDP(t *testing.T) {
+ lb := &LoadBalancerRecord{LoadBalancerID: "lb-udp", Type: LoadBalancerTypeNetwork}
+ tgArn := "arn:tg-dns"
+ listeners := []*ListenerRecord{tcpListener("arn:lst-udp", "lst-udp", ProtocolUDP, 53, tgArn)}
+ tgByArn := map[string]*TargetGroupRecord{
+ tgArn: nlbTG(tgArn, Target{Id: "i-dns", Port: 53, PrivateIP: "10.0.2.10", HealthState: TargetHealthHealthy}),
+ }
+
+ config, _, err := GenerateHAProxyConfigWithCerts(lb, listeners, tgByArn, nil, "10.0.2.1", nil)
+ require.NoError(t, err)
+
+ assert.Contains(t, config, "listen 53 udp;")
+ assert.Contains(t, config, "proxy_responses 1;")
+ assert.Contains(t, config, "10.0.2.10:53")
+}
+
+func TestGenerateNLBStream_TLS(t *testing.T) {
+ lb := &LoadBalancerRecord{LoadBalancerID: "lb-tls", Type: LoadBalancerTypeNetwork}
+ tgArn := "arn:tg-tls"
+ lst := tcpListener("arn:lst-tls", "lst-tls", ProtocolTLS, 443, tgArn)
+ lst.Certificates = []ListenerCertificate{{CertificateArn: "arn:cert-1", IsDefault: true}}
+ tgByArn := map[string]*TargetGroupRecord{
+ tgArn: nlbTG(tgArn, Target{Id: "i-tls", Port: 8443, PrivateIP: "10.0.3.10", HealthState: TargetHealthHealthy}),
+ }
+ certPEM := map[string]string{"arn:cert-1": "LEAF\nCHAIN\nKEY\n"}
+
+ config, certs, err := GenerateHAProxyConfigWithCerts(lb, []*ListenerRecord{lst}, tgByArn, nil, "10.0.3.1", certPEM)
+ require.NoError(t, err)
+
+ wantPath := "/etc/nginx/certs/lb-lb-tls-lst-tls.pem"
+ assert.Contains(t, config, "listen 443 ssl;")
+ assert.Contains(t, config, "ssl_certificate "+wantPath+";")
+ assert.Contains(t, config, "ssl_certificate_key "+wantPath+";")
+ require.Contains(t, certs, wantPath)
+ assert.Equal(t, "LEAF\nCHAIN\nKEY\n", certs[wantPath])
+}
+
+func TestGenerateNLBStream_TCPUDP(t *testing.T) {
+ lb := &LoadBalancerRecord{LoadBalancerID: "lb-both", Type: LoadBalancerTypeNetwork}
+ tgArn := "arn:tg-both"
+ listeners := []*ListenerRecord{tcpListener("arn:lst-both", "lst-both", ProtocolTCPUDP, 5000, tgArn)}
+ tgByArn := map[string]*TargetGroupRecord{
+ tgArn: nlbTG(tgArn, Target{Id: "i-both", Port: 5000, PrivateIP: "10.0.4.10", HealthState: TargetHealthHealthy}),
+ }
+
+ config, _, err := GenerateHAProxyConfigWithCerts(lb, listeners, tgByArn, nil, "10.0.4.1", nil)
+ require.NoError(t, err)
+
+ // Two server blocks on the same port: one TCP, one UDP.
+ assert.Contains(t, config, "listen 5000;")
+ assert.Contains(t, config, "listen 5000 udp;")
+ assert.Equal(t, 2, strings.Count(config, "listen 5000"))
+}
+
+func TestGenerateNLBStream_SkipsDrainingAndNoIP(t *testing.T) {
+ lb := &LoadBalancerRecord{LoadBalancerID: "lb-drain", Type: LoadBalancerTypeNetwork}
+ tgArn := "arn:tg-drain"
+ listeners := []*ListenerRecord{tcpListener("arn:lst-drain", "lst-drain", ProtocolTCP, 3306, tgArn)}
+ tgByArn := map[string]*TargetGroupRecord{
+ tgArn: nlbTG(tgArn,
+ Target{Id: "i-active", Port: 3306, PrivateIP: "10.0.0.1", HealthState: TargetHealthHealthy},
+ Target{Id: "i-drain", Port: 3306, PrivateIP: "10.0.0.2", HealthState: TargetHealthDraining},
+ Target{Id: "i-no-ip", Port: 3306, PrivateIP: "", HealthState: TargetHealthInitial},
+ ),
+ }
+
+ config, _, err := GenerateHAProxyConfigWithCerts(lb, listeners, tgByArn, nil, "0.0.0.0", nil)
+ require.NoError(t, err)
+
+ assert.Contains(t, config, "10.0.0.1:3306")
+ assert.NotContains(t, config, "10.0.0.2")
+}
+
+func TestGenerateNLBStream_EmptyUpstreamPlaceholder(t *testing.T) {
+ lb := &LoadBalancerRecord{LoadBalancerID: "lb-empty", Type: LoadBalancerTypeNetwork}
+ tgArn := "arn:tg-empty"
+ listeners := []*ListenerRecord{tcpListener("arn:lst-empty", "lst-empty", ProtocolTCP, 80, tgArn)}
+ tgByArn := map[string]*TargetGroupRecord{tgArn: nlbTG(tgArn)}
+
+ config, _, err := GenerateHAProxyConfigWithCerts(lb, listeners, tgByArn, nil, "0.0.0.0", nil)
+ require.NoError(t, err)
+
+ // nginx requires >=1 server per upstream; an empty TG renders a down placeholder.
+ assert.Contains(t, config, "server 127.0.0.1:1 down;")
+}
+
+func TestBuildNLBHealthTargets_Resolution(t *testing.T) {
+ tgArn := "arn:aws:elasticloadbalancing:us-east-1:1:targetgroup/db/tg-db"
+ tgByArn := map[string]*TargetGroupRecord{
+ tgArn: nlbTG(tgArn,
+ Target{Id: "i-2", Port: 5432, PrivateIP: "10.0.1.11", HealthState: TargetHealthHealthy},
+ Target{Id: "i-1", Port: 5432, PrivateIP: "10.0.1.10", HealthState: TargetHealthHealthy},
+ Target{Id: "i-drain", Port: 5432, PrivateIP: "10.0.1.12", HealthState: TargetHealthDraining},
+ Target{Id: "i-no-ip", Port: 5432, PrivateIP: "", HealthState: TargetHealthInitial},
+ ),
+ }
+
+ got := buildNLBHealthTargets(tgByArn)
+ require.Len(t, got, 2) // draining + no-IP excluded
+
+ // Sorted by server name for deterministic delivery.
+ assert.Equal(t, sanitizeName("srv", "i-1"), got[0].ServerName)
+ assert.Equal(t, sanitizeName("srv", "i-2"), got[1].ServerName)
+ assert.Equal(t, "10.0.1.10:5432", got[0].Address) // traffic-port -> target port
+ assert.Equal(t, ProtocolTCP, got[0].Protocol)
+}
+
+func TestBuildNLBHealthTargets_ExplicitHCPortAndHTTP(t *testing.T) {
+ tgArn := "arn:tg-http"
+ tg := nlbTG(tgArn, Target{Id: "i-1", Port: 8080, PrivateIP: "10.0.0.9", HealthState: TargetHealthHealthy})
+ tg.HealthCheck.Protocol = ProtocolHTTP
+ tg.HealthCheck.Port = "9000"
+ tg.HealthCheck.Path = "/healthz"
+ tgByArn := map[string]*TargetGroupRecord{tgArn: tg}
+
+ got := buildNLBHealthTargets(tgByArn)
+ require.Len(t, got, 1)
+ assert.Equal(t, "10.0.0.9:9000", got[0].Address) // explicit numeric HC port wins
+ assert.Equal(t, ProtocolHTTP, got[0].Protocol)
+ assert.Equal(t, "/healthz", got[0].Path)
+}
diff --git a/spinifex/handlers/elbv2/service.go b/spinifex/handlers/elbv2/service.go
index 4a6276c46..9492e1eab 100644
--- a/spinifex/handlers/elbv2/service.go
+++ b/spinifex/handlers/elbv2/service.go
@@ -9,6 +9,9 @@ type ELBv2Service interface {
DescribeLoadBalancers(input *elbv2.DescribeLoadBalancersInput, accountID string) (*elbv2.DescribeLoadBalancersOutput, error)
ModifyLoadBalancerAttributes(input *elbv2.ModifyLoadBalancerAttributesInput, accountID string) (*elbv2.ModifyLoadBalancerAttributesOutput, error)
DescribeLoadBalancerAttributes(input *elbv2.DescribeLoadBalancerAttributesInput, accountID string) (*elbv2.DescribeLoadBalancerAttributesOutput, error)
+ SetSecurityGroups(input *elbv2.SetSecurityGroupsInput, accountID string) (*elbv2.SetSecurityGroupsOutput, error)
+ SetIpAddressType(input *elbv2.SetIpAddressTypeInput, accountID string) (*elbv2.SetIpAddressTypeOutput, error)
+ SetSubnets(input *elbv2.SetSubnetsInput, accountID string) (*elbv2.SetSubnetsOutput, error)
CreateTargetGroup(input *elbv2.CreateTargetGroupInput, accountID string) (*elbv2.CreateTargetGroupOutput, error)
ModifyTargetGroup(input *elbv2.ModifyTargetGroupInput, accountID string) (*elbv2.ModifyTargetGroupOutput, error)
@@ -26,6 +29,11 @@ type ELBv2Service interface {
ModifyListener(input *elbv2.ModifyListenerInput, accountID string) (*elbv2.ModifyListenerOutput, error)
DescribeListeners(input *elbv2.DescribeListenersInput, accountID string) (*elbv2.DescribeListenersOutput, error)
+ AddListenerCertificates(input *elbv2.AddListenerCertificatesInput, accountID string) (*elbv2.AddListenerCertificatesOutput, error)
+ RemoveListenerCertificates(input *elbv2.RemoveListenerCertificatesInput, accountID string) (*elbv2.RemoveListenerCertificatesOutput, error)
+ DescribeListenerCertificates(input *elbv2.DescribeListenerCertificatesInput, accountID string) (*elbv2.DescribeListenerCertificatesOutput, error)
+ DescribeSSLPolicies(input *elbv2.DescribeSSLPoliciesInput, accountID string) (*elbv2.DescribeSSLPoliciesOutput, error)
+
CreateRule(input *elbv2.CreateRuleInput, accountID string) (*elbv2.CreateRuleOutput, error)
ModifyRule(input *elbv2.ModifyRuleInput, accountID string) (*elbv2.ModifyRuleOutput, error)
DeleteRule(input *elbv2.DeleteRuleInput, accountID string) (*elbv2.DeleteRuleOutput, error)
@@ -33,6 +41,8 @@ type ELBv2Service interface {
SetRulePriorities(input *elbv2.SetRulePrioritiesInput, accountID string) (*elbv2.SetRulePrioritiesOutput, error)
DescribeTags(input *elbv2.DescribeTagsInput, accountID string) (*elbv2.DescribeTagsOutput, error)
+ AddTags(input *elbv2.AddTagsInput, accountID string) (*elbv2.AddTagsOutput, error)
+ RemoveTags(input *elbv2.RemoveTagsInput, accountID string) (*elbv2.RemoveTagsOutput, error)
LBAgentHeartbeat(input *LBAgentHeartbeatInput, accountID string) (*LBAgentHeartbeatOutput, error)
GetLBConfig(input *GetLBConfigInput, accountID string) (*GetLBConfigOutput, error)
diff --git a/spinifex/handlers/elbv2/service_impl.go b/spinifex/handlers/elbv2/service_impl.go
index 8491c62a4..ed4bb1e5a 100644
--- a/spinifex/handlers/elbv2/service_impl.go
+++ b/spinifex/handlers/elbv2/service_impl.go
@@ -3,13 +3,14 @@ package handlers_elbv2
import (
"context"
"crypto/sha256"
+ "encoding/hex"
"errors"
"fmt"
"log/slog"
- "maps"
"net"
"regexp"
"slices"
+ "sort"
"strings"
"sync"
"time"
@@ -19,6 +20,7 @@ import (
"github.com/aws/aws-sdk-go/service/elbv2"
"github.com/mulgadc/spinifex/spinifex/awserrors"
"github.com/mulgadc/spinifex/spinifex/config"
+ handlers_acm "github.com/mulgadc/spinifex/spinifex/handlers/acm"
handlers_ec2_vpc "github.com/mulgadc/spinifex/spinifex/handlers/ec2/vpc"
"github.com/mulgadc/spinifex/spinifex/tags"
"github.com/mulgadc/spinifex/spinifex/utils"
@@ -87,6 +89,13 @@ func (s *ELBv2ServiceImpl) resolveMgmtRoute(scheme string) (gateway, target stri
if scheme == SchemeInternal && s.MgmtBridgeIP != "" && s.AdvertiseIP != "" {
return s.MgmtBridgeIP, s.AdvertiseIP
}
+ if scheme == SchemeInternal {
+ slog.Warn("resolveMgmtRoute: internal LB has no mgmt return route; lb-agent cannot reach AWSGW and the LB will stay provisioning",
+ "mgmtRouteGateway", s.MgmtRouteGateway,
+ "mgmtRouteTarget", s.MgmtRouteTarget,
+ "mgmtBridgeIP", s.MgmtBridgeIP,
+ "advertiseIP", s.AdvertiseIP)
+ }
return "", ""
}
@@ -97,6 +106,7 @@ var _ ELBv2Service = (*ELBv2ServiceImpl)(nil)
type ELBv2ServiceImpl struct {
config *config.Config
store *Store
+ acmStore *handlers_acm.Store // resolves listener cert ARNs → PEM; nil-safe (HTTPS unavailable when nil)
nc *nats.Conn // NATS connection for JetStream KV store
VPCService *handlers_ec2_vpc.VPCServiceImpl // nil-safe: ENI ops skipped when nil (e.g. in tests)
InstanceLauncher SystemInstanceLauncher // nil-safe: system VM ops skipped when nil
@@ -129,6 +139,16 @@ func NewELBv2ServiceImplWithNATS(cfg *config.Config, nc *nats.Conn) (*ELBv2Servi
return nil, fmt.Errorf("failed to create ELBv2 store: %w", err)
}
+ // ACM store shares the same JetStream KV — read cert PEM directly, no
+ // cross-service NATS hop. Non-fatal: a failure here only disables HTTPS
+ // termination (resolveCertPEM returns an error), it must not block the
+ // daemon from serving ELBv2 control-plane requests.
+ acmStore, acmErr := handlers_acm.NewStore(nc)
+ if acmErr != nil {
+ slog.Warn("ELBv2: ACM store unavailable, HTTPS listeners cannot resolve certs", "err", acmErr)
+ acmStore = nil
+ }
+
region := "us-east-1"
nodeID := ""
if cfg != nil {
@@ -142,14 +162,15 @@ func NewELBv2ServiceImplWithNATS(cfg *config.Config, nc *nats.Conn) (*ELBv2Servi
hc := newHealthChecker(store)
return &ELBv2ServiceImpl{
- config: cfg,
- store: store,
- nc: nc,
- nodeID: nodeID,
- region: region,
- ctx: ctx,
- cancel: cancel,
- hc: hc,
+ config: cfg,
+ store: store,
+ acmStore: acmStore,
+ nc: nc,
+ nodeID: nodeID,
+ region: region,
+ ctx: ctx,
+ cancel: cancel,
+ hc: hc,
}, nil
}
@@ -183,8 +204,6 @@ func (s *ELBv2ServiceImpl) Close() {
// heartbeat re-converges. Acceptable trade-off — heartbeats arrive within
// one health-check interval (default 5-30s) and round-robin transiently
// excluding a backend is safer than serving traffic to a dead one.
-//
-// Refs mulga-siv-119.
func (s *ELBv2ServiceImpl) ResetTargetHealthOnStartup(ctx context.Context) error {
if s == nil || s.store == nil {
return nil
@@ -417,6 +436,81 @@ func buildExtraENIInputs(eniIDs []string, eniDetails map[string]*ec2.NetworkInte
return extras
}
+// lbVMLaunch is the outcome of booting the system VM that backs a load
+// balancer. failed is true when the VM could not be launched (missing system
+// credentials or a launcher error) so the caller can mark the LB failed.
+type lbVMLaunch struct {
+ instanceID string
+ vpcIP string
+ publicIP string
+ hostPorts map[int]int
+ failed bool
+}
+
+// launchLBVM boots the system VM backing a load balancer with the given ENI
+// set. The first ENI is the primary NIC; any additional ENIs are passed as
+// ExtraENIs so the daemon sets up a tap + QEMU NIC per subnet, giving the LB VM
+// data-plane presence in every subnet it spans. When no launcher is configured
+// the call is a no-op (zero value, failed=false) so launcher-less deployments
+// keep the LB Active. Shared by CreateLoadBalancer and the SetSubnets relaunch.
+func (s *ELBv2ServiceImpl) launchLBVM(lbID, scheme string, eniIDs, subnets []string, accountID string) lbVMLaunch {
+ var res lbVMLaunch
+ if s.InstanceLauncher == nil || len(eniIDs) == 0 || len(subnets) == 0 {
+ return res
+ }
+
+ eniDetails := s.describeENIs(eniIDs, accountID)
+ primary := eniDetails[eniIDs[0]]
+ primaryIP := ""
+ primaryMAC := ""
+ if primary != nil {
+ primaryIP = aws.StringValue(primary.PrivateIpAddress)
+ primaryMAC = aws.StringValue(primary.MacAddress)
+ }
+
+ extraENIInputs := buildExtraENIInputs(eniIDs, eniDetails)
+
+ if s.GatewayURL == "" || s.SystemAccessKey == "" || s.SystemSecretKey == "" {
+ slog.Error("launchLBVM: system credentials not configured — cannot launch LB VM", "lbId", lbID)
+ res.failed = true
+ return res
+ }
+
+ nics := s.buildMicrovmNICs(primaryIP, primaryMAC, subnets[0], eniIDs[0], scheme, extraENIInputs, accountID)
+ launchInput := &SystemInstanceInput{
+ InstanceType: s.getSystemInstanceType(),
+ SubnetID: subnets[0],
+ ENIID: eniIDs[0],
+ ENIMac: primaryMAC,
+ ENIIP: primaryIP,
+ ExtraENIs: extraENIInputs,
+ Scheme: scheme,
+ AccountID: accountID,
+ NICs: nics,
+ LBAgentEnv: s.buildLBAgentEnv(lbID),
+ CACert: s.CACert,
+ }
+ // Dev-mode only: forward HTTP/HTTPS ports from host for local testing.
+ // In production (VPC networking), traffic reaches the LB VM's VPC IP directly.
+ if s.config != nil && s.config.Daemon.DevNetworking {
+ launchInput.HostfwdPorts = []int{80, 443}
+ }
+
+ out, launchErr := s.InstanceLauncher.LaunchSystemInstance(launchInput)
+ if launchErr != nil {
+ slog.Error("launchLBVM: failed to launch LB VM", "lbId", lbID, "err", launchErr)
+ res.failed = true
+ return res
+ }
+
+ res.instanceID = out.InstanceID
+ res.vpcIP = out.PrivateIP
+ res.publicIP = out.PublicIP
+ res.hostPorts = out.HostfwdMap
+ slog.Info("launchLBVM: LB VM launched", "lbId", lbID, "instanceId", out.InstanceID, "ip", out.PrivateIP, "publicIp", out.PublicIP, "hostfwd", out.HostfwdMap)
+ return res
+}
+
// loadRecoveryLBIndex returns the instanceID->LB map, populated lazily on
// first successful call. Errors are not cached: a transient JetStream
// failure at startup must not condemn every subsequent recovery candidate
@@ -580,15 +674,29 @@ func (s *ELBv2ServiceImpl) updateStoredConfig(lb *LoadBalancerRecord) error {
}
}
- configContent, err := GenerateHAProxyConfig(lb, listeners, tgByArn, rulesByListener, bindAddr)
+ certPEMByArn, err := s.resolveListenerCerts(listeners, lb.AccountID)
+ if err != nil {
+ slog.Error("updateStoredConfig: failed to resolve certs", "lbId", lb.LoadBalancerID, "err", err)
+ return fmt.Errorf("resolve certs: %w", err)
+ }
+
+ configContent, certFiles, err := GenerateHAProxyConfigWithCerts(lb, listeners, tgByArn, rulesByListener, bindAddr, certPEMByArn)
if err != nil {
slog.Error("updateStoredConfig: failed to generate config", "lbId", lb.LoadBalancerID, "err", err)
return fmt.Errorf("generate config: %w", err)
}
- hash := fmt.Sprintf("%x", sha256.Sum256([]byte(configContent)))
+ hash := configCertHash(configContent, certFiles)
lb.ConfigText = configContent
lb.ConfigHash = hash
+ lb.CertFiles = certFiles
+ // NLBs run nginx, which has no active upstream probing — the agent
+ // probes these targets and reports health. ALBs report via HAProxy stats.
+ if lb.Type == LoadBalancerTypeNetwork {
+ lb.HealthTargets = buildNLBHealthTargets(tgByArn)
+ } else {
+ lb.HealthTargets = nil
+ }
if err := s.store.PutLoadBalancer(lb); err != nil {
slog.Error("updateStoredConfig: failed to persist LB", "lbId", lb.LoadBalancerID, "err", err)
@@ -596,10 +704,100 @@ func (s *ELBv2ServiceImpl) updateStoredConfig(lb *LoadBalancerRecord) error {
}
slog.Info("updateStoredConfig: config stored",
- "lbId", lb.LoadBalancerID, "hash", hash[:12], "size", len(configContent))
+ "lbId", lb.LoadBalancerID, "hash", hash[:12], "size", len(configContent), "certs", len(certFiles))
return nil
}
+// resolveListenerCerts resolves every distinct certificate ARN referenced by
+// the listeners to its combined PEM via the ACM store. Returns nil when no
+// listener carries a certificate (the common HTTP-only case).
+func (s *ELBv2ServiceImpl) resolveListenerCerts(listeners []*ListenerRecord, accountID string) (map[string]string, error) {
+ var out map[string]string
+ for _, l := range listeners {
+ for _, c := range l.Certificates {
+ if _, ok := out[c.CertificateArn]; ok {
+ continue
+ }
+ pem, err := s.resolveCertPEM(c.CertificateArn, accountID)
+ if err != nil {
+ return nil, fmt.Errorf("cert %s: %w", c.CertificateArn, err)
+ }
+ if out == nil {
+ out = make(map[string]string)
+ }
+ out[c.CertificateArn] = pem
+ }
+ }
+ return out, nil
+}
+
+// resolveCertPEM loads a certificate by ARN from the ACM store and returns its
+// combined PEM (leaf + chain + key) in the order HAProxy `ssl crt` expects.
+// Ownership is enforced: a cert belonging to another account is treated as
+// absent.
+func (s *ELBv2ServiceImpl) resolveCertPEM(arn, accountID string) (string, error) {
+ if s.acmStore == nil {
+ return "", errors.New(awserrors.ErrorELBv2CertificateNotFound)
+ }
+ rec, err := s.acmStore.GetCert(arn)
+ if err != nil {
+ return "", fmt.Errorf("get cert: %w", err)
+ }
+ if rec == nil || rec.AccountID != accountID {
+ return "", errors.New(awserrors.ErrorELBv2CertificateNotFound)
+ }
+
+ var b strings.Builder
+ b.WriteString(strings.TrimRight(rec.Certificate, "\n"))
+ b.WriteByte('\n')
+ if rec.CertificateChain != "" {
+ b.WriteString(strings.TrimRight(rec.CertificateChain, "\n"))
+ b.WriteByte('\n')
+ }
+ b.WriteString(strings.TrimRight(rec.PrivateKey, "\n"))
+ b.WriteByte('\n')
+ return b.String(), nil
+}
+
+// validateListenerCerts confirms every certificate ARN resolves in the ACM
+// store and is owned by the account, rejecting an unknown ARN at the API
+// boundary (CreateListener/ModifyListener) with ErrorELBv2CertificateNotFound.
+// Without this, an unresolvable ARN is accepted into the listener and only
+// fails later at config-render time, which aborts updateStoredConfig wholesale
+// and silently freezes the LB's data-plane convergence. Skipped when the ACM
+// store is unavailable (cannot validate; matches the nil-safe resolve path).
+func (s *ELBv2ServiceImpl) validateListenerCerts(certs []ListenerCertificate, accountID string) error {
+ if s.acmStore == nil {
+ return nil
+ }
+ for _, c := range certs {
+ if _, err := s.resolveCertPEM(c.CertificateArn, accountID); err != nil {
+ return errors.New(awserrors.ErrorELBv2CertificateNotFound)
+ }
+ }
+ return nil
+}
+
+// configCertHash hashes the rendered config plus every delivered cert file
+// (path + content, in sorted path order) so a cert rotation changes the hash
+// and triggers an agent reload even when the config text is unchanged.
+func configCertHash(configContent string, certFiles map[string]string) string {
+ h := sha256.New()
+ h.Write([]byte(configContent))
+ paths := make([]string, 0, len(certFiles))
+ for p := range certFiles {
+ paths = append(paths, p)
+ }
+ sort.Strings(paths)
+ for _, p := range paths {
+ h.Write([]byte(p))
+ h.Write([]byte{0})
+ h.Write([]byte(certFiles[p]))
+ h.Write([]byte{0})
+ }
+ return hex.EncodeToString(h.Sum(nil))
+}
+
// updateStoredConfigForTargetGroup finds all LBs that reference the given target
// group (via listeners) and updates their stored config.
func (s *ELBv2ServiceImpl) updateStoredConfigForTargetGroup(tgArn string) error {
@@ -661,12 +859,19 @@ func (s *ELBv2ServiceImpl) LBAgentHeartbeat(input *LBAgentHeartbeatInput, accoun
}
lbID := *input.LBID
+ slog.Debug("LBAgentHeartbeat received", "lbId", lbID, "accountId", accountID)
lb, err := s.store.GetLoadBalancer(lbID)
if err != nil {
slog.Error("LBAgentHeartbeat: failed to get LB", "lbId", lbID, "err", err)
return nil, errors.New(awserrors.ErrorServerInternal)
}
if lb == nil || (lb.AccountID != accountID && accountID != utils.GlobalAccountID) {
+ // The heartbeat reached AWSGW (so the mgmt return path works) but
+ // cannot transition any LB. Without this the reject is silent and a
+ // stuck-in-provisioning LB looks identical to one whose heartbeat
+ // never arrived.
+ slog.Warn("LBAgentHeartbeat: LB not found or account mismatch",
+ "lbId", lbID, "accountId", accountID, "found", lb != nil)
return nil, errors.New(awserrors.ErrorELBv2LoadBalancerNotFound)
}
@@ -719,11 +924,50 @@ func (s *ELBv2ServiceImpl) GetLBConfig(input *GetLBConfigInput, accountID string
}
return &GetLBConfigOutput{
- ConfigText: aws.String(lb.ConfigText),
- ConfigHash: aws.String(lb.ConfigHash),
+ ConfigText: aws.String(lb.ConfigText),
+ ConfigHash: aws.String(lb.ConfigHash),
+ CertFiles: certFilesToSDK(lb.CertFiles),
+ Engine: aws.String(engineForType(lb.Type)),
+ HealthTargets: healthTargetsToSDK(lb.HealthTargets),
}, nil
}
+// healthTargetsToSDK converts the stored health-target specs into the SDK
+// HealthTarget slice delivered to the nginx agent.
+func healthTargetsToSDK(specs []HealthTargetSpec) []*HealthTarget {
+ if len(specs) == 0 {
+ return nil
+ }
+ out := make([]*HealthTarget, 0, len(specs))
+ for i := range specs {
+ out = append(out, &HealthTarget{
+ ServerName: aws.String(specs[i].ServerName),
+ Address: aws.String(specs[i].Address),
+ Protocol: aws.String(specs[i].Protocol),
+ Path: aws.String(specs[i].Path),
+ })
+ }
+ return out
+}
+
+// certFilesToSDK converts the stored path→PEM map into the sorted CertFile
+// slice delivered to the agent. Sorted for deterministic output.
+func certFilesToSDK(certFiles map[string]string) []*CertFile {
+ if len(certFiles) == 0 {
+ return nil
+ }
+ paths := make([]string, 0, len(certFiles))
+ for p := range certFiles {
+ paths = append(paths, p)
+ }
+ sort.Strings(paths)
+ out := make([]*CertFile, 0, len(paths))
+ for _, p := range paths {
+ out = append(out, &CertFile{Path: aws.String(p), PEM: aws.String(certFiles[p])})
+ }
+ return out
+}
+
// lbArnPathSegment returns the ARN path segment for the given LB type:
// "app" for application LBs, "net" for network LBs.
func lbArnPathSegment(lbType string) string {
@@ -762,6 +1006,28 @@ func (s *ELBv2ServiceImpl) resolveTargetIP(instanceID, accountID string) string
return ""
}
+// resolveRegisteredTargetIP returns the private IP to route to for a target
+// being registered, based on the target group's target type. For ip targets the
+// supplied ID is the IP itself; for instance targets it is resolved via the
+// instance's primary ENI. Returns an error when the ID's shape does not match
+// the target type.
+func (s *ELBv2ServiceImpl) resolveRegisteredTargetIP(targetType, id, accountID string) (string, error) {
+ switch targetType {
+ case TargetTypeIP:
+ if net.ParseIP(id) == nil {
+ slog.Warn("RegisterTargets: ip target id is not a valid IP", "id", id)
+ return "", errors.New(awserrors.ErrorInvalidParameterValue)
+ }
+ return id, nil
+ default: // instance
+ if net.ParseIP(id) != nil {
+ slog.Warn("RegisterTargets: instance target group given an IP address", "id", id)
+ return "", errors.New(awserrors.ErrorInvalidParameterValue)
+ }
+ return s.resolveTargetIP(id, accountID), nil
+ }
+}
+
// buildTGArn constructs a target group ARN from components.
func buildTGArn(region, accountID, name, tgID string) string {
return fmt.Sprintf("arn:aws:elasticloadbalancing:%s:%s:targetgroup/%s/%s", region, accountID, name, tgID)
@@ -777,13 +1043,15 @@ const (
elbv2ResourceLoadBalancer = "loadbalancer"
elbv2ResourceTargetGroup = "targetgroup"
elbv2ResourceListener = "listener"
+ elbv2ResourceListenerRule = "listener-rule"
)
// elbv2ResourceTypeFromArn extracts the resource type from an ELBv2 ARN.
// ELBv2 ARNs have the form
// "arn:aws:elasticloadbalancing:{region}:{account}:{type}/...". Returns one
-// of "loadbalancer", "targetgroup", "listener" — anything else yields
-// ErrorInvalidParameterValue so callers can surface a proper API error.
+// of "loadbalancer", "targetgroup", "listener", "listener-rule" — anything
+// else yields ErrorInvalidParameterValue so callers can surface a proper API
+// error.
func elbv2ResourceTypeFromArn(arn string) (string, error) {
parts := strings.SplitN(arn, ":", 6)
if len(parts) < 6 || parts[0] != "arn" || parts[2] != "elasticloadbalancing" {
@@ -796,7 +1064,7 @@ func elbv2ResourceTypeFromArn(arn string) (string, error) {
}
resourceType := resourceSegment[:slash]
switch resourceType {
- case elbv2ResourceLoadBalancer, elbv2ResourceTargetGroup, elbv2ResourceListener:
+ case elbv2ResourceLoadBalancer, elbv2ResourceTargetGroup, elbv2ResourceListener, elbv2ResourceListenerRule:
return resourceType, nil
default:
return "", errors.New(awserrors.ErrorInvalidParameterValue)
@@ -887,18 +1155,28 @@ func (s *ELBv2ServiceImpl) CreateLoadBalancer(input *elbv2.CreateLoadBalancerInp
}
}
- tags := make(map[string]string)
- for _, tag := range input.Tags {
- if tag.Key != nil && tag.Value != nil {
- tags[*tag.Key] = *tag.Value
- }
- }
+ tags := tagsFromSDK(input.Tags)
// Create ENIs in each subnet (when VPC service is available)
var eniIDs []string
var availZones []AvailZoneInfo
vpcID := ""
+ var nlbManagedSGID string
if s.VPCService != nil && len(subnets) > 0 {
+ // NLBs reject customer SGs (rejected above), so the ENIs would fall back
+ // to the VPC default SG whose intra-SG-only ingress drops inbound
+ // listener traffic. Mint a dedicated managed SG up front and attach it to
+ // every LB ENI; CreateListener opens the listener ports on it.
+ eniGroups := securityGroups
+ if lbType == LoadBalancerTypeNetwork {
+ sgID, sgErr := s.createNLBManagedSG(lbID, lbArn, subnets[0], accountID)
+ if sgErr != nil {
+ slog.Error("CreateLoadBalancer: failed to create managed NLB SG", "lbId", lbID, "err", sgErr)
+ return nil, errors.New(awserrors.ErrorServerInternal)
+ }
+ nlbManagedSGID = sgID
+ eniGroups = []string{sgID}
+ }
for _, subnetID := range subnets {
eniIn := &ec2.CreateNetworkInterfaceInput{
SubnetId: aws.String(subnetID),
@@ -914,11 +1192,12 @@ func (s *ELBv2ServiceImpl) CreateLoadBalancer(input *elbv2.CreateLoadBalancerInp
},
}
// SG enforcement gates inbound traffic by the ENI's port-group
- // membership, which is set at ENI creation. Empty Groups
- // intentionally falls back to the VPC default SG inside
+ // membership, which is set at ENI creation. For NLBs this is the
+ // managed SG minted above; for ALBs it is the customer SGs. Empty
+ // Groups intentionally falls back to the VPC default SG inside
// eni.CreateNetworkInterface.
- if len(securityGroups) > 0 {
- eniIn.Groups = aws.StringSlice(securityGroups)
+ if len(eniGroups) > 0 {
+ eniIn.Groups = aws.StringSlice(eniGroups)
}
eniOut, eniErr := s.VPCService.CreateNetworkInterface(eniIn, accountID)
if eniErr != nil {
@@ -930,6 +1209,7 @@ func (s *ELBv2ServiceImpl) CreateLoadBalancer(input *elbv2.CreateLoadBalancerInp
slog.Error("CreateLoadBalancer: rollback failed to delete ENI", "eni", rollbackENI, "err", delErr)
}
}
+ s.deleteNLBManagedSG(nlbManagedSGID, accountID)
slog.Error("CreateLoadBalancer: failed to create ENI", "subnet", subnetID, "err", eniErr)
return nil, errors.New(awserrors.ErrorELBv2SubnetNotFound)
}
@@ -955,64 +1235,14 @@ func (s *ELBv2ServiceImpl) CreateLoadBalancer(input *elbv2.CreateLoadBalancerInp
// The first ENI is the primary NIC; any additional ENIs are passed as
// ExtraENIs so the daemon sets up a tap + QEMU NIC for each, giving the
// ALB VM data-plane presence in every subnet the LB spans.
- var albInstanceID string
- var albVPCIP string
- var hostPorts map[int]int
- var launchFailed bool
- if s.InstanceLauncher != nil && len(eniIDs) > 0 && len(subnets) > 0 {
- eniDetails := s.describeENIs(eniIDs, accountID)
-
- primary := eniDetails[eniIDs[0]]
- primaryIP := ""
- primaryMAC := ""
- if primary != nil {
- primaryIP = aws.StringValue(primary.PrivateIpAddress)
- primaryMAC = aws.StringValue(primary.MacAddress)
- }
-
- extraENIInputs := buildExtraENIInputs(eniIDs, eniDetails)
-
- var launchInput *SystemInstanceInput
- if s.GatewayURL == "" || s.SystemAccessKey == "" || s.SystemSecretKey == "" {
- slog.Error("CreateLoadBalancer: system credentials not configured — cannot launch ALB VM", "lbId", lbID)
- launchFailed = true
- } else {
- nics := s.buildMicrovmNICs(primaryIP, primaryMAC, subnets[0], eniIDs[0], scheme, extraENIInputs, accountID)
- launchInput = &SystemInstanceInput{
- InstanceType: s.getSystemInstanceType(),
- SubnetID: subnets[0],
- ENIID: eniIDs[0],
- ENIMac: primaryMAC,
- ENIIP: primaryIP,
- ExtraENIs: extraENIInputs,
- Scheme: scheme,
- AccountID: accountID,
- NICs: nics,
- LBAgentEnv: s.buildLBAgentEnv(lbID),
- CACert: s.CACert,
- }
- }
- if !launchFailed && launchInput != nil {
- // Dev-mode only: forward HTTP/HTTPS ports from host for local testing.
- // In production (VPC networking), traffic reaches the ALB VM's VPC IP directly.
- if s.config != nil && s.config.Daemon.DevNetworking {
- launchInput.HostfwdPorts = []int{80, 443}
- }
- out, launchErr := s.InstanceLauncher.LaunchSystemInstance(launchInput)
- if launchErr != nil {
- slog.Error("CreateLoadBalancer: failed to launch ALB VM", "lbId", lbID, "err", launchErr)
- launchFailed = true
- } else {
- albInstanceID = out.InstanceID
- albVPCIP = out.PrivateIP
- hostPorts = out.HostfwdMap
- // Record public IP on the first AZ entry for internet-facing ALBs.
- if out.PublicIP != "" && len(availZones) > 0 {
- availZones[0].PublicIP = out.PublicIP
- }
- slog.Info("CreateLoadBalancer: ALB VM launched", "lbId", lbID, "instanceId", albInstanceID, "ip", out.PrivateIP, "publicIp", out.PublicIP, "hostfwd", hostPorts)
- }
- }
+ launch := s.launchLBVM(lbID, scheme, eniIDs, subnets, accountID)
+ albInstanceID := launch.instanceID
+ albVPCIP := launch.vpcIP
+ hostPorts := launch.hostPorts
+ launchFailed := launch.failed
+ // Record public IP on the first AZ entry for internet-facing LBs.
+ if launch.publicIP != "" && len(availZones) > 0 {
+ availZones[0].PublicIP = launch.publicIP
}
// ALB starts in provisioning until the agent inside the VM connects and
@@ -1021,6 +1251,19 @@ func (s *ELBv2ServiceImpl) CreateLoadBalancer(input *elbv2.CreateLoadBalancerInp
state := StateActive
if albInstanceID != "" {
state = StateProvisioning
+ // Internal LBs only leave provisioning once the lb-agent heartbeats,
+ // which needs the mgmt return route. If it can't be resolved the LB
+ // hangs in provisioning forever — fail loud instead so the broken
+ // return path is visible immediately rather than as a generic timeout.
+ if scheme == SchemeInternal {
+ if gw, tgt := s.resolveMgmtRoute(scheme); gw == "" || tgt == "" {
+ slog.Error("CreateLoadBalancer: internal LB has no mgmt return route; marking failed (lb-agent cannot heartbeat AWSGW)",
+ "lbId", lbID,
+ "mgmtBridgeIP", s.MgmtBridgeIP,
+ "advertiseIP", s.AdvertiseIP)
+ state = StateFailed
+ }
+ }
} else if launchFailed {
state = StateFailed
}
@@ -1038,6 +1281,7 @@ func (s *ELBv2ServiceImpl) CreateLoadBalancer(input *elbv2.CreateLoadBalancerInp
State: state,
VpcId: vpcID,
SecurityGroups: securityGroups,
+ NLBManagedSGID: nlbManagedSGID,
Subnets: subnets,
AvailZones: availZones,
ENIs: eniIDs,
@@ -1076,7 +1320,27 @@ func (s *ELBv2ServiceImpl) DeleteLoadBalancer(input *elbv2.DeleteLoadBalancerInp
return nil, errors.New(awserrors.ErrorServerInternal)
}
if lb == nil || lb.AccountID != accountID {
- return nil, errors.New(awserrors.ErrorELBv2LoadBalancerNotFound)
+ // Idempotent: AWS ELBv2 delete returns success on an absent (or
+ // not-owned) load balancer, so tofu destroy retries converge.
+ return &elbv2.DeleteLoadBalancerOutput{}, nil
+ }
+
+ // Delete all listeners (and their rules) for this LB before tearing down
+ // backing artifacts. Cascade through the shared helper, not
+ // store.DeleteListener directly, so rules don't survive and pin their
+ // target groups as ResourceInUse after the LB is gone. A list or cascade
+ // failure aborts the delete with the record intact, so tofu retries
+ // converge instead of leaving orphaned rules behind.
+ listeners, err := s.store.ListListenersByLB(lb.LoadBalancerArn)
+ if err != nil {
+ slog.Error("DeleteLoadBalancer: failed to list listeners for cascade", "lbArn", lb.LoadBalancerArn, "err", err)
+ return nil, errors.New(awserrors.ErrorServerInternal)
+ }
+ for _, l := range listeners {
+ if err := s.deleteListenerCascade(l); err != nil {
+ slog.Error("DeleteLoadBalancer: failed to cascade listener delete", "listenerID", l.ListenerID, "err", err)
+ return nil, errors.New(awserrors.ErrorServerInternal)
+ }
}
// Terminate ALB VM in background (VM termination also cleans up tap device).
@@ -1091,17 +1355,6 @@ func (s *ELBv2ServiceImpl) DeleteLoadBalancer(input *elbv2.DeleteLoadBalancerInp
}()
}
- // Delete all listeners for this LB
- listeners, err := s.store.ListListenersByLB(lb.LoadBalancerArn)
- if err != nil {
- slog.Warn("Failed to list listeners for cleanup", "lbArn", lb.LoadBalancerArn, "err", err)
- }
- for _, l := range listeners {
- if err := s.store.DeleteListener(l.ListenerID); err != nil {
- slog.Warn("Failed to delete listener during LB cleanup", "listenerID", l.ListenerID, "err", err)
- }
- }
-
// Delete system-managed ENIs. Detach first to clear in-use status.
if s.VPCService != nil {
for _, eniID := range lb.ENIs {
@@ -1114,6 +1367,8 @@ func (s *ELBv2ServiceImpl) DeleteLoadBalancer(input *elbv2.DeleteLoadBalancerInp
slog.Warn("Failed to delete ALB ENI during cleanup", "eniId", eniID, "err", eniErr)
}
}
+ // The managed NLB SG can only be removed once its ENIs are gone.
+ s.deleteNLBManagedSG(lb.NLBManagedSGID, accountID)
}
if err := s.store.DeleteLoadBalancer(lb.LoadBalancerID); err != nil {
@@ -1208,10 +1463,14 @@ func (s *ELBv2ServiceImpl) CreateTargetGroup(input *elbv2.CreateTargetGroupInput
return nil, errors.New(awserrors.ErrorELBv2DuplicateTargetGroup)
}
- targetType := "instance"
+ targetType := TargetTypeInstance
if input.TargetType != nil && *input.TargetType != "" {
targetType = *input.TargetType
}
+ if targetType != TargetTypeInstance && targetType != TargetTypeIP {
+ slog.Warn("CreateTargetGroup: unsupported target type", "targetType", targetType)
+ return nil, errors.New(awserrors.ErrorInvalidParameterValue)
+ }
// Use NLB health check defaults for NLB protocols.
var hc HealthCheckConfig
@@ -1258,12 +1517,7 @@ func (s *ELBv2ServiceImpl) CreateTargetGroup(input *elbv2.CreateTargetGroupInput
tgID := utils.GenerateResourceID("tg")
tgArn := buildTGArn(s.region, accountID, name, tgID)
- tags := make(map[string]string)
- for _, tag := range input.Tags {
- if tag.Key != nil && tag.Value != nil {
- tags[*tag.Key] = *tag.Value
- }
- }
+ tags := tagsFromSDK(input.Tags)
record := &TargetGroupRecord{
TargetGroupArn: tgArn,
@@ -1364,16 +1618,37 @@ func (s *ELBv2ServiceImpl) DeleteTargetGroup(input *elbv2.DeleteTargetGroupInput
return nil, errors.New(awserrors.ErrorServerInternal)
}
if tg == nil || tg.AccountID != accountID {
- return nil, errors.New(awserrors.ErrorELBv2TargetGroupNotFound)
+ // Idempotent: AWS ELBv2 delete returns success on an absent (or
+ // not-owned) target group, so tofu destroy retries converge.
+ return &elbv2.DeleteTargetGroupOutput{}, nil
}
- // Check if any listener references this target group
+ // Only a *live* listener or rule pins the target group. A listener (or
+ // rule) whose owning load balancer no longer exists is treated as already
+ // torn down, not a live reference, so an orphan left by a partial teardown
+ // can never pin this target group as ResourceInUse permanently.
+ lbs, err := s.store.ListLoadBalancers()
+ if err != nil {
+ slog.Error("DeleteTargetGroup: failed to list load balancers for in-use check", "err", err)
+ return nil, errors.New(awserrors.ErrorServerInternal)
+ }
+ liveLB := make(map[string]bool, len(lbs))
+ for _, lb := range lbs {
+ liveLB[lb.LoadBalancerArn] = true
+ }
+
+ // Check if any live listener references this target group.
listeners, err := s.store.ListListeners()
if err != nil {
slog.Error("DeleteTargetGroup: failed to list listeners for in-use check", "err", err)
return nil, errors.New(awserrors.ErrorServerInternal)
}
+ liveListener := make(map[string]bool, len(listeners))
for _, l := range listeners {
+ if !liveLB[l.LoadBalancerArn] {
+ continue
+ }
+ liveListener[l.ListenerArn] = true
for _, action := range l.DefaultActions {
if action.TargetGroupArn == tg.TargetGroupArn {
return nil, errors.New(awserrors.ErrorELBv2TargetGroupInUse)
@@ -1381,13 +1656,16 @@ func (s *ELBv2ServiceImpl) DeleteTargetGroup(input *elbv2.DeleteTargetGroupInput
}
}
- // Block deletion when a rule still forwards to the target group.
+ // Block deletion when a live rule still forwards to the target group.
allRules, err := s.store.ListRules()
if err != nil {
slog.Error("DeleteTargetGroup: failed to list rules for in-use check", "err", err)
return nil, errors.New(awserrors.ErrorServerInternal)
}
for _, r := range allRules {
+ if !liveListener[r.ListenerArn] {
+ continue
+ }
for _, action := range r.Actions {
if action.TargetGroupArn == tg.TargetGroupArn {
return nil, errors.New(awserrors.ErrorELBv2TargetGroupInUse)
@@ -1496,8 +1774,11 @@ func (s *ELBv2ServiceImpl) RegisterTargets(input *elbv2.RegisterTargetsInput, ac
continue // Already registered
}
- // Resolve instance ID → private IP via ENI lookup
- privateIP := s.resolveTargetIP(*td.Id, accountID)
+ // Resolve target ID → private IP (instance ENI lookup, or raw IP for ip-type TGs)
+ privateIP, err := s.resolveRegisteredTargetIP(tg.TargetType, *td.Id, accountID)
+ if err != nil {
+ return nil, err
+ }
tg.Targets = append(tg.Targets, Target{
Id: *td.Id,
@@ -1626,6 +1907,145 @@ func (s *ELBv2ServiceImpl) DescribeTargetHealth(input *elbv2.DescribeTargetHealt
// --- Listener operations ---
+// listenerActionFromSDK converts an SDK listener action into the stored form,
+// preserving fixed-response config so HAProxy generation and tofu read-back see
+// the full action (a forward-only conversion drops the fixed-response default
+// and produces a dangling backend + perpetual plan diff).
+func listenerActionFromSDK(a *elbv2.Action) ListenerAction {
+ action := ListenerAction{}
+ if a.Type != nil {
+ action.Type = *a.Type
+ }
+ if a.TargetGroupArn != nil {
+ action.TargetGroupArn = *a.TargetGroupArn
+ }
+ if a.FixedResponseConfig != nil {
+ fr := &FixedResponseAction{}
+ if a.FixedResponseConfig.StatusCode != nil {
+ fr.StatusCode = *a.FixedResponseConfig.StatusCode
+ }
+ if a.FixedResponseConfig.ContentType != nil {
+ fr.ContentType = *a.FixedResponseConfig.ContentType
+ }
+ if a.FixedResponseConfig.MessageBody != nil {
+ fr.MessageBody = *a.FixedResponseConfig.MessageBody
+ }
+ action.FixedResponse = fr
+ }
+ if a.RedirectConfig != nil {
+ rd := &RedirectAction{}
+ if a.RedirectConfig.Protocol != nil {
+ rd.Protocol = *a.RedirectConfig.Protocol
+ }
+ if a.RedirectConfig.Host != nil {
+ rd.Host = *a.RedirectConfig.Host
+ }
+ if a.RedirectConfig.Port != nil {
+ rd.Port = *a.RedirectConfig.Port
+ }
+ if a.RedirectConfig.Path != nil {
+ rd.Path = *a.RedirectConfig.Path
+ }
+ if a.RedirectConfig.Query != nil {
+ rd.Query = *a.RedirectConfig.Query
+ }
+ if a.RedirectConfig.StatusCode != nil {
+ rd.StatusCode = *a.RedirectConfig.StatusCode
+ }
+ action.Redirect = rd
+ }
+ return action
+}
+
+// validateListenerAction enforces the per-type action contract for listener
+// default actions and rule actions. Forward actions are validated against the
+// target group elsewhere; this covers redirect (status code + render-safe
+// fields) so invalid input fails at the API instead of being silently
+// defaulted by the renderer.
+func validateListenerAction(a ListenerAction) error {
+ if a.Type == ActionTypeRedirect {
+ if a.Redirect == nil {
+ return errors.New(awserrors.ErrorMissingParameter)
+ }
+ return validateRedirectAction(a.Redirect)
+ }
+ return nil
+}
+
+// validateRedirectAction rejects an unsupported status code or any field that
+// would break the HAProxy redirect directive once the known AWS placeholders
+// are stripped.
+func validateRedirectAction(rd *RedirectAction) error {
+ if rd == nil {
+ return errors.New(awserrors.ErrorMissingParameter)
+ }
+ switch rd.StatusCode {
+ case "HTTP_301", "HTTP_302":
+ default:
+ return errors.New(awserrors.ErrorInvalidParameterValue)
+ }
+ for _, f := range []string{rd.Protocol, rd.Host, rd.Port, rd.Path, rd.Query} {
+ if !validRedirectField(f) {
+ return errors.New(awserrors.ErrorInvalidParameterValue)
+ }
+ }
+ return nil
+}
+
+// buildListenerCertificates validates and normalises the certificate set and
+// SSL policy for a listener of the given protocol. Secure protocols (HTTPS,
+// TLS) require at least one certificate and receive a default SslPolicy when
+// unset; non-secure protocols must carry neither certificates nor an SslPolicy.
+// Exactly one certificate is marked default.
+func buildListenerCertificates(protocol string, in []*elbv2.Certificate, sslPolicy *string) ([]ListenerCertificate, string, error) {
+ policy := ""
+ if sslPolicy != nil {
+ policy = *sslPolicy
+ }
+
+ if !protocolRequiresCert(protocol) {
+ if len(in) > 0 || policy != "" {
+ return nil, "", errors.New(awserrors.ErrorInvalidParameterValue)
+ }
+ return nil, "", nil
+ }
+
+ if len(in) == 0 {
+ return nil, "", errors.New(awserrors.ErrorELBv2CertificateNotFound)
+ }
+
+ certs := make([]ListenerCertificate, 0, len(in))
+ defaults := 0
+ for _, c := range in {
+ if c == nil || c.CertificateArn == nil || *c.CertificateArn == "" {
+ return nil, "", errors.New(awserrors.ErrorInvalidParameterValue)
+ }
+ isDefault := c.IsDefault != nil && *c.IsDefault
+ if isDefault {
+ defaults++
+ }
+ certs = append(certs, ListenerCertificate{
+ CertificateArn: *c.CertificateArn,
+ IsDefault: isDefault,
+ })
+ }
+ switch defaults {
+ case 0:
+ certs[0].IsDefault = true
+ case 1:
+ default:
+ return nil, "", errors.New(awserrors.ErrorInvalidParameterValue)
+ }
+
+ if policy == "" {
+ policy = DefaultSslPolicy
+ } else if !isKnownSslPolicy(policy) {
+ return nil, "", errors.New(awserrors.ErrorELBv2SSLPolicyNotFound)
+ }
+
+ return certs, policy, nil
+}
+
func (s *ELBv2ServiceImpl) CreateListener(input *elbv2.CreateListenerInput, accountID string) (*elbv2.CreateListenerOutput, error) {
if input.LoadBalancerArn == nil || *input.LoadBalancerArn == "" {
return nil, errors.New(awserrors.ErrorMissingParameter)
@@ -1700,21 +2120,28 @@ func (s *ELBv2ServiceImpl) CreateListener(input *elbv2.CreateListenerInput, acco
}
}
+ certs, sslPolicy, err := buildListenerCertificates(protocol, input.Certificates, input.SslPolicy)
+ if err != nil {
+ return nil, err
+ }
+ if err := s.validateListenerCerts(certs, accountID); err != nil {
+ return nil, err
+ }
+
listenerID := utils.GenerateResourceID("lst")
listenerArn := buildListenerArn(s.region, accountID, lb.Name, lb.LoadBalancerID, listenerID, lb.Type)
var actions []ListenerAction
for _, a := range input.DefaultActions {
- action := ListenerAction{}
- if a.Type != nil {
- action.Type = *a.Type
- }
- if a.TargetGroupArn != nil {
- action.TargetGroupArn = *a.TargetGroupArn
+ action := listenerActionFromSDK(a)
+ if err := validateListenerAction(action); err != nil {
+ return nil, err
}
actions = append(actions, action)
}
+ tags := tagsFromSDK(input.Tags)
+
record := &ListenerRecord{
ListenerArn: listenerArn,
ListenerID: listenerID,
@@ -1724,6 +2151,9 @@ func (s *ELBv2ServiceImpl) CreateListener(input *elbv2.CreateListenerInput, acco
DefaultActions: actions,
AccountID: accountID,
CreatedAt: time.Now().UTC(),
+ Certificates: certs,
+ SslPolicy: sslPolicy,
+ Tags: tags,
}
if err := s.store.PutListener(record); err != nil {
@@ -1731,6 +2161,21 @@ func (s *ELBv2ServiceImpl) CreateListener(input *elbv2.CreateListenerInput, acco
return nil, errors.New(awserrors.ErrorServerInternal)
}
+ // Open the listener port on the NLB's managed front-end SG so inbound
+ // traffic from the configured client CIDRs is admitted by the OVN ACL
+ // (NLB ENIs would otherwise sit in the intra-SG-only default SG).
+ if lb.Type == LoadBalancerTypeNetwork && lb.NLBManagedSGID != "" && s.VPCService != nil {
+ cidrs, cidrErr := s.resolveNLBIngressCIDRs(lb)
+ if cidrErr != nil {
+ slog.Error("CreateListener: resolve ingress CIDRs failed", "lbArn", lb.LoadBalancerArn, "err", cidrErr)
+ return nil, errors.New(awserrors.ErrorServerInternal)
+ }
+ if authErr := s.authorizeNLBListenerPort(lb, protocol, port, cidrs, accountID); authErr != nil {
+ slog.Error("CreateListener: authorize listener port failed", "lbArn", lb.LoadBalancerArn, "port", port, "err", authErr)
+ return nil, errors.New(awserrors.ErrorServerInternal)
+ }
+ }
+
// Start or reload HAProxy now that a listener exists
if err := s.updateStoredConfig(lb); err != nil {
slog.Error("CreateListener: failed to update config", "listenerId", listenerID, "err", err)
@@ -1744,6 +2189,25 @@ func (s *ELBv2ServiceImpl) CreateListener(input *elbv2.CreateListenerInput, acco
}, nil
}
+// deleteListenerCascade removes a listener and all of its rules. Shared by
+// DeleteListener and DeleteLoadBalancer so LB teardown never bypasses the rule
+// cascade and leaves orphan rules that pin a target group as ResourceInUse.
+func (s *ELBv2ServiceImpl) deleteListenerCascade(listener *ListenerRecord) error {
+ rules, err := s.store.ListRulesByListener(listener.ListenerArn)
+ if err != nil {
+ return fmt.Errorf("list rules for listener %s: %w", listener.ListenerArn, err)
+ }
+ for _, r := range rules {
+ if err := s.store.DeleteRule(r.RuleID); err != nil {
+ return fmt.Errorf("delete rule %s: %w", r.RuleID, err)
+ }
+ }
+ if err := s.store.DeleteListener(listener.ListenerID); err != nil {
+ return fmt.Errorf("delete listener %s: %w", listener.ListenerID, err)
+ }
+ return nil
+}
+
func (s *ELBv2ServiceImpl) DeleteListener(input *elbv2.DeleteListenerInput, accountID string) (*elbv2.DeleteListenerOutput, error) {
if input.ListenerArn == nil || *input.ListenerArn == "" {
return nil, errors.New(awserrors.ErrorMissingParameter)
@@ -1755,30 +2219,30 @@ func (s *ELBv2ServiceImpl) DeleteListener(input *elbv2.DeleteListenerInput, acco
return nil, errors.New(awserrors.ErrorServerInternal)
}
if listener == nil || listener.AccountID != accountID {
- return nil, errors.New(awserrors.ErrorELBv2ListenerNotFound)
+ // Idempotent: AWS ELBv2 delete returns success on an absent (or
+ // not-owned) listener, so tofu destroy retries converge.
+ return &elbv2.DeleteListenerOutput{}, nil
}
// Cascade-delete rules so a recreated listener doesn't inherit orphans.
- rules, ruleErr := s.store.ListRulesByListener(listener.ListenerArn)
- if ruleErr != nil {
- slog.Error("DeleteListener: failed to list rules", "listenerArn", listener.ListenerArn, "err", ruleErr)
- return nil, errors.New(awserrors.ErrorServerInternal)
- }
- for _, r := range rules {
- if err := s.store.DeleteRule(r.RuleID); err != nil {
- slog.Error("DeleteListener: failed to delete rule", "ruleId", r.RuleID, "err", err)
- return nil, errors.New(awserrors.ErrorServerInternal)
- }
- }
-
- if err := s.store.DeleteListener(listener.ListenerID); err != nil {
- slog.Error("DeleteListener: failed to delete record", "listenerId", listener.ListenerID, "err", err)
+ if err := s.deleteListenerCascade(listener); err != nil {
+ slog.Error("DeleteListener: failed to cascade-delete listener", "listenerArn", listener.ListenerArn, "err", err)
return nil, errors.New(awserrors.ErrorServerInternal)
}
// Reload or stop HAProxy after listener removal
lb, lbErr := s.store.GetLoadBalancerByArn(listener.LoadBalancerArn)
if lbErr == nil && lb != nil {
+ // Close the listener port on the NLB's managed front-end SG.
+ if lb.Type == LoadBalancerTypeNetwork && lb.NLBManagedSGID != "" && s.VPCService != nil {
+ if cidrs, cidrErr := s.resolveNLBIngressCIDRs(lb); cidrErr == nil {
+ if revokeErr := s.revokeNLBListenerPort(lb, listener.Protocol, listener.Port, cidrs, accountID); revokeErr != nil {
+ slog.Warn("DeleteListener: revoke listener port failed", "lbArn", lb.LoadBalancerArn, "port", listener.Port, "err", revokeErr)
+ }
+ } else {
+ slog.Warn("DeleteListener: resolve ingress CIDRs failed", "lbArn", lb.LoadBalancerArn, "err", cidrErr)
+ }
+ }
if err := s.updateStoredConfig(lb); err != nil {
slog.Error("DeleteListener: failed to update config", "listenerArn", *input.ListenerArn, "err", err)
return nil, errors.New(awserrors.ErrorServerInternal)
@@ -1857,12 +2321,9 @@ func (s *ELBv2ServiceImpl) ModifyListener(input *elbv2.ModifyListenerInput, acco
if len(input.DefaultActions) > 0 {
var actions []ListenerAction
for _, a := range input.DefaultActions {
- action := ListenerAction{}
- if a.Type != nil {
- action.Type = *a.Type
- }
- if a.TargetGroupArn != nil {
- action.TargetGroupArn = *a.TargetGroupArn
+ action := listenerActionFromSDK(a)
+ if err := validateListenerAction(action); err != nil {
+ return nil, err
}
if action.Type == ActionTypeForward && action.TargetGroupArn != "" {
tg, tgErr := s.store.GetTargetGroupByArn(action.TargetGroupArn)
@@ -1899,6 +2360,40 @@ func (s *ELBv2ServiceImpl) ModifyListener(input *elbv2.ModifyListenerInput, acco
}
}
+ // Certificates / SSL policy. The effective protocol after any change drives
+ // the requirement: switching to a non-secure protocol clears cert material;
+ // staying on or switching to a secure protocol validates and defaults it.
+ if !protocolRequiresCert(updated.Protocol) {
+ if len(input.Certificates) > 0 || (input.SslPolicy != nil && *input.SslPolicy != "") {
+ return nil, errors.New(awserrors.ErrorInvalidParameterValue)
+ }
+ updated.Certificates = nil
+ updated.SslPolicy = ""
+ } else {
+ inCerts := input.Certificates
+ if inCerts == nil {
+ for _, c := range updated.Certificates {
+ inCerts = append(inCerts, &elbv2.Certificate{
+ CertificateArn: aws.String(c.CertificateArn),
+ IsDefault: aws.Bool(c.IsDefault),
+ })
+ }
+ }
+ sslPolicy := input.SslPolicy
+ if sslPolicy == nil && updated.SslPolicy != "" {
+ sslPolicy = aws.String(updated.SslPolicy)
+ }
+ certs, policy, certErr := buildListenerCertificates(updated.Protocol, inCerts, sslPolicy)
+ if certErr != nil {
+ return nil, certErr
+ }
+ if certErr := s.validateListenerCerts(certs, accountID); certErr != nil {
+ return nil, certErr
+ }
+ updated.Certificates = certs
+ updated.SslPolicy = policy
+ }
+
if err := s.store.PutListener(&updated); err != nil {
slog.Error("ModifyListener: failed to persist record", "listenerId", updated.ListenerID, "err", err)
return nil, errors.New(awserrors.ErrorServerInternal)
@@ -1957,11 +2452,11 @@ func (s *ELBv2ServiceImpl) DescribeListeners(input *elbv2.DescribeListenersInput
// --- Tag operations ---
// DescribeTags returns tags for one or more ELBv2 resources (load balancers,
-// target groups, listeners). Tag data is read from the existing record stores
-// — Spinifex doesn't have a separate tag KV. Listeners currently never store
-// tags, so they always return an empty Tags slice (matches AWS behaviour for
-// untagged resources). Cross-account or unknown ARNs return the per-resource
-// not-found error so existence isn't leaked across accounts.
+// target groups, listeners, listener rules). Tag data is read from the existing
+// record stores — Spinifex doesn't have a separate tag KV. Untagged resources
+// return an empty Tags slice (matches AWS behaviour). Cross-account or unknown
+// ARNs return the per-resource not-found error so existence isn't leaked across
+// accounts.
func (s *ELBv2ServiceImpl) DescribeTags(input *elbv2.DescribeTagsInput, accountID string) (*elbv2.DescribeTagsOutput, error) {
if input == nil || len(input.ResourceArns) == 0 {
return nil, errors.New(awserrors.ErrorMissingParameter)
@@ -2019,8 +2514,20 @@ func (s *ELBv2ServiceImpl) DescribeTags(input *elbv2.DescribeTagsInput, accountI
}
if l != nil {
found = true
+ tags = l.Tags
ownerAccount = l.AccountID
- // Listeners don't store tags yet — leave map nil.
+ }
+ case elbv2ResourceListenerRule:
+ notFoundError = awserrors.ErrorELBv2RuleNotFound
+ r, rErr := s.store.GetRuleByArn(arn)
+ if rErr != nil {
+ slog.Error("DescribeTags: failed to get rule", "arn", arn, "err", rErr)
+ return nil, errors.New(awserrors.ErrorServerInternal)
+ }
+ if r != nil {
+ found = true
+ tags = r.Tags
+ ownerAccount = r.AccountID
}
}
@@ -2039,6 +2546,18 @@ func (s *ELBv2ServiceImpl) DescribeTags(input *elbv2.DescribeTagsInput, accountI
}, nil
}
+// tagsFromSDK converts an SDK Tag slice into a tag map, skipping entries with a
+// nil key or value. Returns a non-nil (possibly empty) map for storage.
+func tagsFromSDK(tags []*elbv2.Tag) map[string]string {
+ m := make(map[string]string, len(tags))
+ for _, tag := range tags {
+ if tag.Key != nil && tag.Value != nil {
+ m[*tag.Key] = *tag.Value
+ }
+ }
+ return m
+}
+
// tagsMapToSDK converts a tag map into the SDK Tag slice with deterministic
// (sorted-by-key) ordering. Returns nil for empty/nil input so the response
// matches AWS for untagged resources.
@@ -2058,210 +2577,6 @@ func tagsMapToSDK(tags map[string]string) []*elbv2.Tag {
return out
}
-// --- SDK type conversion helpers ---
-
-func (s *ELBv2ServiceImpl) ModifyTargetGroupAttributes(input *elbv2.ModifyTargetGroupAttributesInput, accountID string) (*elbv2.ModifyTargetGroupAttributesOutput, error) {
- if input.TargetGroupArn == nil || *input.TargetGroupArn == "" {
- return nil, errors.New(awserrors.ErrorMissingParameter)
- }
-
- tg, err := s.store.GetTargetGroupByArn(*input.TargetGroupArn)
- if err != nil {
- slog.Error("ModifyTargetGroupAttributes: failed to get TG", "arn", *input.TargetGroupArn, "err", err)
- return nil, errors.New(awserrors.ErrorServerInternal)
- }
- if tg == nil || tg.AccountID != accountID {
- return nil, errors.New(awserrors.ErrorELBv2TargetGroupNotFound)
- }
-
- knownTGAttrs := DefaultTargetGroupAttributes()
- var submitted []*elbv2.TargetGroupAttribute
- dirty := false
- for _, attr := range input.Attributes {
- if attr == nil {
- slog.Warn("ModifyTargetGroupAttributes: skipping nil attribute element", "arn", *input.TargetGroupArn)
- continue
- }
- if attr.Key == nil || attr.Value == nil {
- slog.Warn("ModifyTargetGroupAttributes: skipping attribute with nil Key or Value", "arn", *input.TargetGroupArn)
- continue
- }
- if _, ok := knownTGAttrs[*attr.Key]; !ok {
- slog.Warn("ModifyTargetGroupAttributes: rejecting unknown attribute key", "arn", *input.TargetGroupArn, "key", *attr.Key)
- return nil, errors.New(awserrors.ErrorValidationError)
- }
- submitted = append(submitted, &elbv2.TargetGroupAttribute{
- Key: attr.Key,
- Value: attr.Value,
- })
- existing, exists := tg.Attributes[*attr.Key]
- if exists && existing == *attr.Value {
- continue // value already matches stored — no mutation needed
- }
- if tg.Attributes == nil {
- tg.Attributes = make(map[string]string)
- }
- tg.Attributes[*attr.Key] = *attr.Value
- dirty = true
- }
-
- // If the caller sent attributes but every single one was rejected by the
- // nil guard above, surface that as an error instead of silently returning
- // a successful empty response — otherwise the caller thinks the write
- // landed when nothing was actually applied.
- if len(input.Attributes) > 0 && len(submitted) == 0 {
- slog.Warn("ModifyTargetGroupAttributes: all submitted attributes were invalid", "arn", *input.TargetGroupArn, "submitted_count", len(input.Attributes))
- return nil, errors.New(awserrors.ErrorInvalidParameterValue)
- }
-
- // Skip the NATS/KV write when nothing changed. Terraform re-applies the same
- // attribute set on every drift check, so this kills ~all Modify traffic
- // during steady state and narrows the read-modify-write race window.
- if dirty {
- if err := s.store.PutTargetGroup(tg); err != nil {
- slog.Error("ModifyTargetGroupAttributes: failed to persist TG", "arn", *input.TargetGroupArn, "err", err)
- return nil, errors.New(awserrors.ErrorServerInternal)
- }
- }
-
- return &elbv2.ModifyTargetGroupAttributesOutput{
- Attributes: submitted,
- }, nil
-}
-
-func (s *ELBv2ServiceImpl) DescribeTargetGroupAttributes(input *elbv2.DescribeTargetGroupAttributesInput, accountID string) (*elbv2.DescribeTargetGroupAttributesOutput, error) {
- if input.TargetGroupArn == nil || *input.TargetGroupArn == "" {
- return nil, errors.New(awserrors.ErrorMissingParameter)
- }
-
- tg, err := s.store.GetTargetGroupByArn(*input.TargetGroupArn)
- if err != nil {
- slog.Error("DescribeTargetGroupAttributes: failed to get TG", "arn", *input.TargetGroupArn, "err", err)
- return nil, errors.New(awserrors.ErrorServerInternal)
- }
- if tg == nil || tg.AccountID != accountID {
- return nil, errors.New(awserrors.ErrorELBv2TargetGroupNotFound)
- }
-
- merged := DefaultTargetGroupAttributes()
- maps.Copy(merged, tg.Attributes)
-
- // Sort keys for deterministic output — Terraform diffs and snapshot tests
- // depend on stable attribute ordering.
- keys := slices.Sorted(maps.Keys(merged))
- attrs := make([]*elbv2.TargetGroupAttribute, 0, len(keys))
- for _, k := range keys {
- attrs = append(attrs, &elbv2.TargetGroupAttribute{
- Key: aws.String(k),
- Value: aws.String(merged[k]),
- })
- }
-
- return &elbv2.DescribeTargetGroupAttributesOutput{
- Attributes: attrs,
- }, nil
-}
-
-func (s *ELBv2ServiceImpl) ModifyLoadBalancerAttributes(input *elbv2.ModifyLoadBalancerAttributesInput, accountID string) (*elbv2.ModifyLoadBalancerAttributesOutput, error) {
- if input.LoadBalancerArn == nil || *input.LoadBalancerArn == "" {
- return nil, errors.New(awserrors.ErrorMissingParameter)
- }
-
- lb, err := s.store.GetLoadBalancerByArn(*input.LoadBalancerArn)
- if err != nil {
- slog.Error("ModifyLoadBalancerAttributes: failed to get LB", "arn", *input.LoadBalancerArn, "err", err)
- return nil, errors.New(awserrors.ErrorServerInternal)
- }
- if lb == nil || lb.AccountID != accountID {
- return nil, errors.New(awserrors.ErrorELBv2LoadBalancerNotFound)
- }
-
- knownLBAttrs := DefaultLoadBalancerAttributes(lb.Type)
- var submitted []*elbv2.LoadBalancerAttribute
- dirty := false
- for _, attr := range input.Attributes {
- if attr == nil {
- slog.Warn("ModifyLoadBalancerAttributes: skipping nil attribute element", "arn", *input.LoadBalancerArn)
- continue
- }
- if attr.Key == nil || attr.Value == nil {
- slog.Warn("ModifyLoadBalancerAttributes: skipping attribute with nil Key or Value", "arn", *input.LoadBalancerArn)
- continue
- }
- if _, ok := knownLBAttrs[*attr.Key]; !ok {
- slog.Warn("ModifyLoadBalancerAttributes: rejecting unknown attribute key", "arn", *input.LoadBalancerArn, "key", *attr.Key, "lbType", lb.Type)
- return nil, errors.New(awserrors.ErrorValidationError)
- }
- submitted = append(submitted, &elbv2.LoadBalancerAttribute{
- Key: attr.Key,
- Value: attr.Value,
- })
- existing, exists := lb.Attributes[*attr.Key]
- if exists && existing == *attr.Value {
- continue
- }
- if lb.Attributes == nil {
- lb.Attributes = make(map[string]string)
- }
- lb.Attributes[*attr.Key] = *attr.Value
- dirty = true
- }
-
- // If the caller sent attributes but every single one was rejected by the
- // nil guard above, surface that as an error instead of silently returning
- // a successful empty response.
- if len(input.Attributes) > 0 && len(submitted) == 0 {
- slog.Warn("ModifyLoadBalancerAttributes: all submitted attributes were invalid", "arn", *input.LoadBalancerArn, "submitted_count", len(input.Attributes))
- return nil, errors.New(awserrors.ErrorInvalidParameterValue)
- }
-
- // Skip the NATS/KV write when nothing changed. See
- // ModifyTargetGroupAttributes for the Terraform-drift-check motivation.
- if dirty {
- if err := s.store.PutLoadBalancer(lb); err != nil {
- slog.Error("ModifyLoadBalancerAttributes: failed to persist LB", "arn", *input.LoadBalancerArn, "err", err)
- return nil, errors.New(awserrors.ErrorServerInternal)
- }
- }
-
- return &elbv2.ModifyLoadBalancerAttributesOutput{
- Attributes: submitted,
- }, nil
-}
-
-func (s *ELBv2ServiceImpl) DescribeLoadBalancerAttributes(input *elbv2.DescribeLoadBalancerAttributesInput, accountID string) (*elbv2.DescribeLoadBalancerAttributesOutput, error) {
- if input.LoadBalancerArn == nil || *input.LoadBalancerArn == "" {
- return nil, errors.New(awserrors.ErrorMissingParameter)
- }
-
- lb, err := s.store.GetLoadBalancerByArn(*input.LoadBalancerArn)
- if err != nil {
- slog.Error("DescribeLoadBalancerAttributes: failed to get LB", "arn", *input.LoadBalancerArn, "err", err)
- return nil, errors.New(awserrors.ErrorServerInternal)
- }
- if lb == nil || lb.AccountID != accountID {
- return nil, errors.New(awserrors.ErrorELBv2LoadBalancerNotFound)
- }
-
- merged := DefaultLoadBalancerAttributes(lb.Type)
- maps.Copy(merged, lb.Attributes)
-
- // Sort keys for deterministic output — Terraform diffs and snapshot tests
- // depend on stable attribute ordering.
- keys := slices.Sorted(maps.Keys(merged))
- attrs := make([]*elbv2.LoadBalancerAttribute, 0, len(keys))
- for _, k := range keys {
- attrs = append(attrs, &elbv2.LoadBalancerAttribute{
- Key: aws.String(k),
- Value: aws.String(merged[k]),
- })
- }
-
- return &elbv2.DescribeLoadBalancerAttributesOutput{
- Attributes: attrs,
- }, nil
-}
-
func (s *ELBv2ServiceImpl) lbRecordToSDK(r *LoadBalancerRecord) *elbv2.LoadBalancer {
lb := &elbv2.LoadBalancer{
LoadBalancerArn: aws.String(r.LoadBalancerArn),
@@ -2357,12 +2672,59 @@ func (s *ELBv2ServiceImpl) listenerRecordToSDK(r *ListenerRecord) *elbv2.Listene
}
for _, a := range r.DefaultActions {
- action := &elbv2.Action{
- Type: aws.String(a.Type),
- TargetGroupArn: aws.String(a.TargetGroupArn),
- }
- listener.DefaultActions = append(listener.DefaultActions, action)
+ listener.DefaultActions = append(listener.DefaultActions, listenerActionToSDK(a))
+ }
+
+ if len(r.Certificates) > 0 {
+ listener.Certificates = listenerCertsToSDK(r.Certificates)
+ }
+ if r.SslPolicy != "" {
+ listener.SslPolicy = aws.String(r.SslPolicy)
}
return listener
}
+
+// listenerActionToSDK converts a stored action into the AWS SDK shape,
+// preserving forward / fixed-response / redirect detail so DescribeListeners
+// and DescribeRules round-trip cleanly. Shared by listeners and rules.
+func listenerActionToSDK(a ListenerAction) *elbv2.Action {
+ action := &elbv2.Action{Type: aws.String(a.Type)}
+ if a.TargetGroupArn != "" {
+ action.TargetGroupArn = aws.String(a.TargetGroupArn)
+ }
+ if a.FixedResponse != nil {
+ fr := &elbv2.FixedResponseActionConfig{
+ StatusCode: aws.String(a.FixedResponse.StatusCode),
+ }
+ if a.FixedResponse.ContentType != "" {
+ fr.ContentType = aws.String(a.FixedResponse.ContentType)
+ }
+ if a.FixedResponse.MessageBody != "" {
+ fr.MessageBody = aws.String(a.FixedResponse.MessageBody)
+ }
+ action.FixedResponseConfig = fr
+ }
+ if a.Redirect != nil {
+ rd := &elbv2.RedirectActionConfig{
+ StatusCode: aws.String(a.Redirect.StatusCode),
+ }
+ if a.Redirect.Protocol != "" {
+ rd.Protocol = aws.String(a.Redirect.Protocol)
+ }
+ if a.Redirect.Host != "" {
+ rd.Host = aws.String(a.Redirect.Host)
+ }
+ if a.Redirect.Port != "" {
+ rd.Port = aws.String(a.Redirect.Port)
+ }
+ if a.Redirect.Path != "" {
+ rd.Path = aws.String(a.Redirect.Path)
+ }
+ if a.Redirect.Query != "" {
+ rd.Query = aws.String(a.Redirect.Query)
+ }
+ action.RedirectConfig = rd
+ }
+ return action
+}
diff --git a/spinifex/handlers/elbv2/service_impl_attributes.go b/spinifex/handlers/elbv2/service_impl_attributes.go
new file mode 100644
index 000000000..362c469e5
--- /dev/null
+++ b/spinifex/handlers/elbv2/service_impl_attributes.go
@@ -0,0 +1,265 @@
+package handlers_elbv2
+
+import (
+ "errors"
+ "log/slog"
+ "maps"
+ "slices"
+
+ "github.com/aws/aws-sdk-go/aws"
+ "github.com/aws/aws-sdk-go/service/elbv2"
+ "github.com/mulgadc/spinifex/spinifex/awserrors"
+)
+
+// attrPair is the internal key/value shape the generic attribute handlers work
+// in, decoupled from the two near-identical SDK attribute types
+// (*elbv2.TargetGroupAttribute and *elbv2.LoadBalancerAttribute).
+type attrPair struct {
+ Key string
+ Value string
+}
+
+// attrResource bundles the per-resource-type plumbing the generic Modify/
+// Describe attribute handlers need, so the target-group and load-balancer
+// paths share one implementation.
+type attrResource[R any] struct {
+ arn string
+ accountID string
+ opName string // slog prefix / error context
+ notFound string // awserrors code returned for missing/cross-account
+ fetch func(string) (*R, error)
+ save func(*R) error
+ accountIDOf func(*R) string
+ defaults func(*R) map[string]string
+ attrsOf func(*R) map[string]string
+ setAttrs func(*R, map[string]string)
+}
+
+// load fetches and authorises the record. An empty accountID is rejected
+// explicitly so a direct NATS/internal caller cannot match a record whose
+// AccountID is also empty.
+func (r attrResource[R]) load() (*R, error) {
+ rec, err := r.fetch(r.arn)
+ if err != nil {
+ slog.Error(r.opName+": failed to load record", "arn", r.arn, "err", err)
+ return nil, errors.New(awserrors.ErrorServerInternal)
+ }
+ if rec == nil || r.accountID == "" || r.accountIDOf(rec) != r.accountID {
+ return nil, errors.New(r.notFound)
+ }
+ return rec, nil
+}
+
+// modifyResourceAttributes validates and merges submitted attributes into the
+// record, persisting only when something actually changed. rawCount is the
+// number of attributes the caller submitted before nil filtering, used to
+// distinguish "nothing sent" from "everything sent was invalid".
+func modifyResourceAttributes[R any](res attrResource[R], pairs []attrPair, rawCount int) ([]attrPair, error) {
+ if res.arn == "" {
+ return nil, errors.New(awserrors.ErrorMissingParameter)
+ }
+ rec, err := res.load()
+ if err != nil {
+ return nil, err
+ }
+
+ known := res.defaults(rec)
+ attrs := res.attrsOf(rec)
+ submitted := make([]attrPair, 0, len(pairs))
+ dirty := false
+ for _, p := range pairs {
+ if _, ok := known[p.Key]; !ok {
+ slog.Warn(res.opName+": rejecting unknown attribute key", "arn", res.arn, "key", p.Key)
+ return nil, errors.New(awserrors.ErrorValidationError)
+ }
+ submitted = append(submitted, p)
+ if existing, ok := attrs[p.Key]; ok && existing == p.Value {
+ continue // value already matches stored — no mutation needed
+ }
+ if attrs == nil {
+ attrs = make(map[string]string)
+ }
+ attrs[p.Key] = p.Value
+ dirty = true
+ }
+
+ // Caller sent attributes but every one was dropped by nil filtering: surface
+ // an error rather than a successful empty response the caller misreads as a
+ // landed write.
+ if rawCount > 0 && len(submitted) == 0 {
+ slog.Warn(res.opName+": all submitted attributes were invalid", "arn", res.arn, "submitted_count", rawCount)
+ return nil, errors.New(awserrors.ErrorInvalidParameterValue)
+ }
+
+ // Skip the NATS/KV write when nothing changed. Terraform re-applies the same
+ // attribute set on every drift check, so this kills ~all Modify traffic
+ // during steady state and narrows the read-modify-write race window.
+ if dirty {
+ res.setAttrs(rec, attrs)
+ if err := res.save(rec); err != nil {
+ slog.Error(res.opName+": failed to persist", "arn", res.arn, "err", err)
+ return nil, errors.New(awserrors.ErrorServerInternal)
+ }
+ }
+
+ return submitted, nil
+}
+
+// describeResourceAttributes returns the record's attributes merged over the
+// per-type defaults, sorted by key for deterministic output (Terraform diffs
+// and snapshot tests depend on stable ordering).
+func describeResourceAttributes[R any](res attrResource[R]) ([]attrPair, error) {
+ if res.arn == "" {
+ return nil, errors.New(awserrors.ErrorMissingParameter)
+ }
+ rec, err := res.load()
+ if err != nil {
+ return nil, err
+ }
+
+ merged := res.defaults(rec)
+ maps.Copy(merged, res.attrsOf(rec))
+
+ keys := slices.Sorted(maps.Keys(merged))
+ out := make([]attrPair, 0, len(keys))
+ for _, k := range keys {
+ out = append(out, attrPair{Key: k, Value: merged[k]})
+ }
+ return out, nil
+}
+
+// sdkAttrsIn converts an SDK attribute slice into internal pairs, dropping nil
+// elements and elements with a nil Key/Value. Returns the cleaned pairs and the
+// raw submitted count.
+func sdkAttrsIn[A any](in []A, keyVal func(A) (*string, *string), arn, opName string) ([]attrPair, int) {
+ out := make([]attrPair, 0, len(in))
+ for _, a := range in {
+ k, v := keyVal(a)
+ if k == nil || v == nil {
+ slog.Warn(opName+": skipping invalid attribute element", "arn", arn)
+ continue
+ }
+ out = append(out, attrPair{Key: *k, Value: *v})
+ }
+ return out, len(in)
+}
+
+// sdkAttrsOut converts internal pairs back into a fresh SDK attribute slice via
+// mk. Fresh strings are allocated (never aliasing caller input). Returns nil for
+// an empty set to match AWS for resources with no submitted attributes.
+func sdkAttrsOut[A any](pairs []attrPair, mk func(key, value string) A) []A {
+ if len(pairs) == 0 {
+ return nil
+ }
+ out := make([]A, 0, len(pairs))
+ for _, p := range pairs {
+ out = append(out, mk(p.Key, p.Value))
+ }
+ return out
+}
+
+func (s *ELBv2ServiceImpl) tgAttrResource(arn, accountID, opName string) attrResource[TargetGroupRecord] {
+ return attrResource[TargetGroupRecord]{
+ arn: arn,
+ accountID: accountID,
+ opName: opName,
+ notFound: awserrors.ErrorELBv2TargetGroupNotFound,
+ fetch: s.store.GetTargetGroupByArn,
+ save: s.store.PutTargetGroup,
+ accountIDOf: func(r *TargetGroupRecord) string { return r.AccountID },
+ defaults: func(*TargetGroupRecord) map[string]string { return DefaultTargetGroupAttributes() },
+ attrsOf: func(r *TargetGroupRecord) map[string]string { return r.Attributes },
+ setAttrs: func(r *TargetGroupRecord, m map[string]string) { r.Attributes = m },
+ }
+}
+
+func (s *ELBv2ServiceImpl) lbAttrResource(arn, accountID, opName string) attrResource[LoadBalancerRecord] {
+ return attrResource[LoadBalancerRecord]{
+ arn: arn,
+ accountID: accountID,
+ opName: opName,
+ notFound: awserrors.ErrorELBv2LoadBalancerNotFound,
+ fetch: s.store.GetLoadBalancerByArn,
+ save: s.store.PutLoadBalancer,
+ accountIDOf: func(r *LoadBalancerRecord) string { return r.AccountID },
+ defaults: func(r *LoadBalancerRecord) map[string]string { return DefaultLoadBalancerAttributes(r.Type) },
+ attrsOf: func(r *LoadBalancerRecord) map[string]string { return r.Attributes },
+ setAttrs: func(r *LoadBalancerRecord, m map[string]string) { r.Attributes = m },
+ }
+}
+
+func tgAttrKeyVal(a *elbv2.TargetGroupAttribute) (*string, *string) {
+ if a == nil {
+ return nil, nil
+ }
+ return a.Key, a.Value
+}
+
+func lbAttrKeyVal(a *elbv2.LoadBalancerAttribute) (*string, *string) {
+ if a == nil {
+ return nil, nil
+ }
+ return a.Key, a.Value
+}
+
+func mkTGAttr(key, value string) *elbv2.TargetGroupAttribute {
+ return &elbv2.TargetGroupAttribute{Key: aws.String(key), Value: aws.String(value)}
+}
+
+func mkLBAttr(key, value string) *elbv2.LoadBalancerAttribute {
+ return &elbv2.LoadBalancerAttribute{Key: aws.String(key), Value: aws.String(value)}
+}
+
+func (s *ELBv2ServiceImpl) ModifyTargetGroupAttributes(input *elbv2.ModifyTargetGroupAttributesInput, accountID string) (*elbv2.ModifyTargetGroupAttributesOutput, error) {
+ if input == nil {
+ return nil, errors.New(awserrors.ErrorMissingParameter)
+ }
+ const op = "ModifyTargetGroupAttributes"
+ arn := aws.StringValue(input.TargetGroupArn)
+ pairs, raw := sdkAttrsIn(input.Attributes, tgAttrKeyVal, arn, op)
+ submitted, err := modifyResourceAttributes(s.tgAttrResource(arn, accountID, op), pairs, raw)
+ if err != nil {
+ return nil, err
+ }
+ return &elbv2.ModifyTargetGroupAttributesOutput{Attributes: sdkAttrsOut(submitted, mkTGAttr)}, nil
+}
+
+func (s *ELBv2ServiceImpl) DescribeTargetGroupAttributes(input *elbv2.DescribeTargetGroupAttributesInput, accountID string) (*elbv2.DescribeTargetGroupAttributesOutput, error) {
+ if input == nil {
+ return nil, errors.New(awserrors.ErrorMissingParameter)
+ }
+ const op = "DescribeTargetGroupAttributes"
+ arn := aws.StringValue(input.TargetGroupArn)
+ pairs, err := describeResourceAttributes(s.tgAttrResource(arn, accountID, op))
+ if err != nil {
+ return nil, err
+ }
+ return &elbv2.DescribeTargetGroupAttributesOutput{Attributes: sdkAttrsOut(pairs, mkTGAttr)}, nil
+}
+
+func (s *ELBv2ServiceImpl) ModifyLoadBalancerAttributes(input *elbv2.ModifyLoadBalancerAttributesInput, accountID string) (*elbv2.ModifyLoadBalancerAttributesOutput, error) {
+ if input == nil {
+ return nil, errors.New(awserrors.ErrorMissingParameter)
+ }
+ const op = "ModifyLoadBalancerAttributes"
+ arn := aws.StringValue(input.LoadBalancerArn)
+ pairs, raw := sdkAttrsIn(input.Attributes, lbAttrKeyVal, arn, op)
+ submitted, err := modifyResourceAttributes(s.lbAttrResource(arn, accountID, op), pairs, raw)
+ if err != nil {
+ return nil, err
+ }
+ return &elbv2.ModifyLoadBalancerAttributesOutput{Attributes: sdkAttrsOut(submitted, mkLBAttr)}, nil
+}
+
+func (s *ELBv2ServiceImpl) DescribeLoadBalancerAttributes(input *elbv2.DescribeLoadBalancerAttributesInput, accountID string) (*elbv2.DescribeLoadBalancerAttributesOutput, error) {
+ if input == nil {
+ return nil, errors.New(awserrors.ErrorMissingParameter)
+ }
+ const op = "DescribeLoadBalancerAttributes"
+ arn := aws.StringValue(input.LoadBalancerArn)
+ pairs, err := describeResourceAttributes(s.lbAttrResource(arn, accountID, op))
+ if err != nil {
+ return nil, err
+ }
+ return &elbv2.DescribeLoadBalancerAttributesOutput{Attributes: sdkAttrsOut(pairs, mkLBAttr)}, nil
+}
diff --git a/spinifex/handlers/elbv2/service_impl_health_test.go b/spinifex/handlers/elbv2/service_impl_health_test.go
index 1ea659b14..19553fe75 100644
--- a/spinifex/handlers/elbv2/service_impl_health_test.go
+++ b/spinifex/handlers/elbv2/service_impl_health_test.go
@@ -164,7 +164,7 @@ func TestRegisterTargets_MultipleInstances(t *testing.T) {
// TestResetTargetHealthOnStartup_TransitionsAllNonDrainingToInitial verifies
// that the startup reset rewrites every non-draining target's HealthState
// back to "initial" so a daemon restart can't surface stale "healthy" claims
-// before the lb-agent has posted a fresh report. Refs mulga-siv-119.
+// before the lb-agent has posted a fresh report.
func TestResetTargetHealthOnStartup_TransitionsAllNonDrainingToInitial(t *testing.T) {
store := setupTestNATS(t)
svc := &ELBv2ServiceImpl{store: store}
diff --git a/spinifex/handlers/elbv2/service_impl_lb_network.go b/spinifex/handlers/elbv2/service_impl_lb_network.go
new file mode 100644
index 000000000..215d8e40d
--- /dev/null
+++ b/spinifex/handlers/elbv2/service_impl_lb_network.go
@@ -0,0 +1,373 @@
+package handlers_elbv2
+
+import (
+ "errors"
+ "fmt"
+ "log/slog"
+
+ "github.com/aws/aws-sdk-go/aws"
+ "github.com/aws/aws-sdk-go/service/ec2"
+ "github.com/aws/aws-sdk-go/service/elbv2"
+ "github.com/mulgadc/spinifex/spinifex/awserrors"
+)
+
+// SetIpAddressType sets the load balancer's IP address type. Spinifex ALBs are
+// IPv4-only, so the only accepted value is "ipv4"; dualstack variants are
+// rejected with InvalidConfigurationRequest. The call is idempotent.
+func (s *ELBv2ServiceImpl) SetIpAddressType(input *elbv2.SetIpAddressTypeInput, accountID string) (*elbv2.SetIpAddressTypeOutput, error) {
+ if input == nil || input.LoadBalancerArn == nil || *input.LoadBalancerArn == "" {
+ return nil, errors.New(awserrors.ErrorMissingParameter)
+ }
+ if input.IpAddressType == nil || *input.IpAddressType == "" {
+ return nil, errors.New(awserrors.ErrorMissingParameter)
+ }
+ if *input.IpAddressType != IPAddressTypeIPv4 {
+ return nil, errors.New(awserrors.ErrorELBv2InvalidConfigurationRequest)
+ }
+
+ lb, err := s.store.GetLoadBalancerByArn(*input.LoadBalancerArn)
+ if err != nil {
+ slog.Error("SetIpAddressType: failed to get LB", "arn", *input.LoadBalancerArn, "err", err)
+ return nil, errors.New(awserrors.ErrorServerInternal)
+ }
+ if lb == nil || lb.AccountID != accountID {
+ return nil, errors.New(awserrors.ErrorELBv2LoadBalancerNotFound)
+ }
+
+ if lb.IPAddressType != IPAddressTypeIPv4 {
+ lb.IPAddressType = IPAddressTypeIPv4
+ if err := s.store.PutLoadBalancer(lb); err != nil {
+ slog.Error("SetIpAddressType: failed to persist LB", "arn", *input.LoadBalancerArn, "err", err)
+ return nil, errors.New(awserrors.ErrorServerInternal)
+ }
+ }
+
+ return &elbv2.SetIpAddressTypeOutput{
+ IpAddressType: aws.String(lb.IPAddressType),
+ }, nil
+}
+
+// SetSecurityGroups replaces the security groups associated with an
+// (application) load balancer. The new groups are re-attached to every ENI the
+// ALB spans via ModifyNetworkInterfaceAttribute, which validates them against
+// the ENI's VPC and pushes the change to the live data-plane port before the
+// record is persisted.
+func (s *ELBv2ServiceImpl) SetSecurityGroups(input *elbv2.SetSecurityGroupsInput, accountID string) (*elbv2.SetSecurityGroupsOutput, error) {
+ if input == nil || input.LoadBalancerArn == nil || *input.LoadBalancerArn == "" {
+ return nil, errors.New(awserrors.ErrorMissingParameter)
+ }
+ if len(input.SecurityGroups) == 0 {
+ return nil, errors.New(awserrors.ErrorMissingParameter)
+ }
+
+ lb, err := s.store.GetLoadBalancerByArn(*input.LoadBalancerArn)
+ if err != nil {
+ slog.Error("SetSecurityGroups: failed to get LB", "arn", *input.LoadBalancerArn, "err", err)
+ return nil, errors.New(awserrors.ErrorServerInternal)
+ }
+ if lb == nil || lb.AccountID != accountID {
+ return nil, errors.New(awserrors.ErrorELBv2LoadBalancerNotFound)
+ }
+
+ // NLBs do not support security groups (mirrors CreateLoadBalancer).
+ if lb.Type == LoadBalancerTypeNetwork {
+ return nil, errors.New(awserrors.ErrorELBv2InvalidConfigurationRequest)
+ }
+
+ sgs := make([]string, 0, len(input.SecurityGroups))
+ for _, sg := range input.SecurityGroups {
+ if sg == nil || *sg == "" {
+ return nil, errors.New(awserrors.ErrorInvalidParameterValue)
+ }
+ sgs = append(sgs, *sg)
+ }
+
+ // Re-attach the new groups to each ALB ENI. This validates the groups
+ // against the ENI's VPC and fires the live port-SG update; a failure here
+ // (e.g. unknown SG) aborts before the record is persisted. All ENIs share
+ // the LB's VPC and groups, so a successful first apply implies the rest.
+ if s.VPCService != nil {
+ for _, eniID := range lb.ENIs {
+ if _, err := s.VPCService.ModifyNetworkInterfaceAttribute(&ec2.ModifyNetworkInterfaceAttributeInput{
+ NetworkInterfaceId: aws.String(eniID),
+ Groups: aws.StringSlice(sgs),
+ }, accountID); err != nil {
+ slog.Error("SetSecurityGroups: failed to update ENI groups", "arn", *input.LoadBalancerArn, "eni", eniID, "err", err)
+ return nil, err
+ }
+ }
+ }
+
+ lb.SecurityGroups = sgs
+ if err := s.store.PutLoadBalancer(lb); err != nil {
+ slog.Error("SetSecurityGroups: failed to persist LB", "arn", *input.LoadBalancerArn, "err", err)
+ return nil, errors.New(awserrors.ErrorServerInternal)
+ }
+
+ return &elbv2.SetSecurityGroupsOutput{
+ SecurityGroupIds: aws.StringSlice(sgs),
+ }, nil
+}
+
+// SetSubnets enables the subnets (and their backing ENIs) for a load balancer.
+// It is a full add+remove: a subnet in the request but not on the LB gets a new
+// managed ENI; a subnet on the LB but absent from the request has its ENI
+// removed. The system VM binds its data-plane taps at boot and the launcher
+// exposes no live ENI hotplug, so the new ENI set is applied by relaunching the
+// LB VM — a brief data-plane interruption. The record is mutated only after the
+// new ENIs exist and the VM has been relaunched.
+func (s *ELBv2ServiceImpl) SetSubnets(input *elbv2.SetSubnetsInput, accountID string) (*elbv2.SetSubnetsOutput, error) {
+ if input == nil || input.LoadBalancerArn == nil || *input.LoadBalancerArn == "" {
+ return nil, errors.New(awserrors.ErrorMissingParameter)
+ }
+
+ desired := desiredSubnetSet(input)
+ if len(desired) == 0 {
+ return nil, errors.New(awserrors.ErrorMissingParameter)
+ }
+
+ lb, err := s.store.GetLoadBalancerByArn(*input.LoadBalancerArn)
+ if err != nil {
+ slog.Error("SetSubnets: failed to get LB", "arn", *input.LoadBalancerArn, "err", err)
+ return nil, errors.New(awserrors.ErrorServerInternal)
+ }
+ if lb == nil || lb.AccountID != accountID {
+ return nil, errors.New(awserrors.ErrorELBv2LoadBalancerNotFound)
+ }
+
+ current := subnetENIMap(lb)
+ desiredSet := make(map[string]bool, len(desired))
+ for _, sn := range desired {
+ desiredSet[sn] = true
+ }
+
+ var toAdd, toRemove []string
+ for _, sn := range desired {
+ if _, ok := current[sn]; !ok {
+ toAdd = append(toAdd, sn)
+ }
+ }
+ for sn := range current {
+ if !desiredSet[sn] {
+ toRemove = append(toRemove, sn)
+ }
+ }
+
+ if len(toAdd) == 0 && len(toRemove) == 0 {
+ return s.setSubnetsOutput(lb), nil // idempotent — no change
+ }
+
+ // Without a VPC service we cannot manage ENIs; record the requested subnet
+ // set so the API stays consistent for launcher-less / test deployments.
+ if s.VPCService == nil {
+ lb.Subnets = desired
+ lb.AvailZones = rebuildAvailZones(desired, lb.AvailZones, nil)
+ if err := s.store.PutLoadBalancer(lb); err != nil {
+ slog.Error("SetSubnets: failed to persist LB", "arn", *input.LoadBalancerArn, "err", err)
+ return nil, errors.New(awserrors.ErrorServerInternal)
+ }
+ return s.setSubnetsOutput(lb), nil
+ }
+
+ // Create ENIs for the added subnets first; roll them back on any failure so
+ // a partial apply never leaks ENIs or mutates the live LB.
+ newENIBySubnet := make(map[string]string, len(toAdd))
+ newAZBySubnet := make(map[string]string, len(toAdd))
+ rollbackNewENIs := func() {
+ for _, created := range newENIBySubnet {
+ if _, delErr := s.VPCService.DeleteNetworkInterface(&ec2.DeleteNetworkInterfaceInput{
+ NetworkInterfaceId: aws.String(created),
+ }, accountID); delErr != nil {
+ slog.Error("SetSubnets: rollback failed to delete ENI", "eni", created, "err", delErr)
+ }
+ }
+ }
+ for _, subnetID := range toAdd {
+ eniID, az, eniErr := s.createLBENI(subnetID, lb, accountID)
+ if eniErr != nil {
+ rollbackNewENIs()
+ slog.Error("SetSubnets: failed to create ENI", "subnet", subnetID, "err", eniErr)
+ return nil, errors.New(awserrors.ErrorELBv2SubnetNotFound)
+ }
+ newENIBySubnet[subnetID] = eniID
+ newAZBySubnet[subnetID] = az
+ }
+
+ // Assemble the new ENI set in desired-subnet order (primary = first subnet).
+ newENIs := make([]string, 0, len(desired))
+ for _, sn := range desired {
+ if eniID, ok := current[sn]; ok {
+ newENIs = append(newENIs, eniID)
+ } else {
+ newENIs = append(newENIs, newENIBySubnet[sn])
+ }
+ }
+
+ // Terminate the LB VM before reshaping its ENI set, then relaunch it on the
+ // new set.
+ if lb.InstanceID != "" && s.InstanceLauncher != nil {
+ if err := s.InstanceLauncher.TerminateSystemInstance(lb.InstanceID); err != nil {
+ rollbackNewENIs()
+ slog.Error("SetSubnets: failed to terminate LB VM for relaunch", "arn", *input.LoadBalancerArn, "instanceId", lb.InstanceID, "err", err)
+ return nil, errors.New(awserrors.ErrorServerInternal)
+ }
+ }
+
+ // TerminateSystemInstance tears down the VM but leaves its ENIs marked
+ // in-use, so detach the existing ENIs explicitly: retained ones must be free
+ // to re-attach to the relaunched VM, and removed ones must be detached before
+ // they can be deleted.
+ for _, eniID := range current {
+ if detachErr := s.VPCService.DetachENI(accountID, eniID); detachErr != nil {
+ slog.Warn("SetSubnets: failed to detach ENI before relaunch", "eni", eniID, "err", detachErr)
+ }
+ }
+
+ // Delete ENIs for removed subnets now that they are detached.
+ for _, sn := range toRemove {
+ eniID := current[sn]
+ if _, delErr := s.VPCService.DeleteNetworkInterface(&ec2.DeleteNetworkInterfaceInput{
+ NetworkInterfaceId: aws.String(eniID),
+ }, accountID); delErr != nil {
+ slog.Error("SetSubnets: failed to delete removed ENI", "subnet", sn, "eni", eniID, "err", delErr)
+ }
+ }
+
+ launch := s.launchLBVM(lb.LoadBalancerID, lb.Scheme, newENIs, desired, accountID)
+ availZones := rebuildAvailZones(desired, lb.AvailZones, newAZBySubnet)
+ if launch.publicIP != "" && len(availZones) > 0 {
+ availZones[0].PublicIP = launch.publicIP
+ }
+
+ lb.Subnets = desired
+ lb.ENIs = newENIs
+ lb.AvailZones = availZones
+ lb.InstanceID = launch.instanceID
+ lb.VPCIP = launch.vpcIP
+ lb.HostPorts = launch.hostPorts
+ lb.State = s.lbStateAfterLaunch(launch, lb.Scheme)
+
+ if err := s.store.PutLoadBalancer(lb); err != nil {
+ slog.Error("SetSubnets: failed to persist LB", "arn", *input.LoadBalancerArn, "err", err)
+ return nil, errors.New(awserrors.ErrorServerInternal)
+ }
+
+ slog.Info("SetSubnets completed", "arn", *input.LoadBalancerArn, "subnets", len(desired), "added", len(toAdd), "removed", len(toRemove), "state", lb.State)
+ return s.setSubnetsOutput(lb), nil
+}
+
+// desiredSubnetSet flattens the request's Subnets and SubnetMappings into a
+// de-duplicated, order-preserving subnet-ID list.
+func desiredSubnetSet(input *elbv2.SetSubnetsInput) []string {
+ seen := make(map[string]bool)
+ var out []string
+ add := func(sn string) {
+ if sn != "" && !seen[sn] {
+ seen[sn] = true
+ out = append(out, sn)
+ }
+ }
+ for _, sn := range input.Subnets {
+ if sn != nil {
+ add(*sn)
+ }
+ }
+ for _, m := range input.SubnetMappings {
+ if m != nil && m.SubnetId != nil {
+ add(*m.SubnetId)
+ }
+ }
+ return out
+}
+
+// subnetENIMap pairs each of the LB's current subnets with its backing ENI.
+// CreateLoadBalancer and SetSubnets write Subnets and ENIs in lockstep, so the
+// parallel arrays are authoritative for which ENI lives in which subnet.
+func subnetENIMap(lb *LoadBalancerRecord) map[string]string {
+ m := make(map[string]string, len(lb.Subnets))
+ for i, sn := range lb.Subnets {
+ if i < len(lb.ENIs) {
+ m[sn] = lb.ENIs[i]
+ }
+ }
+ return m
+}
+
+// createLBENI creates a single managed ENI for the LB in the given subnet,
+// tagged and security-grouped like the create-time ENIs. Returns the ENI ID and
+// its availability zone.
+func (s *ELBv2ServiceImpl) createLBENI(subnetID string, lb *LoadBalancerRecord, accountID string) (eniID, az string, err error) {
+ eniIn := &ec2.CreateNetworkInterfaceInput{
+ SubnetId: aws.String(subnetID),
+ Description: aws.String(fmt.Sprintf("ELB %s/%s", lb.Name, lb.LoadBalancerID)),
+ TagSpecifications: []*ec2.TagSpecification{
+ {
+ ResourceType: aws.String("network-interface"),
+ Tags: []*ec2.Tag{
+ {Key: aws.String(elbv2ManagedByTag), Value: aws.String(elbv2ManagedByValue)},
+ {Key: aws.String(elbv2LBTag), Value: aws.String(lb.LoadBalancerArn)},
+ },
+ },
+ },
+ }
+ if groups := lbENIGroups(lb); len(groups) > 0 {
+ eniIn.Groups = aws.StringSlice(groups)
+ }
+ out, err := s.VPCService.CreateNetworkInterface(eniIn, accountID)
+ if err != nil {
+ return "", "", err
+ }
+ eni := out.NetworkInterface
+ return aws.StringValue(eni.NetworkInterfaceId), aws.StringValue(eni.AvailabilityZone), nil
+}
+
+// rebuildAvailZones produces the AvailZoneInfo list for the new subnet set in
+// order, preserving the existing zone name for retained subnets and using
+// newAZBySubnet for added ones. PublicIP is cleared — it is re-derived from the
+// relaunched VM by the caller.
+func rebuildAvailZones(subnets []string, existing []AvailZoneInfo, newAZBySubnet map[string]string) []AvailZoneInfo {
+ bySubnet := make(map[string]AvailZoneInfo, len(existing))
+ for _, az := range existing {
+ bySubnet[az.SubnetId] = az
+ }
+ out := make([]AvailZoneInfo, 0, len(subnets))
+ for _, sn := range subnets {
+ if az, ok := bySubnet[sn]; ok {
+ out = append(out, AvailZoneInfo{ZoneName: az.ZoneName, SubnetId: sn})
+ continue
+ }
+ out = append(out, AvailZoneInfo{ZoneName: newAZBySubnet[sn], SubnetId: sn})
+ }
+ return out
+}
+
+// lbStateAfterLaunch mirrors CreateLoadBalancer's post-launch state gate: a VM
+// that came up enters provisioning until the lb-agent heartbeats; an internal
+// LB with no mgmt return route is failed loud; a failed launch is failed.
+func (s *ELBv2ServiceImpl) lbStateAfterLaunch(launch lbVMLaunch, scheme string) string {
+ if launch.instanceID == "" {
+ if launch.failed {
+ return StateFailed
+ }
+ return StateActive
+ }
+ if scheme == SchemeInternal {
+ if gw, tgt := s.resolveMgmtRoute(scheme); gw == "" || tgt == "" {
+ slog.Error("SetSubnets: internal LB has no mgmt return route; marking failed (lb-agent cannot heartbeat AWSGW)",
+ "mgmtBridgeIP", s.MgmtBridgeIP, "advertiseIP", s.AdvertiseIP)
+ return StateFailed
+ }
+ }
+ return StateProvisioning
+}
+
+// setSubnetsOutput builds the SetSubnets response from the persisted record,
+// reusing the SDK availability-zone projection (which surfaces per-AZ private
+// IPs) so the response matches DescribeLoadBalancers.
+func (s *ELBv2ServiceImpl) setSubnetsOutput(lb *LoadBalancerRecord) *elbv2.SetSubnetsOutput {
+ sdk := s.lbRecordToSDK(lb)
+ return &elbv2.SetSubnetsOutput{
+ AvailabilityZones: sdk.AvailabilityZones,
+ IpAddressType: aws.String(lb.IPAddressType),
+ }
+}
diff --git a/spinifex/handlers/elbv2/service_impl_lb_network_test.go b/spinifex/handlers/elbv2/service_impl_lb_network_test.go
new file mode 100644
index 000000000..578374b5f
--- /dev/null
+++ b/spinifex/handlers/elbv2/service_impl_lb_network_test.go
@@ -0,0 +1,399 @@
+package handlers_elbv2
+
+import (
+ "errors"
+ "testing"
+
+ "github.com/aws/aws-sdk-go/aws"
+ "github.com/aws/aws-sdk-go/service/ec2"
+ "github.com/aws/aws-sdk-go/service/elbv2"
+ "github.com/mulgadc/spinifex/spinifex/awserrors"
+ handlers_ec2_vpc "github.com/mulgadc/spinifex/spinifex/handlers/ec2/vpc"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func createLBArn(t *testing.T, svc *ELBv2ServiceImpl, name string) string {
+ t.Helper()
+ out, err := svc.CreateLoadBalancer(&elbv2.CreateLoadBalancerInput{
+ Name: aws.String(name),
+ }, testAccountID)
+ require.NoError(t, err)
+ require.Len(t, out.LoadBalancers, 1)
+ return *out.LoadBalancers[0].LoadBalancerArn
+}
+
+func describeLB(t *testing.T, svc *ELBv2ServiceImpl, arn string) *elbv2.LoadBalancer {
+ t.Helper()
+ out, err := svc.DescribeLoadBalancers(&elbv2.DescribeLoadBalancersInput{
+ LoadBalancerArns: []*string{aws.String(arn)},
+ }, testAccountID)
+ require.NoError(t, err)
+ require.Len(t, out.LoadBalancers, 1)
+ return out.LoadBalancers[0]
+}
+
+func TestSetIpAddressType_IPv4Idempotent(t *testing.T) {
+ svc := setupTestService(t)
+ arn := createLBArn(t, svc, "ipt-lb")
+
+ out, err := svc.SetIpAddressType(&elbv2.SetIpAddressTypeInput{
+ LoadBalancerArn: aws.String(arn),
+ IpAddressType: aws.String("ipv4"),
+ }, testAccountID)
+ require.NoError(t, err)
+ assert.Equal(t, "ipv4", *out.IpAddressType)
+ assert.Equal(t, "ipv4", *describeLB(t, svc, arn).IpAddressType)
+}
+
+func TestSetIpAddressType_DualstackRejected(t *testing.T) {
+ svc := setupTestService(t)
+ arn := createLBArn(t, svc, "ipt-ds")
+
+ _, err := svc.SetIpAddressType(&elbv2.SetIpAddressTypeInput{
+ LoadBalancerArn: aws.String(arn),
+ IpAddressType: aws.String("dualstack"),
+ }, testAccountID)
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), awserrors.ErrorELBv2InvalidConfigurationRequest)
+}
+
+func TestSetIpAddressType_MissingParams(t *testing.T) {
+ svc := setupTestService(t)
+ arn := createLBArn(t, svc, "ipt-mp")
+
+ _, err := svc.SetIpAddressType(&elbv2.SetIpAddressTypeInput{
+ LoadBalancerArn: aws.String(arn),
+ }, testAccountID)
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), awserrors.ErrorMissingParameter)
+
+ _, err = svc.SetIpAddressType(&elbv2.SetIpAddressTypeInput{
+ IpAddressType: aws.String("ipv4"),
+ }, testAccountID)
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), awserrors.ErrorMissingParameter)
+}
+
+func TestSetIpAddressType_NotFound(t *testing.T) {
+ svc := setupTestService(t)
+ _, err := svc.SetIpAddressType(&elbv2.SetIpAddressTypeInput{
+ LoadBalancerArn: aws.String("arn:aws:elasticloadbalancing:us-east-1:123456789012:loadbalancer/app/missing/lb-deadbeef"),
+ IpAddressType: aws.String("ipv4"),
+ }, testAccountID)
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), awserrors.ErrorELBv2LoadBalancerNotFound)
+}
+
+func TestSetSecurityGroups_UpdatesRecord(t *testing.T) {
+ svc := setupTestService(t)
+ arn := createLBArn(t, svc, "sg-lb")
+
+ out, err := svc.SetSecurityGroups(&elbv2.SetSecurityGroupsInput{
+ LoadBalancerArn: aws.String(arn),
+ SecurityGroups: aws.StringSlice([]string{"sg-aaa", "sg-bbb"}),
+ }, testAccountID)
+ require.NoError(t, err)
+ assert.Equal(t, []string{"sg-aaa", "sg-bbb"}, aws.StringValueSlice(out.SecurityGroupIds))
+ assert.Equal(t, []string{"sg-aaa", "sg-bbb"}, aws.StringValueSlice(describeLB(t, svc, arn).SecurityGroups))
+}
+
+func TestSetSecurityGroups_EmptyRejected(t *testing.T) {
+ svc := setupTestService(t)
+ arn := createLBArn(t, svc, "sg-empty")
+
+ _, err := svc.SetSecurityGroups(&elbv2.SetSecurityGroupsInput{
+ LoadBalancerArn: aws.String(arn),
+ }, testAccountID)
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), awserrors.ErrorMissingParameter)
+}
+
+func TestSetSecurityGroups_NLBRejected(t *testing.T) {
+ svc := setupTestService(t)
+ out, err := svc.CreateLoadBalancer(&elbv2.CreateLoadBalancerInput{
+ Name: aws.String("sg-nlb"),
+ Type: aws.String("network"),
+ }, testAccountID)
+ require.NoError(t, err)
+ arn := *out.LoadBalancers[0].LoadBalancerArn
+
+ _, err = svc.SetSecurityGroups(&elbv2.SetSecurityGroupsInput{
+ LoadBalancerArn: aws.String(arn),
+ SecurityGroups: aws.StringSlice([]string{"sg-aaa"}),
+ }, testAccountID)
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), awserrors.ErrorELBv2InvalidConfigurationRequest)
+}
+
+func TestSetSecurityGroups_CrossAccount(t *testing.T) {
+ svc := setupTestService(t)
+ arn := createLBArn(t, svc, "sg-xacct")
+
+ _, err := svc.SetSecurityGroups(&elbv2.SetSecurityGroupsInput{
+ LoadBalancerArn: aws.String(arn),
+ SecurityGroups: aws.StringSlice([]string{"sg-aaa"}),
+ }, "999999999999")
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), awserrors.ErrorELBv2LoadBalancerNotFound)
+}
+
+// setupSubnetTestService wires a VPC-backed ELBv2 service with a launcher mock
+// so SetSubnets can exercise ENI create/delete plus the LB-VM relaunch.
+func setupSubnetTestService(t *testing.T) (*ELBv2ServiceImpl, *handlers_ec2_vpc.VPCServiceImpl, *mockSystemInstanceLauncher) {
+ t.Helper()
+ svc, vpcSvc := setupTestServiceWithVPC(t)
+ mock := &mockSystemInstanceLauncher{
+ launchResult: &SystemInstanceOutput{InstanceID: "i-lb", PrivateIP: "10.0.1.5"},
+ }
+ svc.InstanceLauncher = mock
+ svc.GatewayURL = "https://10.0.0.1:9999"
+ svc.SystemAccessKey = "AKID"
+ svc.SystemSecretKey = "SECRET"
+ return svc, vpcSvc, mock
+}
+
+func countManagedENIs(t *testing.T, vpcSvc *handlers_ec2_vpc.VPCServiceImpl) int {
+ t.Helper()
+ out, err := vpcSvc.DescribeNetworkInterfaces(&ec2.DescribeNetworkInterfacesInput{}, testAccountID)
+ require.NoError(t, err)
+ n := 0
+ for _, eni := range out.NetworkInterfaces {
+ if eni.RequesterManaged != nil && *eni.RequesterManaged {
+ n++
+ }
+ }
+ return n
+}
+
+func vpcID(t *testing.T, vpcSvc *handlers_ec2_vpc.VPCServiceImpl) string {
+ t.Helper()
+ vpcs, err := vpcSvc.DescribeVpcs(&ec2.DescribeVpcsInput{}, testAccountID)
+ require.NoError(t, err)
+ require.NotEmpty(t, vpcs.Vpcs)
+ return *vpcs.Vpcs[0].VpcId
+}
+
+func TestSetSubnets_AddSubnet(t *testing.T) {
+ svc, vpcSvc, mock := setupSubnetTestService(t)
+ vid := vpcID(t, vpcSvc)
+ sub1 := getTestSubnetID(t, vpcSvc, vid, "10.0.20.0/24", "us-east-1a")
+ sub2 := getTestSubnetID(t, vpcSvc, vid, "10.0.21.0/24", "us-east-1b")
+
+ out, err := svc.CreateLoadBalancer(&elbv2.CreateLoadBalancerInput{
+ Name: aws.String("add-lb"),
+ Subnets: []*string{aws.String(sub1)},
+ }, testAccountID)
+ require.NoError(t, err)
+ arn := *out.LoadBalancers[0].LoadBalancerArn
+ require.Equal(t, 1, countManagedENIs(t, vpcSvc))
+ require.Len(t, mock.launchCalls, 1)
+
+ res, err := svc.SetSubnets(&elbv2.SetSubnetsInput{
+ LoadBalancerArn: aws.String(arn),
+ Subnets: []*string{aws.String(sub1), aws.String(sub2)},
+ }, testAccountID)
+ require.NoError(t, err)
+ assert.Len(t, res.AvailabilityZones, 2)
+
+ // New ENI created in sub2; LB VM relaunched on the new set.
+ assert.Equal(t, 2, countManagedENIs(t, vpcSvc))
+ assert.Len(t, mock.terminateCalls, 1)
+ require.Len(t, mock.launchCalls, 2)
+ assert.Len(t, mock.launchCalls[1].ExtraENIs, 1, "relaunch must carry the added subnet as an ExtraENI")
+
+ lb := describeLB(t, svc, arn)
+ assert.ElementsMatch(t, []string{sub1, sub2}, subnetIDsOfLB(lb))
+}
+
+func TestSetSubnets_RemoveSubnet(t *testing.T) {
+ svc, vpcSvc, mock := setupSubnetTestService(t)
+ vid := vpcID(t, vpcSvc)
+ sub1 := getTestSubnetID(t, vpcSvc, vid, "10.0.22.0/24", "us-east-1a")
+ sub2 := getTestSubnetID(t, vpcSvc, vid, "10.0.23.0/24", "us-east-1b")
+
+ out, err := svc.CreateLoadBalancer(&elbv2.CreateLoadBalancerInput{
+ Name: aws.String("rm-lb"),
+ Subnets: []*string{aws.String(sub1), aws.String(sub2)},
+ }, testAccountID)
+ require.NoError(t, err)
+ arn := *out.LoadBalancers[0].LoadBalancerArn
+ require.Equal(t, 2, countManagedENIs(t, vpcSvc))
+
+ res, err := svc.SetSubnets(&elbv2.SetSubnetsInput{
+ LoadBalancerArn: aws.String(arn),
+ Subnets: []*string{aws.String(sub1)},
+ }, testAccountID)
+ require.NoError(t, err)
+ assert.Len(t, res.AvailabilityZones, 1)
+
+ // sub2's ENI is deleted; only sub1's remains.
+ assert.Equal(t, 1, countManagedENIs(t, vpcSvc))
+ assert.Len(t, mock.terminateCalls, 1)
+ lb := describeLB(t, svc, arn)
+ assert.Equal(t, []string{sub1}, subnetIDsOfLB(lb))
+}
+
+func TestSetSubnets_Replace(t *testing.T) {
+ svc, vpcSvc, _ := setupSubnetTestService(t)
+ vid := vpcID(t, vpcSvc)
+ sub1 := getTestSubnetID(t, vpcSvc, vid, "10.0.24.0/24", "us-east-1a")
+ sub2 := getTestSubnetID(t, vpcSvc, vid, "10.0.25.0/24", "us-east-1b")
+
+ out, err := svc.CreateLoadBalancer(&elbv2.CreateLoadBalancerInput{
+ Name: aws.String("swap-lb"),
+ Subnets: []*string{aws.String(sub1)},
+ }, testAccountID)
+ require.NoError(t, err)
+ arn := *out.LoadBalancers[0].LoadBalancerArn
+
+ _, err = svc.SetSubnets(&elbv2.SetSubnetsInput{
+ LoadBalancerArn: aws.String(arn),
+ Subnets: []*string{aws.String(sub2)},
+ }, testAccountID)
+ require.NoError(t, err)
+
+ // One ENI swapped for another — still a single managed ENI, now in sub2.
+ assert.Equal(t, 1, countManagedENIs(t, vpcSvc))
+ lb := describeLB(t, svc, arn)
+ assert.Equal(t, []string{sub2}, subnetIDsOfLB(lb))
+}
+
+func TestSetSubnets_TerminateFailureRollsBackNewENIs(t *testing.T) {
+ svc, vpcSvc, mock := setupSubnetTestService(t)
+ vid := vpcID(t, vpcSvc)
+ sub1 := getTestSubnetID(t, vpcSvc, vid, "10.0.26.0/24", "us-east-1a")
+ sub2 := getTestSubnetID(t, vpcSvc, vid, "10.0.27.0/24", "us-east-1b")
+
+ out, err := svc.CreateLoadBalancer(&elbv2.CreateLoadBalancerInput{
+ Name: aws.String("rollback-lb"),
+ Subnets: []*string{aws.String(sub1)},
+ }, testAccountID)
+ require.NoError(t, err)
+ arn := *out.LoadBalancers[0].LoadBalancerArn
+ require.Equal(t, 1, countManagedENIs(t, vpcSvc))
+
+ // Terminate fails mid-relaunch (e.g. owning daemon unreachable). The ENI
+ // created for the added subnet must be rolled back, not leaked.
+ mock.terminateErr = errors.New("no responders available")
+
+ _, err = svc.SetSubnets(&elbv2.SetSubnetsInput{
+ LoadBalancerArn: aws.String(arn),
+ Subnets: []*string{aws.String(sub1), aws.String(sub2)},
+ }, testAccountID)
+ require.Error(t, err)
+
+ // No leak: still the single original ENI; LB record unchanged; no relaunch.
+ assert.Equal(t, 1, countManagedENIs(t, vpcSvc))
+ assert.Len(t, mock.launchCalls, 1)
+ lb := describeLB(t, svc, arn)
+ assert.Equal(t, []string{sub1}, subnetIDsOfLB(lb))
+}
+
+func TestSetSubnets_Idempotent(t *testing.T) {
+ svc, vpcSvc, mock := setupSubnetTestService(t)
+ vid := vpcID(t, vpcSvc)
+ sub1 := getTestSubnetID(t, vpcSvc, vid, "10.0.26.0/24", "us-east-1a")
+
+ out, err := svc.CreateLoadBalancer(&elbv2.CreateLoadBalancerInput{
+ Name: aws.String("idem-lb"),
+ Subnets: []*string{aws.String(sub1)},
+ }, testAccountID)
+ require.NoError(t, err)
+ arn := *out.LoadBalancers[0].LoadBalancerArn
+
+ _, err = svc.SetSubnets(&elbv2.SetSubnetsInput{
+ LoadBalancerArn: aws.String(arn),
+ Subnets: []*string{aws.String(sub1)},
+ }, testAccountID)
+ require.NoError(t, err)
+
+ // No change: no relaunch, no terminate, ENI count unchanged.
+ assert.Equal(t, 1, countManagedENIs(t, vpcSvc))
+ assert.Empty(t, mock.terminateCalls)
+ assert.Len(t, mock.launchCalls, 1)
+}
+
+func TestSetSubnets_SubnetMappings(t *testing.T) {
+ svc, vpcSvc, _ := setupSubnetTestService(t)
+ vid := vpcID(t, vpcSvc)
+ sub1 := getTestSubnetID(t, vpcSvc, vid, "10.0.27.0/24", "us-east-1a")
+ sub2 := getTestSubnetID(t, vpcSvc, vid, "10.0.28.0/24", "us-east-1b")
+
+ out, err := svc.CreateLoadBalancer(&elbv2.CreateLoadBalancerInput{
+ Name: aws.String("mapping-lb"),
+ Subnets: []*string{aws.String(sub1)},
+ }, testAccountID)
+ require.NoError(t, err)
+ arn := *out.LoadBalancers[0].LoadBalancerArn
+
+ _, err = svc.SetSubnets(&elbv2.SetSubnetsInput{
+ LoadBalancerArn: aws.String(arn),
+ SubnetMappings: []*elbv2.SubnetMapping{
+ {SubnetId: aws.String(sub1)},
+ {SubnetId: aws.String(sub2)},
+ },
+ }, testAccountID)
+ require.NoError(t, err)
+ assert.Equal(t, 2, countManagedENIs(t, vpcSvc))
+}
+
+func TestSetSubnets_WithoutVPCService(t *testing.T) {
+ svc := setupTestService(t)
+ arn := createLBArn(t, svc, "novpc-subnets")
+
+ res, err := svc.SetSubnets(&elbv2.SetSubnetsInput{
+ LoadBalancerArn: aws.String(arn),
+ Subnets: []*string{aws.String("subnet-aaa"), aws.String("subnet-bbb")},
+ }, testAccountID)
+ require.NoError(t, err)
+ assert.Len(t, res.AvailabilityZones, 2)
+ assert.ElementsMatch(t, []string{"subnet-aaa", "subnet-bbb"}, subnetIDsOfLB(describeLB(t, svc, arn)))
+}
+
+func TestSetSubnets_MissingParams(t *testing.T) {
+ svc := setupTestService(t)
+ arn := createLBArn(t, svc, "subnets-mp")
+
+ _, err := svc.SetSubnets(&elbv2.SetSubnetsInput{
+ LoadBalancerArn: aws.String(arn),
+ }, testAccountID)
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), awserrors.ErrorMissingParameter)
+
+ _, err = svc.SetSubnets(&elbv2.SetSubnetsInput{
+ Subnets: []*string{aws.String("subnet-aaa")},
+ }, testAccountID)
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), awserrors.ErrorMissingParameter)
+}
+
+func TestSetSubnets_NotFound(t *testing.T) {
+ svc := setupTestService(t)
+ _, err := svc.SetSubnets(&elbv2.SetSubnetsInput{
+ LoadBalancerArn: aws.String("arn:aws:elasticloadbalancing:us-east-1:123456789012:loadbalancer/app/missing/lb-deadbeef"),
+ Subnets: []*string{aws.String("subnet-aaa")},
+ }, testAccountID)
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), awserrors.ErrorELBv2LoadBalancerNotFound)
+}
+
+func TestSetSubnets_CrossAccount(t *testing.T) {
+ svc := setupTestService(t)
+ arn := createLBArn(t, svc, "subnets-xacct")
+
+ _, err := svc.SetSubnets(&elbv2.SetSubnetsInput{
+ LoadBalancerArn: aws.String(arn),
+ Subnets: []*string{aws.String("subnet-aaa")},
+ }, "999999999999")
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), awserrors.ErrorELBv2LoadBalancerNotFound)
+}
+
+func subnetIDsOfLB(lb *elbv2.LoadBalancer) []string {
+ out := make([]string, 0, len(lb.AvailabilityZones))
+ for _, az := range lb.AvailabilityZones {
+ out = append(out, aws.StringValue(az.SubnetId))
+ }
+ return out
+}
diff --git a/spinifex/handlers/elbv2/service_impl_listener_certs.go b/spinifex/handlers/elbv2/service_impl_listener_certs.go
new file mode 100644
index 000000000..2ba76fe6b
--- /dev/null
+++ b/spinifex/handlers/elbv2/service_impl_listener_certs.go
@@ -0,0 +1,174 @@
+package handlers_elbv2
+
+import (
+ "errors"
+ "log/slog"
+
+ "github.com/aws/aws-sdk-go/aws"
+ "github.com/aws/aws-sdk-go/service/elbv2"
+ "github.com/mulgadc/spinifex/spinifex/awserrors"
+)
+
+// listenerCertsToSDK renders stored listener certificates as SDK certificates.
+func listenerCertsToSDK(certs []ListenerCertificate) []*elbv2.Certificate {
+ out := make([]*elbv2.Certificate, 0, len(certs))
+ for _, c := range certs {
+ out = append(out, &elbv2.Certificate{
+ CertificateArn: aws.String(c.CertificateArn),
+ IsDefault: aws.Bool(c.IsDefault),
+ })
+ }
+ return out
+}
+
+// AddListenerCertificates attaches additional (SNI) certificates to a secure
+// listener. The default certificate is set at create time and is unaffected;
+// added certificates are non-default. Re-adding an existing certificate is a
+// no-op.
+func (s *ELBv2ServiceImpl) AddListenerCertificates(input *elbv2.AddListenerCertificatesInput, accountID string) (*elbv2.AddListenerCertificatesOutput, error) {
+ if input == nil || input.ListenerArn == nil || *input.ListenerArn == "" {
+ return nil, errors.New(awserrors.ErrorMissingParameter)
+ }
+ if len(input.Certificates) == 0 {
+ return nil, errors.New(awserrors.ErrorMissingParameter)
+ }
+
+ listener, err := s.store.GetListenerByArn(*input.ListenerArn)
+ if err != nil {
+ slog.Error("AddListenerCertificates: failed to get listener", "arn", *input.ListenerArn, "err", err)
+ return nil, errors.New(awserrors.ErrorServerInternal)
+ }
+ if listener == nil || listener.AccountID != accountID {
+ return nil, errors.New(awserrors.ErrorELBv2ListenerNotFound)
+ }
+ if !protocolRequiresCert(listener.Protocol) {
+ return nil, errors.New(awserrors.ErrorELBv2InvalidConfigurationRequest)
+ }
+
+ updated := *listener
+ certs := append([]ListenerCertificate(nil), updated.Certificates...)
+ seen := make(map[string]bool, len(certs))
+ for _, c := range certs {
+ seen[c.CertificateArn] = true
+ }
+ for _, c := range input.Certificates {
+ if c == nil || c.CertificateArn == nil || *c.CertificateArn == "" {
+ return nil, errors.New(awserrors.ErrorInvalidParameterValue)
+ }
+ if seen[*c.CertificateArn] {
+ continue
+ }
+ certs = append(certs, ListenerCertificate{CertificateArn: *c.CertificateArn, IsDefault: false})
+ seen[*c.CertificateArn] = true
+ }
+ updated.Certificates = certs
+
+ if err := s.validateListenerCerts(certs, accountID); err != nil {
+ return nil, err
+ }
+
+ if err := s.store.PutListener(&updated); err != nil {
+ slog.Error("AddListenerCertificates: failed to persist record", "listenerId", updated.ListenerID, "err", err)
+ return nil, errors.New(awserrors.ErrorServerInternal)
+ }
+
+ return &elbv2.AddListenerCertificatesOutput{
+ Certificates: listenerCertsToSDK(certs),
+ }, nil
+}
+
+// RemoveListenerCertificates detaches certificates from a listener by ARN. The
+// default certificate cannot be removed. Removing an absent certificate is a
+// no-op (idempotent).
+func (s *ELBv2ServiceImpl) RemoveListenerCertificates(input *elbv2.RemoveListenerCertificatesInput, accountID string) (*elbv2.RemoveListenerCertificatesOutput, error) {
+ if input == nil || input.ListenerArn == nil || *input.ListenerArn == "" {
+ return nil, errors.New(awserrors.ErrorMissingParameter)
+ }
+ if len(input.Certificates) == 0 {
+ return nil, errors.New(awserrors.ErrorMissingParameter)
+ }
+
+ listener, err := s.store.GetListenerByArn(*input.ListenerArn)
+ if err != nil {
+ slog.Error("RemoveListenerCertificates: failed to get listener", "arn", *input.ListenerArn, "err", err)
+ return nil, errors.New(awserrors.ErrorServerInternal)
+ }
+ if listener == nil || listener.AccountID != accountID {
+ return nil, errors.New(awserrors.ErrorELBv2ListenerNotFound)
+ }
+
+ remove := make(map[string]bool, len(input.Certificates))
+ for _, c := range input.Certificates {
+ if c == nil || c.CertificateArn == nil || *c.CertificateArn == "" {
+ return nil, errors.New(awserrors.ErrorInvalidParameterValue)
+ }
+ remove[*c.CertificateArn] = true
+ }
+
+ updated := *listener
+ kept := make([]ListenerCertificate, 0, len(updated.Certificates))
+ for _, c := range updated.Certificates {
+ if remove[c.CertificateArn] {
+ if c.IsDefault {
+ // The default certificate is managed via the listener itself.
+ return nil, errors.New(awserrors.ErrorELBv2InvalidConfigurationRequest)
+ }
+ continue
+ }
+ kept = append(kept, c)
+ }
+ updated.Certificates = kept
+
+ if err := s.store.PutListener(&updated); err != nil {
+ slog.Error("RemoveListenerCertificates: failed to persist record", "listenerId", updated.ListenerID, "err", err)
+ return nil, errors.New(awserrors.ErrorServerInternal)
+ }
+
+ return &elbv2.RemoveListenerCertificatesOutput{}, nil
+}
+
+// DescribeListenerCertificates returns the certificates attached to a listener.
+func (s *ELBv2ServiceImpl) DescribeListenerCertificates(input *elbv2.DescribeListenerCertificatesInput, accountID string) (*elbv2.DescribeListenerCertificatesOutput, error) {
+ if input == nil || input.ListenerArn == nil || *input.ListenerArn == "" {
+ return nil, errors.New(awserrors.ErrorMissingParameter)
+ }
+
+ listener, err := s.store.GetListenerByArn(*input.ListenerArn)
+ if err != nil {
+ slog.Error("DescribeListenerCertificates: failed to get listener", "arn", *input.ListenerArn, "err", err)
+ return nil, errors.New(awserrors.ErrorServerInternal)
+ }
+ if listener == nil || listener.AccountID != accountID {
+ return nil, errors.New(awserrors.ErrorELBv2ListenerNotFound)
+ }
+
+ return &elbv2.DescribeListenerCertificatesOutput{
+ Certificates: listenerCertsToSDK(listener.Certificates),
+ }, nil
+}
+
+// DescribeSSLPolicies returns the fixed catalog of supported security policies.
+// An explicit Names filter selects a subset; an unknown name is rejected.
+func (s *ELBv2ServiceImpl) DescribeSSLPolicies(input *elbv2.DescribeSSLPoliciesInput, _ string) (*elbv2.DescribeSSLPoliciesOutput, error) {
+ var names []string
+ if input != nil && len(input.Names) > 0 {
+ for _, n := range input.Names {
+ if n == nil || *n == "" {
+ return nil, errors.New(awserrors.ErrorInvalidParameterValue)
+ }
+ if !isKnownSslPolicy(*n) {
+ return nil, errors.New(awserrors.ErrorELBv2SSLPolicyNotFound)
+ }
+ names = append(names, *n)
+ }
+ } else {
+ names = sslPolicyOrder
+ }
+
+ policies := make([]*elbv2.SslPolicy, 0, len(names))
+ for _, n := range names {
+ policies = append(policies, sslPolicyToSDK(sslPolicyCatalog[n]))
+ }
+
+ return &elbv2.DescribeSSLPoliciesOutput{SslPolicies: policies}, nil
+}
diff --git a/spinifex/handlers/elbv2/service_impl_listener_certs_test.go b/spinifex/handlers/elbv2/service_impl_listener_certs_test.go
new file mode 100644
index 000000000..4528af027
--- /dev/null
+++ b/spinifex/handlers/elbv2/service_impl_listener_certs_test.go
@@ -0,0 +1,317 @@
+package handlers_elbv2
+
+import (
+ "testing"
+
+ "github.com/aws/aws-sdk-go/aws"
+ "github.com/aws/aws-sdk-go/service/elbv2"
+ "github.com/mulgadc/spinifex/spinifex/awserrors"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+const testCertArn = "arn:aws:acm:ap-southeast-2:000000000001:certificate/aaaaaaaa-1111"
+const testCertArn2 = "arn:aws:acm:ap-southeast-2:000000000001:certificate/bbbbbbbb-2222"
+
+// fixedResponseAction is a valid default action that needs no target group.
+func fixedResponseAction() []*elbv2.Action {
+ return []*elbv2.Action{{
+ Type: aws.String(ActionTypeFixedResponse),
+ FixedResponseConfig: &elbv2.FixedResponseActionConfig{
+ StatusCode: aws.String("200"),
+ },
+ }}
+}
+
+func createHTTPSListener(t *testing.T, svc *ELBv2ServiceImpl) string {
+ t.Helper()
+ lbArn := createLBArn(t, svc, "https-lb")
+ out, err := svc.CreateListener(&elbv2.CreateListenerInput{
+ LoadBalancerArn: aws.String(lbArn),
+ Protocol: aws.String(ProtocolHTTPS),
+ Port: aws.Int64(443),
+ Certificates: []*elbv2.Certificate{{CertificateArn: aws.String(testCertArn)}},
+ DefaultActions: fixedResponseAction(),
+ }, testAccountID)
+ require.NoError(t, err)
+ return *out.Listeners[0].ListenerArn
+}
+
+func TestCreateListener_HTTPSRequiresCert(t *testing.T) {
+ svc := setupTestService(t)
+ lbArn := createLBArn(t, svc, "https-nocert")
+
+ _, err := svc.CreateListener(&elbv2.CreateListenerInput{
+ LoadBalancerArn: aws.String(lbArn),
+ Protocol: aws.String(ProtocolHTTPS),
+ Port: aws.Int64(443),
+ DefaultActions: fixedResponseAction(),
+ }, testAccountID)
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), awserrors.ErrorELBv2CertificateNotFound)
+}
+
+func TestCreateListener_HTTPSDefaultsSslPolicy(t *testing.T) {
+ svc := setupTestService(t)
+ lbArn := createLBArn(t, svc, "https-default-policy")
+
+ out, err := svc.CreateListener(&elbv2.CreateListenerInput{
+ LoadBalancerArn: aws.String(lbArn),
+ Protocol: aws.String(ProtocolHTTPS),
+ Port: aws.Int64(443),
+ Certificates: []*elbv2.Certificate{{CertificateArn: aws.String(testCertArn)}},
+ DefaultActions: fixedResponseAction(),
+ }, testAccountID)
+ require.NoError(t, err)
+
+ l := out.Listeners[0]
+ assert.Equal(t, DefaultSslPolicy, aws.StringValue(l.SslPolicy))
+ require.Len(t, l.Certificates, 1)
+ assert.Equal(t, testCertArn, aws.StringValue(l.Certificates[0].CertificateArn))
+ assert.True(t, aws.BoolValue(l.Certificates[0].IsDefault), "the sole cert must be marked default")
+
+ // DescribeListeners round-trips certificates and SslPolicy.
+ desc, err := svc.DescribeListeners(&elbv2.DescribeListenersInput{
+ ListenerArns: []*string{l.ListenerArn},
+ }, testAccountID)
+ require.NoError(t, err)
+ require.Len(t, desc.Listeners, 1)
+ assert.Equal(t, DefaultSslPolicy, aws.StringValue(desc.Listeners[0].SslPolicy))
+ require.Len(t, desc.Listeners[0].Certificates, 1)
+}
+
+func TestCreateListener_HTTPRejectsCert(t *testing.T) {
+ svc := setupTestService(t)
+ lbArn := createLBArn(t, svc, "http-withcert")
+
+ _, err := svc.CreateListener(&elbv2.CreateListenerInput{
+ LoadBalancerArn: aws.String(lbArn),
+ Protocol: aws.String(ProtocolHTTP),
+ Port: aws.Int64(80),
+ Certificates: []*elbv2.Certificate{{CertificateArn: aws.String(testCertArn)}},
+ DefaultActions: fixedResponseAction(),
+ }, testAccountID)
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), awserrors.ErrorInvalidParameterValue)
+}
+
+func TestCreateListener_UnknownSslPolicy(t *testing.T) {
+ svc := setupTestService(t)
+ lbArn := createLBArn(t, svc, "https-badpolicy")
+
+ _, err := svc.CreateListener(&elbv2.CreateListenerInput{
+ LoadBalancerArn: aws.String(lbArn),
+ Protocol: aws.String(ProtocolHTTPS),
+ Port: aws.Int64(443),
+ Certificates: []*elbv2.Certificate{{CertificateArn: aws.String(testCertArn)}},
+ SslPolicy: aws.String("ELBSecurityPolicy-Does-Not-Exist"),
+ DefaultActions: fixedResponseAction(),
+ }, testAccountID)
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), awserrors.ErrorELBv2SSLPolicyNotFound)
+}
+
+func TestModifyListener_SwitchToHTTPSRequiresCert(t *testing.T) {
+ svc := setupTestService(t)
+ lbArn := createLBArn(t, svc, "switch-lb")
+ lst, err := svc.CreateListener(&elbv2.CreateListenerInput{
+ LoadBalancerArn: aws.String(lbArn),
+ Protocol: aws.String(ProtocolHTTP),
+ Port: aws.Int64(80),
+ DefaultActions: fixedResponseAction(),
+ }, testAccountID)
+ require.NoError(t, err)
+ arn := lst.Listeners[0].ListenerArn
+
+ // HTTP -> HTTPS without a cert is rejected.
+ _, err = svc.ModifyListener(&elbv2.ModifyListenerInput{
+ ListenerArn: arn,
+ Protocol: aws.String(ProtocolHTTPS),
+ }, testAccountID)
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), awserrors.ErrorELBv2CertificateNotFound)
+
+ // HTTP -> HTTPS with a cert succeeds and defaults the policy.
+ out, err := svc.ModifyListener(&elbv2.ModifyListenerInput{
+ ListenerArn: arn,
+ Protocol: aws.String(ProtocolHTTPS),
+ Certificates: []*elbv2.Certificate{{CertificateArn: aws.String(testCertArn)}},
+ }, testAccountID)
+ require.NoError(t, err)
+ assert.Equal(t, DefaultSslPolicy, aws.StringValue(out.Listeners[0].SslPolicy))
+ require.Len(t, out.Listeners[0].Certificates, 1)
+}
+
+func TestModifyListener_SwitchAwayClearsCerts(t *testing.T) {
+ svc := setupTestService(t)
+ arn := createHTTPSListener(t, svc)
+
+ out, err := svc.ModifyListener(&elbv2.ModifyListenerInput{
+ ListenerArn: aws.String(arn),
+ Protocol: aws.String(ProtocolHTTP),
+ }, testAccountID)
+ require.NoError(t, err)
+ assert.Empty(t, out.Listeners[0].Certificates, "certs cleared when leaving a secure protocol")
+ assert.Nil(t, out.Listeners[0].SslPolicy)
+}
+
+func TestListenerCertificates_AddRemoveDescribe(t *testing.T) {
+ svc := setupTestService(t)
+ arn := createHTTPSListener(t, svc)
+
+ // Add an SNI cert.
+ addOut, err := svc.AddListenerCertificates(&elbv2.AddListenerCertificatesInput{
+ ListenerArn: aws.String(arn),
+ Certificates: []*elbv2.Certificate{{CertificateArn: aws.String(testCertArn2)}},
+ }, testAccountID)
+ require.NoError(t, err)
+ require.Len(t, addOut.Certificates, 2)
+
+ // Re-adding is idempotent.
+ addOut, err = svc.AddListenerCertificates(&elbv2.AddListenerCertificatesInput{
+ ListenerArn: aws.String(arn),
+ Certificates: []*elbv2.Certificate{{CertificateArn: aws.String(testCertArn2)}},
+ }, testAccountID)
+ require.NoError(t, err)
+ require.Len(t, addOut.Certificates, 2)
+
+ // Describe shows both.
+ desc, err := svc.DescribeListenerCertificates(&elbv2.DescribeListenerCertificatesInput{
+ ListenerArn: aws.String(arn),
+ }, testAccountID)
+ require.NoError(t, err)
+ require.Len(t, desc.Certificates, 2)
+
+ // Removing the default cert is rejected.
+ _, err = svc.RemoveListenerCertificates(&elbv2.RemoveListenerCertificatesInput{
+ ListenerArn: aws.String(arn),
+ Certificates: []*elbv2.Certificate{{CertificateArn: aws.String(testCertArn)}},
+ }, testAccountID)
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), awserrors.ErrorELBv2InvalidConfigurationRequest)
+
+ // Removing the SNI cert succeeds.
+ _, err = svc.RemoveListenerCertificates(&elbv2.RemoveListenerCertificatesInput{
+ ListenerArn: aws.String(arn),
+ Certificates: []*elbv2.Certificate{{CertificateArn: aws.String(testCertArn2)}},
+ }, testAccountID)
+ require.NoError(t, err)
+
+ desc, err = svc.DescribeListenerCertificates(&elbv2.DescribeListenerCertificatesInput{
+ ListenerArn: aws.String(arn),
+ }, testAccountID)
+ require.NoError(t, err)
+ require.Len(t, desc.Certificates, 1)
+ assert.Equal(t, testCertArn, aws.StringValue(desc.Certificates[0].CertificateArn))
+}
+
+func TestAddListenerCertificates_HTTPRejected(t *testing.T) {
+ svc := setupTestService(t)
+ lbArn := createLBArn(t, svc, "http-addcert")
+ lst, err := svc.CreateListener(&elbv2.CreateListenerInput{
+ LoadBalancerArn: aws.String(lbArn),
+ Protocol: aws.String(ProtocolHTTP),
+ Port: aws.Int64(80),
+ DefaultActions: fixedResponseAction(),
+ }, testAccountID)
+ require.NoError(t, err)
+
+ _, err = svc.AddListenerCertificates(&elbv2.AddListenerCertificatesInput{
+ ListenerArn: lst.Listeners[0].ListenerArn,
+ Certificates: []*elbv2.Certificate{{CertificateArn: aws.String(testCertArn)}},
+ }, testAccountID)
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), awserrors.ErrorELBv2InvalidConfigurationRequest)
+}
+
+func TestListenerCertificates_ValidationAndNotFound(t *testing.T) {
+ svc := setupTestService(t)
+ arn := createHTTPSListener(t, svc)
+ const otherAccount = "999999999999"
+ badArn := "arn:aws:elasticloadbalancing:us-east-1:000000000001:listener/app/x/y/z"
+
+ // Add: missing arn, empty certs, nil cert entry, cross-account not-found.
+ _, err := svc.AddListenerCertificates(&elbv2.AddListenerCertificatesInput{}, testAccountID)
+ assert.EqualError(t, err, awserrors.ErrorMissingParameter)
+
+ _, err = svc.AddListenerCertificates(&elbv2.AddListenerCertificatesInput{
+ ListenerArn: aws.String(arn),
+ }, testAccountID)
+ assert.EqualError(t, err, awserrors.ErrorMissingParameter)
+
+ _, err = svc.AddListenerCertificates(&elbv2.AddListenerCertificatesInput{
+ ListenerArn: aws.String(arn),
+ Certificates: []*elbv2.Certificate{{}},
+ }, testAccountID)
+ assert.EqualError(t, err, awserrors.ErrorInvalidParameterValue)
+
+ _, err = svc.AddListenerCertificates(&elbv2.AddListenerCertificatesInput{
+ ListenerArn: aws.String(arn),
+ Certificates: []*elbv2.Certificate{{CertificateArn: aws.String(testCertArn2)}},
+ }, otherAccount)
+ assert.EqualError(t, err, awserrors.ErrorELBv2ListenerNotFound)
+
+ // Remove: missing arn, empty certs, nil cert entry, cross-account not-found.
+ _, err = svc.RemoveListenerCertificates(&elbv2.RemoveListenerCertificatesInput{}, testAccountID)
+ assert.EqualError(t, err, awserrors.ErrorMissingParameter)
+
+ _, err = svc.RemoveListenerCertificates(&elbv2.RemoveListenerCertificatesInput{
+ ListenerArn: aws.String(arn),
+ }, testAccountID)
+ assert.EqualError(t, err, awserrors.ErrorMissingParameter)
+
+ _, err = svc.RemoveListenerCertificates(&elbv2.RemoveListenerCertificatesInput{
+ ListenerArn: aws.String(arn),
+ Certificates: []*elbv2.Certificate{{}},
+ }, testAccountID)
+ assert.EqualError(t, err, awserrors.ErrorInvalidParameterValue)
+
+ _, err = svc.RemoveListenerCertificates(&elbv2.RemoveListenerCertificatesInput{
+ ListenerArn: aws.String(badArn),
+ Certificates: []*elbv2.Certificate{{CertificateArn: aws.String(testCertArn)}},
+ }, testAccountID)
+ assert.EqualError(t, err, awserrors.ErrorELBv2ListenerNotFound)
+
+ // Describe: missing arn, cross-account not-found.
+ _, err = svc.DescribeListenerCertificates(&elbv2.DescribeListenerCertificatesInput{}, testAccountID)
+ assert.EqualError(t, err, awserrors.ErrorMissingParameter)
+
+ _, err = svc.DescribeListenerCertificates(&elbv2.DescribeListenerCertificatesInput{
+ ListenerArn: aws.String(arn),
+ }, otherAccount)
+ assert.EqualError(t, err, awserrors.ErrorELBv2ListenerNotFound)
+}
+
+func TestDescribeSSLPolicies_EmptyName(t *testing.T) {
+ svc := setupTestService(t)
+ _, err := svc.DescribeSSLPolicies(&elbv2.DescribeSSLPoliciesInput{
+ Names: []*string{aws.String("")},
+ }, testAccountID)
+ assert.EqualError(t, err, awserrors.ErrorInvalidParameterValue)
+}
+
+func TestDescribeSSLPolicies(t *testing.T) {
+ svc := setupTestService(t)
+
+ // No filter → full catalog.
+ all, err := svc.DescribeSSLPolicies(&elbv2.DescribeSSLPoliciesInput{}, testAccountID)
+ require.NoError(t, err)
+ require.Len(t, all.SslPolicies, len(sslPolicyOrder))
+ assert.Equal(t, DefaultSslPolicy, aws.StringValue(all.SslPolicies[0].Name))
+ require.NotEmpty(t, all.SslPolicies[0].Ciphers)
+ require.NotEmpty(t, all.SslPolicies[0].SslProtocols)
+
+ // Name filter → subset.
+ one, err := svc.DescribeSSLPolicies(&elbv2.DescribeSSLPoliciesInput{
+ Names: []*string{aws.String(DefaultSslPolicy)},
+ }, testAccountID)
+ require.NoError(t, err)
+ require.Len(t, one.SslPolicies, 1)
+
+ // Unknown name → error.
+ _, err = svc.DescribeSSLPolicies(&elbv2.DescribeSSLPoliciesInput{
+ Names: []*string{aws.String("nope")},
+ }, testAccountID)
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), awserrors.ErrorELBv2SSLPolicyNotFound)
+}
diff --git a/spinifex/handlers/elbv2/service_impl_nlb_sg.go b/spinifex/handlers/elbv2/service_impl_nlb_sg.go
new file mode 100644
index 000000000..a1a2f92b5
--- /dev/null
+++ b/spinifex/handlers/elbv2/service_impl_nlb_sg.go
@@ -0,0 +1,252 @@
+package handlers_elbv2
+
+import (
+ "errors"
+ "fmt"
+ "log/slog"
+ "net"
+
+ "github.com/aws/aws-sdk-go/aws"
+ "github.com/aws/aws-sdk-go/service/ec2"
+ "github.com/mulgadc/spinifex/spinifex/awserrors"
+)
+
+// nlbManagedSGName returns the deterministic name of the internal security
+// group minted for an NLB. Stable so recovery paths can re-resolve it.
+func nlbManagedSGName(lbID string) string {
+ return "spinifex-nlb-" + lbID
+}
+
+// lbENIGroups returns the security groups to attach to an LB's data-plane ENIs.
+// NLBs use the internally-minted managed SG (customer SGs are rejected at
+// create time); ALBs use the customer-supplied SGs. Shared by the create-time
+// ENI loop and the SetSubnets relaunch path so both attach the same SG.
+func lbENIGroups(lb *LoadBalancerRecord) []string {
+ if lb.Type == LoadBalancerTypeNetwork {
+ if lb.NLBManagedSGID != "" {
+ return []string{lb.NLBManagedSGID}
+ }
+ return nil
+ }
+ return lb.SecurityGroups
+}
+
+// createNLBManagedSG mints the internal front-end SG for an NLB in the LB's VPC
+// and returns its ID. The VPC is resolved from the LB's first subnet. The SG is
+// tagged like the LB's other managed resources so the ownership sweep can find
+// it.
+func (s *ELBv2ServiceImpl) createNLBManagedSG(lbID, lbArn, subnetID, accountID string) (string, error) {
+ subnet, err := s.VPCService.GetSubnet(accountID, subnetID)
+ if err != nil {
+ return "", fmt.Errorf("resolve subnet %s vpc: %w", subnetID, err)
+ }
+ if subnet == nil || subnet.VpcId == "" {
+ return "", fmt.Errorf("subnet %s has no vpc", subnetID)
+ }
+
+ out, err := s.VPCService.CreateSecurityGroup(&ec2.CreateSecurityGroupInput{
+ GroupName: aws.String(nlbManagedSGName(lbID)),
+ Description: aws.String(fmt.Sprintf("Managed front-end SG for NLB %s", lbID)),
+ VpcId: aws.String(subnet.VpcId),
+ TagSpecifications: []*ec2.TagSpecification{{
+ ResourceType: aws.String("security-group"),
+ Tags: []*ec2.Tag{
+ {Key: aws.String(elbv2ManagedByTag), Value: aws.String(elbv2ManagedByValue)},
+ {Key: aws.String(elbv2LBTag), Value: aws.String(lbArn)},
+ },
+ }},
+ }, accountID)
+ if err != nil {
+ return "", fmt.Errorf("create NLB SG: %w", err)
+ }
+ if out == nil || out.GroupId == nil || *out.GroupId == "" {
+ return "", errors.New("CreateSecurityGroup returned empty GroupId")
+ }
+ return *out.GroupId, nil
+}
+
+// deleteNLBManagedSG best-effort deletes an NLB's managed SG. Callable on the
+// create-rollback and delete paths; a nil VPC service or empty ID is a no-op,
+// and a delete failure is logged but not surfaced (the SG can only be deleted
+// once its ENIs are gone, which the delete path guarantees).
+func (s *ELBv2ServiceImpl) deleteNLBManagedSG(sgID, accountID string) {
+ if s.VPCService == nil || sgID == "" {
+ return
+ }
+ if _, err := s.VPCService.DeleteSecurityGroup(&ec2.DeleteSecurityGroupInput{
+ GroupId: aws.String(sgID),
+ }, accountID); err != nil {
+ slog.Warn("deleteNLBManagedSG: delete failed", "sg", sgID, "err", err)
+ }
+}
+
+// resolveNLBIngressCIDRs returns the client CIDRs a listener port is opened to.
+// An explicit override wins; otherwise the default is scheme-based:
+// internet-facing → 0.0.0.0/0, internal → the LB's VPC CIDR.
+func (s *ELBv2ServiceImpl) resolveNLBIngressCIDRs(lb *LoadBalancerRecord) ([]string, error) {
+ if len(lb.NLBIngressCIDRs) > 0 {
+ return lb.NLBIngressCIDRs, nil
+ }
+ if lb.Scheme == SchemeInternal {
+ cidr, err := s.vpcCIDR(lb.VpcId, lb.AccountID)
+ if err != nil {
+ return nil, err
+ }
+ return []string{cidr}, nil
+ }
+ return []string{"0.0.0.0/0"}, nil
+}
+
+// vpcCIDR returns the primary CIDR block of a VPC.
+func (s *ELBv2ServiceImpl) vpcCIDR(vpcID, accountID string) (string, error) {
+ if s.VPCService == nil {
+ return "", errors.New("vpc service unavailable")
+ }
+ if vpcID == "" {
+ return "", errors.New("empty vpc id")
+ }
+ out, err := s.VPCService.DescribeVpcs(&ec2.DescribeVpcsInput{
+ VpcIds: aws.StringSlice([]string{vpcID}),
+ }, accountID)
+ if err != nil {
+ return "", fmt.Errorf("describe vpc %s: %w", vpcID, err)
+ }
+ for _, v := range out.Vpcs {
+ if v != nil && aws.StringValue(v.CidrBlock) != "" {
+ return *v.CidrBlock, nil
+ }
+ }
+ return "", fmt.Errorf("vpc %s has no cidr", vpcID)
+}
+
+// listenerIPProtocols maps an ELBv2 listener protocol to the IP protocols its
+// SG ingress rule(s) cover. TCP/TLS ride tcp; UDP rides udp; TCP_UDP opens both.
+func listenerIPProtocols(protocol string) []string {
+ switch protocol {
+ case ProtocolUDP:
+ return []string{"udp"}
+ case ProtocolTCPUDP:
+ return []string{"tcp", "udp"}
+ default: // TCP, TLS
+ return []string{"tcp"}
+ }
+}
+
+// authorizeNLBListenerPort opens a listener port on the NLB's managed SG for
+// each (protocol, CIDR) pair. Each rule is authorized in its own call so an
+// already-present rule (Duplicate, swallowed) never blocks a sibling rule —
+// keeping re-create / reconcile idempotent. No-op when there is no managed SG.
+func (s *ELBv2ServiceImpl) authorizeNLBListenerPort(lb *LoadBalancerRecord, protocol string, port int64, cidrs []string, accountID string) error {
+ if s.VPCService == nil || lb.NLBManagedSGID == "" {
+ return nil
+ }
+ for _, proto := range listenerIPProtocols(protocol) {
+ for _, cidr := range cidrs {
+ _, err := s.VPCService.AuthorizeSecurityGroupIngress(&ec2.AuthorizeSecurityGroupIngressInput{
+ GroupId: aws.String(lb.NLBManagedSGID),
+ IpPermissions: []*ec2.IpPermission{{
+ IpProtocol: aws.String(proto),
+ FromPort: aws.Int64(port),
+ ToPort: aws.Int64(port),
+ IpRanges: []*ec2.IpRange{{
+ CidrIp: aws.String(cidr),
+ Description: aws.String(fmt.Sprintf("NLB %s listener :%d", lb.LoadBalancerID, port)),
+ }},
+ }},
+ }, accountID)
+ if err != nil && !awserrors.IsErrorCode(err, awserrors.ErrorInvalidPermissionDuplicate) {
+ return fmt.Errorf("authorize :%d/%s from %s: %w", port, proto, cidr, err)
+ }
+ }
+ }
+ return nil
+}
+
+// revokeNLBListenerPort removes the listener-port rules authorizeNLBListenerPort
+// added. An absent rule (NotFound, swallowed) is fine so delete / re-CIDR paths
+// stay idempotent. No-op when there is no managed SG.
+func (s *ELBv2ServiceImpl) revokeNLBListenerPort(lb *LoadBalancerRecord, protocol string, port int64, cidrs []string, accountID string) error {
+ if s.VPCService == nil || lb.NLBManagedSGID == "" {
+ return nil
+ }
+ for _, proto := range listenerIPProtocols(protocol) {
+ for _, cidr := range cidrs {
+ _, err := s.VPCService.RevokeSecurityGroupIngress(&ec2.RevokeSecurityGroupIngressInput{
+ GroupId: aws.String(lb.NLBManagedSGID),
+ IpPermissions: []*ec2.IpPermission{{
+ IpProtocol: aws.String(proto),
+ FromPort: aws.Int64(port),
+ ToPort: aws.Int64(port),
+ IpRanges: []*ec2.IpRange{{CidrIp: aws.String(cidr)}},
+ }},
+ }, accountID)
+ if err != nil && !awserrors.IsErrorCode(err, awserrors.ErrorInvalidPermissionNotFound) {
+ return fmt.Errorf("revoke :%d/%s from %s: %w", port, proto, cidr, err)
+ }
+ }
+ }
+ return nil
+}
+
+// SetLoadBalancerIngressCIDRs overrides the client CIDRs the NLB's listener
+// ports are opened to (e.g. an EKS public endpoint's publicAccessCidrs) and
+// re-authorizes every existing listener accordingly. Passing nil/empty reverts
+// to the scheme-based default. Idempotent: the old rules are revoked and the
+// new ones authorized, both swallowing the benign duplicate / not-found errors.
+func (s *ELBv2ServiceImpl) SetLoadBalancerIngressCIDRs(lbArn string, cidrs []string, accountID string) error {
+ if lbArn == "" {
+ return errors.New(awserrors.ErrorMissingParameter)
+ }
+ lb, err := s.store.GetLoadBalancerByArn(lbArn)
+ if err != nil {
+ slog.Error("SetLoadBalancerIngressCIDRs: failed to get LB", "arn", lbArn, "err", err)
+ return errors.New(awserrors.ErrorServerInternal)
+ }
+ if lb == nil {
+ return errors.New(awserrors.ErrorELBv2LoadBalancerNotFound)
+ }
+ if lb.Type != LoadBalancerTypeNetwork {
+ return errors.New(awserrors.ErrorELBv2InvalidConfigurationRequest)
+ }
+ for _, c := range cidrs {
+ if _, _, perr := net.ParseCIDR(c); perr != nil {
+ return errors.New(awserrors.ErrorInvalidParameterValue)
+ }
+ }
+
+ oldCIDRs, err := s.resolveNLBIngressCIDRs(lb)
+ if err != nil {
+ slog.Error("SetLoadBalancerIngressCIDRs: resolve old CIDRs failed", "arn", lbArn, "err", err)
+ return errors.New(awserrors.ErrorServerInternal)
+ }
+ listeners, err := s.store.ListListenersByLB(lb.LoadBalancerArn)
+ if err != nil {
+ slog.Error("SetLoadBalancerIngressCIDRs: list listeners failed", "arn", lbArn, "err", err)
+ return errors.New(awserrors.ErrorServerInternal)
+ }
+
+ lb.NLBIngressCIDRs = cidrs
+ newCIDRs, err := s.resolveNLBIngressCIDRs(lb)
+ if err != nil {
+ slog.Error("SetLoadBalancerIngressCIDRs: resolve new CIDRs failed", "arn", lbArn, "err", err)
+ return errors.New(awserrors.ErrorServerInternal)
+ }
+
+ for _, l := range listeners {
+ if err := s.revokeNLBListenerPort(lb, l.Protocol, l.Port, oldCIDRs, accountID); err != nil {
+ slog.Error("SetLoadBalancerIngressCIDRs: revoke failed", "arn", lbArn, "port", l.Port, "err", err)
+ return errors.New(awserrors.ErrorServerInternal)
+ }
+ if err := s.authorizeNLBListenerPort(lb, l.Protocol, l.Port, newCIDRs, accountID); err != nil {
+ slog.Error("SetLoadBalancerIngressCIDRs: authorize failed", "arn", lbArn, "port", l.Port, "err", err)
+ return errors.New(awserrors.ErrorServerInternal)
+ }
+ }
+
+ if err := s.store.PutLoadBalancer(lb); err != nil {
+ slog.Error("SetLoadBalancerIngressCIDRs: persist failed", "arn", lbArn, "err", err)
+ return errors.New(awserrors.ErrorServerInternal)
+ }
+ slog.Info("SetLoadBalancerIngressCIDRs completed", "arn", lbArn, "cidrs", cidrs, "listeners", len(listeners), "accountID", accountID)
+ return nil
+}
diff --git a/spinifex/handlers/elbv2/service_impl_nlb_sg_test.go b/spinifex/handlers/elbv2/service_impl_nlb_sg_test.go
new file mode 100644
index 000000000..b63e6618d
--- /dev/null
+++ b/spinifex/handlers/elbv2/service_impl_nlb_sg_test.go
@@ -0,0 +1,298 @@
+package handlers_elbv2
+
+import (
+ "testing"
+
+ "github.com/aws/aws-sdk-go/aws"
+ "github.com/aws/aws-sdk-go/service/ec2"
+ "github.com/aws/aws-sdk-go/service/elbv2"
+ handlers_ec2_vpc "github.com/mulgadc/spinifex/spinifex/handlers/ec2/vpc"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+// firstSubnet returns the ID + VPC ID of the subnet setupTestServiceWithVPC
+// pre-creates.
+func firstSubnet(t *testing.T, vpcSvc *handlers_ec2_vpc.VPCServiceImpl) (subnetID, vpcID string) {
+ t.Helper()
+ subnets, err := vpcSvc.DescribeSubnets(&ec2.DescribeSubnetsInput{}, testAccountID)
+ require.NoError(t, err)
+ require.NotEmpty(t, subnets.Subnets)
+ return *subnets.Subnets[0].SubnetId, *subnets.Subnets[0].VpcId
+}
+
+// describeSG returns the security group record by ID.
+func describeSG(t *testing.T, vpcSvc *handlers_ec2_vpc.VPCServiceImpl, sgID string) *ec2.SecurityGroup {
+ t.Helper()
+ out, err := vpcSvc.DescribeSecurityGroups(&ec2.DescribeSecurityGroupsInput{
+ GroupIds: aws.StringSlice([]string{sgID}),
+ }, testAccountID)
+ require.NoError(t, err)
+ require.Len(t, out.SecurityGroups, 1)
+ return out.SecurityGroups[0]
+}
+
+// sgHasRule reports whether the SG has an ingress rule for the proto/port/cidr.
+func sgHasRule(sg *ec2.SecurityGroup, proto string, port int64, cidr string) bool {
+ for _, p := range sg.IpPermissions {
+ if aws.StringValue(p.IpProtocol) != proto {
+ continue
+ }
+ if aws.Int64Value(p.FromPort) != port || aws.Int64Value(p.ToPort) != port {
+ continue
+ }
+ for _, r := range p.IpRanges {
+ if aws.StringValue(r.CidrIp) == cidr {
+ return true
+ }
+ }
+ }
+ return false
+}
+
+func managedENI(t *testing.T, vpcSvc *handlers_ec2_vpc.VPCServiceImpl) *ec2.NetworkInterface {
+ t.Helper()
+ out, err := vpcSvc.DescribeNetworkInterfaces(&ec2.DescribeNetworkInterfacesInput{}, testAccountID)
+ require.NoError(t, err)
+ for _, eni := range out.NetworkInterfaces {
+ if eni.RequesterManaged != nil && *eni.RequesterManaged {
+ return eni
+ }
+ }
+ return nil
+}
+
+func createNLB(t *testing.T, svc *ELBv2ServiceImpl, name, scheme, subnetID string) *elbv2.LoadBalancer {
+ t.Helper()
+ in := &elbv2.CreateLoadBalancerInput{
+ Name: aws.String(name),
+ Type: aws.String("network"),
+ Subnets: []*string{aws.String(subnetID)},
+ }
+ if scheme != "" {
+ in.Scheme = aws.String(scheme)
+ }
+ out, err := svc.CreateLoadBalancer(in, testAccountID)
+ require.NoError(t, err)
+ require.Len(t, out.LoadBalancers, 1)
+ return out.LoadBalancers[0]
+}
+
+func createTCPListener(t *testing.T, svc *ELBv2ServiceImpl, lbArn *string, port int64) {
+ t.Helper()
+ tg, err := svc.CreateTargetGroup(&elbv2.CreateTargetGroupInput{
+ Name: aws.String("tg-nlb-sg"),
+ Protocol: aws.String(elbv2.ProtocolEnumTcp),
+ Port: aws.Int64(port),
+ }, testAccountID)
+ require.NoError(t, err)
+ _, err = svc.CreateListener(&elbv2.CreateListenerInput{
+ LoadBalancerArn: lbArn,
+ Protocol: aws.String(elbv2.ProtocolEnumTcp),
+ Port: aws.Int64(port),
+ DefaultActions: []*elbv2.Action{{Type: aws.String("forward"), TargetGroupArn: tg.TargetGroups[0].TargetGroupArn}},
+ }, testAccountID)
+ require.NoError(t, err)
+}
+
+// TestCreateNLB_MintsManagedSGAttachedToENI verifies a network LB gets a
+// dedicated internal SG (not the VPC default) attached to its ENI, recorded in
+// NLBManagedSGID, while DescribeLoadBalancers still reports no customer SGs.
+func TestCreateNLB_MintsManagedSGAttachedToENI(t *testing.T) {
+ svc, vpcSvc := setupTestServiceWithVPC(t)
+ subnetID, vpcID := firstSubnet(t, vpcSvc)
+
+ defaultSGID, err := vpcSvc.FindDefaultSGForVPC(testAccountID, vpcID)
+ require.NoError(t, err)
+
+ lb := createNLB(t, svc, "nlb-sg", "internal", subnetID)
+ assert.Empty(t, lb.SecurityGroups, "NLB must not surface the managed SG as a customer SG")
+
+ rec, err := svc.store.GetLoadBalancerByArn(*lb.LoadBalancerArn)
+ require.NoError(t, err)
+ require.NotEmpty(t, rec.NLBManagedSGID, "NLB record must carry the managed SG id")
+ assert.NotEqual(t, defaultSGID, rec.NLBManagedSGID, "managed SG must be distinct from the VPC default SG")
+
+ eni := managedENI(t, vpcSvc)
+ require.NotNil(t, eni)
+ require.Len(t, eni.Groups, 1, "NLB ENI must join exactly the managed SG, not the default SG")
+ assert.Equal(t, rec.NLBManagedSGID, *eni.Groups[0].GroupId)
+}
+
+// TestCreateALB_NoManagedSG verifies the managed-SG behavior is NLB-only: an ALB
+// keeps the existing customer-SG / default-SG semantics.
+func TestCreateALB_NoManagedSG(t *testing.T) {
+ svc, vpcSvc := setupTestServiceWithVPC(t)
+ subnetID, _ := firstSubnet(t, vpcSvc)
+
+ out, err := svc.CreateLoadBalancer(&elbv2.CreateLoadBalancerInput{
+ Name: aws.String("alb-no-sg"),
+ Subnets: []*string{aws.String(subnetID)},
+ }, testAccountID)
+ require.NoError(t, err)
+
+ rec, err := svc.store.GetLoadBalancerByArn(*out.LoadBalancers[0].LoadBalancerArn)
+ require.NoError(t, err)
+ assert.Empty(t, rec.NLBManagedSGID, "ALB must not mint a managed NLB SG")
+}
+
+// TestCreateListener_Internal_OpensPortFromVPCCIDR verifies an internal NLB's
+// listener port is authorized on the managed SG from the VPC CIDR.
+func TestCreateListener_Internal_OpensPortFromVPCCIDR(t *testing.T) {
+ svc, vpcSvc := setupTestServiceWithVPC(t)
+ subnetID, _ := firstSubnet(t, vpcSvc)
+
+ lb := createNLB(t, svc, "nlb-int", "internal", subnetID)
+ createTCPListener(t, svc, lb.LoadBalancerArn, 443)
+
+ rec, err := svc.store.GetLoadBalancerByArn(*lb.LoadBalancerArn)
+ require.NoError(t, err)
+ sg := describeSG(t, vpcSvc, rec.NLBManagedSGID)
+ assert.True(t, sgHasRule(sg, "tcp", 443, "10.0.0.0/16"),
+ "internal NLB listener :443 must be open from the VPC CIDR")
+ assert.False(t, sgHasRule(sg, "tcp", 443, "0.0.0.0/0"))
+}
+
+// TestCreateListener_InternetFacing_OpensPortFromAnywhere verifies an
+// internet-facing NLB's listener port is authorized from 0.0.0.0/0.
+func TestCreateListener_InternetFacing_OpensPortFromAnywhere(t *testing.T) {
+ svc, vpcSvc := setupTestServiceWithVPC(t)
+ subnetID, _ := firstSubnet(t, vpcSvc)
+
+ lb := createNLB(t, svc, "nlb-pub", "internet-facing", subnetID)
+ createTCPListener(t, svc, lb.LoadBalancerArn, 443)
+
+ rec, err := svc.store.GetLoadBalancerByArn(*lb.LoadBalancerArn)
+ require.NoError(t, err)
+ sg := describeSG(t, vpcSvc, rec.NLBManagedSGID)
+ assert.True(t, sgHasRule(sg, "tcp", 443, "0.0.0.0/0"),
+ "internet-facing NLB listener :443 must be open from 0.0.0.0/0")
+}
+
+// TestCreateListener_TCPUDP_OpensBothProtocols verifies a TCP_UDP listener opens
+// both tcp and udp on the listener port.
+func TestCreateListener_TCPUDP_OpensBothProtocols(t *testing.T) {
+ svc, vpcSvc := setupTestServiceWithVPC(t)
+ subnetID, _ := firstSubnet(t, vpcSvc)
+
+ lb := createNLB(t, svc, "nlb-tcpudp", "internet-facing", subnetID)
+ tg, err := svc.CreateTargetGroup(&elbv2.CreateTargetGroupInput{
+ Name: aws.String("tg-tcpudp"),
+ Protocol: aws.String(elbv2.ProtocolEnumTcpUdp),
+ Port: aws.Int64(53),
+ }, testAccountID)
+ require.NoError(t, err)
+ _, err = svc.CreateListener(&elbv2.CreateListenerInput{
+ LoadBalancerArn: lb.LoadBalancerArn,
+ Protocol: aws.String(elbv2.ProtocolEnumTcpUdp),
+ Port: aws.Int64(53),
+ DefaultActions: []*elbv2.Action{{Type: aws.String("forward"), TargetGroupArn: tg.TargetGroups[0].TargetGroupArn}},
+ }, testAccountID)
+ require.NoError(t, err)
+
+ rec, err := svc.store.GetLoadBalancerByArn(*lb.LoadBalancerArn)
+ require.NoError(t, err)
+ sg := describeSG(t, vpcSvc, rec.NLBManagedSGID)
+ assert.True(t, sgHasRule(sg, "tcp", 53, "0.0.0.0/0"))
+ assert.True(t, sgHasRule(sg, "udp", 53, "0.0.0.0/0"))
+}
+
+// TestSetLoadBalancerIngressCIDRs_RewritesListenerRules verifies the override API
+// swaps the scheme-default rule for the supplied CIDRs across existing listeners
+// and is idempotent on re-invocation.
+func TestSetLoadBalancerIngressCIDRs_RewritesListenerRules(t *testing.T) {
+ svc, vpcSvc := setupTestServiceWithVPC(t)
+ subnetID, _ := firstSubnet(t, vpcSvc)
+
+ lb := createNLB(t, svc, "nlb-cidr", "internet-facing", subnetID)
+ createTCPListener(t, svc, lb.LoadBalancerArn, 443)
+
+ rec, err := svc.store.GetLoadBalancerByArn(*lb.LoadBalancerArn)
+ require.NoError(t, err)
+ sgID := rec.NLBManagedSGID
+
+ // Default opened 0.0.0.0/0.
+ assert.True(t, sgHasRule(describeSG(t, vpcSvc, sgID), "tcp", 443, "0.0.0.0/0"))
+
+ require.NoError(t, svc.SetLoadBalancerIngressCIDRs(*lb.LoadBalancerArn, []string{"203.0.113.0/24"}, testAccountID))
+
+ sg := describeSG(t, vpcSvc, sgID)
+ assert.True(t, sgHasRule(sg, "tcp", 443, "203.0.113.0/24"), "new CIDR must be authorized")
+ assert.False(t, sgHasRule(sg, "tcp", 443, "0.0.0.0/0"), "old default rule must be revoked")
+
+ // Idempotent re-apply.
+ require.NoError(t, svc.SetLoadBalancerIngressCIDRs(*lb.LoadBalancerArn, []string{"203.0.113.0/24"}, testAccountID))
+ sg = describeSG(t, vpcSvc, sgID)
+ assert.True(t, sgHasRule(sg, "tcp", 443, "203.0.113.0/24"))
+
+ // Persisted override drives the resolved CIDRs.
+ rec, err = svc.store.GetLoadBalancerByArn(*lb.LoadBalancerArn)
+ require.NoError(t, err)
+ assert.Equal(t, []string{"203.0.113.0/24"}, rec.NLBIngressCIDRs)
+}
+
+func TestSetLoadBalancerIngressCIDRs_RejectsBadInput(t *testing.T) {
+ svc, vpcSvc := setupTestServiceWithVPC(t)
+ subnetID, _ := firstSubnet(t, vpcSvc)
+
+ lb := createNLB(t, svc, "nlb-badcidr", "internet-facing", subnetID)
+ err := svc.SetLoadBalancerIngressCIDRs(*lb.LoadBalancerArn, []string{"not-a-cidr"}, testAccountID)
+ require.Error(t, err)
+
+ // ALB is rejected — managed SG is NLB-only.
+ albOut, err := svc.CreateLoadBalancer(&elbv2.CreateLoadBalancerInput{
+ Name: aws.String("alb-reject"),
+ Subnets: []*string{aws.String(subnetID)},
+ }, testAccountID)
+ require.NoError(t, err)
+ err = svc.SetLoadBalancerIngressCIDRs(*albOut.LoadBalancers[0].LoadBalancerArn, []string{"10.0.0.0/8"}, testAccountID)
+ require.Error(t, err)
+}
+
+// TestDeleteListener_RevokesPort verifies removing a listener closes its port on
+// the managed SG.
+func TestDeleteListener_RevokesPort(t *testing.T) {
+ svc, vpcSvc := setupTestServiceWithVPC(t)
+ subnetID, _ := firstSubnet(t, vpcSvc)
+
+ lb := createNLB(t, svc, "nlb-del-lst", "internet-facing", subnetID)
+ createTCPListener(t, svc, lb.LoadBalancerArn, 443)
+
+ lst, err := svc.DescribeListeners(&elbv2.DescribeListenersInput{LoadBalancerArn: lb.LoadBalancerArn}, testAccountID)
+ require.NoError(t, err)
+ require.Len(t, lst.Listeners, 1)
+
+ rec, err := svc.store.GetLoadBalancerByArn(*lb.LoadBalancerArn)
+ require.NoError(t, err)
+ require.True(t, sgHasRule(describeSG(t, vpcSvc, rec.NLBManagedSGID), "tcp", 443, "0.0.0.0/0"))
+
+ _, err = svc.DeleteListener(&elbv2.DeleteListenerInput{ListenerArn: lst.Listeners[0].ListenerArn}, testAccountID)
+ require.NoError(t, err)
+
+ assert.False(t, sgHasRule(describeSG(t, vpcSvc, rec.NLBManagedSGID), "tcp", 443, "0.0.0.0/0"),
+ "deleting the listener must close its port")
+}
+
+// TestDeleteNLB_DeletesManagedSG verifies the managed SG is removed on LB
+// teardown.
+func TestDeleteNLB_DeletesManagedSG(t *testing.T) {
+ svc, vpcSvc := setupTestServiceWithVPC(t)
+ subnetID, _ := firstSubnet(t, vpcSvc)
+
+ lb := createNLB(t, svc, "nlb-del", "internal", subnetID)
+ rec, err := svc.store.GetLoadBalancerByArn(*lb.LoadBalancerArn)
+ require.NoError(t, err)
+ sgID := rec.NLBManagedSGID
+ require.NotEmpty(t, sgID)
+
+ _, err = svc.DeleteLoadBalancer(&elbv2.DeleteLoadBalancerInput{LoadBalancerArn: lb.LoadBalancerArn}, testAccountID)
+ require.NoError(t, err)
+
+ out, err := vpcSvc.DescribeSecurityGroups(&ec2.DescribeSecurityGroupsInput{
+ GroupIds: aws.StringSlice([]string{sgID}),
+ }, testAccountID)
+ // Deleted SG: either an empty result or a not-found error is acceptable.
+ if err == nil {
+ assert.Empty(t, out.SecurityGroups, "managed SG must be deleted with the NLB")
+ }
+}
diff --git a/spinifex/handlers/elbv2/service_impl_rules.go b/spinifex/handlers/elbv2/service_impl_rules.go
index 476dc8d51..c1818a868 100644
--- a/spinifex/handlers/elbv2/service_impl_rules.go
+++ b/spinifex/handlers/elbv2/service_impl_rules.go
@@ -86,6 +86,8 @@ func (s *ELBv2ServiceImpl) CreateRule(input *elbv2.CreateRuleInput, accountID st
return nil, errors.New(awserrors.ErrorServerInternal)
}
+ tags := tagsFromSDK(input.Tags)
+
record := &RuleRecord{
RuleArn: ruleArn,
RuleID: ruleID,
@@ -95,6 +97,7 @@ func (s *ELBv2ServiceImpl) CreateRule(input *elbv2.CreateRuleInput, accountID st
Actions: actions,
AccountID: accountID,
CreatedAt: time.Now().UTC(),
+ Tags: tags,
}
if err := s.store.PutRule(record); err != nil {
@@ -176,10 +179,16 @@ func (s *ELBv2ServiceImpl) DeleteRule(input *elbv2.DeleteRuleInput, accountID st
return nil, errors.New(awserrors.ErrorServerInternal)
}
if rule == nil || rule.AccountID != accountID {
- return nil, errors.New(awserrors.ErrorELBv2RuleNotFound)
+ // Idempotent: AWS ELBv2 delete returns success on an absent (or
+ // not-owned) rule, so tofu destroy retries converge.
+ return &elbv2.DeleteRuleOutput{}, nil
}
- listener, _ := s.store.GetListenerByArn(rule.ListenerArn)
+ listener, err := s.store.GetListenerByArn(rule.ListenerArn)
+ if err != nil {
+ slog.Error("DeleteRule: failed to get listener for reload", "listenerArn", rule.ListenerArn, "err", err)
+ return nil, errors.New(awserrors.ErrorServerInternal)
+ }
if err := s.store.DeleteRule(rule.RuleID); err != nil {
slog.Error("DeleteRule: failed to delete", "ruleId", rule.RuleID, "err", err)
@@ -549,25 +558,35 @@ func (s *ELBv2ServiceImpl) validateAndConvertRuleActions(in []*elbv2.Action, lis
if a == nil || a.Type == nil {
return nil, errors.New(awserrors.ErrorMissingParameter)
}
- if *a.Type != ActionTypeForward {
- // redirect / fixed-response / authenticate-* deferred to mulga-951.
+ action := listenerActionFromSDK(a)
+ switch action.Type {
+ case ActionTypeForward:
+ if action.TargetGroupArn == "" {
+ return nil, errors.New(awserrors.ErrorMissingParameter)
+ }
+ tg, err := s.store.GetTargetGroupByArn(action.TargetGroupArn)
+ if err != nil {
+ slog.Error("rule action: failed to get target group", "arn", action.TargetGroupArn, "err", err)
+ return nil, errors.New(awserrors.ErrorServerInternal)
+ }
+ if tg == nil {
+ return nil, errors.New(awserrors.ErrorELBv2TargetGroupNotFound)
+ }
+ if !isCompatibleProtocol(listenerProto, tg.Protocol) {
+ return nil, errors.New(awserrors.ErrorELBv2IncompatibleProtocols)
+ }
+ case ActionTypeRedirect:
+ if err := validateRedirectAction(action.Redirect); err != nil {
+ return nil, err
+ }
+ case ActionTypeFixedResponse:
+ if action.FixedResponse == nil {
+ return nil, errors.New(awserrors.ErrorMissingParameter)
+ }
+ default:
return nil, errors.New(awserrors.ErrorELBv2InvalidConfigurationRequest)
}
- if a.TargetGroupArn == nil || *a.TargetGroupArn == "" {
- return nil, errors.New(awserrors.ErrorMissingParameter)
- }
- tg, err := s.store.GetTargetGroupByArn(*a.TargetGroupArn)
- if err != nil {
- slog.Error("rule action: failed to get target group", "arn", *a.TargetGroupArn, "err", err)
- return nil, errors.New(awserrors.ErrorServerInternal)
- }
- if tg == nil {
- return nil, errors.New(awserrors.ErrorELBv2TargetGroupNotFound)
- }
- if !isCompatibleProtocol(listenerProto, tg.Protocol) {
- return nil, errors.New(awserrors.ErrorELBv2IncompatibleProtocols)
- }
- out = append(out, ListenerAction{Type: *a.Type, TargetGroupArn: *a.TargetGroupArn})
+ out = append(out, action)
}
return out, nil
}
@@ -596,10 +615,7 @@ func ruleRecordToSDK(r *RuleRecord) *elbv2.Rule {
rule.Conditions = append(rule.Conditions, ruleConditionToSDK(c))
}
for _, a := range r.Actions {
- rule.Actions = append(rule.Actions, &elbv2.Action{
- Type: aws.String(a.Type),
- TargetGroupArn: aws.String(a.TargetGroupArn),
- })
+ rule.Actions = append(rule.Actions, listenerActionToSDK(a))
}
return rule
}
@@ -647,10 +663,7 @@ func defaultRuleFromListener(l *ListenerRecord) *elbv2.Rule {
IsDefault: aws.Bool(true),
}
for _, a := range l.DefaultActions {
- rule.Actions = append(rule.Actions, &elbv2.Action{
- Type: aws.String(a.Type),
- TargetGroupArn: aws.String(a.TargetGroupArn),
- })
+ rule.Actions = append(rule.Actions, listenerActionToSDK(a))
}
return rule
}
diff --git a/spinifex/handlers/elbv2/service_impl_rules_extra_test.go b/spinifex/handlers/elbv2/service_impl_rules_extra_test.go
index 3a1ba79ae..1f8c03f21 100644
--- a/spinifex/handlers/elbv2/service_impl_rules_extra_test.go
+++ b/spinifex/handlers/elbv2/service_impl_rules_extra_test.go
@@ -87,9 +87,10 @@ func TestDeleteRule_InputValidation(t *testing.T) {
require.Error(t, err)
assert.Contains(t, err.Error(), "MissingParameter")
- _, err = env.svc.DeleteRule(&elbv2.DeleteRuleInput{RuleArn: aws.String("arn:none")}, testAccountID)
- require.Error(t, err)
- assert.Contains(t, err.Error(), "RuleNotFound")
+ // AWS ELBv2 delete is idempotent: absent rule -> success, not NotFound.
+ out, err := env.svc.DeleteRule(&elbv2.DeleteRuleInput{RuleArn: aws.String("arn:none")}, testAccountID)
+ require.NoError(t, err)
+ assert.NotNil(t, out)
}
func TestDescribeRules_InputValidation(t *testing.T) {
@@ -377,11 +378,23 @@ func TestHAProxyRender_UnsupportedField(t *testing.T) {
require.Error(t, err)
}
-func TestBuildHAProxyRule_RejectsNonForward(t *testing.T) {
- _, err := buildHAProxyRule(&RuleRecord{
+func TestBuildHAProxyRule_RoutesToBackend(t *testing.T) {
+ rule, err := buildHAProxyRule(&RuleRecord{
+ RuleID: "r1",
+ Conditions: []RuleCondition{{Field: RuleFieldPathPattern, Values: []string{"/a"}}},
+ }, "bk_target")
+ require.NoError(t, err)
+ assert.Equal(t, "bk_target", rule.Backend)
+}
+
+func TestRegisterRuleBackend_RejectsUnknownAction(t *testing.T) {
+ noopBackend := func(string) {}
+ noopFixed := func(string, *FixedResponseAction) {}
+ noopRedirect := func(string, *HAProxyRedirect) {}
+ _, err := registerRuleBackend(&RuleRecord{
RuleID: "r1",
- Actions: []ListenerAction{{Type: "redirect"}},
- })
+ Actions: []ListenerAction{{Type: "authenticate-cognito"}},
+ }, &ListenerRecord{Protocol: ProtocolHTTP}, noopBackend, noopFixed, noopRedirect)
require.Error(t, err)
}
diff --git a/spinifex/handlers/elbv2/service_impl_rules_test.go b/spinifex/handlers/elbv2/service_impl_rules_test.go
index 384d5951f..4bc334d7a 100644
--- a/spinifex/handlers/elbv2/service_impl_rules_test.go
+++ b/spinifex/handlers/elbv2/service_impl_rules_test.go
@@ -70,6 +70,46 @@ func TestCreateRule_PathPattern(t *testing.T) {
assert.Contains(t, *out.Rules[0].RuleArn, ":listener-rule/")
}
+// TestDeleteLoadBalancer_CascadesRuleDeletion is a regression guard:
+// DeleteLoadBalancer must cascade through listener rule deletion. Previously it
+// called store.DeleteListener directly, bypassing the rule cascade, so a rule's
+// target group stayed pinned as ResourceInUse and was permanently undeletable.
+func TestDeleteLoadBalancer_CascadesRuleDeletion(t *testing.T) {
+ env := newRuleTestEnv(t, "del-cascade")
+
+ // Rule on the listener forwards to the alt TG.
+ _, err := env.svc.CreateRule(&elbv2.CreateRuleInput{
+ ListenerArn: aws.String(env.listenerArn),
+ Priority: aws.Int64(10),
+ Conditions: []*elbv2.RuleCondition{{Field: aws.String("path-pattern"), Values: aws.StringSlice([]string{"/api/*"})}},
+ Actions: []*elbv2.Action{{Type: aws.String("forward"), TargetGroupArn: aws.String(env.tgAltArn)}},
+ }, testAccountID)
+ require.NoError(t, err)
+
+ // Tear down the LB.
+ _, err = env.svc.DeleteLoadBalancer(&elbv2.DeleteLoadBalancerInput{
+ LoadBalancerArn: aws.String(env.lbArn),
+ }, testAccountID)
+ require.NoError(t, err)
+
+ // LB deletion must cascade rule deletion — no rule may survive.
+ rules, err := env.svc.store.ListRules()
+ require.NoError(t, err)
+ assert.Empty(t, rules, "LB deletion must cascade rule deletion")
+
+ // Both target groups must now be deletable: neither an orphan rule nor a
+ // stale listener default action may pin them as ResourceInUse.
+ _, err = env.svc.DeleteTargetGroup(&elbv2.DeleteTargetGroupInput{
+ TargetGroupArn: aws.String(env.tgAltArn),
+ }, testAccountID)
+ require.NoError(t, err, "rule target group must not be pinned after LB deletion")
+
+ _, err = env.svc.DeleteTargetGroup(&elbv2.DeleteTargetGroupInput{
+ TargetGroupArn: aws.String(env.tgArn),
+ }, testAccountID)
+ require.NoError(t, err, "listener default-action target group must not be pinned after LB deletion")
+}
+
func TestCreateRule_PriorityInUse(t *testing.T) {
env := newRuleTestEnv(t, "cr-pri")
@@ -107,14 +147,75 @@ func TestCreateRule_InvalidPriority(t *testing.T) {
}
}
-func TestCreateRule_RejectsRedirect(t *testing.T) {
+func TestCreateRule_RedirectAction(t *testing.T) {
+ env := newRuleTestEnv(t, "cr-redir")
+
+ out, err := env.svc.CreateRule(&elbv2.CreateRuleInput{
+ ListenerArn: aws.String(env.listenerArn),
+ Priority: aws.Int64(1),
+ Conditions: []*elbv2.RuleCondition{{Field: aws.String("path-pattern"), Values: aws.StringSlice([]string{"/old/*"})}},
+ Actions: []*elbv2.Action{{
+ Type: aws.String("redirect"),
+ RedirectConfig: &elbv2.RedirectActionConfig{
+ Protocol: aws.String("HTTPS"),
+ StatusCode: aws.String("HTTP_301"),
+ },
+ }},
+ }, testAccountID)
+ require.NoError(t, err)
+ require.Len(t, out.Rules, 1)
+ require.Len(t, out.Rules[0].Actions, 1)
+ assert.Equal(t, "redirect", *out.Rules[0].Actions[0].Type)
+ require.NotNil(t, out.Rules[0].Actions[0].RedirectConfig)
+ assert.Equal(t, "HTTP_301", *out.Rules[0].Actions[0].RedirectConfig.StatusCode)
+}
+
+func TestCreateRule_FixedResponseAction(t *testing.T) {
+ env := newRuleTestEnv(t, "cr-fixed")
+
+ out, err := env.svc.CreateRule(&elbv2.CreateRuleInput{
+ ListenerArn: aws.String(env.listenerArn),
+ Priority: aws.Int64(1),
+ Conditions: []*elbv2.RuleCondition{{Field: aws.String("path-pattern"), Values: aws.StringSlice([]string{"/down"})}},
+ Actions: []*elbv2.Action{{
+ Type: aws.String("fixed-response"),
+ FixedResponseConfig: &elbv2.FixedResponseActionConfig{
+ StatusCode: aws.String("503"),
+ ContentType: aws.String("text/plain"),
+ MessageBody: aws.String("maintenance"),
+ },
+ }},
+ }, testAccountID)
+ require.NoError(t, err)
+ require.Len(t, out.Rules, 1)
+ assert.Equal(t, "fixed-response", *out.Rules[0].Actions[0].Type)
+ require.NotNil(t, out.Rules[0].Actions[0].FixedResponseConfig)
+}
+
+func TestCreateRule_RejectsBadRedirectStatus(t *testing.T) {
+ env := newRuleTestEnv(t, "cr-badredir")
+
+ _, err := env.svc.CreateRule(&elbv2.CreateRuleInput{
+ ListenerArn: aws.String(env.listenerArn),
+ Priority: aws.Int64(1),
+ Conditions: []*elbv2.RuleCondition{{Field: aws.String("path-pattern"), Values: aws.StringSlice([]string{"/a"})}},
+ Actions: []*elbv2.Action{{
+ Type: aws.String("redirect"),
+ RedirectConfig: &elbv2.RedirectActionConfig{StatusCode: aws.String("HTTP_418")},
+ }},
+ }, testAccountID)
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), "InvalidParameterValue")
+}
+
+func TestCreateRule_RejectsUnknownAction(t *testing.T) {
env := newRuleTestEnv(t, "cr-rej")
_, err := env.svc.CreateRule(&elbv2.CreateRuleInput{
ListenerArn: aws.String(env.listenerArn),
Priority: aws.Int64(1),
Conditions: []*elbv2.RuleCondition{{Field: aws.String("path-pattern"), Values: aws.StringSlice([]string{"/a"})}},
- Actions: []*elbv2.Action{{Type: aws.String("redirect")}},
+ Actions: []*elbv2.Action{{Type: aws.String("authenticate-cognito")}},
}, testAccountID)
require.Error(t, err)
assert.Contains(t, err.Error(), "InvalidConfigurationRequest")
diff --git a/spinifex/handlers/elbv2/service_impl_tags.go b/spinifex/handlers/elbv2/service_impl_tags.go
new file mode 100644
index 000000000..5238397d2
--- /dev/null
+++ b/spinifex/handlers/elbv2/service_impl_tags.go
@@ -0,0 +1,185 @@
+package handlers_elbv2
+
+import (
+ "errors"
+ "log/slog"
+ "maps"
+
+ "github.com/aws/aws-sdk-go/service/elbv2"
+ "github.com/mulgadc/spinifex/spinifex/awserrors"
+)
+
+// taggable bundles a resource's current tags with a persist closure so the
+// AddTags/RemoveTags mutation logic is identical across resource types.
+type taggable struct {
+ tags map[string]string
+ owner string
+ save func(map[string]string) error
+}
+
+// resolveTaggable loads the ELBv2 resource named by arn and returns a handle for
+// reading and persisting its tags. found is false when the resource doesn't
+// exist; notFoundError carries the per-type API error for that case. Store
+// failures are logged and returned as ErrorServerInternal.
+func (s *ELBv2ServiceImpl) resolveTaggable(arn string) (h taggable, notFoundError string, found bool, err error) {
+ resourceType, terr := elbv2ResourceTypeFromArn(arn)
+ if terr != nil {
+ return taggable{}, "", false, terr
+ }
+
+ switch resourceType {
+ case elbv2ResourceLoadBalancer:
+ notFoundError = awserrors.ErrorELBv2LoadBalancerNotFound
+ lb, e := s.store.GetLoadBalancerByArn(arn)
+ if e != nil {
+ slog.Error("resolveTaggable: failed to get LB", "arn", arn, "err", e)
+ return taggable{}, notFoundError, false, errors.New(awserrors.ErrorServerInternal)
+ }
+ if lb != nil {
+ found = true
+ h = taggable{tags: lb.Tags, owner: lb.AccountID, save: func(t map[string]string) error {
+ lb.Tags = t
+ return s.store.PutLoadBalancer(lb)
+ }}
+ }
+ case elbv2ResourceTargetGroup:
+ notFoundError = awserrors.ErrorELBv2TargetGroupNotFound
+ tg, e := s.store.GetTargetGroupByArn(arn)
+ if e != nil {
+ slog.Error("resolveTaggable: failed to get target group", "arn", arn, "err", e)
+ return taggable{}, notFoundError, false, errors.New(awserrors.ErrorServerInternal)
+ }
+ if tg != nil {
+ found = true
+ h = taggable{tags: tg.Tags, owner: tg.AccountID, save: func(t map[string]string) error {
+ tg.Tags = t
+ return s.store.PutTargetGroup(tg)
+ }}
+ }
+ case elbv2ResourceListener:
+ notFoundError = awserrors.ErrorELBv2ListenerNotFound
+ l, e := s.store.GetListenerByArn(arn)
+ if e != nil {
+ slog.Error("resolveTaggable: failed to get listener", "arn", arn, "err", e)
+ return taggable{}, notFoundError, false, errors.New(awserrors.ErrorServerInternal)
+ }
+ if l != nil {
+ found = true
+ h = taggable{tags: l.Tags, owner: l.AccountID, save: func(t map[string]string) error {
+ l.Tags = t
+ return s.store.PutListener(l)
+ }}
+ }
+ case elbv2ResourceListenerRule:
+ notFoundError = awserrors.ErrorELBv2RuleNotFound
+ r, e := s.store.GetRuleByArn(arn)
+ if e != nil {
+ slog.Error("resolveTaggable: failed to get rule", "arn", arn, "err", e)
+ return taggable{}, notFoundError, false, errors.New(awserrors.ErrorServerInternal)
+ }
+ if r != nil {
+ found = true
+ h = taggable{tags: r.Tags, owner: r.AccountID, save: func(t map[string]string) error {
+ r.Tags = t
+ return s.store.PutRule(r)
+ }}
+ }
+ }
+
+ return h, notFoundError, found, nil
+}
+
+// AddTags adds or overwrites tags on one or more ELBv2 resources. Tags are
+// validated up-front so a malformed key can't leave a partial apply spanning
+// the ResourceArns. Cross-account or unknown ARNs yield the per-resource
+// not-found error.
+func (s *ELBv2ServiceImpl) AddTags(input *elbv2.AddTagsInput, accountID string) (*elbv2.AddTagsOutput, error) {
+ if input == nil || len(input.ResourceArns) == 0 || len(input.Tags) == 0 {
+ return nil, errors.New(awserrors.ErrorMissingParameter)
+ }
+
+ newTags := make(map[string]string, len(input.Tags))
+ for _, t := range input.Tags {
+ if t == nil || t.Key == nil || *t.Key == "" {
+ return nil, errors.New(awserrors.ErrorInvalidParameterValue)
+ }
+ v := ""
+ if t.Value != nil {
+ v = *t.Value
+ }
+ newTags[*t.Key] = v
+ }
+
+ for _, arnPtr := range input.ResourceArns {
+ if arnPtr == nil || *arnPtr == "" {
+ return nil, errors.New(awserrors.ErrorMissingParameter)
+ }
+ arn := *arnPtr
+
+ h, notFoundError, found, err := s.resolveTaggable(arn)
+ if err != nil {
+ return nil, err
+ }
+ if !found || h.owner != accountID {
+ return nil, errors.New(notFoundError)
+ }
+
+ merged := h.tags
+ if merged == nil {
+ merged = make(map[string]string, len(newTags))
+ }
+ maps.Copy(merged, newTags)
+ if err := h.save(merged); err != nil {
+ slog.Error("AddTags: failed to persist", "arn", arn, "err", err)
+ return nil, errors.New(awserrors.ErrorServerInternal)
+ }
+ }
+
+ return &elbv2.AddTagsOutput{}, nil
+}
+
+// RemoveTags removes the named tag keys from one or more ELBv2 resources.
+// Removal is idempotent — keys absent from a resource are silently ignored,
+// matching AWS. Cross-account or unknown ARNs yield the per-resource not-found
+// error.
+func (s *ELBv2ServiceImpl) RemoveTags(input *elbv2.RemoveTagsInput, accountID string) (*elbv2.RemoveTagsOutput, error) {
+ if input == nil || len(input.ResourceArns) == 0 || len(input.TagKeys) == 0 {
+ return nil, errors.New(awserrors.ErrorMissingParameter)
+ }
+
+ keys := make([]string, 0, len(input.TagKeys))
+ for _, k := range input.TagKeys {
+ if k == nil || *k == "" {
+ return nil, errors.New(awserrors.ErrorInvalidParameterValue)
+ }
+ keys = append(keys, *k)
+ }
+
+ for _, arnPtr := range input.ResourceArns {
+ if arnPtr == nil || *arnPtr == "" {
+ return nil, errors.New(awserrors.ErrorMissingParameter)
+ }
+ arn := *arnPtr
+
+ h, notFoundError, found, err := s.resolveTaggable(arn)
+ if err != nil {
+ return nil, err
+ }
+ if !found || h.owner != accountID {
+ return nil, errors.New(notFoundError)
+ }
+
+ if len(h.tags) == 0 {
+ continue
+ }
+ for _, k := range keys {
+ delete(h.tags, k)
+ }
+ if err := h.save(h.tags); err != nil {
+ slog.Error("RemoveTags: failed to persist", "arn", arn, "err", err)
+ return nil, errors.New(awserrors.ErrorServerInternal)
+ }
+ }
+
+ return &elbv2.RemoveTagsOutput{}, nil
+}
diff --git a/spinifex/handlers/elbv2/service_impl_tags_test.go b/spinifex/handlers/elbv2/service_impl_tags_test.go
new file mode 100644
index 000000000..bd54509e6
--- /dev/null
+++ b/spinifex/handlers/elbv2/service_impl_tags_test.go
@@ -0,0 +1,200 @@
+package handlers_elbv2
+
+import (
+ "testing"
+
+ "github.com/aws/aws-sdk-go/aws"
+ "github.com/aws/aws-sdk-go/service/elbv2"
+ "github.com/mulgadc/spinifex/spinifex/awserrors"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+// tagsFor is a small helper that reads back a resource's tags via DescribeTags
+// as a plain map for assertions.
+func tagsFor(t *testing.T, svc *ELBv2ServiceImpl, arn string) map[string]string {
+ t.Helper()
+ out, err := svc.DescribeTags(&elbv2.DescribeTagsInput{ResourceArns: []*string{aws.String(arn)}}, testAccountID)
+ require.NoError(t, err)
+ require.Len(t, out.TagDescriptions, 1)
+ m := make(map[string]string)
+ for _, tag := range out.TagDescriptions[0].Tags {
+ m[*tag.Key] = *tag.Value
+ }
+ return m
+}
+
+func TestAddTags_LoadBalancer(t *testing.T) {
+ svc := setupTestService(t)
+ lbOut, err := svc.CreateLoadBalancer(&elbv2.CreateLoadBalancerInput{
+ Name: aws.String("addtags-lb"),
+ Tags: []*elbv2.Tag{{Key: aws.String("Env"), Value: aws.String("dev")}},
+ }, testAccountID)
+ require.NoError(t, err)
+ arn := *lbOut.LoadBalancers[0].LoadBalancerArn
+
+ _, err = svc.AddTags(&elbv2.AddTagsInput{
+ ResourceArns: []*string{aws.String(arn)},
+ Tags: []*elbv2.Tag{
+ {Key: aws.String("App"), Value: aws.String("nginx")},
+ {Key: aws.String("Env"), Value: aws.String("prod")}, // overwrite
+ },
+ }, testAccountID)
+ require.NoError(t, err)
+
+ assert.Equal(t, map[string]string{"App": "nginx", "Env": "prod"}, tagsFor(t, svc, arn))
+}
+
+func TestAddTags_Listener(t *testing.T) {
+ svc := setupTestService(t)
+ lbOut, _ := svc.CreateLoadBalancer(&elbv2.CreateLoadBalancerInput{Name: aws.String("addtags-lst-lb")}, testAccountID)
+ tgOut, _ := svc.CreateTargetGroup(&elbv2.CreateTargetGroupInput{Name: aws.String("addtags-lst-tg")}, testAccountID)
+ lstOut, err := svc.CreateListener(&elbv2.CreateListenerInput{
+ LoadBalancerArn: lbOut.LoadBalancers[0].LoadBalancerArn,
+ Protocol: aws.String("HTTP"),
+ Port: aws.Int64(80),
+ DefaultActions: []*elbv2.Action{
+ {Type: aws.String("forward"), TargetGroupArn: tgOut.TargetGroups[0].TargetGroupArn},
+ },
+ }, testAccountID)
+ require.NoError(t, err)
+ arn := *lstOut.Listeners[0].ListenerArn
+
+ _, err = svc.AddTags(&elbv2.AddTagsInput{
+ ResourceArns: []*string{aws.String(arn)},
+ Tags: []*elbv2.Tag{{Key: aws.String("team"), Value: aws.String("platform")}},
+ }, testAccountID)
+ require.NoError(t, err)
+
+ assert.Equal(t, map[string]string{"team": "platform"}, tagsFor(t, svc, arn))
+}
+
+func TestAddTags_CreateTimeTagsPreserved(t *testing.T) {
+ svc := setupTestService(t)
+ lbOut, _ := svc.CreateLoadBalancer(&elbv2.CreateLoadBalancerInput{Name: aws.String("addtags-tg-lb")}, testAccountID)
+ tgOut, err := svc.CreateTargetGroup(&elbv2.CreateTargetGroupInput{
+ Name: aws.String("addtags-tg"),
+ Tags: []*elbv2.Tag{{Key: aws.String("owner"), Value: aws.String("alice")}},
+ }, testAccountID)
+ require.NoError(t, err)
+ _ = lbOut
+ arn := *tgOut.TargetGroups[0].TargetGroupArn
+
+ _, err = svc.AddTags(&elbv2.AddTagsInput{
+ ResourceArns: []*string{aws.String(arn)},
+ Tags: []*elbv2.Tag{{Key: aws.String("cost-center"), Value: aws.String("42")}},
+ }, testAccountID)
+ require.NoError(t, err)
+
+ assert.Equal(t, map[string]string{"owner": "alice", "cost-center": "42"}, tagsFor(t, svc, arn))
+}
+
+func TestAddTags_NotFound(t *testing.T) {
+ svc := setupTestService(t)
+ _, err := svc.AddTags(&elbv2.AddTagsInput{
+ ResourceArns: []*string{aws.String("arn:aws:elasticloadbalancing:us-east-1:123456789012:loadbalancer/app/missing/lb-deadbeef")},
+ Tags: []*elbv2.Tag{{Key: aws.String("k"), Value: aws.String("v")}},
+ }, testAccountID)
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), awserrors.ErrorELBv2LoadBalancerNotFound)
+}
+
+func TestAddTags_CrossAccount(t *testing.T) {
+ svc := setupTestService(t)
+ lbOut, _ := svc.CreateLoadBalancer(&elbv2.CreateLoadBalancerInput{Name: aws.String("addtags-xacct")}, testAccountID)
+ arn := *lbOut.LoadBalancers[0].LoadBalancerArn
+
+ _, err := svc.AddTags(&elbv2.AddTagsInput{
+ ResourceArns: []*string{aws.String(arn)},
+ Tags: []*elbv2.Tag{{Key: aws.String("k"), Value: aws.String("v")}},
+ }, "999999999999")
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), awserrors.ErrorELBv2LoadBalancerNotFound)
+
+ // Owner's tags untouched.
+ assert.Empty(t, tagsFor(t, svc, arn))
+}
+
+func TestAddTags_MissingParams(t *testing.T) {
+ svc := setupTestService(t)
+ lbOut, _ := svc.CreateLoadBalancer(&elbv2.CreateLoadBalancerInput{Name: aws.String("addtags-mp")}, testAccountID)
+ arn := lbOut.LoadBalancers[0].LoadBalancerArn
+
+ _, err := svc.AddTags(&elbv2.AddTagsInput{ResourceArns: []*string{arn}}, testAccountID)
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), awserrors.ErrorMissingParameter)
+
+ _, err = svc.AddTags(&elbv2.AddTagsInput{Tags: []*elbv2.Tag{{Key: aws.String("k"), Value: aws.String("v")}}}, testAccountID)
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), awserrors.ErrorMissingParameter)
+}
+
+func TestAddTags_EmptyKeyRejected(t *testing.T) {
+ svc := setupTestService(t)
+ lbOut, _ := svc.CreateLoadBalancer(&elbv2.CreateLoadBalancerInput{Name: aws.String("addtags-ek")}, testAccountID)
+ arn := lbOut.LoadBalancers[0].LoadBalancerArn
+
+ _, err := svc.AddTags(&elbv2.AddTagsInput{
+ ResourceArns: []*string{arn},
+ Tags: []*elbv2.Tag{{Key: aws.String(""), Value: aws.String("v")}},
+ }, testAccountID)
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), awserrors.ErrorInvalidParameterValue)
+}
+
+func TestRemoveTags_LoadBalancer(t *testing.T) {
+ svc := setupTestService(t)
+ lbOut, _ := svc.CreateLoadBalancer(&elbv2.CreateLoadBalancerInput{
+ Name: aws.String("rmtags-lb"),
+ Tags: []*elbv2.Tag{
+ {Key: aws.String("App"), Value: aws.String("nginx")},
+ {Key: aws.String("Env"), Value: aws.String("prod")},
+ },
+ }, testAccountID)
+ arn := *lbOut.LoadBalancers[0].LoadBalancerArn
+
+ _, err := svc.RemoveTags(&elbv2.RemoveTagsInput{
+ ResourceArns: []*string{aws.String(arn)},
+ TagKeys: []*string{aws.String("Env")},
+ }, testAccountID)
+ require.NoError(t, err)
+
+ assert.Equal(t, map[string]string{"App": "nginx"}, tagsFor(t, svc, arn))
+}
+
+func TestRemoveTags_Idempotent(t *testing.T) {
+ svc := setupTestService(t)
+ lbOut, _ := svc.CreateLoadBalancer(&elbv2.CreateLoadBalancerInput{
+ Name: aws.String("rmtags-idem"),
+ Tags: []*elbv2.Tag{{Key: aws.String("App"), Value: aws.String("nginx")}},
+ }, testAccountID)
+ arn := *lbOut.LoadBalancers[0].LoadBalancerArn
+
+ // Removing an absent key is a no-op, not an error.
+ _, err := svc.RemoveTags(&elbv2.RemoveTagsInput{
+ ResourceArns: []*string{aws.String(arn)},
+ TagKeys: []*string{aws.String("DoesNotExist")},
+ }, testAccountID)
+ require.NoError(t, err)
+ assert.Equal(t, map[string]string{"App": "nginx"}, tagsFor(t, svc, arn))
+}
+
+func TestRemoveTags_NotFound(t *testing.T) {
+ svc := setupTestService(t)
+ _, err := svc.RemoveTags(&elbv2.RemoveTagsInput{
+ ResourceArns: []*string{aws.String("arn:aws:elasticloadbalancing:us-east-1:123456789012:targetgroup/missing/tg-deadbeef")},
+ TagKeys: []*string{aws.String("k")},
+ }, testAccountID)
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), awserrors.ErrorELBv2TargetGroupNotFound)
+}
+
+func TestRemoveTags_MissingParams(t *testing.T) {
+ svc := setupTestService(t)
+ lbOut, _ := svc.CreateLoadBalancer(&elbv2.CreateLoadBalancerInput{Name: aws.String("rmtags-mp")}, testAccountID)
+ arn := lbOut.LoadBalancers[0].LoadBalancerArn
+
+ _, err := svc.RemoveTags(&elbv2.RemoveTagsInput{ResourceArns: []*string{arn}}, testAccountID)
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), awserrors.ErrorMissingParameter)
+}
diff --git a/spinifex/handlers/elbv2/service_impl_test.go b/spinifex/handlers/elbv2/service_impl_test.go
index 3a9f08118..0696c6f48 100644
--- a/spinifex/handlers/elbv2/service_impl_test.go
+++ b/spinifex/handlers/elbv2/service_impl_test.go
@@ -1,6 +1,7 @@
package handlers_elbv2
import (
+ "encoding/xml"
"sort"
"strings"
"sync"
@@ -22,6 +23,10 @@ func setupTestService(t *testing.T) *ELBv2ServiceImpl {
svc, err := NewELBv2ServiceImplWithNATS(nil, nc)
require.NoError(t, err)
+ // Seed the certificate ARNs the listener tests attach so the fail-closed
+ // cert validation in Create/Modify/AddListenerCertificates resolves them.
+ putTestCert(t, svc, testCertArn, testAccountID, "LEAF", "", "KEY")
+ putTestCert(t, svc, testCertArn2, testAccountID, "LEAF", "", "KEY")
return svc
}
@@ -142,7 +147,7 @@ func TestCreateLoadBalancer_NetworkType_RejectsSecurityGroups(t *testing.T) {
assert.Contains(t, err.Error(), "InvalidConfigurationRequest")
}
-func TestCreateLoadBalancer_CrossZoneAttributes(t *testing.T) {
+func TestCreateLoadBalancer_NLBCrossZoneAttribute(t *testing.T) {
svc := setupTestService(t)
// NLB: no stored attributes, Describe should return default cross-zone=false.
@@ -164,6 +169,10 @@ func TestCreateLoadBalancer_CrossZoneAttributes(t *testing.T) {
nlbAttrs[*a.Key] = *a.Value
}
assert.Equal(t, "false", nlbAttrs["load_balancing.cross_zone.enabled"])
+}
+
+func TestCreateLoadBalancer_ALBCrossZoneAttribute(t *testing.T) {
+ svc := setupTestService(t)
// ALB: no stored attributes either, Describe should return default cross-zone=true.
albOut, err := svc.CreateLoadBalancer(&elbv2.CreateLoadBalancerInput{
@@ -229,15 +238,16 @@ func TestDeleteLoadBalancer(t *testing.T) {
assert.Empty(t, desc.LoadBalancers)
}
-func TestDeleteLoadBalancer_NotFound(t *testing.T) {
+func TestDeleteLoadBalancer_IdempotentOnAbsent(t *testing.T) {
svc := setupTestService(t)
- _, err := svc.DeleteLoadBalancer(&elbv2.DeleteLoadBalancerInput{
+ out, err := svc.DeleteLoadBalancer(&elbv2.DeleteLoadBalancerInput{
LoadBalancerArn: aws.String("arn:aws:elasticloadbalancing:us-east-1:123456789012:loadbalancer/app/nope/xyz"),
}, testAccountID)
- assert.Error(t, err)
- assert.Contains(t, err.Error(), "LoadBalancerNotFound")
+ // AWS ELBv2 delete is idempotent: absent LB -> success, not NotFound.
+ require.NoError(t, err)
+ assert.NotNil(t, out)
}
func TestDeleteLoadBalancer_CleansUpListeners(t *testing.T) {
@@ -696,14 +706,15 @@ func TestDeleteTargetGroup_InUse(t *testing.T) {
assert.Contains(t, err.Error(), "ResourceInUse")
}
-func TestDeleteTargetGroup_NotFound(t *testing.T) {
+func TestDeleteTargetGroup_IdempotentOnAbsent(t *testing.T) {
svc := setupTestService(t)
- _, err := svc.DeleteTargetGroup(&elbv2.DeleteTargetGroupInput{
+ out, err := svc.DeleteTargetGroup(&elbv2.DeleteTargetGroupInput{
TargetGroupArn: aws.String("arn:aws:elasticloadbalancing:us-east-1:123456789012:targetgroup/nope/xyz"),
}, testAccountID)
- assert.Error(t, err)
- assert.Contains(t, err.Error(), "TargetGroupNotFound")
+ // AWS ELBv2 delete is idempotent: absent TG -> success, not NotFound.
+ require.NoError(t, err)
+ assert.NotNil(t, out)
}
func TestDescribeTargetGroups_FilterByLBArn(t *testing.T) {
@@ -763,6 +774,93 @@ func TestRegisterTargets(t *testing.T) {
assert.Equal(t, int64(8080), *health.TargetHealthDescriptions[1].Target.Port)
}
+func TestRegisterTargets_IPType(t *testing.T) {
+ svc := setupTestService(t)
+
+ tgOut, err := svc.CreateTargetGroup(&elbv2.CreateTargetGroupInput{
+ Name: aws.String("ip-tg"),
+ Port: aws.Int64(80),
+ TargetType: aws.String("ip"),
+ }, testAccountID)
+ require.NoError(t, err)
+ tgArn := tgOut.TargetGroups[0].TargetGroupArn
+ assert.Equal(t, "ip", *tgOut.TargetGroups[0].TargetType)
+
+ _, err = svc.RegisterTargets(&elbv2.RegisterTargetsInput{
+ TargetGroupArn: tgArn,
+ Targets: []*elbv2.TargetDescription{
+ {Id: aws.String("10.0.1.20")},
+ {Id: aws.String("10.0.1.21"), Port: aws.Int64(8080)},
+ },
+ }, testAccountID)
+ require.NoError(t, err)
+
+ // ip targets must carry the supplied IP as PrivateIP, not an empty
+ // ENI-resolution result — otherwise HAProxy/health-check silently drop them.
+ tg, err := svc.store.GetTargetGroupByArn(*tgArn)
+ require.NoError(t, err)
+ require.Len(t, tg.Targets, 2)
+ ipByID := make(map[string]string)
+ for _, target := range tg.Targets {
+ ipByID[target.Id] = target.PrivateIP
+ }
+ assert.Equal(t, "10.0.1.20", ipByID["10.0.1.20"])
+ assert.Equal(t, "10.0.1.21", ipByID["10.0.1.21"])
+
+ health, err := svc.DescribeTargetHealth(&elbv2.DescribeTargetHealthInput{
+ TargetGroupArn: tgArn,
+ }, testAccountID)
+ require.NoError(t, err)
+ require.Len(t, health.TargetHealthDescriptions, 2)
+}
+
+func TestRegisterTargets_IPType_RejectsNonIP(t *testing.T) {
+ svc := setupTestService(t)
+ tgOut, err := svc.CreateTargetGroup(&elbv2.CreateTargetGroupInput{
+ Name: aws.String("ip-tg-bad"),
+ TargetType: aws.String("ip"),
+ }, testAccountID)
+ require.NoError(t, err)
+
+ _, err = svc.RegisterTargets(&elbv2.RegisterTargetsInput{
+ TargetGroupArn: tgOut.TargetGroups[0].TargetGroupArn,
+ Targets: []*elbv2.TargetDescription{{Id: aws.String("i-notanip")}},
+ }, testAccountID)
+ assert.EqualError(t, err, awserrors.ErrorInvalidParameterValue)
+}
+
+func TestRegisterTargets_InstanceType_RejectsIP(t *testing.T) {
+ svc := setupTestService(t)
+ tgOut, err := svc.CreateTargetGroup(&elbv2.CreateTargetGroupInput{
+ Name: aws.String("inst-tg-badid"),
+ }, testAccountID)
+ require.NoError(t, err)
+
+ _, err = svc.RegisterTargets(&elbv2.RegisterTargetsInput{
+ TargetGroupArn: tgOut.TargetGroups[0].TargetGroupArn,
+ Targets: []*elbv2.TargetDescription{{Id: aws.String("10.0.0.5")}},
+ }, testAccountID)
+ assert.EqualError(t, err, awserrors.ErrorInvalidParameterValue)
+}
+
+func TestCreateTargetGroup_RejectsUnsupportedTargetType(t *testing.T) {
+ svc := setupTestService(t)
+ _, err := svc.CreateTargetGroup(&elbv2.CreateTargetGroupInput{
+ Name: aws.String("lambda-tg"),
+ TargetType: aws.String("lambda"),
+ }, testAccountID)
+ assert.EqualError(t, err, awserrors.ErrorInvalidParameterValue)
+}
+
+func TestCreateTargetGroup_DefaultsToInstanceType(t *testing.T) {
+ svc := setupTestService(t)
+ tgOut, err := svc.CreateTargetGroup(&elbv2.CreateTargetGroupInput{
+ Name: aws.String("default-tt"),
+ }, testAccountID)
+ require.NoError(t, err)
+ assert.Equal(t, "instance", *tgOut.TargetGroups[0].TargetType)
+}
+
func TestRegisterTargets_Idempotent(t *testing.T) {
svc := setupTestService(t)
@@ -865,6 +963,127 @@ func TestCreateListener(t *testing.T) {
assert.Equal(t, "forward", *l.DefaultActions[0].Type)
}
+func TestCreateListener_RedirectDefault(t *testing.T) {
+ svc := setupTestService(t)
+
+ lbOut, _ := svc.CreateLoadBalancer(&elbv2.CreateLoadBalancerInput{Name: aws.String("redir-lb")}, testAccountID)
+
+ out, err := svc.CreateListener(&elbv2.CreateListenerInput{
+ LoadBalancerArn: lbOut.LoadBalancers[0].LoadBalancerArn,
+ Protocol: aws.String("HTTP"),
+ Port: aws.Int64(80),
+ DefaultActions: []*elbv2.Action{{
+ Type: aws.String("redirect"),
+ RedirectConfig: &elbv2.RedirectActionConfig{
+ Protocol: aws.String("HTTPS"),
+ Port: aws.String("443"),
+ StatusCode: aws.String("HTTP_301"),
+ },
+ }},
+ }, testAccountID)
+ require.NoError(t, err)
+ require.Len(t, out.Listeners, 1)
+ require.Len(t, out.Listeners[0].DefaultActions, 1)
+ a := out.Listeners[0].DefaultActions[0]
+ assert.Equal(t, "redirect", *a.Type)
+ require.NotNil(t, a.RedirectConfig)
+ assert.Equal(t, "HTTPS", *a.RedirectConfig.Protocol)
+ assert.Equal(t, "HTTP_301", *a.RedirectConfig.StatusCode)
+}
+
+func TestCreateListener_RedirectFullFields(t *testing.T) {
+ svc := setupTestService(t)
+
+ lbOut, _ := svc.CreateLoadBalancer(&elbv2.CreateLoadBalancerInput{Name: aws.String("redirfull-lb")}, testAccountID)
+
+ out, err := svc.CreateListener(&elbv2.CreateListenerInput{
+ LoadBalancerArn: lbOut.LoadBalancers[0].LoadBalancerArn,
+ Protocol: aws.String("HTTP"),
+ Port: aws.Int64(80),
+ DefaultActions: []*elbv2.Action{{
+ Type: aws.String("redirect"),
+ RedirectConfig: &elbv2.RedirectActionConfig{
+ Protocol: aws.String("HTTPS"),
+ Host: aws.String("new.example.com"),
+ Port: aws.String("8443"),
+ Path: aws.String("/moved"),
+ Query: aws.String("ref=1"),
+ StatusCode: aws.String("HTTP_302"),
+ },
+ }},
+ }, testAccountID)
+ require.NoError(t, err)
+
+ rc := out.Listeners[0].DefaultActions[0].RedirectConfig
+ require.NotNil(t, rc)
+ assert.Equal(t, "new.example.com", *rc.Host)
+ assert.Equal(t, "8443", *rc.Port)
+ assert.Equal(t, "/moved", *rc.Path)
+ assert.Equal(t, "ref=1", *rc.Query)
+ assert.Equal(t, "HTTP_302", *rc.StatusCode)
+
+ // Read back through Describe to exercise the stored→SDK path.
+ desc, err := svc.DescribeListeners(&elbv2.DescribeListenersInput{
+ LoadBalancerArn: lbOut.LoadBalancers[0].LoadBalancerArn,
+ }, testAccountID)
+ require.NoError(t, err)
+ require.Len(t, desc.Listeners, 1)
+ assert.Equal(t, "new.example.com", *desc.Listeners[0].DefaultActions[0].RedirectConfig.Host)
+}
+
+func TestModifyListener_ToRedirect(t *testing.T) {
+ svc := setupTestService(t)
+
+ lbOut, _ := svc.CreateLoadBalancer(&elbv2.CreateLoadBalancerInput{Name: aws.String("mod-redir-lb")}, testAccountID)
+ tgOut, _ := svc.CreateTargetGroup(&elbv2.CreateTargetGroupInput{Name: aws.String("mod-redir-tg")}, testAccountID)
+
+ lstOut, err := svc.CreateListener(&elbv2.CreateListenerInput{
+ LoadBalancerArn: lbOut.LoadBalancers[0].LoadBalancerArn,
+ Protocol: aws.String("HTTP"),
+ Port: aws.Int64(80),
+ DefaultActions: []*elbv2.Action{{Type: aws.String("forward"), TargetGroupArn: tgOut.TargetGroups[0].TargetGroupArn}},
+ }, testAccountID)
+ require.NoError(t, err)
+
+ out, err := svc.ModifyListener(&elbv2.ModifyListenerInput{
+ ListenerArn: lstOut.Listeners[0].ListenerArn,
+ DefaultActions: []*elbv2.Action{{
+ Type: aws.String("redirect"),
+ RedirectConfig: &elbv2.RedirectActionConfig{Protocol: aws.String("HTTPS"), StatusCode: aws.String("HTTP_301")},
+ }},
+ }, testAccountID)
+ require.NoError(t, err)
+ assert.Equal(t, "redirect", *out.Listeners[0].DefaultActions[0].Type)
+
+ // A bad redirect on modify is rejected.
+ _, err = svc.ModifyListener(&elbv2.ModifyListenerInput{
+ ListenerArn: lstOut.Listeners[0].ListenerArn,
+ DefaultActions: []*elbv2.Action{{
+ Type: aws.String("redirect"),
+ RedirectConfig: &elbv2.RedirectActionConfig{StatusCode: aws.String("HTTP_999")},
+ }},
+ }, testAccountID)
+ require.Error(t, err)
+}
+
+func TestCreateListener_RejectsBadRedirect(t *testing.T) {
+ svc := setupTestService(t)
+
+ lbOut, _ := svc.CreateLoadBalancer(&elbv2.CreateLoadBalancerInput{Name: aws.String("badredir-lb")}, testAccountID)
+
+ _, err := svc.CreateListener(&elbv2.CreateListenerInput{
+ LoadBalancerArn: lbOut.LoadBalancers[0].LoadBalancerArn,
+ Protocol: aws.String("HTTP"),
+ Port: aws.Int64(80),
+ DefaultActions: []*elbv2.Action{{
+ Type: aws.String("redirect"),
+ RedirectConfig: &elbv2.RedirectActionConfig{StatusCode: aws.String("HTTP_500")},
+ }},
+ }, testAccountID)
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), "InvalidParameterValue")
+}
+
func TestCreateListener_DuplicatePort(t *testing.T) {
svc := setupTestService(t)
@@ -924,14 +1143,15 @@ func TestDeleteListener(t *testing.T) {
assert.Empty(t, desc.Listeners)
}
-func TestDeleteListener_NotFound(t *testing.T) {
+func TestDeleteListener_IdempotentOnAbsent(t *testing.T) {
svc := setupTestService(t)
- _, err := svc.DeleteListener(&elbv2.DeleteListenerInput{
+ out, err := svc.DeleteListener(&elbv2.DeleteListenerInput{
ListenerArn: aws.String("arn:nonexistent"),
}, testAccountID)
- assert.Error(t, err)
- assert.Contains(t, err.Error(), "ListenerNotFound")
+ // AWS ELBv2 delete is idempotent: absent listener -> success, not NotFound.
+ require.NoError(t, err)
+ assert.NotNil(t, out)
}
func TestModifyListener_Port(t *testing.T) {
@@ -980,8 +1200,9 @@ func TestModifyListener_Protocol(t *testing.T) {
require.NoError(t, err)
out, err := svc.ModifyListener(&elbv2.ModifyListenerInput{
- ListenerArn: lstOut.Listeners[0].ListenerArn,
- Protocol: aws.String("HTTPS"),
+ ListenerArn: lstOut.Listeners[0].ListenerArn,
+ Protocol: aws.String("HTTPS"),
+ Certificates: []*elbv2.Certificate{{CertificateArn: aws.String(testCertArn)}},
}, testAccountID)
require.NoError(t, err)
assert.Equal(t, "HTTPS", *out.Listeners[0].Protocol)
@@ -1204,10 +1425,14 @@ func TestModifyListener_NLB_AcceptsAllProtocols(t *testing.T) {
}, testAccountID)
require.NoError(t, err)
- out, err := svc.ModifyListener(&elbv2.ModifyListenerInput{
+ modIn := &elbv2.ModifyListenerInput{
ListenerArn: lstOut.Listeners[0].ListenerArn,
Protocol: aws.String(tc.listenerProto),
- }, testAccountID)
+ }
+ if protocolRequiresCert(tc.listenerProto) {
+ modIn.Certificates = []*elbv2.Certificate{{CertificateArn: aws.String(testCertArn)}}
+ }
+ out, err := svc.ModifyListener(modIn, testAccountID)
require.NoError(t, err)
assert.Equal(t, tc.listenerProto, *out.Listeners[0].Protocol)
})
@@ -1389,14 +1614,18 @@ func TestCreateListener_NLB_AllProtocols(t *testing.T) {
Port: aws.Int64(8080),
}, testAccountID)
- out, err := svc.CreateListener(&elbv2.CreateListenerInput{
+ createIn := &elbv2.CreateListenerInput{
LoadBalancerArn: lbOut.LoadBalancers[0].LoadBalancerArn,
Protocol: aws.String(proto),
Port: aws.Int64(8080),
DefaultActions: []*elbv2.Action{
{Type: aws.String("forward"), TargetGroupArn: tgOut.TargetGroups[0].TargetGroupArn},
},
- }, testAccountID)
+ }
+ if protocolRequiresCert(proto) {
+ createIn.Certificates = []*elbv2.Certificate{{CertificateArn: aws.String(testCertArn)}}
+ }
+ out, err := svc.CreateListener(createIn, testAccountID)
require.NoError(t, err)
assert.Equal(t, proto, *out.Listeners[0].Protocol)
@@ -1492,6 +1721,7 @@ func TestCreateListener_NLB_ProtocolCompatibility_TLSToTCP(t *testing.T) {
LoadBalancerArn: lbOut.LoadBalancers[0].LoadBalancerArn,
Protocol: aws.String("TLS"),
Port: aws.Int64(443),
+ Certificates: []*elbv2.Certificate{{CertificateArn: aws.String(testCertArn)}},
DefaultActions: []*elbv2.Action{
{Type: aws.String("forward"), TargetGroupArn: tgOut.TargetGroups[0].TargetGroupArn},
},
@@ -1819,6 +2049,91 @@ func TestGetLBConfig_ReturnsStoredConfig(t *testing.T) {
assert.Equal(t, "deadbeef", *out.ConfigHash)
}
+func TestGetLBConfig_DeliversHealthTargetsForNLB(t *testing.T) {
+ svc := setupTestService(t)
+
+ lb := &LoadBalancerRecord{
+ LoadBalancerArn: "arn:aws:elasticloadbalancing:us-east-1:123456789012:loadbalancer/net/nlb-hc/lb-nlbhc",
+ LoadBalancerID: "lb-nlbhc",
+ Name: "nlb-hc",
+ Type: LoadBalancerTypeNetwork,
+ State: StateActive,
+ ConfigText: "stream {}\n",
+ ConfigHash: "hc1",
+ AccountID: testAccountID,
+ HealthTargets: []HealthTargetSpec{
+ {ServerName: "srv_i-1", Address: "10.0.0.1:80", Protocol: ProtocolTCP},
+ },
+ }
+ require.NoError(t, svc.store.PutLoadBalancer(lb))
+
+ out, err := svc.GetLBConfig(&GetLBConfigInput{LBID: aws.String("lb-nlbhc")}, testAccountID)
+ require.NoError(t, err)
+ assert.Equal(t, EngineNginx, *out.Engine)
+ require.Len(t, out.HealthTargets, 1)
+ assert.Equal(t, "srv_i-1", *out.HealthTargets[0].ServerName)
+ assert.Equal(t, "10.0.0.1:80", *out.HealthTargets[0].Address)
+ assert.Equal(t, ProtocolTCP, *out.HealthTargets[0].Protocol)
+}
+
+func TestGetLBConfig_HealthTargetsWireShape(t *testing.T) {
+ svc := setupTestService(t)
+ require.NoError(t, svc.store.PutLoadBalancer(&LoadBalancerRecord{
+ LoadBalancerID: "lb-wire",
+ Type: LoadBalancerTypeNetwork,
+ AccountID: testAccountID,
+ ConfigText: "stream {}\n",
+ ConfigHash: "w1",
+ HealthTargets: []HealthTargetSpec{
+ {ServerName: "srv_i-1", Address: "10.0.0.1:80", Protocol: ProtocolHTTP, Path: "/healthz"},
+ },
+ }))
+
+ out, err := svc.GetLBConfig(&GetLBConfigInput{LBID: aws.String("lb-wire")}, testAccountID)
+ require.NoError(t, err)
+
+ // Confirm the marshalled member shape matches what the lb-agent parses
+ // (GetLBConfigResult>HealthTargets>member>{ServerName,Address,Protocol,Path}).
+ payload := utils.GenerateIAMXMLPayload("GetLBConfig", *out)
+ xmlBytes, err := utils.MarshalToXML(payload)
+ require.NoError(t, err)
+ var parsed struct {
+ Members []struct {
+ ServerName string `xml:"ServerName"`
+ Address string `xml:"Address"`
+ Protocol string `xml:"Protocol"`
+ Path string `xml:"Path"`
+ } `xml:"GetLBConfigResult>HealthTargets>member"`
+ }
+ require.NoError(t, xml.Unmarshal(xmlBytes, &parsed))
+ require.Len(t, parsed.Members, 1)
+ assert.Equal(t, "srv_i-1", parsed.Members[0].ServerName)
+ assert.Equal(t, "10.0.0.1:80", parsed.Members[0].Address)
+ assert.Equal(t, ProtocolHTTP, parsed.Members[0].Protocol)
+ assert.Equal(t, "/healthz", parsed.Members[0].Path)
+}
+
+func TestGetLBConfig_NoHealthTargetsForALB(t *testing.T) {
+ svc := setupTestService(t)
+
+ lb := &LoadBalancerRecord{
+ LoadBalancerArn: "arn:aws:elasticloadbalancing:us-east-1:123456789012:loadbalancer/app/alb-hc/lb-albhc",
+ LoadBalancerID: "lb-albhc",
+ Name: "alb-hc",
+ Type: LoadBalancerTypeApplication,
+ State: StateActive,
+ ConfigText: "global\n",
+ ConfigHash: "hc2",
+ AccountID: testAccountID,
+ }
+ require.NoError(t, svc.store.PutLoadBalancer(lb))
+
+ out, err := svc.GetLBConfig(&GetLBConfigInput{LBID: aws.String("lb-albhc")}, testAccountID)
+ require.NoError(t, err)
+ assert.Equal(t, EngineHAProxy, *out.Engine)
+ assert.Empty(t, out.HealthTargets) // ALBs report health via HAProxy stats
+}
+
func TestGetLBConfig_MissingLBID(t *testing.T) {
svc := setupTestService(t)
@@ -2181,7 +2496,11 @@ func TestDescribeLoadBalancerAttributes_ALBDefaults(t *testing.T) {
for _, a := range out.Attributes {
attrMap[*a.Key] = *a.Value
}
- // ALB default cross-zone is true — comes from the per-type default, not seeding.
+ // Every default key/value must round-trip through Describe, not just
+ // cross-zone. ALB default cross-zone is true — from the per-type default.
+ for k, v := range defaults {
+ assert.Equal(t, v, attrMap[k], "default mismatch for key %s", k)
+ }
assert.Equal(t, "true", attrMap["load_balancing.cross_zone.enabled"])
}
@@ -2273,8 +2592,8 @@ func TestDefaultLoadBalancerAttributes_NLBCoversExpectedKeys(t *testing.T) {
}
// TestModifyLoadBalancerAttributes_AcceptsConnectionLogsKey is a regression
-// guard for mulga-931: terraform sends connection_logs.s3.enabled on every
-// aws_lb apply and the handler must accept it.
+// guard: terraform sends connection_logs.s3.enabled on every aws_lb apply and
+// the handler must accept it.
func TestModifyLoadBalancerAttributes_AcceptsConnectionLogsKey(t *testing.T) {
svc := setupTestService(t)
lbOut, err := svc.CreateLoadBalancer(&elbv2.CreateLoadBalancerInput{
@@ -2295,70 +2614,33 @@ func TestModifyLoadBalancerAttributes_AcceptsConnectionLogsKey(t *testing.T) {
require.NoError(t, err, "terraform-sent attribute keys must be accepted")
}
-func TestModifyDescribeTargetGroupAttributes_RoundTrip(t *testing.T) {
+// TestModifyLoadBalancerAttributes_EmptyStringClears verifies that a non-nil
+// empty-string Value is treated as a real value, not skipped as invalid. AWS
+// uses "" to clear access_logs.s3.bucket, so the handler must persist it.
+func TestModifyLoadBalancerAttributes_EmptyStringClears(t *testing.T) {
svc := setupTestService(t)
- tgOut, err := svc.CreateTargetGroup(&elbv2.CreateTargetGroupInput{Name: aws.String("tg-attr-rt")}, testAccountID)
+ lbOut, err := svc.CreateLoadBalancer(&elbv2.CreateLoadBalancerInput{Name: aws.String("alb-clear")}, testAccountID)
require.NoError(t, err)
- arn := tgOut.TargetGroups[0].TargetGroupArn
+ arn := lbOut.LoadBalancers[0].LoadBalancerArn
- modOut, err := svc.ModifyTargetGroupAttributes(&elbv2.ModifyTargetGroupAttributesInput{
- TargetGroupArn: arn,
- Attributes: []*elbv2.TargetGroupAttribute{
- {Key: aws.String("deregistration_delay.timeout_seconds"), Value: aws.String("60")},
- {Key: aws.String("stickiness.enabled"), Value: aws.String("true")},
+ // Set a bucket, then clear it with an empty string.
+ _, err = svc.ModifyLoadBalancerAttributes(&elbv2.ModifyLoadBalancerAttributesInput{
+ LoadBalancerArn: arn,
+ Attributes: []*elbv2.LoadBalancerAttribute{
+ {Key: aws.String("access_logs.s3.bucket"), Value: aws.String("my-logs")},
},
}, testAccountID)
require.NoError(t, err)
- // Assert the exact echoed key/value pairs, not just the length — a regression
- // that pointed at the wrong source or dropped Value would pass a length check.
- require.Len(t, modOut.Attributes, 2)
- modMap := make(map[string]string, len(modOut.Attributes))
- for _, a := range modOut.Attributes {
- require.NotNil(t, a.Key)
- require.NotNil(t, a.Value)
- modMap[*a.Key] = *a.Value
- }
- assert.Equal(t, "60", modMap["deregistration_delay.timeout_seconds"])
- assert.Equal(t, "true", modMap["stickiness.enabled"])
-
- descOut, err := svc.DescribeTargetGroupAttributes(&elbv2.DescribeTargetGroupAttributesInput{
- TargetGroupArn: arn,
- }, testAccountID)
- require.NoError(t, err)
- attrMap := make(map[string]string)
- for _, a := range descOut.Attributes {
- attrMap[*a.Key] = *a.Value
- }
- assert.Equal(t, "60", attrMap["deregistration_delay.timeout_seconds"])
- assert.Equal(t, "true", attrMap["stickiness.enabled"])
- // Unmodified defaults should still be present
- assert.Equal(t, "lb_cookie", attrMap["stickiness.type"])
-}
-
-func TestModifyDescribeLoadBalancerAttributes_RoundTrip(t *testing.T) {
- svc := setupTestService(t)
- lbOut, err := svc.CreateLoadBalancer(&elbv2.CreateLoadBalancerInput{Name: aws.String("lb-attr-rt")}, testAccountID)
- require.NoError(t, err)
- arn := lbOut.LoadBalancers[0].LoadBalancerArn
modOut, err := svc.ModifyLoadBalancerAttributes(&elbv2.ModifyLoadBalancerAttributesInput{
LoadBalancerArn: arn,
Attributes: []*elbv2.LoadBalancerAttribute{
- {Key: aws.String("idle_timeout.timeout_seconds"), Value: aws.String("120")},
- {Key: aws.String("deletion_protection.enabled"), Value: aws.String("true")},
+ {Key: aws.String("access_logs.s3.bucket"), Value: aws.String("")},
},
}, testAccountID)
require.NoError(t, err)
- // Assert the exact echoed key/value pairs, not just the length.
- require.Len(t, modOut.Attributes, 2)
- modMap := make(map[string]string, len(modOut.Attributes))
- for _, a := range modOut.Attributes {
- require.NotNil(t, a.Key)
- require.NotNil(t, a.Value)
- modMap[*a.Key] = *a.Value
- }
- assert.Equal(t, "120", modMap["idle_timeout.timeout_seconds"])
- assert.Equal(t, "true", modMap["deletion_protection.enabled"])
+ require.Len(t, modOut.Attributes, 1, "empty-string Value must be echoed, not skipped")
+ assert.Equal(t, "", *modOut.Attributes[0].Value)
descOut, err := svc.DescribeLoadBalancerAttributes(&elbv2.DescribeLoadBalancerAttributesInput{
LoadBalancerArn: arn,
@@ -2368,560 +2650,455 @@ func TestModifyDescribeLoadBalancerAttributes_RoundTrip(t *testing.T) {
for _, a := range descOut.Attributes {
attrMap[*a.Key] = *a.Value
}
- assert.Equal(t, "120", attrMap["idle_timeout.timeout_seconds"])
- assert.Equal(t, "true", attrMap["deletion_protection.enabled"])
- // Unmodified defaults should still be present
- assert.Equal(t, "true", attrMap["routing.http2.enabled"])
+ assert.Equal(t, "", attrMap["access_logs.s3.bucket"], "empty-string clear must persist")
}
-func TestModifyTargetGroupAttributes_NotFound(t *testing.T) {
- svc := setupTestService(t)
- _, err := svc.ModifyTargetGroupAttributes(&elbv2.ModifyTargetGroupAttributesInput{
- TargetGroupArn: aws.String("arn:aws:elasticloadbalancing:us-east-1:123456789012:targetgroup/missing/tg-missing"),
- Attributes: []*elbv2.TargetGroupAttribute{
- {Key: aws.String("stickiness.enabled"), Value: aws.String("true")},
- },
- }, testAccountID)
- assert.EqualError(t, err, awserrors.ErrorELBv2TargetGroupNotFound)
+// --- Attribute mirror-pair tests (table-driven over TG/LB) ---
+//
+// ModifyTargetGroupAttributes/ModifyLoadBalancerAttributes (and their Describe
+// counterparts) are mirror pairs differing only by record type, store methods,
+// default set and not-found error. The behavioural tests below run once per
+// kind via t.Run instead of being hand-copied per resource.
+
+// rawAttr models one submitted SDK attribute, including the invalid shapes a
+// handler must skip: a nil slice element, a nil Key, or a nil Value.
+type rawAttr struct {
+ nilElem bool
+ key *string
+ val *string
}
-func TestDescribeTargetGroupAttributes_NotFound(t *testing.T) {
- svc := setupTestService(t)
- _, err := svc.DescribeTargetGroupAttributes(&elbv2.DescribeTargetGroupAttributesInput{
- TargetGroupArn: aws.String("arn:aws:elasticloadbalancing:us-east-1:123456789012:targetgroup/missing/tg-missing"),
- }, testAccountID)
- assert.EqualError(t, err, awserrors.ErrorELBv2TargetGroupNotFound)
-}
+func kvAttr(k, v string) rawAttr { return rawAttr{key: aws.String(k), val: aws.String(v)} }
-func TestModifyLoadBalancerAttributes_NotFound(t *testing.T) {
- svc := setupTestService(t)
- _, err := svc.ModifyLoadBalancerAttributes(&elbv2.ModifyLoadBalancerAttributesInput{
- LoadBalancerArn: aws.String("arn:aws:elasticloadbalancing:us-east-1:123456789012:loadbalancer/app/missing/lb-missing"),
- Attributes: []*elbv2.LoadBalancerAttribute{
- {Key: aws.String("idle_timeout.timeout_seconds"), Value: aws.String("30")},
- },
- }, testAccountID)
- assert.EqualError(t, err, awserrors.ErrorELBv2LoadBalancerNotFound)
-}
-
-func TestDescribeLoadBalancerAttributes_NotFound(t *testing.T) {
- svc := setupTestService(t)
- _, err := svc.DescribeLoadBalancerAttributes(&elbv2.DescribeLoadBalancerAttributesInput{
- LoadBalancerArn: aws.String("arn:aws:elasticloadbalancing:us-east-1:123456789012:loadbalancer/app/missing/lb-missing"),
- }, testAccountID)
- assert.EqualError(t, err, awserrors.ErrorELBv2LoadBalancerNotFound)
-}
-
-func TestModifyTargetGroupAttributes_MissingArn(t *testing.T) {
- svc := setupTestService(t)
- _, err := svc.ModifyTargetGroupAttributes(&elbv2.ModifyTargetGroupAttributesInput{
- Attributes: []*elbv2.TargetGroupAttribute{
- {Key: aws.String("stickiness.enabled"), Value: aws.String("true")},
- },
- }, testAccountID)
- assert.EqualError(t, err, awserrors.ErrorMissingParameter)
-}
-
-func TestDescribeTargetGroupAttributes_MissingArn(t *testing.T) {
- svc := setupTestService(t)
- _, err := svc.DescribeTargetGroupAttributes(&elbv2.DescribeTargetGroupAttributesInput{}, testAccountID)
- assert.EqualError(t, err, awserrors.ErrorMissingParameter)
-}
-
-func TestModifyLoadBalancerAttributes_MissingArn(t *testing.T) {
- svc := setupTestService(t)
- _, err := svc.ModifyLoadBalancerAttributes(&elbv2.ModifyLoadBalancerAttributesInput{
- Attributes: []*elbv2.LoadBalancerAttribute{
- {Key: aws.String("idle_timeout.timeout_seconds"), Value: aws.String("30")},
- },
- }, testAccountID)
- assert.EqualError(t, err, awserrors.ErrorMissingParameter)
-}
-
-func TestDescribeLoadBalancerAttributes_MissingArn(t *testing.T) {
- svc := setupTestService(t)
- _, err := svc.DescribeLoadBalancerAttributes(&elbv2.DescribeLoadBalancerAttributesInput{}, testAccountID)
- assert.EqualError(t, err, awserrors.ErrorMissingParameter)
-}
-
-func TestModifyTargetGroupAttributes_WrongAccount(t *testing.T) {
- svc := setupTestService(t)
- tgOut, err := svc.CreateTargetGroup(&elbv2.CreateTargetGroupInput{Name: aws.String("tg-attr-wrong-acct")}, testAccountID)
- require.NoError(t, err)
-
- _, err = svc.ModifyTargetGroupAttributes(&elbv2.ModifyTargetGroupAttributesInput{
- TargetGroupArn: tgOut.TargetGroups[0].TargetGroupArn,
- Attributes: []*elbv2.TargetGroupAttribute{
- {Key: aws.String("stickiness.enabled"), Value: aws.String("true")},
- },
- }, "999999999999")
- assert.EqualError(t, err, awserrors.ErrorELBv2TargetGroupNotFound)
-}
-
-func TestDescribeTargetGroupAttributes_WrongAccount(t *testing.T) {
- svc := setupTestService(t)
- tgOut, err := svc.CreateTargetGroup(&elbv2.CreateTargetGroupInput{Name: aws.String("tg-desc-attr-wrong-acct")}, testAccountID)
- require.NoError(t, err)
-
- _, err = svc.DescribeTargetGroupAttributes(&elbv2.DescribeTargetGroupAttributesInput{
- TargetGroupArn: tgOut.TargetGroups[0].TargetGroupArn,
- }, "999999999999")
- assert.EqualError(t, err, awserrors.ErrorELBv2TargetGroupNotFound)
-}
-
-func TestModifyLoadBalancerAttributes_WrongAccount(t *testing.T) {
- svc := setupTestService(t)
- lbOut, err := svc.CreateLoadBalancer(&elbv2.CreateLoadBalancerInput{Name: aws.String("lb-attr-wrong-acct")}, testAccountID)
- require.NoError(t, err)
-
- _, err = svc.ModifyLoadBalancerAttributes(&elbv2.ModifyLoadBalancerAttributesInput{
- LoadBalancerArn: lbOut.LoadBalancers[0].LoadBalancerArn,
- Attributes: []*elbv2.LoadBalancerAttribute{
- {Key: aws.String("idle_timeout.timeout_seconds"), Value: aws.String("30")},
- },
- }, "999999999999")
- assert.EqualError(t, err, awserrors.ErrorELBv2LoadBalancerNotFound)
-}
-
-func TestDescribeLoadBalancerAttributes_WrongAccount(t *testing.T) {
- svc := setupTestService(t)
- lbOut, err := svc.CreateLoadBalancer(&elbv2.CreateLoadBalancerInput{Name: aws.String("lb-desc-attr-wrong-acct")}, testAccountID)
- require.NoError(t, err)
-
- _, err = svc.DescribeLoadBalancerAttributes(&elbv2.DescribeLoadBalancerAttributesInput{
- LoadBalancerArn: lbOut.LoadBalancers[0].LoadBalancerArn,
- }, "999999999999")
- assert.EqualError(t, err, awserrors.ErrorELBv2LoadBalancerNotFound)
-}
-
-// TestModifyTargetGroupAttributes_SkipsInvalidEntries verifies that nil slice
-// elements, nil Keys, and nil Values are skipped (with a warning) rather than
-// panicking or being silently dropped. Valid attributes in the same call must
-// still be applied.
-func TestModifyTargetGroupAttributes_SkipsInvalidEntries(t *testing.T) {
- svc := setupTestService(t)
- tgOut, err := svc.CreateTargetGroup(&elbv2.CreateTargetGroupInput{Name: aws.String("tg-attr-skip")}, testAccountID)
- require.NoError(t, err)
- arn := tgOut.TargetGroups[0].TargetGroupArn
-
- modOut, err := svc.ModifyTargetGroupAttributes(&elbv2.ModifyTargetGroupAttributesInput{
- TargetGroupArn: arn,
- Attributes: []*elbv2.TargetGroupAttribute{
- nil, // nil element must not panic
- {Key: nil, Value: aws.String("v")},
- {Key: aws.String("k"), Value: nil},
- {Key: aws.String("stickiness.enabled"), Value: aws.String("true")},
- },
- }, testAccountID)
- require.NoError(t, err)
- // Only the one valid attribute should be returned.
- require.Len(t, modOut.Attributes, 1)
- assert.Equal(t, "stickiness.enabled", *modOut.Attributes[0].Key)
- assert.Equal(t, "true", *modOut.Attributes[0].Value)
-
- // The valid attribute should have been persisted.
- descOut, err := svc.DescribeTargetGroupAttributes(&elbv2.DescribeTargetGroupAttributesInput{
- TargetGroupArn: arn,
- }, testAccountID)
- require.NoError(t, err)
- attrMap := make(map[string]string)
- for _, a := range descOut.Attributes {
- attrMap[*a.Key] = *a.Value
+func kvAttrs(kv ...[2]string) []rawAttr {
+ out := make([]rawAttr, len(kv))
+ for i, p := range kv {
+ out[i] = kvAttr(p[0], p[1])
}
- assert.Equal(t, "true", attrMap["stickiness.enabled"])
+ return out
}
-// TestModifyLoadBalancerAttributes_SkipsInvalidEntries mirrors the TG case for
-// the LB handler: nil elements and nil Key/Value fields must be skipped, not
-// panic or swallow valid attributes in the same request.
-func TestModifyLoadBalancerAttributes_SkipsInvalidEntries(t *testing.T) {
- svc := setupTestService(t)
- lbOut, err := svc.CreateLoadBalancer(&elbv2.CreateLoadBalancerInput{Name: aws.String("lb-attr-skip")}, testAccountID)
- require.NoError(t, err)
- arn := lbOut.LoadBalancers[0].LoadBalancerArn
+type echoPair struct{ Key, Val string }
- modOut, err := svc.ModifyLoadBalancerAttributes(&elbv2.ModifyLoadBalancerAttributesInput{
- LoadBalancerArn: arn,
- Attributes: []*elbv2.LoadBalancerAttribute{
- nil, // nil element must not panic
- {Key: nil, Value: aws.String("v")},
- {Key: aws.String("k"), Value: nil},
- {Key: aws.String("idle_timeout.timeout_seconds"), Value: aws.String("75")},
- },
- }, testAccountID)
- require.NoError(t, err)
- require.Len(t, modOut.Attributes, 1)
- assert.Equal(t, "idle_timeout.timeout_seconds", *modOut.Attributes[0].Key)
- assert.Equal(t, "75", *modOut.Attributes[0].Value)
-
- descOut, err := svc.DescribeLoadBalancerAttributes(&elbv2.DescribeLoadBalancerAttributesInput{
- LoadBalancerArn: arn,
- }, testAccountID)
- require.NoError(t, err)
- attrMap := make(map[string]string)
- for _, a := range descOut.Attributes {
- attrMap[*a.Key] = *a.Value
+func echoMap(pairs []echoPair) map[string]string {
+ m := make(map[string]string, len(pairs))
+ for _, p := range pairs {
+ m[p.Key] = p.Val
}
- assert.Equal(t, "75", attrMap["idle_timeout.timeout_seconds"])
+ return m
}
-// TestModifyTargetGroupAttributes_AllInvalidReturnsError guards against the
-// silent-success case where every submitted attribute trips the nil guard and
-// the handler returned 200 OK with an empty response body — the caller would
-// think the write landed when nothing was actually applied.
-func TestModifyTargetGroupAttributes_AllInvalidReturnsError(t *testing.T) {
- svc := setupTestService(t)
- tgOut, err := svc.CreateTargetGroup(&elbv2.CreateTargetGroupInput{Name: aws.String("tg-all-invalid")}, testAccountID)
- require.NoError(t, err)
-
- _, err = svc.ModifyTargetGroupAttributes(&elbv2.ModifyTargetGroupAttributesInput{
- TargetGroupArn: tgOut.TargetGroups[0].TargetGroupArn,
- Attributes: []*elbv2.TargetGroupAttribute{
- nil,
- {Key: nil, Value: aws.String("v")},
- {Key: aws.String("k"), Value: nil},
- },
- }, testAccountID)
- assert.EqualError(t, err, awserrors.ErrorInvalidParameterValue)
-}
-
-// TestModifyLoadBalancerAttributes_AllInvalidReturnsError mirrors the TG case.
-func TestModifyLoadBalancerAttributes_AllInvalidReturnsError(t *testing.T) {
- svc := setupTestService(t)
- lbOut, err := svc.CreateLoadBalancer(&elbv2.CreateLoadBalancerInput{Name: aws.String("lb-all-invalid")}, testAccountID)
- require.NoError(t, err)
-
- _, err = svc.ModifyLoadBalancerAttributes(&elbv2.ModifyLoadBalancerAttributesInput{
- LoadBalancerArn: lbOut.LoadBalancers[0].LoadBalancerArn,
- Attributes: []*elbv2.LoadBalancerAttribute{
- nil,
- {Key: nil, Value: aws.String("v")},
- {Key: aws.String("k"), Value: nil},
- },
- }, testAccountID)
- assert.EqualError(t, err, awserrors.ErrorInvalidParameterValue)
-}
-
-// TestModifyTargetGroupAttributes_SequentialMerge verifies that successive
-// Modify calls accumulate keys instead of replacing the entire attribute map.
-// A future refactor that did `tg.Attributes = newMap` would pass every other
-// test (single-call round-trip covers the happy path) but silently wipe
-// previous attributes on every subsequent Modify — the most likely real-world
-// ALB/TG bug.
-func TestModifyTargetGroupAttributes_SequentialMerge(t *testing.T) {
- svc := setupTestService(t)
- tgOut, err := svc.CreateTargetGroup(&elbv2.CreateTargetGroupInput{Name: aws.String("tg-seq-merge")}, testAccountID)
- require.NoError(t, err)
- arn := tgOut.TargetGroups[0].TargetGroupArn
-
- // Call 1: set deregistration_delay.timeout_seconds.
- _, err = svc.ModifyTargetGroupAttributes(&elbv2.ModifyTargetGroupAttributesInput{
- TargetGroupArn: arn,
- Attributes: []*elbv2.TargetGroupAttribute{
- {Key: aws.String("deregistration_delay.timeout_seconds"), Value: aws.String("45")},
- },
- }, testAccountID)
- require.NoError(t, err)
-
- // Call 2: set a different key; must not wipe the first one.
- _, err = svc.ModifyTargetGroupAttributes(&elbv2.ModifyTargetGroupAttributesInput{
- TargetGroupArn: arn,
- Attributes: []*elbv2.TargetGroupAttribute{
- {Key: aws.String("stickiness.enabled"), Value: aws.String("true")},
- },
- }, testAccountID)
- require.NoError(t, err)
-
- descOut, err := svc.DescribeTargetGroupAttributes(&elbv2.DescribeTargetGroupAttributesInput{
- TargetGroupArn: arn,
- }, testAccountID)
- require.NoError(t, err)
- attrMap := make(map[string]string)
- for _, a := range descOut.Attributes {
- attrMap[*a.Key] = *a.Value
+// attrArnPtr returns nil for an empty ARN so the MissingArn cases exercise the
+// real "no ARN supplied" path rather than an empty-string ARN.
+func attrArnPtr(arn string) *string {
+ if arn == "" {
+ return nil
}
- assert.Equal(t, "45", attrMap["deregistration_delay.timeout_seconds"], "first-call key must survive second Modify")
- assert.Equal(t, "true", attrMap["stickiness.enabled"], "second-call key must be present")
-
- // Call 3: overwrite the same key with a new value.
- _, err = svc.ModifyTargetGroupAttributes(&elbv2.ModifyTargetGroupAttributesInput{
- TargetGroupArn: arn,
- Attributes: []*elbv2.TargetGroupAttribute{
- {Key: aws.String("stickiness.enabled"), Value: aws.String("false")},
- },
- }, testAccountID)
- require.NoError(t, err)
-
- descOut, err = svc.DescribeTargetGroupAttributes(&elbv2.DescribeTargetGroupAttributesInput{
- TargetGroupArn: arn,
- }, testAccountID)
- require.NoError(t, err)
- attrMap = make(map[string]string)
- for _, a := range descOut.Attributes {
- attrMap[*a.Key] = *a.Value
- }
- assert.Equal(t, "false", attrMap["stickiness.enabled"], "same-key overwrite must replace the value")
- assert.Equal(t, "45", attrMap["deregistration_delay.timeout_seconds"], "unrelated key must still survive")
+ return aws.String(arn)
}
-// TestModifyLoadBalancerAttributes_SequentialMerge mirrors the TG case.
-func TestModifyLoadBalancerAttributes_SequentialMerge(t *testing.T) {
- svc := setupTestService(t)
- lbOut, err := svc.CreateLoadBalancer(&elbv2.CreateLoadBalancerInput{Name: aws.String("lb-seq-merge")}, testAccountID)
- require.NoError(t, err)
- arn := lbOut.LoadBalancers[0].LoadBalancerArn
+// attrKind bundles everything the table-driven attribute tests need to drive
+// one resource type (target group or load balancer).
+type attrKind struct {
+ name string
+ notFound string
+ missingArn string
- _, err = svc.ModifyLoadBalancerAttributes(&elbv2.ModifyLoadBalancerAttributesInput{
- LoadBalancerArn: arn,
- Attributes: []*elbv2.LoadBalancerAttribute{
- {Key: aws.String("idle_timeout.timeout_seconds"), Value: aws.String("120")},
- },
- }, testAccountID)
- require.NoError(t, err)
+ keyA, valA, valA2 string // primary key, value, and an overwrite value
+ keyB, valB string // secondary key/value
+ defaultKey string // a default present after create
+ defaultVal string
+ unknownKey string // a key the handler must reject
- _, err = svc.ModifyLoadBalancerAttributes(&elbv2.ModifyLoadBalancerAttributesInput{
- LoadBalancerArn: arn,
- Attributes: []*elbv2.LoadBalancerAttribute{
- {Key: aws.String("deletion_protection.enabled"), Value: aws.String("true")},
- },
- }, testAccountID)
- require.NoError(t, err)
-
- descOut, err := svc.DescribeLoadBalancerAttributes(&elbv2.DescribeLoadBalancerAttributesInput{
- LoadBalancerArn: arn,
- }, testAccountID)
- require.NoError(t, err)
- attrMap := make(map[string]string)
- for _, a := range descOut.Attributes {
- attrMap[*a.Key] = *a.Value
- }
- assert.Equal(t, "120", attrMap["idle_timeout.timeout_seconds"], "first-call key must survive second Modify")
- assert.Equal(t, "true", attrMap["deletion_protection.enabled"], "second-call key must be present")
-
- // Same-key overwrite.
- _, err = svc.ModifyLoadBalancerAttributes(&elbv2.ModifyLoadBalancerAttributesInput{
- LoadBalancerArn: arn,
- Attributes: []*elbv2.LoadBalancerAttribute{
- {Key: aws.String("idle_timeout.timeout_seconds"), Value: aws.String("90")},
- },
- }, testAccountID)
- require.NoError(t, err)
-
- descOut, err = svc.DescribeLoadBalancerAttributes(&elbv2.DescribeLoadBalancerAttributesInput{
- LoadBalancerArn: arn,
- }, testAccountID)
- require.NoError(t, err)
- attrMap = make(map[string]string)
- for _, a := range descOut.Attributes {
- attrMap[*a.Key] = *a.Value
- }
- assert.Equal(t, "90", attrMap["idle_timeout.timeout_seconds"], "same-key overwrite must replace the value")
- assert.Equal(t, "true", attrMap["deletion_protection.enabled"], "unrelated key must still survive")
+ create func(t *testing.T, svc *ELBv2ServiceImpl, name string) string
+ modify func(svc *ELBv2ServiceImpl, arn, accountID string, attrs []rawAttr) ([]echoPair, error)
+ describe func(svc *ELBv2ServiceImpl, arn, accountID string) ([]echoPair, error)
+ revision func(t *testing.T, svc *ELBv2ServiceImpl, arn string) uint64
}
-// TestModifyTargetGroupAttributes_NoopSkipsPersist verifies that a
-// re-submission of the same attribute values does not bump the underlying KV
-// revision — Terraform's drift check hits Modify on every apply, so the
-// steady-state path must be a no-op at the storage layer.
-func TestModifyTargetGroupAttributes_NoopSkipsPersist(t *testing.T) {
- svc := setupTestService(t)
- tgOut, err := svc.CreateTargetGroup(&elbv2.CreateTargetGroupInput{Name: aws.String("tg-noop")}, testAccountID)
- require.NoError(t, err)
- tgArn := tgOut.TargetGroups[0].TargetGroupArn
-
- // First modify — must hit the store.
- _, err = svc.ModifyTargetGroupAttributes(&elbv2.ModifyTargetGroupAttributesInput{
- TargetGroupArn: tgArn,
- Attributes: []*elbv2.TargetGroupAttribute{
- {Key: aws.String("stickiness.enabled"), Value: aws.String("true")},
+func attrKinds() []attrKind {
+ return []attrKind{
+ {
+ name: "TargetGroup",
+ notFound: awserrors.ErrorELBv2TargetGroupNotFound,
+ missingArn: "arn:aws:elasticloadbalancing:us-east-1:123456789012:targetgroup/missing/tg-missing",
+ keyA: "deregistration_delay.timeout_seconds", valA: "45", valA2: "30",
+ keyB: "stickiness.enabled", valB: "true",
+ defaultKey: "stickiness.type", defaultVal: "lb_cookie",
+ unknownKey: "stickness.enabled", // typo of "stickiness"
+ create: func(t *testing.T, svc *ELBv2ServiceImpl, name string) string {
+ out, err := svc.CreateTargetGroup(&elbv2.CreateTargetGroupInput{Name: aws.String(name)}, testAccountID)
+ require.NoError(t, err)
+ return *out.TargetGroups[0].TargetGroupArn
+ },
+ modify: func(svc *ELBv2ServiceImpl, arn, accountID string, attrs []rawAttr) ([]echoPair, error) {
+ sdk := make([]*elbv2.TargetGroupAttribute, len(attrs))
+ for i, a := range attrs {
+ if a.nilElem {
+ continue
+ }
+ sdk[i] = &elbv2.TargetGroupAttribute{Key: a.key, Value: a.val}
+ }
+ out, err := svc.ModifyTargetGroupAttributes(&elbv2.ModifyTargetGroupAttributesInput{
+ TargetGroupArn: attrArnPtr(arn), Attributes: sdk,
+ }, accountID)
+ if err != nil {
+ return nil, err
+ }
+ pairs := make([]echoPair, len(out.Attributes))
+ for i, a := range out.Attributes {
+ pairs[i] = echoPair{*a.Key, *a.Value}
+ }
+ return pairs, nil
+ },
+ describe: func(svc *ELBv2ServiceImpl, arn, accountID string) ([]echoPair, error) {
+ out, err := svc.DescribeTargetGroupAttributes(&elbv2.DescribeTargetGroupAttributesInput{
+ TargetGroupArn: attrArnPtr(arn),
+ }, accountID)
+ if err != nil {
+ return nil, err
+ }
+ pairs := make([]echoPair, len(out.Attributes))
+ for i, a := range out.Attributes {
+ pairs[i] = echoPair{*a.Key, *a.Value}
+ }
+ return pairs, nil
+ },
+ revision: func(t *testing.T, svc *ELBv2ServiceImpl, arn string) uint64 {
+ tg, err := svc.store.GetTargetGroupByArn(arn)
+ require.NoError(t, err)
+ entry, err := svc.store.kv.Get(KeyPrefixTG + tg.TargetGroupID)
+ require.NoError(t, err)
+ return entry.Revision()
+ },
},
- }, testAccountID)
- require.NoError(t, err)
-
- // Capture KV revision after the first write.
- tg, err := svc.store.GetTargetGroupByArn(*tgArn)
- require.NoError(t, err)
- entry, err := svc.store.kv.Get(KeyPrefixTG + tg.TargetGroupID)
- require.NoError(t, err)
- revBefore := entry.Revision()
-
- // Second modify with identical values — must skip the Put.
- modOut, err := svc.ModifyTargetGroupAttributes(&elbv2.ModifyTargetGroupAttributesInput{
- TargetGroupArn: tgArn,
- Attributes: []*elbv2.TargetGroupAttribute{
- {Key: aws.String("stickiness.enabled"), Value: aws.String("true")},
+ {
+ name: "LoadBalancer",
+ notFound: awserrors.ErrorELBv2LoadBalancerNotFound,
+ missingArn: "arn:aws:elasticloadbalancing:us-east-1:123456789012:loadbalancer/app/missing/lb-missing",
+ keyA: "idle_timeout.timeout_seconds", valA: "120", valA2: "90",
+ keyB: "deletion_protection.enabled", valB: "true",
+ defaultKey: "routing.http2.enabled", defaultVal: "true",
+ unknownKey: "stickiness.enabled", // valid TG key — cross-product mistake
+ create: func(t *testing.T, svc *ELBv2ServiceImpl, name string) string {
+ out, err := svc.CreateLoadBalancer(&elbv2.CreateLoadBalancerInput{Name: aws.String(name)}, testAccountID)
+ require.NoError(t, err)
+ return *out.LoadBalancers[0].LoadBalancerArn
+ },
+ modify: func(svc *ELBv2ServiceImpl, arn, accountID string, attrs []rawAttr) ([]echoPair, error) {
+ sdk := make([]*elbv2.LoadBalancerAttribute, len(attrs))
+ for i, a := range attrs {
+ if a.nilElem {
+ continue
+ }
+ sdk[i] = &elbv2.LoadBalancerAttribute{Key: a.key, Value: a.val}
+ }
+ out, err := svc.ModifyLoadBalancerAttributes(&elbv2.ModifyLoadBalancerAttributesInput{
+ LoadBalancerArn: attrArnPtr(arn), Attributes: sdk,
+ }, accountID)
+ if err != nil {
+ return nil, err
+ }
+ pairs := make([]echoPair, len(out.Attributes))
+ for i, a := range out.Attributes {
+ pairs[i] = echoPair{*a.Key, *a.Value}
+ }
+ return pairs, nil
+ },
+ describe: func(svc *ELBv2ServiceImpl, arn, accountID string) ([]echoPair, error) {
+ out, err := svc.DescribeLoadBalancerAttributes(&elbv2.DescribeLoadBalancerAttributesInput{
+ LoadBalancerArn: attrArnPtr(arn),
+ }, accountID)
+ if err != nil {
+ return nil, err
+ }
+ pairs := make([]echoPair, len(out.Attributes))
+ for i, a := range out.Attributes {
+ pairs[i] = echoPair{*a.Key, *a.Value}
+ }
+ return pairs, nil
+ },
+ revision: func(t *testing.T, svc *ELBv2ServiceImpl, arn string) uint64 {
+ lb, err := svc.store.GetLoadBalancerByArn(arn)
+ require.NoError(t, err)
+ entry, err := svc.store.kv.Get(KeyPrefixLB + lb.LoadBalancerID)
+ require.NoError(t, err)
+ return entry.Revision()
+ },
},
- }, testAccountID)
- require.NoError(t, err)
- require.Len(t, modOut.Attributes, 1)
-
- entry, err = svc.store.kv.Get(KeyPrefixTG + tg.TargetGroupID)
- require.NoError(t, err)
- assert.Equal(t, revBefore, entry.Revision(), "identical modify must not increment KV revision")
-
- // Empty attribute list is also a no-op.
- _, err = svc.ModifyTargetGroupAttributes(&elbv2.ModifyTargetGroupAttributesInput{
- TargetGroupArn: tgArn,
- }, testAccountID)
- require.NoError(t, err)
- entry, err = svc.store.kv.Get(KeyPrefixTG + tg.TargetGroupID)
- require.NoError(t, err)
- assert.Equal(t, revBefore, entry.Revision(), "empty modify must not increment KV revision")
-}
-
-// TestModifyLoadBalancerAttributes_NoopSkipsPersist mirrors the TG case.
-func TestModifyLoadBalancerAttributes_NoopSkipsPersist(t *testing.T) {
- svc := setupTestService(t)
- lbOut, err := svc.CreateLoadBalancer(&elbv2.CreateLoadBalancerInput{Name: aws.String("lb-noop")}, testAccountID)
- require.NoError(t, err)
- lbArn := lbOut.LoadBalancers[0].LoadBalancerArn
-
- _, err = svc.ModifyLoadBalancerAttributes(&elbv2.ModifyLoadBalancerAttributesInput{
- LoadBalancerArn: lbArn,
- Attributes: []*elbv2.LoadBalancerAttribute{
- {Key: aws.String("idle_timeout.timeout_seconds"), Value: aws.String("75")},
- },
- }, testAccountID)
- require.NoError(t, err)
-
- lb, err := svc.store.GetLoadBalancerByArn(*lbArn)
- require.NoError(t, err)
- entry, err := svc.store.kv.Get(KeyPrefixLB + lb.LoadBalancerID)
- require.NoError(t, err)
- revBefore := entry.Revision()
-
- _, err = svc.ModifyLoadBalancerAttributes(&elbv2.ModifyLoadBalancerAttributesInput{
- LoadBalancerArn: lbArn,
- Attributes: []*elbv2.LoadBalancerAttribute{
- {Key: aws.String("idle_timeout.timeout_seconds"), Value: aws.String("75")},
- },
- }, testAccountID)
- require.NoError(t, err)
-
- entry, err = svc.store.kv.Get(KeyPrefixLB + lb.LoadBalancerID)
- require.NoError(t, err)
- assert.Equal(t, revBefore, entry.Revision(), "identical modify must not increment KV revision")
-}
-
-// TestModifyTargetGroupAttributes_RejectsUnknownKey guards against silently
-// persisting typo'd or cross-product attribute keys. AWS rejects unknown keys
-// with ValidationError; we must match that so Terraform surfaces the typo at
-// plan time instead of letting it drift into KV forever.
-func TestModifyTargetGroupAttributes_RejectsUnknownKey(t *testing.T) {
- svc := setupTestService(t)
- tgOut, err := svc.CreateTargetGroup(&elbv2.CreateTargetGroupInput{Name: aws.String("tg-unknown-key")}, testAccountID)
- require.NoError(t, err)
-
- _, err = svc.ModifyTargetGroupAttributes(&elbv2.ModifyTargetGroupAttributesInput{
- TargetGroupArn: tgOut.TargetGroups[0].TargetGroupArn,
- Attributes: []*elbv2.TargetGroupAttribute{
- {Key: aws.String("stickness.enabled"), Value: aws.String("true")}, // typo of "stickiness"
- },
- }, testAccountID)
- assert.EqualError(t, err, awserrors.ErrorValidationError)
-
- // The rejected key must not have been persisted.
- descOut, err := svc.DescribeTargetGroupAttributes(&elbv2.DescribeTargetGroupAttributesInput{
- TargetGroupArn: tgOut.TargetGroups[0].TargetGroupArn,
- }, testAccountID)
- require.NoError(t, err)
- for _, a := range descOut.Attributes {
- assert.NotEqual(t, "stickness.enabled", *a.Key, "unknown key must not appear in Describe")
}
}
-// TestModifyLoadBalancerAttributes_RejectsUnknownKey mirrors the TG case.
-func TestModifyLoadBalancerAttributes_RejectsUnknownKey(t *testing.T) {
- svc := setupTestService(t)
- lbOut, err := svc.CreateLoadBalancer(&elbv2.CreateLoadBalancerInput{Name: aws.String("lb-unknown-key")}, testAccountID)
- require.NoError(t, err)
+func TestAttributeRoundTrip(t *testing.T) {
+ for _, k := range attrKinds() {
+ t.Run(k.name, func(t *testing.T) {
+ svc := setupTestService(t)
+ arn := k.create(t, svc, "attr-rt")
- _, err = svc.ModifyLoadBalancerAttributes(&elbv2.ModifyLoadBalancerAttributesInput{
- LoadBalancerArn: lbOut.LoadBalancers[0].LoadBalancerArn,
- Attributes: []*elbv2.LoadBalancerAttribute{
- // Valid TG attribute key sent to LB handler — common cross-product mistake.
- {Key: aws.String("stickiness.enabled"), Value: aws.String("true")},
- },
- }, testAccountID)
- assert.EqualError(t, err, awserrors.ErrorValidationError)
-}
+ // Assert the exact echoed key/value pairs, not just the length — a
+ // regression that pointed at the wrong source or dropped Value would
+ // pass a length check.
+ echoed, err := k.modify(svc, arn, testAccountID, kvAttrs([2]string{k.keyA, k.valA}, [2]string{k.keyB, k.valB}))
+ require.NoError(t, err)
+ require.Len(t, echoed, 2)
+ em := echoMap(echoed)
+ assert.Equal(t, k.valA, em[k.keyA])
+ assert.Equal(t, k.valB, em[k.keyB])
-// TestDescribeTargetGroupAttributes_SortedOrder verifies that attributes are
-// returned in a stable, sorted-by-key order. Go map iteration is randomised,
-// so without explicit sorting Terraform would see spurious plan diffs between
-// back-to-back describe calls.
-func TestDescribeTargetGroupAttributes_SortedOrder(t *testing.T) {
- svc := setupTestService(t)
- tgOut, err := svc.CreateTargetGroup(&elbv2.CreateTargetGroupInput{Name: aws.String("tg-attr-sorted")}, testAccountID)
- require.NoError(t, err)
- arn := tgOut.TargetGroups[0].TargetGroupArn
-
- // Modify a few attributes to ensure the merged map contains both defaults
- // and overrides.
- _, err = svc.ModifyTargetGroupAttributes(&elbv2.ModifyTargetGroupAttributesInput{
- TargetGroupArn: arn,
- Attributes: []*elbv2.TargetGroupAttribute{
- {Key: aws.String("stickiness.enabled"), Value: aws.String("true")},
- {Key: aws.String("deregistration_delay.timeout_seconds"), Value: aws.String("45")},
- },
- }, testAccountID)
- require.NoError(t, err)
-
- // Call Describe multiple times; each result must be identical and sorted.
- var firstKeys []string
- for i := range 5 {
- descOut, err := svc.DescribeTargetGroupAttributes(&elbv2.DescribeTargetGroupAttributesInput{
- TargetGroupArn: arn,
- }, testAccountID)
- require.NoError(t, err)
-
- keys := make([]string, len(descOut.Attributes))
- for j, a := range descOut.Attributes {
- keys[j] = *a.Key
- }
- assert.True(t, sort.StringsAreSorted(keys), "attributes must be sorted by key, got %v", keys)
- if i == 0 {
- firstKeys = keys
- } else {
- assert.Equal(t, firstKeys, keys, "attribute order must be stable across calls")
- }
+ desc, err := k.describe(svc, arn, testAccountID)
+ require.NoError(t, err)
+ dm := echoMap(desc)
+ assert.Equal(t, k.valA, dm[k.keyA])
+ assert.Equal(t, k.valB, dm[k.keyB])
+ // Unmodified defaults should still be present.
+ assert.Equal(t, k.defaultVal, dm[k.defaultKey])
+ })
}
}
-// TestDescribeLoadBalancerAttributes_SortedOrder mirrors the TG test: LB
-// describe responses must be sorted and stable across repeated calls.
-func TestDescribeLoadBalancerAttributes_SortedOrder(t *testing.T) {
- svc := setupTestService(t)
- lbOut, err := svc.CreateLoadBalancer(&elbv2.CreateLoadBalancerInput{Name: aws.String("lb-attr-sorted")}, testAccountID)
- require.NoError(t, err)
- arn := lbOut.LoadBalancers[0].LoadBalancerArn
+func TestAttributeNotFound(t *testing.T) {
+ for _, k := range attrKinds() {
+ t.Run(k.name, func(t *testing.T) {
+ svc := setupTestService(t)
- _, err = svc.ModifyLoadBalancerAttributes(&elbv2.ModifyLoadBalancerAttributesInput{
- LoadBalancerArn: arn,
- Attributes: []*elbv2.LoadBalancerAttribute{
- {Key: aws.String("idle_timeout.timeout_seconds"), Value: aws.String("90")},
- {Key: aws.String("deletion_protection.enabled"), Value: aws.String("true")},
- },
- }, testAccountID)
- require.NoError(t, err)
+ _, err := k.modify(svc, k.missingArn, testAccountID, kvAttrs([2]string{k.keyB, k.valB}))
+ assert.EqualError(t, err, k.notFound)
- var firstKeys []string
- for i := range 5 {
- descOut, err := svc.DescribeLoadBalancerAttributes(&elbv2.DescribeLoadBalancerAttributesInput{
- LoadBalancerArn: arn,
- }, testAccountID)
- require.NoError(t, err)
+ _, err = k.describe(svc, k.missingArn, testAccountID)
+ assert.EqualError(t, err, k.notFound)
+ })
+ }
+}
- keys := make([]string, len(descOut.Attributes))
- for j, a := range descOut.Attributes {
- keys[j] = *a.Key
- }
- assert.True(t, sort.StringsAreSorted(keys), "attributes must be sorted by key, got %v", keys)
- if i == 0 {
- firstKeys = keys
- } else {
- assert.Equal(t, firstKeys, keys, "attribute order must be stable across calls")
- }
+func TestAttributeMissingArn(t *testing.T) {
+ for _, k := range attrKinds() {
+ t.Run(k.name, func(t *testing.T) {
+ svc := setupTestService(t)
+
+ _, err := k.modify(svc, "", testAccountID, kvAttrs([2]string{k.keyB, k.valB}))
+ assert.EqualError(t, err, awserrors.ErrorMissingParameter)
+
+ _, err = k.describe(svc, "", testAccountID)
+ assert.EqualError(t, err, awserrors.ErrorMissingParameter)
+ })
+ }
+}
+
+func TestAttributeWrongAccount(t *testing.T) {
+ for _, k := range attrKinds() {
+ t.Run(k.name, func(t *testing.T) {
+ svc := setupTestService(t)
+ arn := k.create(t, svc, "attr-wrong-acct")
+
+ _, err := k.modify(svc, arn, "999999999999", kvAttrs([2]string{k.keyB, k.valB}))
+ assert.EqualError(t, err, k.notFound)
+
+ _, err = k.describe(svc, arn, "999999999999")
+ assert.EqualError(t, err, k.notFound)
+
+ // The rejected modify must not have mutated the record — read back as
+ // the real owner and confirm keyB is still at its default, not valB.
+ desc, err := k.describe(svc, arn, testAccountID)
+ require.NoError(t, err)
+ assert.NotEqual(t, k.valB, echoMap(desc)[k.keyB], "wrong-account modify must not mutate the record")
+ })
+ }
+}
+
+// TestAttributeCrossAccountIsolation verifies that two accounts each holding a
+// resource never see each other's attributes: a Modify by account A must not be
+// visible to a Describe by account B, even for similarly-named resources.
+func TestAttributeCrossAccountIsolation(t *testing.T) {
+ const accountB = "222222222222"
+ for _, k := range attrKinds() {
+ t.Run(k.name, func(t *testing.T) {
+ svcA := setupTestService(t)
+ arnA := k.create(t, svcA, "attr-iso")
+
+ // Account A sets keyB.
+ _, err := k.modify(svcA, arnA, testAccountID, kvAttrs([2]string{k.keyB, k.valB}))
+ require.NoError(t, err)
+
+ // Account B cannot see or describe A's resource at all.
+ _, err = k.describe(svcA, arnA, accountB)
+ assert.EqualError(t, err, k.notFound)
+ _, err = k.modify(svcA, arnA, accountB, kvAttrs([2]string{k.keyB, "false"}))
+ assert.EqualError(t, err, k.notFound)
+
+ // A's value is untouched.
+ desc, err := k.describe(svcA, arnA, testAccountID)
+ require.NoError(t, err)
+ assert.Equal(t, k.valB, echoMap(desc)[k.keyB])
+ })
+ }
+}
+
+// TestAttributeSkipsInvalidEntries verifies that nil slice elements, nil Keys,
+// and nil Values are skipped (with a warning) rather than panicking or being
+// silently dropped. Valid attributes in the same call must still be applied.
+func TestAttributeSkipsInvalidEntries(t *testing.T) {
+ for _, k := range attrKinds() {
+ t.Run(k.name, func(t *testing.T) {
+ svc := setupTestService(t)
+ arn := k.create(t, svc, "attr-skip")
+
+ echoed, err := k.modify(svc, arn, testAccountID, []rawAttr{
+ {nilElem: true}, // nil element must not panic
+ {key: nil, val: aws.String("v")},
+ {key: aws.String("k"), val: nil},
+ kvAttr(k.keyB, k.valB),
+ })
+ require.NoError(t, err)
+ // Only the one valid attribute should be returned.
+ require.Len(t, echoed, 1)
+ assert.Equal(t, k.keyB, echoed[0].Key)
+ assert.Equal(t, k.valB, echoed[0].Val)
+
+ // The valid attribute should have been persisted.
+ desc, err := k.describe(svc, arn, testAccountID)
+ require.NoError(t, err)
+ assert.Equal(t, k.valB, echoMap(desc)[k.keyB])
+ })
+ }
+}
+
+// TestAttributeAllInvalidReturnsError guards against the silent-success case
+// where every submitted attribute trips the nil guard and the handler returns
+// 200 OK with an empty body — the caller would think the write landed when
+// nothing was applied.
+func TestAttributeAllInvalidReturnsError(t *testing.T) {
+ for _, k := range attrKinds() {
+ t.Run(k.name, func(t *testing.T) {
+ svc := setupTestService(t)
+ arn := k.create(t, svc, "attr-all-invalid")
+
+ _, err := k.modify(svc, arn, testAccountID, []rawAttr{
+ {nilElem: true},
+ {key: nil, val: aws.String("v")},
+ {key: aws.String("k"), val: nil},
+ })
+ assert.EqualError(t, err, awserrors.ErrorInvalidParameterValue)
+ })
+ }
+}
+
+// TestAttributeSequentialMerge verifies that successive Modify calls accumulate
+// keys instead of replacing the entire attribute map. A refactor that did
+// `rec.Attributes = newMap` would pass every other test (single-call round-trip
+// covers the happy path) but silently wipe previous attributes on every
+// subsequent Modify — the most likely real-world bug.
+func TestAttributeSequentialMerge(t *testing.T) {
+ for _, k := range attrKinds() {
+ t.Run(k.name, func(t *testing.T) {
+ svc := setupTestService(t)
+ arn := k.create(t, svc, "attr-seq-merge")
+
+ // Call 1: set keyA.
+ _, err := k.modify(svc, arn, testAccountID, kvAttrs([2]string{k.keyA, k.valA}))
+ require.NoError(t, err)
+ // Call 2: set a different key; must not wipe the first.
+ _, err = k.modify(svc, arn, testAccountID, kvAttrs([2]string{k.keyB, k.valB}))
+ require.NoError(t, err)
+
+ desc, err := k.describe(svc, arn, testAccountID)
+ require.NoError(t, err)
+ dm := echoMap(desc)
+ assert.Equal(t, k.valA, dm[k.keyA], "first-call key must survive second Modify")
+ assert.Equal(t, k.valB, dm[k.keyB], "second-call key must be present")
+
+ // Call 3: overwrite keyA with a new value.
+ _, err = k.modify(svc, arn, testAccountID, kvAttrs([2]string{k.keyA, k.valA2}))
+ require.NoError(t, err)
+
+ desc, err = k.describe(svc, arn, testAccountID)
+ require.NoError(t, err)
+ dm = echoMap(desc)
+ assert.Equal(t, k.valA2, dm[k.keyA], "same-key overwrite must replace the value")
+ assert.Equal(t, k.valB, dm[k.keyB], "unrelated key must still survive")
+ })
+ }
+}
+
+// TestAttributeNoopSkipsPersist verifies that re-submitting identical values
+// does not bump the KV revision — Terraform's drift check hits Modify on every
+// apply, so the steady-state path must be a storage-layer no-op.
+func TestAttributeNoopSkipsPersist(t *testing.T) {
+ for _, k := range attrKinds() {
+ t.Run(k.name, func(t *testing.T) {
+ svc := setupTestService(t)
+ arn := k.create(t, svc, "attr-noop")
+
+ // First modify — must hit the store.
+ _, err := k.modify(svc, arn, testAccountID, kvAttrs([2]string{k.keyA, k.valA}))
+ require.NoError(t, err)
+ revBefore := k.revision(t, svc, arn)
+
+ // Second modify with identical values — must skip the Put.
+ echoed, err := k.modify(svc, arn, testAccountID, kvAttrs([2]string{k.keyA, k.valA}))
+ require.NoError(t, err)
+ require.Len(t, echoed, 1)
+ assert.Equal(t, revBefore, k.revision(t, svc, arn), "identical modify must not increment KV revision")
+
+ // Empty attribute list is also a no-op.
+ _, err = k.modify(svc, arn, testAccountID, nil)
+ require.NoError(t, err)
+ assert.Equal(t, revBefore, k.revision(t, svc, arn), "empty modify must not increment KV revision")
+ })
+ }
+}
+
+// TestAttributeRejectsUnknownKey guards against silently persisting typo'd or
+// cross-product attribute keys. AWS rejects unknown keys with ValidationError;
+// we match that so Terraform surfaces the typo at plan time instead of letting
+// it drift into KV forever.
+func TestAttributeRejectsUnknownKey(t *testing.T) {
+ for _, k := range attrKinds() {
+ t.Run(k.name, func(t *testing.T) {
+ svc := setupTestService(t)
+ arn := k.create(t, svc, "attr-unknown-key")
+
+ _, err := k.modify(svc, arn, testAccountID, kvAttrs([2]string{k.unknownKey, "true"}))
+ assert.EqualError(t, err, awserrors.ErrorValidationError)
+
+ // The rejected key must not have been persisted.
+ desc, err := k.describe(svc, arn, testAccountID)
+ require.NoError(t, err)
+ _, present := echoMap(desc)[k.unknownKey]
+ assert.False(t, present, "unknown key must not appear in Describe")
+ })
+ }
+}
+
+// TestAttributeSortedOrder verifies attributes are returned in a stable,
+// sorted-by-key order. Go map iteration is randomised, so without explicit
+// sorting Terraform would see spurious plan diffs between describe calls.
+func TestAttributeSortedOrder(t *testing.T) {
+ for _, k := range attrKinds() {
+ t.Run(k.name, func(t *testing.T) {
+ svc := setupTestService(t)
+ arn := k.create(t, svc, "attr-sorted")
+
+ // Modify a couple of attributes so the merged map mixes defaults and
+ // overrides.
+ _, err := k.modify(svc, arn, testAccountID, kvAttrs([2]string{k.keyA, k.valA}, [2]string{k.keyB, k.valB}))
+ require.NoError(t, err)
+
+ var firstKeys []string
+ for i := range 5 {
+ desc, err := k.describe(svc, arn, testAccountID)
+ require.NoError(t, err)
+ keys := make([]string, len(desc))
+ for j, p := range desc {
+ keys[j] = p.Key
+ }
+ assert.True(t, sort.StringsAreSorted(keys), "attributes must be sorted by key, got %v", keys)
+ if i == 0 {
+ firstKeys = keys
+ } else {
+ assert.Equal(t, firstKeys, keys, "attribute order must be stable across calls")
+ }
+ }
+ })
}
}
@@ -2968,9 +3145,9 @@ func TestDescribeTags_Listener_NoTags(t *testing.T) {
}, testAccountID)
require.NoError(t, err)
- // Listeners don't store tags yet — must still return a TagDescription
- // (with an empty Tags slice), not an error. This is the case the
- // Terraform AWS provider hits during post-create refresh.
+ // Listener created without tags must still return a TagDescription (with an
+ // empty Tags slice), not an error. This is the case the Terraform AWS
+ // provider hits during post-create refresh.
out, err := svc.DescribeTags(&elbv2.DescribeTagsInput{
ResourceArns: []*string{lstOut.Listeners[0].ListenerArn},
}, testAccountID)
@@ -2980,6 +3157,55 @@ func TestDescribeTags_Listener_NoTags(t *testing.T) {
assert.Empty(t, out.TagDescriptions[0].Tags)
}
+func TestDescribeTags_ListenerRule_NoTags(t *testing.T) {
+ svc := setupTestService(t)
+ lbOut, _ := svc.CreateLoadBalancer(&elbv2.CreateLoadBalancerInput{Name: aws.String("tags-rule-lb")}, testAccountID)
+ tgOut, _ := svc.CreateTargetGroup(&elbv2.CreateTargetGroupInput{Name: aws.String("tags-rule-tg")}, testAccountID)
+ lstOut, err := svc.CreateListener(&elbv2.CreateListenerInput{
+ LoadBalancerArn: lbOut.LoadBalancers[0].LoadBalancerArn,
+ Protocol: aws.String("HTTP"),
+ Port: aws.Int64(80),
+ DefaultActions: []*elbv2.Action{
+ {Type: aws.String("forward"), TargetGroupArn: tgOut.TargetGroups[0].TargetGroupArn},
+ },
+ }, testAccountID)
+ require.NoError(t, err)
+
+ ruleOut, err := svc.CreateRule(&elbv2.CreateRuleInput{
+ ListenerArn: lstOut.Listeners[0].ListenerArn,
+ Priority: aws.Int64(10),
+ Conditions: []*elbv2.RuleCondition{
+ {Field: aws.String("host-header"), Values: aws.StringSlice([]string{"app.example.com"})},
+ },
+ Actions: []*elbv2.Action{
+ {Type: aws.String("forward"), TargetGroupArn: tgOut.TargetGroups[0].TargetGroupArn},
+ },
+ }, testAccountID)
+ require.NoError(t, err)
+
+ // Rule created without tags must still return a TagDescription (with an
+ // empty Tags slice), not an error. This is the case the Terraform AWS
+ // provider hits during post-create refresh of aws_lb_listener_rule.
+ out, err := svc.DescribeTags(&elbv2.DescribeTagsInput{
+ ResourceArns: []*string{ruleOut.Rules[0].RuleArn},
+ }, testAccountID)
+ require.NoError(t, err)
+ require.Len(t, out.TagDescriptions, 1)
+ assert.Equal(t, *ruleOut.Rules[0].RuleArn, *out.TagDescriptions[0].ResourceArn)
+ assert.Empty(t, out.TagDescriptions[0].Tags)
+}
+
+func TestDescribeTags_RuleNotFound(t *testing.T) {
+ svc := setupTestService(t)
+ _, err := svc.DescribeTags(&elbv2.DescribeTagsInput{
+ ResourceArns: []*string{
+ aws.String("arn:aws:elasticloadbalancing:us-east-1:123456789012:listener-rule/app/missing/lb-x/lst-y/rule-deadbeef"),
+ },
+ }, testAccountID)
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), awserrors.ErrorELBv2RuleNotFound)
+}
+
func TestDescribeTags_MultipleArns(t *testing.T) {
svc := setupTestService(t)
lbOut, _ := svc.CreateLoadBalancer(&elbv2.CreateLoadBalancerInput{
diff --git a/spinifex/handlers/elbv2/service_impl_vpc_test.go b/spinifex/handlers/elbv2/service_impl_vpc_test.go
index 652f12227..2e316ed50 100644
--- a/spinifex/handlers/elbv2/service_impl_vpc_test.go
+++ b/spinifex/handlers/elbv2/service_impl_vpc_test.go
@@ -139,7 +139,7 @@ func TestCreateLoadBalancer_MultipleSubnets(t *testing.T) {
// TestCreateLoadBalancer_MultiSubnet_AllENIsPassedToLauncher verifies that
// every subnet's ENI is threaded through to SystemInstanceInput, not just
-// the first one. Regression guard for mulga-929.
+// the first one.
func TestCreateLoadBalancer_MultiSubnet_AllENIsPassedToLauncher(t *testing.T) {
svc, vpcSvc := setupTestServiceWithVPC(t)
diff --git a/spinifex/handlers/elbv2/service_nats.go b/spinifex/handlers/elbv2/service_nats.go
index 605ac2fbf..903cfdf30 100644
--- a/spinifex/handlers/elbv2/service_nats.go
+++ b/spinifex/handlers/elbv2/service_nats.go
@@ -111,6 +111,14 @@ func (s *NATSELBv2Service) DescribeTags(input *elbv2.DescribeTagsInput, accountI
return utils.NATSRequest[elbv2.DescribeTagsOutput](s.natsConn, "elbv2.DescribeTags", input, defaultTimeout, accountID)
}
+func (s *NATSELBv2Service) AddTags(input *elbv2.AddTagsInput, accountID string) (*elbv2.AddTagsOutput, error) {
+ return utils.NATSRequest[elbv2.AddTagsOutput](s.natsConn, "elbv2.AddTags", input, defaultTimeout, accountID)
+}
+
+func (s *NATSELBv2Service) RemoveTags(input *elbv2.RemoveTagsInput, accountID string) (*elbv2.RemoveTagsOutput, error) {
+ return utils.NATSRequest[elbv2.RemoveTagsOutput](s.natsConn, "elbv2.RemoveTags", input, defaultTimeout, accountID)
+}
+
func (s *NATSELBv2Service) ModifyTargetGroupAttributes(input *elbv2.ModifyTargetGroupAttributesInput, accountID string) (*elbv2.ModifyTargetGroupAttributesOutput, error) {
return utils.NATSRequest[elbv2.ModifyTargetGroupAttributesOutput](s.natsConn, "elbv2.ModifyTargetGroupAttributes", input, defaultTimeout, accountID)
}
@@ -127,6 +135,34 @@ func (s *NATSELBv2Service) DescribeLoadBalancerAttributes(input *elbv2.DescribeL
return utils.NATSRequest[elbv2.DescribeLoadBalancerAttributesOutput](s.natsConn, "elbv2.DescribeLoadBalancerAttributes", input, defaultTimeout, accountID)
}
+func (s *NATSELBv2Service) SetSecurityGroups(input *elbv2.SetSecurityGroupsInput, accountID string) (*elbv2.SetSecurityGroupsOutput, error) {
+ return utils.NATSRequest[elbv2.SetSecurityGroupsOutput](s.natsConn, "elbv2.SetSecurityGroups", input, defaultTimeout, accountID)
+}
+
+func (s *NATSELBv2Service) SetIpAddressType(input *elbv2.SetIpAddressTypeInput, accountID string) (*elbv2.SetIpAddressTypeOutput, error) {
+ return utils.NATSRequest[elbv2.SetIpAddressTypeOutput](s.natsConn, "elbv2.SetIpAddressType", input, defaultTimeout, accountID)
+}
+
+func (s *NATSELBv2Service) SetSubnets(input *elbv2.SetSubnetsInput, accountID string) (*elbv2.SetSubnetsOutput, error) {
+ return utils.NATSRequest[elbv2.SetSubnetsOutput](s.natsConn, "elbv2.SetSubnets", input, defaultTimeout, accountID)
+}
+
+func (s *NATSELBv2Service) AddListenerCertificates(input *elbv2.AddListenerCertificatesInput, accountID string) (*elbv2.AddListenerCertificatesOutput, error) {
+ return utils.NATSRequest[elbv2.AddListenerCertificatesOutput](s.natsConn, "elbv2.AddListenerCertificates", input, defaultTimeout, accountID)
+}
+
+func (s *NATSELBv2Service) RemoveListenerCertificates(input *elbv2.RemoveListenerCertificatesInput, accountID string) (*elbv2.RemoveListenerCertificatesOutput, error) {
+ return utils.NATSRequest[elbv2.RemoveListenerCertificatesOutput](s.natsConn, "elbv2.RemoveListenerCertificates", input, defaultTimeout, accountID)
+}
+
+func (s *NATSELBv2Service) DescribeListenerCertificates(input *elbv2.DescribeListenerCertificatesInput, accountID string) (*elbv2.DescribeListenerCertificatesOutput, error) {
+ return utils.NATSRequest[elbv2.DescribeListenerCertificatesOutput](s.natsConn, "elbv2.DescribeListenerCertificates", input, defaultTimeout, accountID)
+}
+
+func (s *NATSELBv2Service) DescribeSSLPolicies(input *elbv2.DescribeSSLPoliciesInput, accountID string) (*elbv2.DescribeSSLPoliciesOutput, error) {
+ return utils.NATSRequest[elbv2.DescribeSSLPoliciesOutput](s.natsConn, "elbv2.DescribeSSLPolicies", input, defaultTimeout, accountID)
+}
+
func (s *NATSELBv2Service) LBAgentHeartbeat(input *LBAgentHeartbeatInput, accountID string) (*LBAgentHeartbeatOutput, error) {
return utils.NATSRequest[LBAgentHeartbeatOutput](s.natsConn, "elbv2.LBAgentHeartbeat", input, defaultTimeout, accountID)
}
diff --git a/spinifex/handlers/elbv2/service_nats_e2e_test.go b/spinifex/handlers/elbv2/service_nats_e2e_test.go
index 243772e79..604f1ae3c 100644
--- a/spinifex/handlers/elbv2/service_nats_e2e_test.go
+++ b/spinifex/handlers/elbv2/service_nats_e2e_test.go
@@ -38,6 +38,20 @@ func setupNATSELBv2Test(t *testing.T) (ELBv2Service, *ELBv2ServiceImpl) {
"elbv2.DeleteListener": func(msg *nats.Msg) { handleNATSMsg(msg, backend.DeleteListener) },
"elbv2.ModifyListener": func(msg *nats.Msg) { handleNATSMsg(msg, backend.ModifyListener) },
"elbv2.DescribeListeners": func(msg *nats.Msg) { handleNATSMsg(msg, backend.DescribeListeners) },
+ "elbv2.CreateRule": func(msg *nats.Msg) { handleNATSMsg(msg, backend.CreateRule) },
+ "elbv2.DescribeRules": func(msg *nats.Msg) { handleNATSMsg(msg, backend.DescribeRules) },
+ "elbv2.AddTags": func(msg *nats.Msg) { handleNATSMsg(msg, backend.AddTags) },
+ "elbv2.RemoveTags": func(msg *nats.Msg) { handleNATSMsg(msg, backend.RemoveTags) },
+ "elbv2.DescribeTags": func(msg *nats.Msg) { handleNATSMsg(msg, backend.DescribeTags) },
+ "elbv2.SetSecurityGroups": func(msg *nats.Msg) { handleNATSMsg(msg, backend.SetSecurityGroups) },
+ "elbv2.SetIpAddressType": func(msg *nats.Msg) { handleNATSMsg(msg, backend.SetIpAddressType) },
+
+ "elbv2.ModifyTargetGroupAttributes": func(msg *nats.Msg) { handleNATSMsg(msg, backend.ModifyTargetGroupAttributes) },
+ "elbv2.DescribeTargetGroupAttributes": func(msg *nats.Msg) { handleNATSMsg(msg, backend.DescribeTargetGroupAttributes) },
+ "elbv2.ModifyLoadBalancerAttributes": func(msg *nats.Msg) { handleNATSMsg(msg, backend.ModifyLoadBalancerAttributes) },
+ "elbv2.DescribeLoadBalancerAttributes": func(msg *nats.Msg) {
+ handleNATSMsg(msg, backend.DescribeLoadBalancerAttributes)
+ },
}
for topic, handler := range topics {
@@ -281,3 +295,192 @@ func TestNATSE2E_TargetGroupInUseProtection(t *testing.T) {
}, testAccountID)
require.Error(t, err)
}
+
+func TestNATSE2E_TagsLifecycle(t *testing.T) {
+ client, _ := setupNATSELBv2Test(t)
+
+ lbOut, err := client.CreateLoadBalancer(&elbv2.CreateLoadBalancerInput{Name: aws.String("e2e-tags-lb")}, testAccountID)
+ require.NoError(t, err)
+ lbArn := lbOut.LoadBalancers[0].LoadBalancerArn
+
+ _, err = client.AddTags(&elbv2.AddTagsInput{
+ ResourceArns: []*string{lbArn},
+ Tags: []*elbv2.Tag{
+ {Key: aws.String("App"), Value: aws.String("nginx")},
+ {Key: aws.String("Env"), Value: aws.String("prod")},
+ },
+ }, testAccountID)
+ require.NoError(t, err)
+
+ desc, err := client.DescribeTags(&elbv2.DescribeTagsInput{ResourceArns: []*string{lbArn}}, testAccountID)
+ require.NoError(t, err)
+ require.Len(t, desc.TagDescriptions, 1)
+ got := map[string]string{}
+ for _, tag := range desc.TagDescriptions[0].Tags {
+ got[*tag.Key] = *tag.Value
+ }
+ assert.Equal(t, map[string]string{"App": "nginx", "Env": "prod"}, got)
+
+ _, err = client.RemoveTags(&elbv2.RemoveTagsInput{
+ ResourceArns: []*string{lbArn},
+ TagKeys: []*string{aws.String("Env")},
+ }, testAccountID)
+ require.NoError(t, err)
+
+ desc2, err := client.DescribeTags(&elbv2.DescribeTagsInput{ResourceArns: []*string{lbArn}}, testAccountID)
+ require.NoError(t, err)
+ require.Len(t, desc2.TagDescriptions, 1)
+ got2 := map[string]string{}
+ for _, tag := range desc2.TagDescriptions[0].Tags {
+ got2[*tag.Key] = *tag.Value
+ }
+ assert.Equal(t, map[string]string{"App": "nginx"}, got2)
+}
+
+func TestNATSE2E_AttributeRoundTrip(t *testing.T) {
+ client, _ := setupNATSELBv2Test(t)
+
+ tgOut, err := client.CreateTargetGroup(&elbv2.CreateTargetGroupInput{Name: aws.String("e2e-attr-tg")}, testAccountID)
+ require.NoError(t, err)
+ tgArn := tgOut.TargetGroups[0].TargetGroupArn
+
+ _, err = client.ModifyTargetGroupAttributes(&elbv2.ModifyTargetGroupAttributesInput{
+ TargetGroupArn: tgArn,
+ Attributes: []*elbv2.TargetGroupAttribute{
+ {Key: aws.String("deregistration_delay.timeout_seconds"), Value: aws.String("120")},
+ },
+ }, testAccountID)
+ require.NoError(t, err)
+
+ tgAttrs, err := client.DescribeTargetGroupAttributes(&elbv2.DescribeTargetGroupAttributesInput{TargetGroupArn: tgArn}, testAccountID)
+ require.NoError(t, err)
+ tgGot := map[string]string{}
+ for _, a := range tgAttrs.Attributes {
+ tgGot[*a.Key] = *a.Value
+ }
+ assert.Equal(t, "120", tgGot["deregistration_delay.timeout_seconds"])
+
+ lbOut, err := client.CreateLoadBalancer(&elbv2.CreateLoadBalancerInput{Name: aws.String("e2e-attr-lb")}, testAccountID)
+ require.NoError(t, err)
+ lbArn := lbOut.LoadBalancers[0].LoadBalancerArn
+
+ _, err = client.ModifyLoadBalancerAttributes(&elbv2.ModifyLoadBalancerAttributesInput{
+ LoadBalancerArn: lbArn,
+ Attributes: []*elbv2.LoadBalancerAttribute{
+ {Key: aws.String("deletion_protection.enabled"), Value: aws.String("true")},
+ },
+ }, testAccountID)
+ require.NoError(t, err)
+
+ lbAttrs, err := client.DescribeLoadBalancerAttributes(&elbv2.DescribeLoadBalancerAttributesInput{LoadBalancerArn: lbArn}, testAccountID)
+ require.NoError(t, err)
+ lbGot := map[string]string{}
+ for _, a := range lbAttrs.Attributes {
+ lbGot[*a.Key] = *a.Value
+ }
+ assert.Equal(t, "true", lbGot["deletion_protection.enabled"])
+}
+
+func TestNATSE2E_RedirectListener(t *testing.T) {
+ client, _ := setupNATSELBv2Test(t)
+
+ lbOut, err := client.CreateLoadBalancer(&elbv2.CreateLoadBalancerInput{Name: aws.String("e2e-redir-lb")}, testAccountID)
+ require.NoError(t, err)
+ lbArn := lbOut.LoadBalancers[0].LoadBalancerArn
+
+ _, err = client.CreateListener(&elbv2.CreateListenerInput{
+ LoadBalancerArn: lbArn,
+ Protocol: aws.String("HTTP"),
+ Port: aws.Int64(80),
+ DefaultActions: []*elbv2.Action{{
+ Type: aws.String("redirect"),
+ RedirectConfig: &elbv2.RedirectActionConfig{
+ Protocol: aws.String("HTTPS"),
+ Port: aws.String("443"),
+ StatusCode: aws.String("HTTP_301"),
+ },
+ }},
+ }, testAccountID)
+ require.NoError(t, err)
+
+ desc, err := client.DescribeListeners(&elbv2.DescribeListenersInput{LoadBalancerArn: lbArn}, testAccountID)
+ require.NoError(t, err)
+ require.Len(t, desc.Listeners, 1)
+ require.Len(t, desc.Listeners[0].DefaultActions, 1)
+ action := desc.Listeners[0].DefaultActions[0]
+ assert.Equal(t, "redirect", *action.Type)
+ require.NotNil(t, action.RedirectConfig, "redirect action config must survive the NATS round-trip")
+ assert.Equal(t, "HTTP_301", *action.RedirectConfig.StatusCode)
+ assert.Equal(t, "HTTPS", *action.RedirectConfig.Protocol)
+}
+
+func TestNATSE2E_IPTargetType(t *testing.T) {
+ client, _ := setupNATSELBv2Test(t)
+
+ tgOut, err := client.CreateTargetGroup(&elbv2.CreateTargetGroupInput{
+ Name: aws.String("e2e-ip-tg"),
+ TargetType: aws.String("ip"),
+ }, testAccountID)
+ require.NoError(t, err)
+ require.Equal(t, "ip", *tgOut.TargetGroups[0].TargetType)
+ tgArn := tgOut.TargetGroups[0].TargetGroupArn
+
+ _, err = client.RegisterTargets(&elbv2.RegisterTargetsInput{
+ TargetGroupArn: tgArn,
+ Targets: []*elbv2.TargetDescription{
+ {Id: aws.String("10.0.1.20")},
+ {Id: aws.String("10.0.1.21"), Port: aws.Int64(8080)},
+ },
+ }, testAccountID)
+ require.NoError(t, err)
+
+ health, err := client.DescribeTargetHealth(&elbv2.DescribeTargetHealthInput{TargetGroupArn: tgArn}, testAccountID)
+ require.NoError(t, err)
+ require.Len(t, health.TargetHealthDescriptions, 2)
+
+ // A non-IP id must be rejected for an ip target group, end-to-end.
+ _, err = client.RegisterTargets(&elbv2.RegisterTargetsInput{
+ TargetGroupArn: tgArn,
+ Targets: []*elbv2.TargetDescription{{Id: aws.String("i-notanip")}},
+ }, testAccountID)
+ require.Error(t, err)
+}
+
+func TestNATSE2E_SetSecurityGroupsAndIpAddressType(t *testing.T) {
+ client, _ := setupNATSELBv2Test(t)
+
+ lbOut, err := client.CreateLoadBalancer(&elbv2.CreateLoadBalancerInput{
+ Name: aws.String("e2e-net-lb"),
+ SecurityGroups: []*string{aws.String("sg-aaa")},
+ }, testAccountID)
+ require.NoError(t, err)
+ lbArn := lbOut.LoadBalancers[0].LoadBalancerArn
+
+ _, err = client.SetSecurityGroups(&elbv2.SetSecurityGroupsInput{
+ LoadBalancerArn: lbArn,
+ SecurityGroups: []*string{aws.String("sg-bbb"), aws.String("sg-ccc")},
+ }, testAccountID)
+ require.NoError(t, err)
+
+ // Spinifex ALBs are IPv4-only; "ipv4" is the only accepted value.
+ _, err = client.SetIpAddressType(&elbv2.SetIpAddressTypeInput{
+ LoadBalancerArn: lbArn,
+ IpAddressType: aws.String("ipv4"),
+ }, testAccountID)
+ require.NoError(t, err)
+
+ // A dualstack request must be rejected end-to-end.
+ _, err = client.SetIpAddressType(&elbv2.SetIpAddressTypeInput{
+ LoadBalancerArn: lbArn,
+ IpAddressType: aws.String("dualstack"),
+ }, testAccountID)
+ require.Error(t, err)
+
+ desc, err := client.DescribeLoadBalancers(&elbv2.DescribeLoadBalancersInput{
+ LoadBalancerArns: []*string{lbArn},
+ }, testAccountID)
+ require.NoError(t, err)
+ require.Len(t, desc.LoadBalancers, 1)
+ assert.ElementsMatch(t, []string{"sg-bbb", "sg-ccc"}, aws.StringValueSlice(desc.LoadBalancers[0].SecurityGroups))
+ assert.Equal(t, "ipv4", *desc.LoadBalancers[0].IpAddressType)
+}
diff --git a/spinifex/handlers/elbv2/ssl_policies.go b/spinifex/handlers/elbv2/ssl_policies.go
new file mode 100644
index 000000000..104cefc21
--- /dev/null
+++ b/spinifex/handlers/elbv2/ssl_policies.go
@@ -0,0 +1,92 @@
+package handlers_elbv2
+
+import (
+ "github.com/aws/aws-sdk-go/aws"
+ "github.com/aws/aws-sdk-go/service/elbv2"
+)
+
+// protocolRequiresCert reports whether a listener protocol terminates TLS and
+// therefore requires at least one certificate: ALB HTTPS and NLB TLS.
+func protocolRequiresCert(protocol string) bool {
+ switch protocol {
+ case ProtocolHTTPS, ProtocolTLS:
+ return true
+ default:
+ return false
+ }
+}
+
+// sslPolicyCipher is a named cipher with its negotiation priority.
+type sslPolicyCipher struct {
+ name string
+ priority int64
+}
+
+// sslPolicyDef is a static security-policy definition served by
+// DescribeSSLPolicies. Spinifex does not terminate TLS yet, so these exist to
+// satisfy listener SslPolicy validation and terraform reads.
+type sslPolicyDef struct {
+ name string
+ protocols []string
+ ciphers []sslPolicyCipher
+}
+
+// sslPolicyCatalog is the fixed set of supported security policies, keyed by
+// name. Static data — no persistence.
+var sslPolicyCatalog = map[string]sslPolicyDef{
+ DefaultSslPolicy: {
+ name: DefaultSslPolicy,
+ protocols: []string{"TLSv1.2"},
+ ciphers: []sslPolicyCipher{
+ {"ECDHE-ECDSA-AES128-GCM-SHA256", 1},
+ {"ECDHE-RSA-AES128-GCM-SHA256", 2},
+ {"ECDHE-ECDSA-AES256-GCM-SHA384", 3},
+ {"ECDHE-RSA-AES256-GCM-SHA384", 4},
+ },
+ },
+ "ELBSecurityPolicy-TLS13-1-2-2021-06": {
+ name: "ELBSecurityPolicy-TLS13-1-2-2021-06",
+ protocols: []string{"TLSv1.3", "TLSv1.2"},
+ ciphers: []sslPolicyCipher{
+ {"TLS_AES_128_GCM_SHA256", 1},
+ {"TLS_AES_256_GCM_SHA384", 2},
+ {"TLS_CHACHA20_POLY1305_SHA256", 3},
+ {"ECDHE-ECDSA-AES128-GCM-SHA256", 4},
+ {"ECDHE-RSA-AES128-GCM-SHA256", 5},
+ },
+ },
+}
+
+// sslPolicyOrder is the deterministic order DescribeSSLPolicies returns the
+// catalog in.
+var sslPolicyOrder = []string{
+ DefaultSslPolicy,
+ "ELBSecurityPolicy-TLS13-1-2-2021-06",
+}
+
+// isKnownSslPolicy reports whether name is in the catalog.
+func isKnownSslPolicy(name string) bool {
+ _, ok := sslPolicyCatalog[name]
+ return ok
+}
+
+// sslPolicyToSDK renders a catalog definition as an SDK SslPolicy.
+func sslPolicyToSDK(def sslPolicyDef) *elbv2.SslPolicy {
+ ciphers := make([]*elbv2.Cipher, 0, len(def.ciphers))
+ for _, c := range def.ciphers {
+ ciphers = append(ciphers, &elbv2.Cipher{
+ Name: aws.String(c.name),
+ Priority: aws.Int64(c.priority),
+ })
+ }
+ protocols := make([]*string, 0, len(def.protocols))
+ for _, p := range def.protocols {
+ protocols = append(protocols, aws.String(p))
+ }
+ return &elbv2.SslPolicy{
+ Name: aws.String(def.name),
+ SslProtocols: protocols,
+ Ciphers: ciphers,
+ SupportedLoadBalancerTypes: []*string{aws.String("application")},
+ }
+}
diff --git a/spinifex/handlers/elbv2/store.go b/spinifex/handlers/elbv2/store.go
index e852dce01..e2c04c012 100644
--- a/spinifex/handlers/elbv2/store.go
+++ b/spinifex/handlers/elbv2/store.go
@@ -92,9 +92,9 @@ func (s *Store) ListLoadBalancers() ([]*LoadBalancerRecord, error) {
}
// GetLoadBalancerByArn finds a load balancer by its ARN via a direct KV lookup
-// on the short ID embedded in the ARN's final path segment. Falls back to a
-// linear scan only if the ARN can't be parsed. Terraform hits Describe*Attributes
-// on every plan/refresh so this must be O(1), not O(n).
+// on the short ID embedded in the ARN's final path segment. Returns (nil, nil)
+// if the ARN can't be parsed or no record matches. Terraform hits
+// Describe*Attributes on every plan/refresh so this must be O(1), not O(n).
func (s *Store) GetLoadBalancerByArn(arn string) (*LoadBalancerRecord, error) {
// ELBv2 LB ARN: arn:aws:elasticloadbalancing:{region}:{account}:loadbalancer/{app,net}/{name}/{lbID}
idx := strings.LastIndex(arn, "/")
@@ -106,11 +106,16 @@ func (s *Store) GetLoadBalancerByArn(arn string) (*LoadBalancerRecord, error) {
if err != nil {
return nil, err
}
+ if lb == nil {
+ return nil, nil
+ }
// Defence-in-depth: ensure the record actually belongs to this ARN.
// Short IDs are random hex, so collisions are effectively impossible, but
// a mismatch here would indicate KV corruption and must not be silently
// served as a successful lookup.
- if lb == nil || lb.LoadBalancerArn != arn {
+ if lb.LoadBalancerArn != arn {
+ slog.Error("load balancer KV record ARN mismatch",
+ "requested_arn", arn, "stored_arn", lb.LoadBalancerArn, "lb_id", lbID)
return nil, nil
}
return lb, nil
@@ -187,7 +192,12 @@ func (s *Store) GetTargetGroupByArn(arn string) (*TargetGroupRecord, error) {
if err != nil {
return nil, err
}
- if tg == nil || tg.TargetGroupArn != arn {
+ if tg == nil {
+ return nil, nil
+ }
+ if tg.TargetGroupArn != arn {
+ slog.Error("target group KV record ARN mismatch",
+ "requested_arn", arn, "stored_arn", tg.TargetGroupArn, "tg_id", tgID)
return nil, nil
}
return tg, nil
@@ -322,18 +332,26 @@ func (s *Store) ListListenersByLB(lbArn string) ([]*ListenerRecord, error) {
return result, nil
}
-// GetListenerByArn finds a listener by its ARN.
+// GetListenerByArn finds a listener by its ARN via a direct KV lookup on the
+// short ID embedded in the ARN's final path segment. See GetLoadBalancerByArn
+// for the motivation — Terraform calls DescribeTags on every aws_lb_listener
+// on every plan, and DescribeTags can carry many ARNs per call, so this must
+// be O(1) per ARN, not an O(n) listener-KV scan.
func (s *Store) GetListenerByArn(arn string) (*ListenerRecord, error) {
- listeners, err := s.ListListeners()
+ // ELBv2 listener ARN: arn:aws:elasticloadbalancing:{region}:{account}:listener/{app,net}/{name}/{lbID}/{listenerID}
+ idx := strings.LastIndex(arn, "/")
+ if idx < 0 || idx == len(arn)-1 {
+ return nil, nil
+ }
+ listenerID := arn[idx+1:]
+ l, err := s.GetListener(listenerID)
if err != nil {
return nil, err
}
- for _, l := range listeners {
- if l.ListenerArn == arn {
- return l, nil
- }
+ if l == nil || l.ListenerArn != arn {
+ return nil, nil
}
- return nil, nil
+ return l, nil
}
// --- Rule CRUD ---
diff --git a/spinifex/handlers/elbv2/store_test.go b/spinifex/handlers/elbv2/store_test.go
index ab9d4bf86..6c1285c90 100644
--- a/spinifex/handlers/elbv2/store_test.go
+++ b/spinifex/handlers/elbv2/store_test.go
@@ -350,6 +350,16 @@ func TestGetListenerByArn(t *testing.T) {
require.NoError(t, err)
require.NotNil(t, got)
assert.Equal(t, l.ListenerID, got.ListenerID)
+
+ got, err = store.GetListenerByArn("arn:nonexistent")
+ require.NoError(t, err)
+ assert.Nil(t, got)
+
+ // ARN that parses to a real listener ID but a different ARN must not match
+ // (defence-in-depth against the parse trick serving a wrong record).
+ got, err = store.GetListenerByArn("arn:aws:elasticloadbalancing:us-east-1:" + testAccountID + ":listener/app/other/lbX/" + l.ListenerID)
+ require.NoError(t, err)
+ assert.Nil(t, got)
}
// --- Cross-resource isolation: shared IDs across record types must not collide ---
diff --git a/spinifex/handlers/elbv2/types.go b/spinifex/handlers/elbv2/types.go
index f0ef8daa3..e11682691 100644
--- a/spinifex/handlers/elbv2/types.go
+++ b/spinifex/handlers/elbv2/types.go
@@ -1,6 +1,9 @@
package handlers_elbv2
-import "time"
+import (
+ "maps"
+ "time"
+)
const (
// LoadBalancer types
@@ -16,6 +19,10 @@ const (
StateActive = "active"
StateFailed = "failed"
+ // Target group target types (v1 supports instance and ip)
+ TargetTypeInstance = "instance"
+ TargetTypeIP = "ip"
+
// Target health states
TargetHealthInitial = "initial"
TargetHealthHealthy = "healthy"
@@ -33,8 +40,20 @@ const (
ProtocolTLS = "TLS"
ProtocolTCPUDP = "TCP_UDP"
+ // Data-plane engines. ALBs (L7) run HAProxy; NLBs (L4) run nginx `stream`
+ // because HAProxy cannot load-balance UDP. The agent learns which engine to
+ // run from the GetLBConfig response.
+ EngineHAProxy = "haproxy"
+ EngineNginx = "nginx"
+
+ // DefaultSslPolicy is applied to a secure listener when the caller does
+ // not specify an SslPolicy.
+ DefaultSslPolicy = "ELBSecurityPolicy-2016-08"
+
// Listener action types
- ActionTypeForward = "forward"
+ ActionTypeForward = "forward"
+ ActionTypeFixedResponse = "fixed-response"
+ ActionTypeRedirect = "redirect"
// Rule condition fields
RuleFieldHostHeader = "host-header"
@@ -52,7 +71,7 @@ const (
MaxRulesPerListener = 100
MaxConditionsPerRule = 5
MaxValuesPerCondition = 5
- MaxActionsPerRule = 1 // forward only; mulga-951 may bump
+ MaxActionsPerRule = 1 // single terminal action: forward, redirect, or fixed-response
MaxConditionValueLen = 128
MaxHTTPHeaderNameLen = 40
@@ -81,30 +100,52 @@ const (
// LoadBalancerRecord represents a stored load balancer (ALB or NLB).
type LoadBalancerRecord struct {
- LoadBalancerArn string `json:"load_balancer_arn"`
- LoadBalancerID string `json:"load_balancer_id"` // Short ID (hex suffix)
- DNSName string `json:"dns_name"`
- Name string `json:"name"`
- Scheme string `json:"scheme"` // "internet-facing" or "internal"
- Type string `json:"type"` // Always "application"
- State string `json:"state"` // "provisioning", "active", "failed"
- VpcId string `json:"vpc_id"`
- SecurityGroups []string `json:"security_groups"`
- Subnets []string `json:"subnets"`
- AvailZones []AvailZoneInfo `json:"availability_zones"`
- ENIs []string `json:"enis,omitempty"` // ENI IDs created for this ALB (internal)
- InstanceID string `json:"instance_id,omitempty"` // ALB VM instance ID (system-managed)
- VPCIP string `json:"vpc_ip,omitempty"` // VPC private IP of the ALB VM
- ConfigText string `json:"config_text,omitempty"` // Pre-computed HAProxy config
- ConfigHash string `json:"config_hash,omitempty"` // SHA256 of ConfigText
- LastHeartbeat time.Time `json:"last_heartbeat"` // Last agent heartbeat timestamp
- HostPorts map[int]int `json:"host_ports,omitempty"` // Dev mode: guest port → host port forwarding
- NodeID string `json:"node_id"` // Daemon node running this ALB
- IPAddressType string `json:"ip_address_type"` // "ipv4"
- Attributes map[string]string `json:"attributes,omitempty"`
- Tags map[string]string `json:"tags,omitempty"`
- AccountID string `json:"account_id"`
- CreatedAt time.Time `json:"created_at"`
+ LoadBalancerArn string `json:"load_balancer_arn"`
+ LoadBalancerID string `json:"load_balancer_id"` // Short ID (hex suffix)
+ DNSName string `json:"dns_name"`
+ Name string `json:"name"`
+ Scheme string `json:"scheme"` // "internet-facing" or "internal"
+ Type string `json:"type"` // Always "application"
+ State string `json:"state"` // "provisioning", "active", "failed"
+ VpcId string `json:"vpc_id"`
+ SecurityGroups []string `json:"security_groups"`
+ // NLBManagedSGID is the internal security group the ELBv2 service mints for
+ // a network LB (NLBs reject customer SGs, so this is created and owned here).
+ // It is attached to every LB ENI and is the SG listener-port ingress is
+ // authorized on. Not surfaced by DescribeLoadBalancers.
+ NLBManagedSGID string `json:"nlb_managed_sg_id,omitempty"`
+ // NLBIngressCIDRs overrides the scheme-based default client CIDRs that
+ // listener ports are opened to on NLBManagedSGID. Empty ⇒ default
+ // (internet-facing: 0.0.0.0/0; internal: the VPC CIDR).
+ NLBIngressCIDRs []string `json:"nlb_ingress_cidrs,omitempty"`
+ Subnets []string `json:"subnets"`
+ AvailZones []AvailZoneInfo `json:"availability_zones"`
+ ENIs []string `json:"enis,omitempty"` // ENI IDs created for this ALB (internal)
+ InstanceID string `json:"instance_id,omitempty"` // ALB VM instance ID (system-managed)
+ VPCIP string `json:"vpc_ip,omitempty"` // VPC private IP of the ALB VM
+ ConfigText string `json:"config_text,omitempty"` // Pre-computed HAProxy config
+ ConfigHash string `json:"config_hash,omitempty"` // SHA256 of ConfigText + cert material
+ CertFiles map[string]string `json:"cert_files,omitempty"` // Absolute path → combined PEM (cert+chain+key), delivered with config
+ HealthTargets []HealthTargetSpec `json:"health_targets,omitempty"` // NLB only: backends the nginx agent actively probes (HAProxy reports its own)
+ LastHeartbeat time.Time `json:"last_heartbeat"` // Last agent heartbeat timestamp
+ HostPorts map[int]int `json:"host_ports,omitempty"` // Dev mode: guest port → host port forwarding
+ NodeID string `json:"node_id"` // Daemon node running this ALB
+ IPAddressType string `json:"ip_address_type"` // "ipv4"
+ Attributes map[string]string `json:"attributes,omitempty"`
+ Tags map[string]string `json:"tags,omitempty"`
+ AccountID string `json:"account_id"`
+ CreatedAt time.Time `json:"created_at"`
+}
+
+// HealthTargetSpec is a backend the nginx agent actively health-checks. It is
+// computed when the NLB config is stored and delivered to the agent via
+// GetLBConfig. ServerName matches the health checker's key
+// (sanitizeName("srv", target.Id)).
+type HealthTargetSpec struct {
+ ServerName string `json:"server_name"`
+ Address string `json:"address"` // ip:port to probe
+ Protocol string `json:"protocol"` // TCP | HTTP | HTTPS
+ Path string `json:"path,omitempty"`
}
// AvailZoneInfo tracks subnet-to-AZ mapping for a load balancer.
@@ -122,7 +163,7 @@ type TargetGroupRecord struct {
Protocol string `json:"protocol"` // "HTTP" or "HTTPS"
Port int64 `json:"port"` // Default target port
VpcId string `json:"vpc_id"`
- TargetType string `json:"target_type"` // "instance" for v1
+ TargetType string `json:"target_type"` // TargetTypeInstance or TargetTypeIP
HealthCheck HealthCheckConfig `json:"health_check"`
Targets []Target `json:"targets"`
Attributes map[string]string `json:"attributes,omitempty"`
@@ -175,11 +216,11 @@ func DefaultNLBHealthCheck() HealthCheckConfig {
// Target represents a registered target in a target group.
type Target struct {
- Id string `json:"id"` // Instance ID (e.g. i-xxxxx)
+ Id string `json:"id"` // Instance ID (i-xxxxx) or IP for ip-type TGs
Port int64 `json:"port"` // Override port (0 = use TG default)
HealthState string `json:"health_state"` // "initial", "healthy", "unhealthy", "draining"
HealthDesc string `json:"health_desc"` // Reason for current state
- PrivateIP string `json:"private_ip"` // Resolved from instance ENI
+ PrivateIP string `json:"private_ip"` // Instance ENI IP, or the target IP for ip-type TGs
}
// ListenerRecord represents a stored Listener.
@@ -192,12 +233,50 @@ type ListenerRecord struct {
DefaultActions []ListenerAction `json:"default_actions"`
AccountID string `json:"account_id"`
CreatedAt time.Time `json:"created_at"`
+
+ // Certificates holds the listener's TLS certificates for a secure
+ // protocol (ALB HTTPS / NLB TLS). The first IsDefault entry is the
+ // default cert; the rest are additional SNI certs. Empty for non-secure
+ // listeners. SslPolicy is the negotiated security policy name.
+ Certificates []ListenerCertificate `json:"certificates,omitempty"`
+ SslPolicy string `json:"ssl_policy,omitempty"`
+
+ Tags map[string]string `json:"tags,omitempty"`
}
-// ListenerAction defines a listener's default action.
+// ListenerCertificate is a TLS certificate reference attached to a listener.
+type ListenerCertificate struct {
+ CertificateArn string `json:"certificate_arn"`
+ IsDefault bool `json:"is_default"`
+}
+
+// ListenerAction defines a listener default action or a rule action.
type ListenerAction struct {
- Type string `json:"type"` // "forward"
+ Type string `json:"type"` // "forward", "redirect", or "fixed-response"
TargetGroupArn string `json:"target_group_arn"`
+ // FixedResponse is populated when Type == "fixed-response" (no target group).
+ FixedResponse *FixedResponseAction `json:"fixed_response,omitempty"`
+ // Redirect is populated when Type == "redirect".
+ Redirect *RedirectAction `json:"redirect,omitempty"`
+}
+
+// FixedResponseAction holds the canned reply for a "fixed-response" action.
+type FixedResponseAction struct {
+ StatusCode string `json:"status_code"`
+ ContentType string `json:"content_type,omitempty"`
+ MessageBody string `json:"message_body,omitempty"`
+}
+
+// RedirectAction holds the target for a "redirect" action. Fields mirror the
+// AWS RedirectActionConfig and may contain the `#{protocol}`, `#{host}`,
+// `#{port}`, `#{path}`, and `#{query}` placeholders.
+type RedirectAction struct {
+ Protocol string `json:"protocol,omitempty"`
+ Host string `json:"host,omitempty"`
+ Port string `json:"port,omitempty"`
+ Path string `json:"path,omitempty"`
+ Query string `json:"query,omitempty"`
+ StatusCode string `json:"status_code"` // "HTTP_301" or "HTTP_302"
}
// RuleRecord represents a stored listener rule.
@@ -210,6 +289,8 @@ type RuleRecord struct {
Actions []ListenerAction `json:"actions"`
AccountID string `json:"account_id"`
CreatedAt time.Time `json:"created_at"`
+
+ Tags map[string]string `json:"tags,omitempty"`
}
// RuleCondition is one routing predicate on a rule. Only the field block
@@ -240,50 +321,71 @@ type RuleQueryStringKV struct {
// provider sends every attribute it knows about, and any key missing here
// gets rejected with ValidationError, surfacing as "UnknownError" in tofu.
func DefaultLoadBalancerAttributes(lbType string) map[string]string {
- // Common to all load balancer types.
- attrs := map[string]string{
- "deletion_protection.enabled": "false",
- "load_balancing.cross_zone.enabled": "false",
- }
-
switch lbType {
case LoadBalancerTypeApplication:
- // ALB-specific attributes. Defaults match real AWS values as of the
- // aws-sdk-go 1.55 elbv2 API documentation.
- attrs["load_balancing.cross_zone.enabled"] = "true"
- attrs["access_logs.s3.enabled"] = "false"
- attrs["access_logs.s3.bucket"] = ""
- attrs["access_logs.s3.prefix"] = ""
- attrs["connection_logs.s3.enabled"] = "false"
- attrs["connection_logs.s3.bucket"] = ""
- attrs["connection_logs.s3.prefix"] = ""
- attrs["idle_timeout.timeout_seconds"] = "60"
- attrs["client_keep_alive.seconds"] = "3600"
- attrs["routing.http.desync_mitigation_mode"] = "defensive"
- attrs["routing.http.drop_invalid_header_fields.enabled"] = "false"
- attrs["routing.http.preserve_host_header.enabled"] = "false"
- attrs["routing.http.x_amzn_tls_version_and_cipher_suite.enabled"] = "false"
- attrs["routing.http.xff_client_port.enabled"] = "false"
- attrs["routing.http.xff_header_processing.mode"] = "append"
- attrs["routing.http2.enabled"] = "true"
- attrs["waf.fail_open.enabled"] = "false"
- attrs["zonal_shift.config.enabled"] = "false"
+ return maps.Clone(albAttributeDefaults)
case LoadBalancerTypeNetwork:
- // NLB-specific attributes.
- attrs["access_logs.s3.enabled"] = "false"
- attrs["access_logs.s3.bucket"] = ""
- attrs["access_logs.s3.prefix"] = ""
- attrs["dns_record.client_routing_policy"] = "any_availability_zone"
- attrs["ipv6.deny_all_igw_traffic"] = "false"
- attrs["zonal_shift.config.enabled"] = "false"
+ return maps.Clone(nlbAttributeDefaults)
+ default:
+ return maps.Clone(lbBaseAttributeDefaults)
}
-
- return attrs
}
// DefaultTargetGroupAttributes returns the default attribute set for target groups.
func DefaultTargetGroupAttributes() map[string]string {
- return map[string]string{
+ return maps.Clone(targetGroupAttributeDefaults)
+}
+
+// Default attribute sets are package-level so Describe calls clone a prebuilt
+// map instead of rebuilding it on every request. Callers receive a clone, never
+// the shared map, so mutation cannot leak back into the defaults.
+//
+// access_logs.s3.bucket / .prefix (and the connection_logs equivalents) default
+// to empty strings: that matches real AWS, where logging is disabled by default
+// and the bucket/prefix are unset until the operator opts in. Terraform sends
+// these empty strings on every apply, so they must be present as known keys.
+var (
+ // Common to all load balancer types; also the fallback for unknown types.
+ lbBaseAttributeDefaults = map[string]string{
+ "deletion_protection.enabled": "false",
+ "load_balancing.cross_zone.enabled": "false",
+ }
+
+ // ALB defaults match real AWS values as of the aws-sdk-go 1.55 elbv2 API.
+ albAttributeDefaults = map[string]string{
+ "deletion_protection.enabled": "false",
+ "load_balancing.cross_zone.enabled": "true",
+ "access_logs.s3.enabled": "false",
+ "access_logs.s3.bucket": "",
+ "access_logs.s3.prefix": "",
+ "connection_logs.s3.enabled": "false",
+ "connection_logs.s3.bucket": "",
+ "connection_logs.s3.prefix": "",
+ "idle_timeout.timeout_seconds": "60",
+ "client_keep_alive.seconds": "3600",
+ "routing.http.desync_mitigation_mode": "defensive",
+ "routing.http.drop_invalid_header_fields.enabled": "false",
+ "routing.http.preserve_host_header.enabled": "false",
+ "routing.http.x_amzn_tls_version_and_cipher_suite.enabled": "false",
+ "routing.http.xff_client_port.enabled": "false",
+ "routing.http.xff_header_processing.mode": "append",
+ "routing.http2.enabled": "true",
+ "waf.fail_open.enabled": "false",
+ "zonal_shift.config.enabled": "false",
+ }
+
+ nlbAttributeDefaults = map[string]string{
+ "deletion_protection.enabled": "false",
+ "load_balancing.cross_zone.enabled": "false",
+ "access_logs.s3.enabled": "false",
+ "access_logs.s3.bucket": "",
+ "access_logs.s3.prefix": "",
+ "dns_record.client_routing_policy": "any_availability_zone",
+ "ipv6.deny_all_igw_traffic": "false",
+ "zonal_shift.config.enabled": "false",
+ }
+
+ targetGroupAttributeDefaults = map[string]string{
"deregistration_delay.timeout_seconds": "300",
"stickiness.enabled": "false",
"stickiness.type": "lb_cookie",
@@ -292,4 +394,4 @@ func DefaultTargetGroupAttributes() map[string]string {
"load_balancing.algorithm.type": "round_robin",
"slow_start.duration_seconds": "0",
}
-}
+)
diff --git a/spinifex/handlers/iam/imds_rpc.go b/spinifex/handlers/iam/imds_rpc.go
new file mode 100644
index 000000000..9cb1bc48e
--- /dev/null
+++ b/spinifex/handlers/iam/imds_rpc.go
@@ -0,0 +1,57 @@
+package handlers_iam
+
+import (
+ "github.com/aws/aws-sdk-go/service/iam"
+ "github.com/mulgadc/spinifex/spinifex/utils"
+ "github.com/nats-io/nats.go"
+)
+
+// imdsResponderQueue load-balances the IMDS control-plane RPCs across every
+// awsgw in the cluster, matching the cluster-wide worker convention.
+const imdsResponderQueue = "spinifex-workers"
+
+// SubjectResolveInstanceProfile and SubjectGetRole are the internal, ACL-gated
+// subjects the IMDS handler (in vpcd) calls to resolve an instance profile and its
+// role from awsgw's in-process IAM. Only these two read RPCs are surfaced.
+const (
+ SubjectResolveInstanceProfile = "imds.iam.resolve_instance_profile"
+ SubjectGetRole = "imds.iam.get_role"
+)
+
+// ResolveInstanceProfileRequest is the SubjectResolveInstanceProfile payload.
+type ResolveInstanceProfileRequest struct {
+ AccountID string `json:"account_id"`
+ NameOrARN string `json:"name_or_arn"`
+}
+
+// GetRoleRequest is the SubjectGetRole payload.
+type GetRoleRequest struct {
+ AccountID string `json:"account_id"`
+ Input *iam.GetRoleInput `json:"input"`
+}
+
+// SubscribeIMDSResponders wires the profile/role responders onto nc,
+// queue-grouped so any awsgw can answer. The returned subscriptions are for
+// caller-side cleanup.
+func (s *IAMServiceImpl) SubscribeIMDSResponders(nc *nats.Conn) ([]*nats.Subscription, error) {
+ profileSub, err := nc.QueueSubscribe(SubjectResolveInstanceProfile, imdsResponderQueue, func(msg *nats.Msg) {
+ utils.ServeNATSRequest(msg, func(req *ResolveInstanceProfileRequest) (*InstanceProfile, error) {
+ return s.ResolveInstanceProfile(req.AccountID, req.NameOrARN)
+ })
+ })
+ if err != nil {
+ return nil, err
+ }
+
+ roleSub, err := nc.QueueSubscribe(SubjectGetRole, imdsResponderQueue, func(msg *nats.Msg) {
+ utils.ServeNATSRequest(msg, func(req *GetRoleRequest) (*iam.GetRoleOutput, error) {
+ return s.GetRole(req.AccountID, req.Input)
+ })
+ })
+ if err != nil {
+ _ = profileSub.Unsubscribe()
+ return nil, err
+ }
+
+ return []*nats.Subscription{profileSub, roleSub}, nil
+}
diff --git a/spinifex/handlers/iam/instance_profiles.go b/spinifex/handlers/iam/instance_profiles.go
index a4b86ead1..12a445d7f 100644
--- a/spinifex/handlers/iam/instance_profiles.go
+++ b/spinifex/handlers/iam/instance_profiles.go
@@ -42,13 +42,7 @@ func (s *IAMServiceImpl) CreateInstanceProfile(accountID string, input *iam.Crea
ARN: fmt.Sprintf("arn:aws:iam::%s:instance-profile%s%s", accountID, path, profileName),
Path: path,
CreatedAt: time.Now().UTC().Format(time.RFC3339),
- Tags: []Tag{},
- }
-
- for _, tag := range input.Tags {
- if tag.Key != nil && tag.Value != nil {
- profile.Tags = append(profile.Tags, Tag{Key: *tag.Key, Value: *tag.Value})
- }
+ Tags: copyTags(input.Tags),
}
data, err := json.Marshal(profile)
diff --git a/spinifex/handlers/iam/oidc_providers.go b/spinifex/handlers/iam/oidc_providers.go
index 8ddd65ccf..c1e8109fa 100644
--- a/spinifex/handlers/iam/oidc_providers.go
+++ b/spinifex/handlers/iam/oidc_providers.go
@@ -3,13 +3,25 @@ package handlers_iam
import (
"crypto/sha256"
"encoding/hex"
+ "encoding/json"
+ "errors"
"fmt"
+ "log/slog"
+ "net/url"
+ "strings"
+ "time"
+
+ "github.com/aws/aws-sdk-go/aws"
+ "github.com/aws/aws-sdk-go/service/iam"
+ "github.com/mulgadc/spinifex/spinifex/awserrors"
+ "github.com/mulgadc/spinifex/spinifex/utils"
+ "github.com/nats-io/nats.go"
)
// Per-account IAM JetStream KV bucket holds resources that are inherently
// account-scoped and not appropriate for the global IAM buckets — currently
// the OIDC identity-provider registry consumed by STS AssumeRoleWithWebIdentity
-// and (in a later sprint) by IAM's CreateOpenIDConnectProvider CRUD.
+// and by IAM's CreateOpenIDConnectProvider CRUD.
//
// Lazy creation: callers that read MUST tolerate ErrBucketNotFound (no provider
// has been registered for the account yet); callers that write MUST use
@@ -52,3 +64,241 @@ func OIDCProviderKey(issuer string) string {
func OIDCProviderARN(accountID, issuerHostPath string) string {
return fmt.Sprintf("arn:aws:iam::%s:oidc-provider/%s", accountID, issuerHostPath)
}
+
+// GetOrCreateIAMAccountBucket opens the per-account IAM bucket, creating it on
+// first use. Writers (CreateOpenIDConnectProvider) MUST use this; readers that
+// must tolerate a not-yet-created bucket should call js.KeyValue directly and
+// treat nats.ErrBucketNotFound as "empty".
+func GetOrCreateIAMAccountBucket(js nats.JetStreamContext, accountID string, replicas int) (nats.KeyValue, error) {
+ return getOrCreateBucket(js, IAMAccountBucketName(accountID), 1, max(replicas, 1))
+}
+
+// OIDCProviderRecord is the stored shape of a registered OIDC identity provider.
+// Url carries the full issuer URL including scheme (the exact string the
+// cluster apiserver places in the token `iss` claim); the KV key is its hash,
+// and the provider ARN is derived by stripping the scheme.
+type OIDCProviderRecord struct {
+ Url string `json:"url"`
+ ClientIDList []string `json:"clientIDList,omitempty"`
+ ThumbprintList []string `json:"thumbprintList,omitempty"`
+ CreatedAt string `json:"createdAt"`
+ Tags []Tag `json:"tags,omitempty"`
+}
+
+// issuerFromOIDCProviderARN reverses OIDCProviderARN: it extracts the
+// scheme-less host/path suffix and prepends https:// to reconstruct the issuer
+// URL (and therefore its KV key). v1 issuers are always https, so this
+// round-trips Create's input.Url.
+func issuerFromOIDCProviderARN(arn string) (string, error) {
+ _, hostPath, ok := strings.Cut(arn, ":oidc-provider/")
+ if !ok {
+ return "", errors.New("not an oidc-provider ARN")
+ }
+ if hostPath == "" {
+ return "", errors.New("oidc-provider ARN missing host/path")
+ }
+ return "https://" + hostPath, nil
+}
+
+// validateOIDCProviderURL enforces the v1 issuer shape: a parseable https URL
+// with a host. We are the IdP, so no thumbprint/cert-chain validation applies.
+func validateOIDCProviderURL(raw string) error {
+ if raw == "" {
+ return errors.New("url is required")
+ }
+ u, err := url.Parse(raw)
+ if err != nil {
+ return fmt.Errorf("parse url: %w", err)
+ }
+ if u.Scheme != "https" {
+ return fmt.Errorf("url scheme must be https, got %q", u.Scheme)
+ }
+ if u.Host == "" {
+ return errors.New("url missing host")
+ }
+ return nil
+}
+
+func awsStrings(in []*string) []string {
+ out := make([]string, 0, len(in))
+ for _, s := range in {
+ if s != nil {
+ out = append(out, *s)
+ }
+ }
+ return out
+}
+
+func (s *IAMServiceImpl) CreateOpenIDConnectProvider(accountID string, input *iam.CreateOpenIDConnectProviderInput) (*iam.CreateOpenIDConnectProviderOutput, error) {
+ issuer := aws.StringValue(input.Url)
+ if err := validateOIDCProviderURL(issuer); err != nil {
+ slog.Debug("CreateOpenIDConnectProvider: invalid Url", "url", issuer, "err", err)
+ return nil, errors.New(awserrors.ErrorIAMInvalidInput)
+ }
+
+ record := OIDCProviderRecord{
+ Url: issuer,
+ ClientIDList: awsStrings(input.ClientIDList),
+ ThumbprintList: awsStrings(input.ThumbprintList),
+ CreatedAt: time.Now().UTC().Format(time.RFC3339),
+ Tags: copyTags(input.Tags),
+ }
+ data, err := json.Marshal(record)
+ if err != nil {
+ return nil, fmt.Errorf("marshal OIDC provider: %w", err)
+ }
+
+ kv, err := GetOrCreateIAMAccountBucket(s.js, accountID, s.replicas)
+ if err != nil {
+ return nil, fmt.Errorf("open IAM account bucket: %w", err)
+ }
+
+ if _, err := kv.Create(OIDCProviderKey(issuer), data); err != nil {
+ if errors.Is(err, nats.ErrKeyExists) {
+ return nil, errors.New(awserrors.ErrorIAMEntityAlreadyExists)
+ }
+ return nil, fmt.Errorf("store OIDC provider: %w", err)
+ }
+
+ arn := OIDCProviderARN(accountID, stripIssuerScheme(issuer))
+ slog.Info("IAM OIDC provider created", "accountID", accountID, "url", issuer, "arn", arn)
+ return &iam.CreateOpenIDConnectProviderOutput{
+ OpenIDConnectProviderArn: aws.String(arn),
+ Tags: tagsToSDK(record.Tags),
+ }, nil
+}
+
+func (s *IAMServiceImpl) GetOpenIDConnectProvider(accountID string, input *iam.GetOpenIDConnectProviderInput) (*iam.GetOpenIDConnectProviderOutput, error) {
+ record, err := s.getOIDCProvider(accountID, aws.StringValue(input.OpenIDConnectProviderArn))
+ if err != nil {
+ return nil, err
+ }
+ return &iam.GetOpenIDConnectProviderOutput{
+ Url: aws.String(stripIssuerScheme(record.Url)),
+ ClientIDList: aws.StringSlice(record.ClientIDList),
+ ThumbprintList: aws.StringSlice(record.ThumbprintList),
+ CreateDate: aws.Time(parseCreatedAt(record.CreatedAt)),
+ Tags: tagsToSDK(record.Tags),
+ }, nil
+}
+
+func (s *IAMServiceImpl) ListOpenIDConnectProviders(accountID string, _ *iam.ListOpenIDConnectProvidersInput) (*iam.ListOpenIDConnectProvidersOutput, error) {
+ out := &iam.ListOpenIDConnectProvidersOutput{
+ OpenIDConnectProviderList: []*iam.OpenIDConnectProviderListEntry{},
+ }
+
+ kv, err := s.js.KeyValue(IAMAccountBucketName(accountID))
+ if err != nil {
+ if errors.Is(err, nats.ErrBucketNotFound) {
+ return out, nil
+ }
+ return nil, fmt.Errorf("open IAM account bucket: %w", err)
+ }
+
+ keys, err := kv.Keys()
+ if err != nil {
+ if errors.Is(err, nats.ErrNoKeysFound) {
+ return out, nil
+ }
+ return nil, fmt.Errorf("list OIDC provider keys: %w", err)
+ }
+
+ for _, key := range keys {
+ if key == utils.VersionKey || !strings.HasPrefix(key, oidcProvidersKeyPrefix) {
+ continue
+ }
+ entry, err := kv.Get(key)
+ if err != nil {
+ if errors.Is(err, nats.ErrKeyNotFound) {
+ continue
+ }
+ slog.Warn("ListOpenIDConnectProviders: get failed", "key", key, "err", err)
+ continue
+ }
+ var record OIDCProviderRecord
+ if err := json.Unmarshal(entry.Value(), &record); err != nil {
+ slog.Warn("ListOpenIDConnectProviders: unmarshal failed", "key", key, "err", err)
+ continue
+ }
+ out.OpenIDConnectProviderList = append(out.OpenIDConnectProviderList,
+ &iam.OpenIDConnectProviderListEntry{
+ Arn: aws.String(OIDCProviderARN(accountID, stripIssuerScheme(record.Url))),
+ })
+ }
+ return out, nil
+}
+
+func (s *IAMServiceImpl) DeleteOpenIDConnectProvider(accountID string, input *iam.DeleteOpenIDConnectProviderInput) (*iam.DeleteOpenIDConnectProviderOutput, error) {
+ arn := aws.StringValue(input.OpenIDConnectProviderArn)
+ issuer, err := issuerFromOIDCProviderARN(arn)
+ if err != nil {
+ return nil, errors.New(awserrors.ErrorIAMNoSuchEntity)
+ }
+
+ kv, err := s.js.KeyValue(IAMAccountBucketName(accountID))
+ if err != nil {
+ if errors.Is(err, nats.ErrBucketNotFound) {
+ return nil, errors.New(awserrors.ErrorIAMNoSuchEntity)
+ }
+ return nil, fmt.Errorf("open IAM account bucket: %w", err)
+ }
+
+ key := OIDCProviderKey(issuer)
+ if _, err := kv.Get(key); err != nil {
+ if errors.Is(err, nats.ErrKeyNotFound) {
+ return nil, errors.New(awserrors.ErrorIAMNoSuchEntity)
+ }
+ return nil, fmt.Errorf("get OIDC provider: %w", err)
+ }
+ if err := kv.Delete(key); err != nil {
+ return nil, fmt.Errorf("delete OIDC provider: %w", err)
+ }
+
+ slog.Info("IAM OIDC provider deleted", "accountID", accountID, "arn", arn)
+ return &iam.DeleteOpenIDConnectProviderOutput{}, nil
+}
+
+func (s *IAMServiceImpl) getOIDCProvider(accountID, arn string) (*OIDCProviderRecord, error) {
+ issuer, err := issuerFromOIDCProviderARN(arn)
+ if err != nil {
+ return nil, errors.New(awserrors.ErrorIAMNoSuchEntity)
+ }
+ kv, err := s.js.KeyValue(IAMAccountBucketName(accountID))
+ if err != nil {
+ if errors.Is(err, nats.ErrBucketNotFound) {
+ return nil, errors.New(awserrors.ErrorIAMNoSuchEntity)
+ }
+ return nil, fmt.Errorf("open IAM account bucket: %w", err)
+ }
+ entry, err := kv.Get(OIDCProviderKey(issuer))
+ if err != nil {
+ if errors.Is(err, nats.ErrKeyNotFound) {
+ return nil, errors.New(awserrors.ErrorIAMNoSuchEntity)
+ }
+ return nil, fmt.Errorf("get OIDC provider: %w", err)
+ }
+ var record OIDCProviderRecord
+ if err := json.Unmarshal(entry.Value(), &record); err != nil {
+ return nil, fmt.Errorf("unmarshal OIDC provider: %w", err)
+ }
+ return &record, nil
+}
+
+// stripIssuerScheme drops the https:// prefix to produce the scheme-less
+// host/path form AWS uses in the oidc-provider ARN suffix.
+func stripIssuerScheme(issuer string) string {
+ return strings.TrimPrefix(issuer, "https://")
+}
+
+// tagsToSDK converts stored Tags into the SDK shape used by OIDC-provider
+// responses.
+func tagsToSDK(tags []Tag) []*iam.Tag {
+ if len(tags) == 0 {
+ return nil
+ }
+ out := make([]*iam.Tag, 0, len(tags))
+ for _, t := range tags {
+ out = append(out, &iam.Tag{Key: aws.String(t.Key), Value: aws.String(t.Value)})
+ }
+ return out
+}
diff --git a/spinifex/handlers/iam/oidc_providers_test.go b/spinifex/handlers/iam/oidc_providers_test.go
new file mode 100644
index 000000000..f1f886a6a
--- /dev/null
+++ b/spinifex/handlers/iam/oidc_providers_test.go
@@ -0,0 +1,173 @@
+package handlers_iam
+
+import (
+ "testing"
+
+ "github.com/aws/aws-sdk-go/aws"
+ "github.com/aws/aws-sdk-go/service/iam"
+ "github.com/mulgadc/spinifex/spinifex/awserrors"
+)
+
+const testIssuer = "https://10.0.0.1:9999/oidc/eks/ap-southeast-2/000000000001/h1"
+
+func TestCreateOpenIDConnectProvider(t *testing.T) {
+ svc := setupTestIAMService(t)
+ acct := "000000000001"
+
+ out, err := svc.CreateOpenIDConnectProvider(acct, &iam.CreateOpenIDConnectProviderInput{
+ Url: aws.String(testIssuer),
+ ClientIDList: aws.StringSlice([]string{"sts.amazonaws.com"}),
+ })
+ if err != nil {
+ t.Fatalf("CreateOpenIDConnectProvider: %v", err)
+ }
+
+ wantARN := OIDCProviderARN(acct, stripIssuerScheme(testIssuer))
+ if aws.StringValue(out.OpenIDConnectProviderArn) != wantARN {
+ t.Fatalf("ARN = %q, want %q", aws.StringValue(out.OpenIDConnectProviderArn), wantARN)
+ }
+
+ // The registry key STS reads must now exist for this exact issuer.
+ kv, err := svc.js.KeyValue(IAMAccountBucketName(acct))
+ if err != nil {
+ t.Fatalf("open account bucket: %v", err)
+ }
+ if _, err := kv.Get(OIDCProviderKey(testIssuer)); err != nil {
+ t.Fatalf("provider key not written for issuer: %v", err)
+ }
+}
+
+func TestCreateOpenIDConnectProvider_Idempotent(t *testing.T) {
+ svc := setupTestIAMService(t)
+ acct := "000000000001"
+ in := &iam.CreateOpenIDConnectProviderInput{Url: aws.String(testIssuer)}
+
+ if _, err := svc.CreateOpenIDConnectProvider(acct, in); err != nil {
+ t.Fatalf("first create: %v", err)
+ }
+ _, err := svc.CreateOpenIDConnectProvider(acct, in)
+ if err == nil || err.Error() != awserrors.ErrorIAMEntityAlreadyExists {
+ t.Fatalf("duplicate create err = %v, want EntityAlreadyExists", err)
+ }
+}
+
+func TestCreateOpenIDConnectProvider_InvalidURL(t *testing.T) {
+ svc := setupTestIAMService(t)
+ for _, bad := range []string{"", "http://insecure.example", "://nohost", "ftp://x"} {
+ _, err := svc.CreateOpenIDConnectProvider("000000000001",
+ &iam.CreateOpenIDConnectProviderInput{Url: aws.String(bad)})
+ if err == nil || err.Error() != awserrors.ErrorIAMInvalidInput {
+ t.Fatalf("url %q: err = %v, want InvalidInput", bad, err)
+ }
+ }
+}
+
+func TestGetOpenIDConnectProvider(t *testing.T) {
+ svc := setupTestIAMService(t)
+ acct := "000000000001"
+ created, err := svc.CreateOpenIDConnectProvider(acct, &iam.CreateOpenIDConnectProviderInput{
+ Url: aws.String(testIssuer),
+ ClientIDList: aws.StringSlice([]string{"sts.amazonaws.com"}),
+ })
+ if err != nil {
+ t.Fatalf("create: %v", err)
+ }
+
+ got, err := svc.GetOpenIDConnectProvider(acct, &iam.GetOpenIDConnectProviderInput{
+ OpenIDConnectProviderArn: created.OpenIDConnectProviderArn,
+ })
+ if err != nil {
+ t.Fatalf("get: %v", err)
+ }
+ if aws.StringValue(got.Url) != stripIssuerScheme(testIssuer) {
+ t.Fatalf("Url = %q, want %q", aws.StringValue(got.Url), stripIssuerScheme(testIssuer))
+ }
+ if len(got.ClientIDList) != 1 || aws.StringValue(got.ClientIDList[0]) != "sts.amazonaws.com" {
+ t.Fatalf("ClientIDList = %v", aws.StringValueSlice(got.ClientIDList))
+ }
+}
+
+func TestGetOpenIDConnectProvider_NotFound(t *testing.T) {
+ svc := setupTestIAMService(t)
+ _, err := svc.GetOpenIDConnectProvider("000000000001", &iam.GetOpenIDConnectProviderInput{
+ OpenIDConnectProviderArn: aws.String(OIDCProviderARN("000000000001", "10.0.0.1:9999/oidc/eks/x/y/z")),
+ })
+ if err == nil || err.Error() != awserrors.ErrorIAMNoSuchEntity {
+ t.Fatalf("err = %v, want NoSuchEntity", err)
+ }
+}
+
+func TestListOpenIDConnectProviders(t *testing.T) {
+ svc := setupTestIAMService(t)
+ acct := "000000000001"
+
+ // Empty account (bucket not yet created) lists nothing, not an error.
+ empty, err := svc.ListOpenIDConnectProviders(acct, &iam.ListOpenIDConnectProvidersInput{})
+ if err != nil {
+ t.Fatalf("list empty: %v", err)
+ }
+ if len(empty.OpenIDConnectProviderList) != 0 {
+ t.Fatalf("empty list = %d entries", len(empty.OpenIDConnectProviderList))
+ }
+
+ issuers := []string{testIssuer, "https://10.0.0.1:9999/oidc/eks/ap-southeast-2/000000000001/h2"}
+ for _, iss := range issuers {
+ if _, err := svc.CreateOpenIDConnectProvider(acct, &iam.CreateOpenIDConnectProviderInput{Url: aws.String(iss)}); err != nil {
+ t.Fatalf("create %s: %v", iss, err)
+ }
+ }
+
+ out, err := svc.ListOpenIDConnectProviders(acct, &iam.ListOpenIDConnectProvidersInput{})
+ if err != nil {
+ t.Fatalf("list: %v", err)
+ }
+ if len(out.OpenIDConnectProviderList) != 2 {
+ t.Fatalf("list = %d entries, want 2", len(out.OpenIDConnectProviderList))
+ }
+ want := map[string]bool{
+ OIDCProviderARN(acct, stripIssuerScheme(issuers[0])): true,
+ OIDCProviderARN(acct, stripIssuerScheme(issuers[1])): true,
+ }
+ for _, e := range out.OpenIDConnectProviderList {
+ if !want[aws.StringValue(e.Arn)] {
+ t.Fatalf("unexpected ARN %q", aws.StringValue(e.Arn))
+ }
+ }
+}
+
+func TestDeleteOpenIDConnectProvider(t *testing.T) {
+ svc := setupTestIAMService(t)
+ acct := "000000000001"
+ created, err := svc.CreateOpenIDConnectProvider(acct, &iam.CreateOpenIDConnectProviderInput{Url: aws.String(testIssuer)})
+ if err != nil {
+ t.Fatalf("create: %v", err)
+ }
+
+ if _, err := svc.DeleteOpenIDConnectProvider(acct, &iam.DeleteOpenIDConnectProviderInput{
+ OpenIDConnectProviderArn: created.OpenIDConnectProviderArn,
+ }); err != nil {
+ t.Fatalf("delete: %v", err)
+ }
+
+ // Second delete is NoSuchEntity.
+ _, err = svc.DeleteOpenIDConnectProvider(acct, &iam.DeleteOpenIDConnectProviderInput{
+ OpenIDConnectProviderArn: created.OpenIDConnectProviderArn,
+ })
+ if err == nil || err.Error() != awserrors.ErrorIAMNoSuchEntity {
+ t.Fatalf("second delete err = %v, want NoSuchEntity", err)
+ }
+}
+
+func TestIssuerProviderARNRoundTrip(t *testing.T) {
+ arn := OIDCProviderARN("000000000001", stripIssuerScheme(testIssuer))
+ got, err := issuerFromOIDCProviderARN(arn)
+ if err != nil {
+ t.Fatalf("reverse: %v", err)
+ }
+ if got != testIssuer {
+ t.Fatalf("round-trip issuer = %q, want %q", got, testIssuer)
+ }
+ if OIDCProviderKey(got) != OIDCProviderKey(testIssuer) {
+ t.Fatal("round-trip key mismatch")
+ }
+}
diff --git a/spinifex/handlers/iam/roles.go b/spinifex/handlers/iam/roles.go
index 83bed719a..63601a690 100644
--- a/spinifex/handlers/iam/roles.go
+++ b/spinifex/handlers/iam/roles.go
@@ -69,13 +69,7 @@ func (s *IAMServiceImpl) CreateRole(accountID string, input *iam.CreateRoleInput
MaxSessionDuration: maxSession,
CreatedAt: time.Now().UTC().Format(time.RFC3339),
AttachedPolicies: []string{},
- Tags: []Tag{},
- }
-
- for _, tag := range input.Tags {
- if tag.Key != nil && tag.Value != nil {
- role.Tags = append(role.Tags, Tag{Key: *tag.Key, Value: *tag.Value})
- }
+ Tags: copyTags(input.Tags),
}
data, err := json.Marshal(role)
diff --git a/spinifex/handlers/iam/service.go b/spinifex/handlers/iam/service.go
index 257e7a0e7..4fb5e969a 100644
--- a/spinifex/handlers/iam/service.go
+++ b/spinifex/handlers/iam/service.go
@@ -54,6 +54,14 @@ type IAMService interface {
AddRoleToInstanceProfile(accountID string, input *iam.AddRoleToInstanceProfileInput) (*iam.AddRoleToInstanceProfileOutput, error)
RemoveRoleFromInstanceProfile(accountID string, input *iam.RemoveRoleFromInstanceProfileInput) (*iam.RemoveRoleFromInstanceProfileOutput, error)
+ // OIDC identity-provider registry — account-scoped. Registers a cluster
+ // issuer as a trusted federated IdP so STS AssumeRoleWithWebIdentity will
+ // honour tokens it signs (IRSA).
+ CreateOpenIDConnectProvider(accountID string, input *iam.CreateOpenIDConnectProviderInput) (*iam.CreateOpenIDConnectProviderOutput, error)
+ GetOpenIDConnectProvider(accountID string, input *iam.GetOpenIDConnectProviderInput) (*iam.GetOpenIDConnectProviderOutput, error)
+ ListOpenIDConnectProviders(accountID string, input *iam.ListOpenIDConnectProvidersInput) (*iam.ListOpenIDConnectProvidersOutput, error)
+ DeleteOpenIDConnectProvider(accountID string, input *iam.DeleteOpenIDConnectProviderInput) (*iam.DeleteOpenIDConnectProviderOutput, error)
+
// ResolveInstanceProfile dereferences a RunInstancesInput.IamInstanceProfile
// reference (name or ARN) to the canonical InstanceProfile record. Used by
// EC2 paths only. Cross-account ARNs are rejected as a defence-in-depth
diff --git a/spinifex/handlers/iam/service_impl.go b/spinifex/handlers/iam/service_impl.go
index f205e2f42..23609d22b 100644
--- a/spinifex/handlers/iam/service_impl.go
+++ b/spinifex/handlers/iam/service_impl.go
@@ -87,6 +87,10 @@ type IAMServiceImpl struct {
instanceProfilesBucket nats.KeyValue
masterKey []byte
decrypter *Decrypter
+ // replicas is the JetStream replication factor for lazily-created
+ // per-account buckets (the OIDC-provider registry). Matches the factor
+ // the eagerly-created buckets above were built with.
+ replicas int
}
var _ IAMService = (*IAMServiceImpl)(nil)
@@ -189,6 +193,7 @@ func NewIAMServiceImpl(natsConn *nats.Conn, masterKey []byte, clusterSize int) (
instanceProfilesBucket: instanceProfilesBucket,
masterKey: masterKey,
decrypter: decrypter,
+ replicas: replicas,
}, nil
}
@@ -207,6 +212,18 @@ func getOrCreateBucket(js nats.JetStreamContext, name string, history uint8, rep
return kv, nil
}
+// copyTags converts SDK IAM tags into the stored Tag slice, skipping entries
+// with a nil key or value. Returns a non-nil (possibly empty) slice.
+func copyTags(tags []*iam.Tag) []Tag {
+ out := make([]Tag, 0, len(tags))
+ for _, tag := range tags {
+ if tag.Key != nil && tag.Value != nil {
+ out = append(out, Tag{Key: *tag.Key, Value: *tag.Value})
+ }
+ }
+ return out
+}
+
// ---------------------------------------------------------------------------
// User CRUD
// ---------------------------------------------------------------------------
@@ -239,16 +256,10 @@ func (s *IAMServiceImpl) CreateUser(accountID string, input *iam.CreateUserInput
Path: path,
CreatedAt: time.Now().UTC().Format(time.RFC3339),
AccessKeys: []string{},
- Tags: []Tag{},
+ Tags: copyTags(input.Tags),
AttachedPolicies: []string{},
}
- for _, tag := range input.Tags {
- if tag.Key != nil && tag.Value != nil {
- user.Tags = append(user.Tags, Tag{Key: *tag.Key, Value: *tag.Value})
- }
- }
-
data, err := json.Marshal(user)
if err != nil {
return nil, fmt.Errorf("marshal user: %w", err)
diff --git a/spinifex/handlers/imds/buckets.go b/spinifex/handlers/imds/buckets.go
new file mode 100644
index 000000000..6e2b68fad
--- /dev/null
+++ b/spinifex/handlers/imds/buckets.go
@@ -0,0 +1,72 @@
+package handlers_imds
+
+import (
+ "fmt"
+
+ "github.com/mulgadc/spinifex/spinifex/migrate"
+ "github.com/nats-io/nats.go"
+)
+
+const (
+ // KVBucketIMDSSubnetVeth is the canonical record that a subnet has an IMDS
+ // localport installed. Keyed by subnetID, the value carries the localport
+ // spec the BindManager replays to materialise host-side state on every chassis.
+ KVBucketIMDSSubnetVeth = "spinifex-network-imds-subnet-veth"
+ KVBucketIMDSSubnetVethVersion = 1
+
+ // KVBucketENIByVPCIP is a pure reverse index (vpcID/ip → eniID) so the IMDS
+ // handler can resolve a request's datapath-attested source IP to an ENI in
+ // one KV read instead of an O(N) scan of the ENI bucket.
+ KVBucketENIByVPCIP = "spinifex-network-eni-by-vpc-ip"
+ KVBucketENIByVPCIPVersion = 1
+)
+
+// InitBuckets opens (or creates) both IMDS KV buckets and runs any pending
+// migrations. History is fixed at 1: both are write-once-per-key, so retained
+// revisions only waste storage and lengthen tombstone lifetime.
+func InitBuckets(js nats.JetStreamContext, replicas int) (subnetVeth, eniByIP nats.KeyValue, err error) {
+ subnetVeth, err = initIMDSBucket(js, KVBucketIMDSSubnetVeth, KVBucketIMDSSubnetVethVersion, replicas)
+ if err != nil {
+ return nil, nil, err
+ }
+
+ eniByIP, err = initIMDSBucket(js, KVBucketENIByVPCIP, KVBucketENIByVPCIPVersion, replicas)
+ if err != nil {
+ return nil, nil, err
+ }
+
+ return subnetVeth, eniByIP, nil
+}
+
+// InitENIByIPBucket opens (or creates) just the eni-by-vpc-ip reverse-index
+// bucket, for the ENI controller (in the daemon), which may start before the
+// IMDS service and so needs an init path independent of InitBuckets. Idempotent.
+func InitENIByIPBucket(js nats.JetStreamContext, replicas int) (nats.KeyValue, error) {
+ return initIMDSBucket(js, KVBucketENIByVPCIP, KVBucketENIByVPCIPVersion, replicas)
+}
+
+// initIMDSBucket opens (or creates) a single IMDS KV bucket and runs any
+// pending migrations.
+func initIMDSBucket(js nats.JetStreamContext, bucket string, version, replicas int) (nats.KeyValue, error) {
+ if replicas < 1 {
+ replicas = 1
+ }
+
+ kv, err := js.CreateKeyValue(&nats.KeyValueConfig{
+ Bucket: bucket,
+ History: 1,
+ Replicas: replicas,
+ })
+ if err != nil {
+ kv, err = js.KeyValue(bucket)
+ if err != nil {
+ return nil, fmt.Errorf("open %s bucket: %w", bucket, err)
+ }
+ }
+
+ if err := migrate.DefaultRegistry.RunKV(bucket, kv, version); err != nil {
+ return nil, fmt.Errorf("migrate %s: %w", bucket, err)
+ }
+
+ return kv, nil
+}
diff --git a/spinifex/handlers/imds/buckets_test.go b/spinifex/handlers/imds/buckets_test.go
new file mode 100644
index 000000000..4857cefb3
--- /dev/null
+++ b/spinifex/handlers/imds/buckets_test.go
@@ -0,0 +1,31 @@
+package handlers_imds
+
+import (
+ "testing"
+
+ "github.com/mulgadc/spinifex/spinifex/testutil"
+ "github.com/stretchr/testify/require"
+)
+
+func TestInitENIByIPBucket(t *testing.T) {
+ _, _, js := testutil.StartTestJetStream(t)
+ kv, err := InitENIByIPBucket(js, 1)
+ require.NoError(t, err)
+ require.NotNil(t, kv)
+}
+
+// InitBuckets clamps replicas < 1 to 1, and a second call converges on the
+// open-existing fallback rather than failing on the already-created bucket.
+func TestInitBuckets_ReplicaClampAndIdempotent(t *testing.T) {
+ _, _, js := testutil.StartTestJetStream(t)
+
+ subnetVeth, eniByIP, err := InitBuckets(js, 0)
+ require.NoError(t, err)
+ require.NotNil(t, subnetVeth)
+ require.NotNil(t, eniByIP)
+
+ subnetVeth2, eniByIP2, err := InitBuckets(js, 1)
+ require.NoError(t, err)
+ require.NotNil(t, subnetVeth2)
+ require.NotNil(t, eniByIP2)
+}
diff --git a/spinifex/handlers/imds/constants.go b/spinifex/handlers/imds/constants.go
new file mode 100644
index 000000000..d5e0e7ea9
--- /dev/null
+++ b/spinifex/handlers/imds/constants.go
@@ -0,0 +1,6 @@
+package handlers_imds
+
+// MetaDataServerIP is the link-local address the EC2 Instance Metadata Service is
+// served on, identical to AWS. Exported so the network topology layer can claim it
+// on the subnet-switch localport that answers IMDS over L2 for every guest.
+const MetaDataServerIP = "169.254.169.254"
diff --git a/spinifex/handlers/imds/credentials.go b/spinifex/handlers/imds/credentials.go
new file mode 100644
index 000000000..1a57e01bf
--- /dev/null
+++ b/spinifex/handlers/imds/credentials.go
@@ -0,0 +1,120 @@
+package handlers_imds
+
+import (
+ "encoding/json"
+ "fmt"
+ "sync"
+ "time"
+
+ "github.com/aws/aws-sdk-go/aws"
+ "github.com/aws/aws-sdk-go/service/sts"
+)
+
+const (
+ // credDurationSeconds is the lifetime of instance-role credentials. 1 hour
+ // is the AWS default and matches the storage-backed STS v1 design.
+ credDurationSeconds = 3600
+
+ // credRefreshWindow is how far before expiry a cached credential is treated
+ // as stale and re-minted. AWS SDKs refresh at the same 5-minute mark.
+ credRefreshWindow = 5 * time.Minute
+)
+
+// instanceCredential is the JSON body served at
+// /latest/meta-data/iam/security-credentials/, byte-for-byte matching the
+// AWS shape so unmodified SDK credential providers parse it.
+type instanceCredential struct {
+ Code string `json:"Code"`
+ LastUpdated string `json:"LastUpdated"`
+ Type string `json:"Type"`
+ AccessKeyId string `json:"AccessKeyId"`
+ SecretAccessKey string `json:"SecretAccessKey"`
+ Token string `json:"Token"`
+ Expiration string `json:"Expiration"`
+ AccountId string `json:"AccountId"`
+}
+
+// stsAssumer is the narrow slice of STSService the IMDS credential path needs:
+// the in-process, EC2-service-principal AssumeRole entry point. Narrowed to an
+// interface so the cred cache is unit-testable with a fake.
+type stsAssumer interface {
+ AssumeRoleForInstance(accountID, roleARN, instanceID string, durationSeconds int64) (*sts.AssumeRoleOutput, error)
+}
+
+// cachedCred holds a rendered credential body and the instant at which it should
+// be re-minted (expiry minus the refresh window).
+type cachedCred struct {
+ body []byte
+ refreshAt time.Time
+}
+
+// credCache memoises minted instance-role credentials per (ENI, role) until the
+// refresh window. It keeps STS off the hot path: the typical request returns a
+// cached body, and only a near-expiry request triggers a fresh assume.
+type credCache struct {
+ sts stsAssumer
+ mu sync.Mutex
+ entries map[string]cachedCred
+}
+
+func newCredCache(assumer stsAssumer) *credCache {
+ return &credCache{sts: assumer, entries: make(map[string]cachedCred)}
+}
+
+// get returns the credential JSON body for an instance's role, minting fresh
+// credentials via STS when the cache is cold or within the refresh window. The
+// cache key is (eniID, roleName) so two ENIs sharing a role don't collide.
+func (c *credCache) get(eni *eniFacts, roleName, roleARN string, now time.Time) ([]byte, error) {
+ key := eni.eniID + "\x00" + roleName
+
+ c.mu.Lock()
+ if entry, ok := c.entries[key]; ok && now.Before(entry.refreshAt) {
+ body := entry.body
+ c.mu.Unlock()
+ return body, nil
+ }
+ c.mu.Unlock()
+
+ out, err := c.sts.AssumeRoleForInstance(eni.accountID, roleARN, eni.instanceID, credDurationSeconds)
+ if err != nil {
+ return nil, err
+ }
+ if out == nil || out.Credentials == nil {
+ return nil, fmt.Errorf("assume role for instance %s returned no credentials", eni.instanceID)
+ }
+
+ expiration := aws.TimeValue(out.Credentials.Expiration)
+ body, err := json.Marshal(instanceCredential{
+ Code: "Success",
+ LastUpdated: now.UTC().Format(time.RFC3339),
+ Type: "AWS-HMAC",
+ AccessKeyId: aws.StringValue(out.Credentials.AccessKeyId),
+ SecretAccessKey: aws.StringValue(out.Credentials.SecretAccessKey),
+ Token: aws.StringValue(out.Credentials.SessionToken),
+ Expiration: expiration.UTC().Format(time.RFC3339),
+ AccountId: eni.accountID,
+ })
+ if err != nil {
+ return nil, fmt.Errorf("marshal instance credential: %w", err)
+ }
+
+ c.mu.Lock()
+ c.entries[key] = cachedCred{body: body, refreshAt: expiration.Add(-credRefreshWindow)}
+ c.mu.Unlock()
+
+ return body, nil
+}
+
+// sweep drops fully-expired entries (past refreshAt + the refresh window, i.e.
+// past the credential's actual expiry), bounding the cache against ENI churn:
+// a terminated instance's entry is never overwritten and would otherwise leak.
+// Evicting a live entry would only force a re-mint, so this is always safe.
+func (c *credCache) sweep(now time.Time) {
+ c.mu.Lock()
+ defer c.mu.Unlock()
+ for key, entry := range c.entries {
+ if now.After(entry.refreshAt.Add(credRefreshWindow)) {
+ delete(c.entries, key)
+ }
+ }
+}
diff --git a/spinifex/handlers/imds/credentials_test.go b/spinifex/handlers/imds/credentials_test.go
new file mode 100644
index 000000000..58d87007b
--- /dev/null
+++ b/spinifex/handlers/imds/credentials_test.go
@@ -0,0 +1,139 @@
+package handlers_imds
+
+import (
+ "encoding/json"
+ "errors"
+ "testing"
+ "time"
+
+ "github.com/aws/aws-sdk-go/aws"
+ "github.com/aws/aws-sdk-go/service/sts"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+// fakeAssumer is a programmable stsAssumer that records call counts.
+type fakeAssumer struct {
+ out *sts.AssumeRoleOutput
+ err error
+ calls int
+}
+
+func (f *fakeAssumer) AssumeRoleForInstance(_, _, _ string, _ int64) (*sts.AssumeRoleOutput, error) {
+ f.calls++
+ return f.out, f.err
+}
+
+func assumeOutput(akid string, exp time.Time) *sts.AssumeRoleOutput {
+ return &sts.AssumeRoleOutput{
+ Credentials: &sts.Credentials{
+ AccessKeyId: aws.String(akid),
+ SecretAccessKey: aws.String("secret"),
+ SessionToken: aws.String("token"),
+ Expiration: aws.Time(exp),
+ },
+ }
+}
+
+func TestCredCache_MintsAndRendersAWSShape(t *testing.T) {
+ now := time.Unix(1_700_000_000, 0).UTC()
+ exp := now.Add(time.Hour)
+ assumer := &fakeAssumer{out: assumeOutput("ASIAEXAMPLE", exp)}
+ cache := newCredCache(assumer)
+
+ eni := &eniFacts{eniID: "eni-aaa", accountID: "111122223333", instanceID: "i-123"}
+ body, err := cache.get(eni, "app-role", "arn:aws:iam::111122223333:role/app-role", now)
+ require.NoError(t, err)
+
+ var cred instanceCredential
+ require.NoError(t, json.Unmarshal(body, &cred))
+ assert.Equal(t, "Success", cred.Code)
+ assert.Equal(t, "AWS-HMAC", cred.Type)
+ assert.Equal(t, "ASIAEXAMPLE", cred.AccessKeyId)
+ assert.Equal(t, "secret", cred.SecretAccessKey)
+ assert.Equal(t, "token", cred.Token)
+ assert.Equal(t, "111122223333", cred.AccountId)
+ assert.Equal(t, exp.Format(time.RFC3339), cred.Expiration)
+}
+
+func TestCredCache_ServesCachedUntilRefreshWindow(t *testing.T) {
+ now := time.Unix(1_700_000_000, 0).UTC()
+ exp := now.Add(time.Hour)
+ assumer := &fakeAssumer{out: assumeOutput("ASIAEXAMPLE", exp)}
+ cache := newCredCache(assumer)
+ eni := &eniFacts{eniID: "eni-aaa", accountID: "111122223333", instanceID: "i-123"}
+
+ _, err := cache.get(eni, "app-role", "arn:aws:iam::111122223333:role/app-role", now)
+ require.NoError(t, err)
+ // Still well outside the 5-minute refresh window → cached, no new mint.
+ _, err = cache.get(eni, "app-role", "arn:aws:iam::111122223333:role/app-role", now.Add(50*time.Minute))
+ require.NoError(t, err)
+ assert.Equal(t, 1, assumer.calls, "second request inside the refresh window must hit the cache")
+}
+
+func TestCredCache_RemintsInsideRefreshWindow(t *testing.T) {
+ now := time.Unix(1_700_000_000, 0).UTC()
+ exp := now.Add(time.Hour)
+ assumer := &fakeAssumer{out: assumeOutput("ASIAEXAMPLE", exp)}
+ cache := newCredCache(assumer)
+ eni := &eniFacts{eniID: "eni-aaa", accountID: "111122223333", instanceID: "i-123"}
+
+ _, err := cache.get(eni, "app-role", "arn:aws:iam::111122223333:role/app-role", now)
+ require.NoError(t, err)
+ // 56 minutes in → within 5 min of expiry → re-mint.
+ assumer.out = assumeOutput("ASIAREFRESHED", now.Add(2*time.Hour))
+ _, err = cache.get(eni, "app-role", "arn:aws:iam::111122223333:role/app-role", now.Add(56*time.Minute))
+ require.NoError(t, err)
+ assert.Equal(t, 2, assumer.calls, "request inside the refresh window must re-mint")
+}
+
+func TestCredCache_PropagatesAssumeError(t *testing.T) {
+ now := time.Unix(1_700_000_000, 0).UTC()
+ assumer := &fakeAssumer{err: errors.New("AccessDenied")}
+ cache := newCredCache(assumer)
+ eni := &eniFacts{eniID: "eni-aaa", accountID: "111122223333", instanceID: "i-123"}
+
+ _, err := cache.get(eni, "app-role", "arn:aws:iam::111122223333:role/app-role", now)
+ require.Error(t, err)
+}
+
+func TestCredCache_SweepEvictsExpiredOnly(t *testing.T) {
+ now := time.Unix(1_700_000_000, 0).UTC()
+ assumer := &fakeAssumer{out: assumeOutput("ASIAEXAMPLE", now.Add(time.Hour))}
+ cache := newCredCache(assumer)
+
+ stale := &eniFacts{eniID: "eni-gone", accountID: "111122223333", instanceID: "i-old"}
+ live := &eniFacts{eniID: "eni-live", accountID: "111122223333", instanceID: "i-new"}
+ _, err := cache.get(stale, "app-role", "arn:aws:iam::111122223333:role/app-role", now)
+ require.NoError(t, err)
+
+ // Mint the live entry an hour later so its expiry is still in the future
+ // when we sweep at the original expiry boundary.
+ assumer.out = assumeOutput("ASIALIVE", now.Add(2*time.Hour))
+ _, err = cache.get(live, "app-role", "arn:aws:iam::111122223333:role/app-role", now.Add(time.Hour))
+ require.NoError(t, err)
+
+ // Sweep just past the stale entry's full expiry (mint + 1h). The live entry
+ // expires at mint + 2h, so it must survive.
+ cache.sweep(now.Add(time.Hour).Add(time.Second))
+
+ cache.mu.Lock()
+ _, staleExists := cache.entries[stale.eniID+"\x00"+"app-role"]
+ _, liveExists := cache.entries[live.eniID+"\x00"+"app-role"]
+ cache.mu.Unlock()
+ assert.False(t, staleExists, "expired entry must be swept")
+ assert.True(t, liveExists, "still-valid entry must survive the sweep")
+}
+
+func TestCredCache_PerENIPerRoleKeying(t *testing.T) {
+ now := time.Unix(1_700_000_000, 0).UTC()
+ exp := now.Add(time.Hour)
+ assumer := &fakeAssumer{out: assumeOutput("ASIAEXAMPLE", exp)}
+ cache := newCredCache(assumer)
+
+ a := &eniFacts{eniID: "eni-aaa", accountID: "111122223333", instanceID: "i-1"}
+ b := &eniFacts{eniID: "eni-bbb", accountID: "111122223333", instanceID: "i-2"}
+ _, _ = cache.get(a, "app-role", "arn:aws:iam::111122223333:role/app-role", now)
+ _, _ = cache.get(b, "app-role", "arn:aws:iam::111122223333:role/app-role", now)
+ assert.Equal(t, 2, assumer.calls, "distinct ENIs must not share a cache entry")
+}
diff --git a/spinifex/handlers/imds/instance_lookup.go b/spinifex/handlers/imds/instance_lookup.go
new file mode 100644
index 000000000..123cc0096
--- /dev/null
+++ b/spinifex/handlers/imds/instance_lookup.go
@@ -0,0 +1,87 @@
+package handlers_imds
+
+import (
+ "encoding/base64"
+ "fmt"
+ "log/slog"
+
+ "github.com/aws/aws-sdk-go/aws"
+ "github.com/aws/aws-sdk-go/service/ec2"
+ gateway_ec2_instance "github.com/mulgadc/spinifex/spinifex/gateway/ec2/instance"
+ "github.com/nats-io/nats.go"
+)
+
+// natsInstanceLookup resolves the instance-only metadata fields via the same
+// account-scoped NATS fan-out the gateway's DescribeInstances path uses, since
+// the instance record lives in each daemon's in-memory manager, not a central KV.
+type natsInstanceLookup struct {
+ nc *nats.Conn
+ expectedNodes int
+}
+
+var _ instanceLookup = (*natsInstanceLookup)(nil)
+
+func (l *natsInstanceLookup) describe(accountID, instanceID string) (*instanceFacts, error) {
+ out, err := gateway_ec2_instance.DescribeInstances(&ec2.DescribeInstancesInput{
+ InstanceIds: []*string{aws.String(instanceID)},
+ }, l.nc, l.expectedNodes, accountID)
+ if err != nil {
+ return nil, fmt.Errorf("describe instance %s: %w", instanceID, err)
+ }
+
+ inst := firstInstance(out)
+ if inst == nil {
+ // The ENI references an instance the daemons no longer report (mid
+ // terminate, or not yet visible). Treated as a miss → 404.
+ return nil, nil
+ }
+
+ facts := &instanceFacts{
+ instanceType: aws.StringValue(inst.InstanceType),
+ imageID: aws.StringValue(inst.ImageId),
+ }
+ if inst.IamInstanceProfile != nil {
+ facts.iamInstanceProfileArn = aws.StringValue(inst.IamInstanceProfile.Arn)
+ }
+
+ facts.userData = l.userData(accountID, instanceID)
+ return facts, nil
+}
+
+// userData fetches the base64 user-data blob via DescribeInstanceAttribute and
+// decodes it. A miss or decode failure yields nil — /latest/user-data then 404s,
+// matching AWS for instances launched without user-data.
+func (l *natsInstanceLookup) userData(accountID, instanceID string) []byte {
+ attr, err := gateway_ec2_instance.DescribeInstanceAttribute(&ec2.DescribeInstanceAttributeInput{
+ InstanceId: aws.String(instanceID),
+ Attribute: aws.String("userData"),
+ }, l.nc, l.expectedNodes, accountID)
+ if err != nil || attr == nil || attr.UserData == nil || attr.UserData.Value == nil {
+ return nil
+ }
+ decoded, err := base64.StdEncoding.DecodeString(aws.StringValue(attr.UserData.Value))
+ if err != nil {
+ slog.Warn("IMDS: failed to decode instance user-data", "instance_id", instanceID, "err", err)
+ return nil
+ }
+ return decoded
+}
+
+// firstInstance returns the first instance in a DescribeInstances response, or
+// nil when the response carries none.
+func firstInstance(out *ec2.DescribeInstancesOutput) *ec2.Instance {
+ if out == nil {
+ return nil
+ }
+ for _, res := range out.Reservations {
+ if res == nil {
+ continue
+ }
+ for _, inst := range res.Instances {
+ if inst != nil {
+ return inst
+ }
+ }
+ }
+ return nil
+}
diff --git a/spinifex/handlers/imds/instance_lookup_test.go b/spinifex/handlers/imds/instance_lookup_test.go
new file mode 100644
index 000000000..e68321635
--- /dev/null
+++ b/spinifex/handlers/imds/instance_lookup_test.go
@@ -0,0 +1,33 @@
+package handlers_imds
+
+import (
+ "testing"
+
+ "github.com/aws/aws-sdk-go/aws"
+ "github.com/aws/aws-sdk-go/service/ec2"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestFirstInstance_NilAndEmpty(t *testing.T) {
+ assert.Nil(t, firstInstance(nil))
+ assert.Nil(t, firstInstance(&ec2.DescribeInstancesOutput{}))
+ assert.Nil(t, firstInstance(&ec2.DescribeInstancesOutput{
+ Reservations: []*ec2.Reservation{nil, {Instances: []*ec2.Instance{nil}}},
+ }))
+}
+
+// firstInstance skips nil reservations and nil instance slots and returns the
+// first concrete instance it finds.
+func TestFirstInstance_SkipsNilsAndReturnsFirst(t *testing.T) {
+ out := &ec2.DescribeInstancesOutput{
+ Reservations: []*ec2.Reservation{
+ nil,
+ {Instances: []*ec2.Instance{nil}},
+ {Instances: []*ec2.Instance{{InstanceId: aws.String("i-first")}, {InstanceId: aws.String("i-second")}}},
+ },
+ }
+ got := firstInstance(out)
+ require.NotNil(t, got)
+ assert.Equal(t, "i-first", aws.StringValue(got.InstanceId))
+}
diff --git a/spinifex/handlers/imds/metadata.go b/spinifex/handlers/imds/metadata.go
new file mode 100644
index 000000000..7dc9509e5
--- /dev/null
+++ b/spinifex/handlers/imds/metadata.go
@@ -0,0 +1,393 @@
+package handlers_imds
+
+import (
+ "encoding/json"
+ "fmt"
+ "io"
+ "log/slog"
+ "net/http"
+ "strconv"
+ "strings"
+ "time"
+
+ "github.com/aws/aws-sdk-go/aws"
+ "github.com/aws/aws-sdk-go/service/iam"
+ "github.com/mulgadc/spinifex/spinifex/awserrors"
+ "github.com/mulgadc/spinifex/spinifex/utils"
+)
+
+// HTTP paths. IMDSv2 token issuance is a PUT; everything else is a token-gated
+// GET under /latest/.
+const (
+ pathToken = "/latest/api/token" //nolint:gosec // URL path, not a credential
+ prefixMetaData = "/latest/meta-data/"
+ pathMetaDataRoot = "/latest/meta-data"
+ pathUserData = "/latest/user-data"
+ prefixSecurityCreds = "/latest/meta-data/iam/security-credentials/" //nolint:gosec // URL path, not a credential
+ pathSecurityCredsDir = "/latest/meta-data/iam/security-credentials" //nolint:gosec // URL path, not a credential
+
+ hdrToken = "X-Aws-Ec2-Metadata-Token" //nolint:gosec // HTTP header name, not a credential
+ hdrTokenTTL = "X-Aws-Ec2-Metadata-Token-Ttl-Seconds" //nolint:gosec // HTTP header name, not a credential
+
+ hdrForwardedFor = "X-Forwarded-For"
+)
+
+// rejectForwarded enforces AWS IMDS's SSRF defence: any request carrying an
+// X-Forwarded-For header is refused with 403 before token or identity checks,
+// blocking the classic "trick a server-side app into relaying metadata" attack.
+func rejectForwarded(next http.Handler) http.Handler {
+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.Header.Get(hdrForwardedFor) != "" {
+ w.WriteHeader(http.StatusForbidden)
+ return
+ }
+ next.ServeHTTP(w, r)
+ })
+}
+
+// handleToken serves PUT /latest/api/token. It binds a fresh token to the
+// requesting ENI (resolved from the datapath-attested source IP) and returns it
+// as a text/plain body, matching AWS IMDSv2.
+func (s *IMDSServiceImpl) handleToken(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodPut {
+ w.Header().Set("Allow", http.MethodPut)
+ w.WriteHeader(http.StatusMethodNotAllowed)
+ return
+ }
+
+ ttl, ok := parseTokenTTL(r.Header.Get(hdrTokenTTL))
+ if !ok {
+ w.WriteHeader(http.StatusBadRequest)
+ return
+ }
+
+ eni := s.resolveCaller(r)
+ if eni == nil {
+ w.WriteHeader(http.StatusNotFound)
+ return
+ }
+
+ token, err := s.tokens.issue(eni.eniID, ttl, s.now())
+ if err != nil {
+ slog.Error("IMDS: token issuance failed", "eni_id", eni.eniID, "err", err)
+ w.WriteHeader(http.StatusInternalServerError)
+ return
+ }
+
+ w.Header().Set("Content-Type", "text/plain")
+ w.Header().Set(hdrTokenTTL, strconv.Itoa(int(ttl.Seconds())))
+ _, _ = w.Write([]byte(token))
+}
+
+// handleMetadata serves every token-gated read path. It resolves the caller's
+// ENI, enforces the IMDSv2 token, then dispatches on the request path.
+func (s *IMDSServiceImpl) handleMetadata(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodGet {
+ w.Header().Set("Allow", http.MethodGet)
+ w.WriteHeader(http.StatusMethodNotAllowed)
+ return
+ }
+
+ eni := s.resolveCaller(r)
+ if eni == nil {
+ w.WriteHeader(http.StatusNotFound)
+ return
+ }
+
+ // IMDSv2: a valid, ENI-bound token is mandatory on every read. A missing,
+ // unknown, expired, or wrong-ENI token is an indistinguishable 401 so a
+ // guest cannot probe which tokens exist.
+ if !s.tokens.validate(r.Header.Get(hdrToken), eni.eniID, s.now()) {
+ w.WriteHeader(http.StatusUnauthorized)
+ return
+ }
+
+ s.dispatch(w, r, eni)
+}
+
+// resolveCaller maps the request's (vpcID-from-context, source-IP) to the owning
+// ENI, or nil when no mapping exists (logged for operator triage). The listener
+// identifies the subnet; its statically-resolved VPC is what keys the
+// eni-by-vpc-ip index. A backend error is also surfaced as nil → the caller
+// 404s, never 500s, matching AWS's opaque "eventually available" boot posture.
+func (s *IMDSServiceImpl) resolveCaller(r *http.Request) *eniFacts {
+ vpcID, _ := r.Context().Value(ctxKeyVPCID).(string)
+ subnetID, _ := r.Context().Value(ctxKeySubnetID).(string)
+ srcIP := utils.ClientIP(r.RemoteAddr)
+ if vpcID == "" || srcIP == "" {
+ slog.Warn("IMDS: request missing VPC context or source IP", "vpc_id", vpcID, "subnet_id", subnetID, "remote_addr", r.RemoteAddr)
+ return nil
+ }
+
+ eni, err := s.resolver.resolveENI(vpcID, srcIP)
+ if err != nil {
+ slog.Error("IMDS: ENI resolution failed", "vpc_id", vpcID, "subnet_id", subnetID, "src_ip", srcIP, "err", err)
+ return nil
+ }
+ if eni == nil {
+ slog.Warn("IMDS: no ENI for source IP", "vpc_id", vpcID, "subnet_id", subnetID, "src_ip", srcIP)
+ return nil
+ }
+ return eni
+}
+
+// dispatch routes a token-validated GET to the right metadata producer.
+func (s *IMDSServiceImpl) dispatch(w http.ResponseWriter, r *http.Request, eni *eniFacts) {
+ path := r.URL.Path
+
+ // Credential fetch for a specific role: /iam/security-credentials/.
+ if strings.HasPrefix(path, prefixSecurityCreds) && len(path) > len(prefixSecurityCreds) {
+ s.serveRoleCredentials(w, eni, strings.TrimPrefix(path, prefixSecurityCreds))
+ return
+ }
+
+ switch path {
+ case pathMetaDataRoot, prefixMetaData:
+ writeText(w, "ami-id\nhostname\niam/\ninstance-id\ninstance-type\nlocal-hostname\nlocal-ipv4\nmac\nplacement/\npublic-ipv4\nsecurity-groups")
+ case prefixMetaData + "instance-id":
+ writeText(w, eni.instanceID)
+ case prefixMetaData + "local-ipv4":
+ writeText(w, eni.privateIP)
+ case prefixMetaData + "public-ipv4":
+ writeText(w, eni.publicIP)
+ case prefixMetaData + "mac":
+ writeText(w, eni.mac)
+ case prefixMetaData + "security-groups":
+ writeText(w, strings.Join(s.resolver.resolveSGNames(eni.accountID, eni.securityGroupIDs), "\n"))
+ case prefixMetaData + "hostname", prefixMetaData + "local-hostname":
+ writeText(w, synthHostname(eni.privateIP, regionFromAZ(eni.availabilityZone)))
+ case prefixMetaData + "placement", prefixMetaData + "placement/":
+ writeText(w, "availability-zone\nregion")
+ case prefixMetaData + "placement/availability-zone":
+ writeText(w, eni.availabilityZone)
+ case prefixMetaData + "placement/region":
+ writeText(w, regionFromAZ(eni.availabilityZone))
+ case prefixMetaData + "instance-type":
+ s.serveInstanceField(w, eni, func(i *instanceFacts) string { return i.instanceType })
+ case prefixMetaData + "ami-id":
+ s.serveInstanceField(w, eni, func(i *instanceFacts) string { return i.imageID })
+ case prefixMetaData + "iam", prefixMetaData + "iam/":
+ writeText(w, "info\nsecurity-credentials/")
+ case prefixMetaData + "iam/info":
+ s.serveIAMInfo(w, eni)
+ case pathSecurityCredsDir, prefixSecurityCreds:
+ s.serveSecurityCredentialsList(w, eni)
+ case pathUserData:
+ s.serveUserData(w, eni)
+ default:
+ // /latest/dynamic/instance-identity/document and
+ // /latest/meta-data/network/interfaces/... are out of scope in v1.
+ w.WriteHeader(http.StatusNotFound)
+ }
+}
+
+// serveInstanceField resolves the instance record and writes one of its
+// string fields, 404ing when the instance is no longer visible.
+func (s *IMDSServiceImpl) serveInstanceField(w http.ResponseWriter, eni *eniFacts, field func(*instanceFacts) string) {
+ inst := s.instanceFor(w, eni)
+ if inst == nil {
+ return
+ }
+ writeText(w, field(inst))
+}
+
+// serveUserData writes the instance's user-data, or 404 when there is none —
+// matching AWS for instances launched without user-data.
+func (s *IMDSServiceImpl) serveUserData(w http.ResponseWriter, eni *eniFacts) {
+ inst := s.instanceFor(w, eni)
+ if inst == nil {
+ return
+ }
+ if len(inst.userData) == 0 {
+ w.WriteHeader(http.StatusNotFound)
+ return
+ }
+ w.Header().Set("Content-Type", "text/plain")
+ _, _ = w.Write(inst.userData)
+}
+
+// serveIAMInfo writes {InstanceProfileArn, InstanceProfileId}, 404 when the
+// instance has no instance profile, or 500 when the profile can't be resolved.
+func (s *IMDSServiceImpl) serveIAMInfo(w http.ResponseWriter, eni *eniFacts) {
+ profile, err := s.profileFor(eni)
+ if err != nil {
+ w.WriteHeader(http.StatusInternalServerError)
+ return
+ }
+ if profile == nil {
+ w.WriteHeader(http.StatusNotFound)
+ return
+ }
+ writeJSON(w, map[string]string{
+ "InstanceProfileArn": profile.ARN,
+ "InstanceProfileId": profile.InstanceProfileID,
+ })
+}
+
+// serveSecurityCredentialsList writes the role name(s) under the profile, one
+// per line. No profile/role is an empty 200 (matching AWS); a backend failure is
+// a 500, never an empty 200 that would silently strip the instance's creds.
+func (s *IMDSServiceImpl) serveSecurityCredentialsList(w http.ResponseWriter, eni *eniFacts) {
+ profile, err := s.profileFor(eni)
+ if err != nil {
+ w.WriteHeader(http.StatusInternalServerError)
+ return
+ }
+ if profile == nil || profile.RoleName == "" {
+ writeText(w, "")
+ return
+ }
+ writeText(w, profile.RoleName)
+}
+
+// serveRoleCredentials mints (or serves cached) credentials for the named role.
+// AWS only accepts the actual role name as the path parameter, not the profile
+// name, so a mismatch is 404; a backend failure resolving the profile is 500.
+func (s *IMDSServiceImpl) serveRoleCredentials(w http.ResponseWriter, eni *eniFacts, roleParam string) {
+ profile, err := s.profileFor(eni)
+ if err != nil {
+ w.WriteHeader(http.StatusInternalServerError)
+ return
+ }
+ if profile == nil || profile.RoleName == "" || roleParam != profile.RoleName {
+ w.WriteHeader(http.StatusNotFound)
+ return
+ }
+
+ role, err := s.iam.GetRole(eni.accountID, &iam.GetRoleInput{RoleName: aws.String(profile.RoleName)})
+ if err != nil || role == nil || role.Role == nil || role.Role.Arn == nil {
+ slog.Error("IMDS: resolve role ARN failed", "account_id", eni.accountID, "role", profile.RoleName, "err", err)
+ w.WriteHeader(http.StatusInternalServerError)
+ return
+ }
+
+ body, err := s.creds.get(eni, profile.RoleName, *role.Role.Arn, s.now())
+ if err != nil {
+ // AWS surfaces a trust-policy failure as a JSON body with Code:"Failed";
+ // the SDK reports it as "EC2RoleProvider failed". Mirror that rather than
+ // a bare HTTP error so the SDK error message is AWS-accurate.
+ slog.Warn("IMDS: AssumeRoleForInstance failed", "account_id", eni.accountID, "role", profile.RoleName, "instance_id", eni.instanceID, "err", err)
+ writeJSON(w, map[string]string{
+ "Code": "Failed",
+ "LastUpdated": s.now().UTC().Format(time.RFC3339),
+ "Message": "failed to assume instance role",
+ })
+ return
+ }
+
+ w.Header().Set("Content-Type", "application/json")
+ _, _ = w.Write(body)
+}
+
+// instanceFor resolves the instance record for an ENI, writing the appropriate
+// HTTP error and returning nil on miss/failure so callers can early-return.
+func (s *IMDSServiceImpl) instanceFor(w http.ResponseWriter, eni *eniFacts) *instanceFacts {
+ inst, err := s.resolver.resolveInstance(eni)
+ if err != nil {
+ slog.Error("IMDS: instance resolution failed", "instance_id", eni.instanceID, "err", err)
+ w.WriteHeader(http.StatusInternalServerError)
+ return nil
+ }
+ if inst == nil {
+ w.WriteHeader(http.StatusNotFound)
+ return nil
+ }
+ return inst
+}
+
+// profileFor resolves the instance's IAM instance profile. Returns (profile, nil)
+// when resolvable, (nil, nil) when genuinely absent (404/empty), (nil, err) on
+// backend failure (500) — collapsing err into absent would silently drop creds.
+func (s *IMDSServiceImpl) profileFor(eni *eniFacts) (*resolvedProfile, error) {
+ inst, err := s.resolver.resolveInstance(eni)
+ if err != nil {
+ slog.Error("IMDS: instance resolution failed", "instance_id", eni.instanceID, "err", err)
+ return nil, err
+ }
+ if inst == nil || inst.iamInstanceProfileArn == "" {
+ return nil, nil
+ }
+ profile, err := s.iam.ResolveInstanceProfile(eni.accountID, inst.iamInstanceProfileArn)
+ if err != nil {
+ if err.Error() == awserrors.ErrorIAMNoSuchEntity {
+ // The profile was deleted out from under the instance. AWS treats this
+ // as "no instance role", not a backend fault — NoSuchEntity is raised
+ // only for a genuinely absent record, never a transient.
+ return nil, nil
+ }
+ slog.Error("IMDS: resolve instance profile failed", "account_id", eni.accountID, "arn", inst.iamInstanceProfileArn, "err", err)
+ return nil, err
+ }
+ if profile == nil {
+ return nil, nil
+ }
+ return &resolvedProfile{ARN: profile.ARN, InstanceProfileID: profile.InstanceProfileID, RoleName: profile.RoleName}, nil
+}
+
+// resolvedProfile is the slice of an IAM instance profile the metadata surface
+// serves, decoupled from the handlers_iam type so this package needn't import
+// it for the field access.
+type resolvedProfile struct {
+ ARN string
+ InstanceProfileID string
+ RoleName string
+}
+
+// parseTokenTTL validates the X-aws-ec2-metadata-token-ttl-seconds header.
+func parseTokenTTL(raw string) (time.Duration, bool) {
+ if raw == "" {
+ return 0, false
+ }
+ n, err := strconv.Atoi(raw)
+ if err != nil || n < tokenTTLMin || n > tokenTTLMax {
+ return 0, false
+ }
+ return time.Duration(n) * time.Second, true
+}
+
+// regionFromAZ derives the region from an availability-zone name by stripping
+// the trailing AZ letter ("ap-southeast-2a" → "ap-southeast-2").
+func regionFromAZ(az string) string {
+ if az == "" {
+ return ""
+ }
+ last := az[len(az)-1]
+ if last >= 'a' && last <= 'z' {
+ return az[:len(az)-1]
+ }
+ return az
+}
+
+// synthHostname builds the AWS-style internal hostname
+// "ip-10-0-1-5..compute.internal" from a private IP and region.
+func synthHostname(ip, region string) string {
+ if ip == "" || region == "" {
+ return ""
+ }
+ return fmt.Sprintf("ip-%s.%s.compute.internal", strings.ReplaceAll(ip, ".", "-"), region)
+}
+
+func writeText(w http.ResponseWriter, body string) {
+ w.Header().Set("Content-Type", "text/plain")
+ _, _ = io.WriteString(w, body)
+}
+
+func writeJSON(w http.ResponseWriter, v any) {
+ data, err := json.Marshal(v)
+ if err != nil {
+ w.WriteHeader(http.StatusInternalServerError)
+ return
+ }
+ w.Header().Set("Content-Type", "application/json")
+ _, _ = w.Write(data)
+}
+
+// ctxKey is the unexported context-key type for values the bind manager threads
+// into each request: the subnet the listener serves and that subnet's
+// statically-resolved owning VPC (which keys the eni-by-vpc-ip index).
+type ctxKey int
+
+const (
+ ctxKeyVPCID ctxKey = iota
+ ctxKeySubnetID
+)
diff --git a/spinifex/handlers/imds/metadata_test.go b/spinifex/handlers/imds/metadata_test.go
new file mode 100644
index 000000000..7f308eb14
--- /dev/null
+++ b/spinifex/handlers/imds/metadata_test.go
@@ -0,0 +1,418 @@
+package handlers_imds
+
+import (
+ "context"
+ "errors"
+ "net/http"
+ "net/http/httptest"
+ "testing"
+ "time"
+
+ "github.com/aws/aws-sdk-go/aws"
+ "github.com/aws/aws-sdk-go/service/iam"
+ "github.com/aws/aws-sdk-go/service/sts"
+ "github.com/mulgadc/spinifex/spinifex/awserrors"
+ handlers_iam "github.com/mulgadc/spinifex/spinifex/handlers/iam"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+// ----- fakes -------------------------------------------------------------
+
+type fakeResolver struct {
+ eni *eniFacts
+ eniErr error
+ inst *instanceFacts
+ instErr error
+ sgNames map[string]string // sg-id → group name; absent IDs fall back to the ID
+}
+
+func (f *fakeResolver) resolveENI(_, _ string) (*eniFacts, error) { return f.eni, f.eniErr }
+func (f *fakeResolver) resolveInstance(_ *eniFacts) (*instanceFacts, error) {
+ return f.inst, f.instErr
+}
+
+func (f *fakeResolver) resolveSGNames(_ string, sgIDs []string) []string {
+ out := make([]string, len(sgIDs))
+ for i, id := range sgIDs {
+ if name, ok := f.sgNames[id]; ok {
+ out[i] = name
+ } else {
+ out[i] = id
+ }
+ }
+ return out
+}
+
+type fakeIAM struct {
+ profile *handlers_iam.InstanceProfile
+ profileErr error
+ roleARN string
+ roleErr error
+}
+
+func (f *fakeIAM) ResolveInstanceProfile(_, _ string) (*handlers_iam.InstanceProfile, error) {
+ return f.profile, f.profileErr
+}
+
+func (f *fakeIAM) GetRole(_ string, _ *iam.GetRoleInput) (*iam.GetRoleOutput, error) {
+ if f.roleErr != nil {
+ return nil, f.roleErr
+ }
+ return &iam.GetRoleOutput{Role: &iam.Role{Arn: aws.String(f.roleARN)}}, nil
+}
+
+const (
+ testVPC = "vpc-abc12345"
+ testIP = "10.0.1.5"
+)
+
+func testENI() *eniFacts {
+ return &eniFacts{
+ eniID: "eni-aaa",
+ accountID: "111122223333",
+ instanceID: "i-0123456789",
+ vpcID: testVPC,
+ subnetID: "subnet-1",
+ privateIP: testIP,
+ publicIP: "203.0.113.7",
+ mac: "02:11:22:33:44:55",
+ availabilityZone: "ap-southeast-2a",
+ securityGroupIDs: []string{"sg-1", "sg-2"},
+ }
+}
+
+// newTestService builds an IMDSServiceImpl with injected fakes and a fixed
+// clock. The bind manager is left nil — the HTTP handler is exercised directly.
+func newTestService(res eniResolver, fIAM profileLookup, assumer stsAssumer) (*IMDSServiceImpl, time.Time) {
+ now := time.Unix(1_700_000_000, 0).UTC()
+ return &IMDSServiceImpl{
+ resolver: res,
+ tokens: newTokenStore(),
+ creds: newCredCache(assumer),
+ iam: fIAM,
+ now: func() time.Time { return now },
+ }, now
+}
+
+// get issues a token-gated GET with the VPC context + source IP a real listener
+// would have threaded in, plus the supplied token header.
+func get(t *testing.T, h http.Handler, path, token string) *httptest.ResponseRecorder {
+ t.Helper()
+ req := httptest.NewRequest(http.MethodGet, "http://"+MetaDataServerIP+path, nil)
+ req.RemoteAddr = testIP + ":50000"
+ req = req.WithContext(context.WithValue(req.Context(), ctxKeyVPCID, testVPC))
+ if token != "" {
+ req.Header.Set(hdrToken, token)
+ }
+ rec := httptest.NewRecorder()
+ h.ServeHTTP(rec, req)
+ return rec
+}
+
+// issueToken performs the PUT /latest/api/token handshake and returns the token.
+func issueToken(t *testing.T, h http.Handler) string {
+ t.Helper()
+ req := httptest.NewRequest(http.MethodPut, "http://"+MetaDataServerIP+pathToken, nil)
+ req.RemoteAddr = testIP + ":50000"
+ req = req.WithContext(context.WithValue(req.Context(), ctxKeyVPCID, testVPC))
+ req.Header.Set(hdrTokenTTL, "60")
+ rec := httptest.NewRecorder()
+ h.ServeHTTP(rec, req)
+ require.Equal(t, http.StatusOK, rec.Code)
+ require.NotEmpty(t, rec.Body.String())
+ return rec.Body.String()
+}
+
+// ----- token model ------------------------------------------------------
+
+func TestHTTP_TokenlessGETRejected(t *testing.T) {
+ svc, _ := newTestService(&fakeResolver{eni: testENI()}, &fakeIAM{}, &fakeAssumer{})
+ h := svc.httpHandler()
+ rec := get(t, h, prefixMetaData+"instance-id", "")
+ assert.Equal(t, http.StatusUnauthorized, rec.Code)
+ assert.Empty(t, rec.Body.String())
+}
+
+func TestHTTP_TokenPUTValidation(t *testing.T) {
+ svc, _ := newTestService(&fakeResolver{eni: testENI()}, &fakeIAM{}, &fakeAssumer{})
+ h := svc.httpHandler()
+
+ for _, ttl := range []string{"", "0", "21601", "notanumber"} {
+ req := httptest.NewRequest(http.MethodPut, "http://"+MetaDataServerIP+pathToken, nil)
+ req.RemoteAddr = testIP + ":50000"
+ req = req.WithContext(context.WithValue(req.Context(), ctxKeyVPCID, testVPC))
+ if ttl != "" {
+ req.Header.Set(hdrTokenTTL, ttl)
+ }
+ rec := httptest.NewRecorder()
+ h.ServeHTTP(rec, req)
+ assert.Equal(t, http.StatusBadRequest, rec.Code, "ttl=%q", ttl)
+ }
+}
+
+func TestHTTP_TokenGETMethodNotAllowed(t *testing.T) {
+ svc, _ := newTestService(&fakeResolver{eni: testENI()}, &fakeIAM{}, &fakeAssumer{})
+ h := svc.httpHandler()
+ req := httptest.NewRequest(http.MethodGet, "http://"+MetaDataServerIP+pathToken, nil)
+ req.RemoteAddr = testIP + ":50000"
+ req = req.WithContext(context.WithValue(req.Context(), ctxKeyVPCID, testVPC))
+ rec := httptest.NewRecorder()
+ h.ServeHTTP(rec, req)
+ assert.Equal(t, http.StatusMethodNotAllowed, rec.Code)
+}
+
+// TestHTTP_ForwardedForRejected pins AWS IMDS's SSRF defence: any request
+// carrying X-Forwarded-For is refused with 403 regardless of token or identity,
+// on both the read path and token issuance.
+func TestHTTP_ForwardedForRejected(t *testing.T) {
+ svc, _ := newTestService(&fakeResolver{eni: testENI()}, &fakeIAM{}, &fakeAssumer{})
+ h := svc.httpHandler()
+
+ // A valid token + resolvable ENI is still refused once X-Forwarded-For is set.
+ token := issueToken(t, h)
+ req := httptest.NewRequest(http.MethodGet, "http://"+MetaDataServerIP+prefixMetaData+"instance-id", nil)
+ req.RemoteAddr = testIP + ":50000"
+ req = req.WithContext(context.WithValue(req.Context(), ctxKeyVPCID, testVPC))
+ req.Header.Set(hdrToken, token)
+ req.Header.Set(hdrForwardedFor, "10.0.0.99")
+ rec := httptest.NewRecorder()
+ h.ServeHTTP(rec, req)
+ assert.Equal(t, http.StatusForbidden, rec.Code)
+
+ // Token issuance is refused too.
+ put := httptest.NewRequest(http.MethodPut, "http://"+MetaDataServerIP+pathToken, nil)
+ put.RemoteAddr = testIP + ":50000"
+ put = put.WithContext(context.WithValue(put.Context(), ctxKeyVPCID, testVPC))
+ put.Header.Set(hdrTokenTTL, "60")
+ put.Header.Set(hdrForwardedFor, "10.0.0.99")
+ putRec := httptest.NewRecorder()
+ h.ServeHTTP(putRec, put)
+ assert.Equal(t, http.StatusForbidden, putRec.Code)
+}
+
+func TestHTTP_ENIMissReturns404(t *testing.T) {
+ svc, _ := newTestService(&fakeResolver{eni: nil}, &fakeIAM{}, &fakeAssumer{})
+ h := svc.httpHandler()
+
+ // Token PUT and metadata GET both 404 when the source IP maps to no ENI.
+ req := httptest.NewRequest(http.MethodPut, "http://"+MetaDataServerIP+pathToken, nil)
+ req.RemoteAddr = testIP + ":50000"
+ req = req.WithContext(context.WithValue(req.Context(), ctxKeyVPCID, testVPC))
+ req.Header.Set(hdrTokenTTL, "60")
+ rec := httptest.NewRecorder()
+ h.ServeHTTP(rec, req)
+ assert.Equal(t, http.StatusNotFound, rec.Code)
+}
+
+// ----- metadata surface --------------------------------------------------
+
+func TestHTTP_MetadataPaths(t *testing.T) {
+ res := &fakeResolver{
+ eni: testENI(),
+ inst: &instanceFacts{instanceType: "t3.micro", imageID: "ami-12345", userData: []byte("#!/bin/sh\necho hi")},
+ sgNames: map[string]string{"sg-1": "web-sg", "sg-2": "db-sg"},
+ }
+ svc, _ := newTestService(res, &fakeIAM{}, &fakeAssumer{})
+ h := svc.httpHandler()
+ token := issueToken(t, h)
+
+ cases := []struct{ path, want string }{
+ {prefixMetaData + "instance-id", "i-0123456789"},
+ {prefixMetaData + "instance-type", "t3.micro"},
+ {prefixMetaData + "ami-id", "ami-12345"},
+ {prefixMetaData + "local-ipv4", "10.0.1.5"},
+ {prefixMetaData + "public-ipv4", "203.0.113.7"},
+ {prefixMetaData + "mac", "02:11:22:33:44:55"},
+ {prefixMetaData + "placement/availability-zone", "ap-southeast-2a"},
+ {prefixMetaData + "placement/region", "ap-southeast-2"},
+ {prefixMetaData + "security-groups", "web-sg\ndb-sg"},
+ {prefixMetaData + "hostname", "ip-10-0-1-5.ap-southeast-2.compute.internal"},
+ {prefixMetaData + "local-hostname", "ip-10-0-1-5.ap-southeast-2.compute.internal"},
+ {pathUserData, "#!/bin/sh\necho hi"},
+ }
+ for _, c := range cases {
+ rec := get(t, h, c.path, token)
+ assert.Equal(t, http.StatusOK, rec.Code, "path=%s", c.path)
+ assert.Equal(t, c.want, rec.Body.String(), "path=%s", c.path)
+ }
+}
+
+func TestHTTP_DirectoryListing(t *testing.T) {
+ svc, _ := newTestService(&fakeResolver{eni: testENI()}, &fakeIAM{}, &fakeAssumer{})
+ h := svc.httpHandler()
+ token := issueToken(t, h)
+ rec := get(t, h, pathMetaDataRoot, token)
+ assert.Equal(t, http.StatusOK, rec.Code)
+ assert.Contains(t, rec.Body.String(), "instance-id")
+ assert.Contains(t, rec.Body.String(), "iam/")
+}
+
+func TestHTTP_UserDataAbsent404(t *testing.T) {
+ res := &fakeResolver{eni: testENI(), inst: &instanceFacts{}}
+ svc, _ := newTestService(res, &fakeIAM{}, &fakeAssumer{})
+ h := svc.httpHandler()
+ token := issueToken(t, h)
+ rec := get(t, h, pathUserData, token)
+ assert.Equal(t, http.StatusNotFound, rec.Code)
+}
+
+func TestHTTP_OutOfScopePaths404(t *testing.T) {
+ res := &fakeResolver{eni: testENI(), inst: &instanceFacts{}}
+ svc, _ := newTestService(res, &fakeIAM{}, &fakeAssumer{})
+ h := svc.httpHandler()
+ token := issueToken(t, h)
+ for _, p := range []string{
+ "/latest/dynamic/instance-identity/document",
+ prefixMetaData + "network/interfaces/macs",
+ prefixMetaData + "nonsense",
+ } {
+ rec := get(t, h, p, token)
+ assert.Equal(t, http.StatusNotFound, rec.Code, "path=%s", p)
+ }
+}
+
+// ----- IAM / credentials -------------------------------------------------
+
+func profileFixture() *handlers_iam.InstanceProfile {
+ return &handlers_iam.InstanceProfile{
+ InstanceProfileName: "app-profile",
+ InstanceProfileID: "AIPAEXAMPLE",
+ AccountID: "111122223333",
+ ARN: "arn:aws:iam::111122223333:instance-profile/app-profile",
+ RoleName: "app-role",
+ }
+}
+
+func TestHTTP_IAMInfo(t *testing.T) {
+ res := &fakeResolver{eni: testENI(), inst: &instanceFacts{iamInstanceProfileArn: "arn:aws:iam::111122223333:instance-profile/app-profile"}}
+ svc, _ := newTestService(res, &fakeIAM{profile: profileFixture()}, &fakeAssumer{})
+ h := svc.httpHandler()
+ token := issueToken(t, h)
+
+ rec := get(t, h, prefixMetaData+"iam/info", token)
+ assert.Equal(t, http.StatusOK, rec.Code)
+ assert.Contains(t, rec.Body.String(), "arn:aws:iam::111122223333:instance-profile/app-profile")
+ assert.Contains(t, rec.Body.String(), "AIPAEXAMPLE")
+}
+
+func TestHTTP_IAMInfoNoProfile404(t *testing.T) {
+ res := &fakeResolver{eni: testENI(), inst: &instanceFacts{}} // no profile ARN
+ svc, _ := newTestService(res, &fakeIAM{}, &fakeAssumer{})
+ h := svc.httpHandler()
+ token := issueToken(t, h)
+ rec := get(t, h, prefixMetaData+"iam/info", token)
+ assert.Equal(t, http.StatusNotFound, rec.Code)
+}
+
+func TestHTTP_SecurityCredentialsList(t *testing.T) {
+ res := &fakeResolver{eni: testENI(), inst: &instanceFacts{iamInstanceProfileArn: "arn:aws:iam::111122223333:instance-profile/app-profile"}}
+ svc, _ := newTestService(res, &fakeIAM{profile: profileFixture()}, &fakeAssumer{})
+ h := svc.httpHandler()
+ token := issueToken(t, h)
+ rec := get(t, h, pathSecurityCredsDir, token)
+ assert.Equal(t, http.StatusOK, rec.Code)
+ assert.Equal(t, "app-role", rec.Body.String())
+}
+
+func TestHTTP_SecurityCredentialsListNoRoleEmpty(t *testing.T) {
+ res := &fakeResolver{eni: testENI(), inst: &instanceFacts{}}
+ svc, _ := newTestService(res, &fakeIAM{}, &fakeAssumer{})
+ h := svc.httpHandler()
+ token := issueToken(t, h)
+ rec := get(t, h, pathSecurityCredsDir, token)
+ assert.Equal(t, http.StatusOK, rec.Code)
+ assert.Empty(t, rec.Body.String())
+}
+
+// A backend failure resolving the instance must surface as 500, never an empty
+// 200 — otherwise a transient hiccup is indistinguishable from "instance has no
+// role" and the SDK silently proceeds unauthenticated.
+func TestHTTP_SecurityCredentialsListBackendError500(t *testing.T) {
+ res := &fakeResolver{eni: testENI(), instErr: errors.New("backend unavailable")}
+ svc, _ := newTestService(res, &fakeIAM{}, &fakeAssumer{})
+ h := svc.httpHandler()
+ token := issueToken(t, h)
+ rec := get(t, h, pathSecurityCredsDir, token)
+ assert.Equal(t, http.StatusInternalServerError, rec.Code)
+}
+
+// A profile deleted out from under a running instance (the ARN dereferences to
+// NoSuchEntity) is "no instance role", not a backend fault: iam/info 404s, the
+// creds list is an empty 200, and the role path 404s — matching AWS. Only a
+// genuine backend error (TestHTTP_*BackendError500) is a 500.
+func TestHTTP_IAMInfoDeletedProfile404(t *testing.T) {
+ res := &fakeResolver{eni: testENI(), inst: &instanceFacts{iamInstanceProfileArn: "arn:aws:iam::111122223333:instance-profile/gone"}}
+ svc, _ := newTestService(res, &fakeIAM{profileErr: errors.New(awserrors.ErrorIAMNoSuchEntity)}, &fakeAssumer{})
+ h := svc.httpHandler()
+ token := issueToken(t, h)
+ rec := get(t, h, prefixMetaData+"iam/info", token)
+ assert.Equal(t, http.StatusNotFound, rec.Code)
+}
+
+func TestHTTP_SecurityCredentialsListDeletedProfileEmpty(t *testing.T) {
+ res := &fakeResolver{eni: testENI(), inst: &instanceFacts{iamInstanceProfileArn: "arn:aws:iam::111122223333:instance-profile/gone"}}
+ svc, _ := newTestService(res, &fakeIAM{profileErr: errors.New(awserrors.ErrorIAMNoSuchEntity)}, &fakeAssumer{})
+ h := svc.httpHandler()
+ token := issueToken(t, h)
+ rec := get(t, h, pathSecurityCredsDir, token)
+ assert.Equal(t, http.StatusOK, rec.Code)
+ assert.Empty(t, rec.Body.String())
+}
+
+func TestHTTP_RoleCredentialsDeletedProfile404(t *testing.T) {
+ res := &fakeResolver{eni: testENI(), inst: &instanceFacts{iamInstanceProfileArn: "arn:aws:iam::111122223333:instance-profile/gone"}}
+ svc, _ := newTestService(res, &fakeIAM{profileErr: errors.New(awserrors.ErrorIAMNoSuchEntity)}, &fakeAssumer{})
+ h := svc.httpHandler()
+ token := issueToken(t, h)
+ rec := get(t, h, prefixSecurityCreds+"app-role", token)
+ assert.Equal(t, http.StatusNotFound, rec.Code)
+}
+
+func TestHTTP_IAMInfoBackendError500(t *testing.T) {
+ res := &fakeResolver{eni: testENI(), inst: &instanceFacts{iamInstanceProfileArn: "arn:aws:iam::111122223333:instance-profile/app-profile"}}
+ svc, _ := newTestService(res, &fakeIAM{profileErr: errors.New("iam unavailable")}, &fakeAssumer{})
+ h := svc.httpHandler()
+ token := issueToken(t, h)
+ rec := get(t, h, prefixMetaData+"iam/info", token)
+ assert.Equal(t, http.StatusInternalServerError, rec.Code)
+}
+
+func TestHTTP_RoleCredentialsBackendError500(t *testing.T) {
+ res := &fakeResolver{eni: testENI(), instErr: errors.New("backend unavailable")}
+ svc, _ := newTestService(res, &fakeIAM{}, &fakeAssumer{})
+ h := svc.httpHandler()
+ token := issueToken(t, h)
+ rec := get(t, h, prefixSecurityCreds+"app-role", token)
+ assert.Equal(t, http.StatusInternalServerError, rec.Code)
+}
+
+func TestHTTP_RoleCredentials(t *testing.T) {
+ now := time.Unix(1_700_000_000, 0).UTC()
+ res := &fakeResolver{eni: testENI(), inst: &instanceFacts{iamInstanceProfileArn: "arn:aws:iam::111122223333:instance-profile/app-profile"}}
+ assumer := &fakeAssumer{out: &sts.AssumeRoleOutput{Credentials: &sts.Credentials{
+ AccessKeyId: aws.String("ASIAEXAMPLE"),
+ SecretAccessKey: aws.String("secret"),
+ SessionToken: aws.String("token"),
+ Expiration: aws.Time(now.Add(time.Hour)),
+ }}}
+ svc, _ := newTestService(res, &fakeIAM{profile: profileFixture(), roleARN: "arn:aws:iam::111122223333:role/app-role"}, assumer)
+ h := svc.httpHandler()
+ token := issueToken(t, h)
+
+ rec := get(t, h, prefixSecurityCreds+"app-role", token)
+ assert.Equal(t, http.StatusOK, rec.Code)
+ assert.Contains(t, rec.Body.String(), "ASIAEXAMPLE")
+ assert.Contains(t, rec.Body.String(), `"Code":"Success"`)
+}
+
+func TestHTTP_RoleCredentialsWrongRole404(t *testing.T) {
+ res := &fakeResolver{eni: testENI(), inst: &instanceFacts{iamInstanceProfileArn: "arn:aws:iam::111122223333:instance-profile/app-profile"}}
+ svc, _ := newTestService(res, &fakeIAM{profile: profileFixture(), roleARN: "arn:aws:iam::111122223333:role/app-role"}, &fakeAssumer{})
+ h := svc.httpHandler()
+ token := issueToken(t, h)
+
+ // AWS only accepts the actual role name, never the profile name.
+ rec := get(t, h, prefixSecurityCreds+"app-profile", token)
+ assert.Equal(t, http.StatusNotFound, rec.Code)
+}
diff --git a/spinifex/handlers/imds/nats_clients.go b/spinifex/handlers/imds/nats_clients.go
new file mode 100644
index 000000000..adbf15362
--- /dev/null
+++ b/spinifex/handlers/imds/nats_clients.go
@@ -0,0 +1,134 @@
+package handlers_imds
+
+import (
+ "sync"
+ "time"
+
+ "github.com/aws/aws-sdk-go/aws"
+ "github.com/aws/aws-sdk-go/service/iam"
+ "github.com/aws/aws-sdk-go/service/sts"
+ handlers_iam "github.com/mulgadc/spinifex/spinifex/handlers/iam"
+ handlers_sts "github.com/mulgadc/spinifex/spinifex/handlers/sts"
+ "github.com/mulgadc/spinifex/spinifex/utils"
+ "github.com/nats-io/nats.go"
+)
+
+const (
+ // imdsRPCTimeout bounds each internal control-plane round-trip to awsgw.
+ imdsRPCTimeout = 10 * time.Second
+
+ // profileCacheTTL memoises profile/role lookups. Both mappings are effectively
+ // static, so a short TTL keeps the iam/* GETs off a NATS round-trip per request.
+ profileCacheTTL = 5 * time.Minute
+)
+
+// NATSSTSAssumer is the NATS-backed stsAssumer. It mints instance-role credentials
+// via awsgw's SubjectAssumeRoleForInstance responder, keeping no local cache —
+// credCache already memoises minted credentials, so STS stays off the hot path.
+type NATSSTSAssumer struct {
+ nc *nats.Conn
+}
+
+var _ stsAssumer = (*NATSSTSAssumer)(nil)
+
+// NewNATSSTSAssumer constructs a NATSSTSAssumer over nc.
+func NewNATSSTSAssumer(nc *nats.Conn) *NATSSTSAssumer {
+ return &NATSSTSAssumer{nc: nc}
+}
+
+func (a *NATSSTSAssumer) AssumeRoleForInstance(accountID, roleARN, instanceID string, durationSeconds int64) (*sts.AssumeRoleOutput, error) {
+ return utils.NATSRequest[sts.AssumeRoleOutput](a.nc, handlers_sts.SubjectAssumeRoleForInstance, handlers_sts.AssumeRoleForInstanceRequest{
+ AccountID: accountID,
+ RoleARN: roleARN,
+ InstanceID: instanceID,
+ DurationSeconds: durationSeconds,
+ }, imdsRPCTimeout, accountID)
+}
+
+// NATSProfileLookup is the NATS-backed profileLookup. It resolves instance
+// profiles and roles via awsgw's IAM responders, fronted by a short-TTL cache
+// so repeat iam/* GETs don't take a round-trip each.
+type NATSProfileLookup struct {
+ nc *nats.Conn
+ profiles *ttlCache[*handlers_iam.InstanceProfile]
+ roles *ttlCache[*iam.GetRoleOutput]
+}
+
+var _ profileLookup = (*NATSProfileLookup)(nil)
+
+// NewNATSProfileLookup constructs a NATSProfileLookup over nc.
+func NewNATSProfileLookup(nc *nats.Conn) *NATSProfileLookup {
+ return &NATSProfileLookup{
+ nc: nc,
+ profiles: newTTLCache[*handlers_iam.InstanceProfile](profileCacheTTL),
+ roles: newTTLCache[*iam.GetRoleOutput](profileCacheTTL),
+ }
+}
+
+func (p *NATSProfileLookup) ResolveInstanceProfile(accountID, nameOrARN string) (*handlers_iam.InstanceProfile, error) {
+ key := accountID + "\x00" + nameOrARN
+ if v, ok := p.profiles.get(key); ok {
+ return v, nil
+ }
+ out, err := utils.NATSRequest[handlers_iam.InstanceProfile](p.nc, handlers_iam.SubjectResolveInstanceProfile, handlers_iam.ResolveInstanceProfileRequest{
+ AccountID: accountID,
+ NameOrARN: nameOrARN,
+ }, imdsRPCTimeout, accountID)
+ if err != nil {
+ return nil, err
+ }
+ p.profiles.put(key, out)
+ return out, nil
+}
+
+func (p *NATSProfileLookup) GetRole(accountID string, input *iam.GetRoleInput) (*iam.GetRoleOutput, error) {
+ key := accountID + "\x00" + aws.StringValue(input.RoleName)
+ if v, ok := p.roles.get(key); ok {
+ return v, nil
+ }
+ out, err := utils.NATSRequest[iam.GetRoleOutput](p.nc, handlers_iam.SubjectGetRole, handlers_iam.GetRoleRequest{
+ AccountID: accountID,
+ Input: input,
+ }, imdsRPCTimeout, accountID)
+ if err != nil {
+ return nil, err
+ }
+ p.roles.put(key, out)
+ return out, nil
+}
+
+// ttlCache is a minimal concurrency-safe map with per-entry expiry, used to
+// keep effectively-static IAM lookups off the NATS round-trip. now is a field
+// so tests can drive expiry deterministically.
+type ttlCache[V any] struct {
+ mu sync.Mutex
+ ttl time.Duration
+ now func() time.Time
+ m map[string]ttlCacheEntry[V]
+}
+
+type ttlCacheEntry[V any] struct {
+ val V
+ expiresAt time.Time
+}
+
+func newTTLCache[V any](ttl time.Duration) *ttlCache[V] {
+ return &ttlCache[V]{ttl: ttl, now: time.Now, m: make(map[string]ttlCacheEntry[V])}
+}
+
+func (c *ttlCache[V]) get(key string) (V, bool) {
+ c.mu.Lock()
+ defer c.mu.Unlock()
+ entry, ok := c.m[key]
+ if !ok || c.now().After(entry.expiresAt) {
+ var zero V
+ return zero, false
+ }
+ return entry.val, true
+}
+
+func (c *ttlCache[V]) put(key string, v V) {
+ c.mu.Lock()
+ defer c.mu.Unlock()
+ c.m[key] = ttlCacheEntry[V]{val: v, expiresAt: c.now().Add(c.ttl)}
+}
diff --git a/spinifex/handlers/imds/nats_clients_test.go b/spinifex/handlers/imds/nats_clients_test.go
new file mode 100644
index 000000000..1eaa71e56
--- /dev/null
+++ b/spinifex/handlers/imds/nats_clients_test.go
@@ -0,0 +1,136 @@
+package handlers_imds
+
+import (
+ "sync/atomic"
+ "testing"
+ "time"
+
+ "github.com/aws/aws-sdk-go/aws"
+ "github.com/aws/aws-sdk-go/service/iam"
+ "github.com/aws/aws-sdk-go/service/sts"
+ handlers_iam "github.com/mulgadc/spinifex/spinifex/handlers/iam"
+ handlers_sts "github.com/mulgadc/spinifex/spinifex/handlers/sts"
+ "github.com/mulgadc/spinifex/spinifex/testutil"
+ "github.com/mulgadc/spinifex/spinifex/utils"
+ "github.com/nats-io/nats.go"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+const imdsTestAccountID = "123456789012"
+
+// serveCounted subscribes fn to subject and returns a counter of how many
+// requests the responder actually handled — the assertion hook for the TTL
+// cache (a cache hit must not reach the responder).
+func serveCounted[I any, O any](t *testing.T, nc *nats.Conn, subject string, fn func(*I) (*O, error)) *int32 {
+ t.Helper()
+ var calls int32
+ sub, err := nc.Subscribe(subject, func(msg *nats.Msg) {
+ atomic.AddInt32(&calls, 1)
+ utils.ServeNATSRequest(msg, fn)
+ })
+ require.NoError(t, err)
+ t.Cleanup(func() { _ = sub.Unsubscribe() })
+ return &calls
+}
+
+func TestNATSSTSAssumer_RoundTrip(t *testing.T) {
+ _, nc := testutil.StartTestNATS(t)
+
+ var gotReq handlers_sts.AssumeRoleForInstanceRequest
+ calls := serveCounted(t, nc, handlers_sts.SubjectAssumeRoleForInstance,
+ func(req *handlers_sts.AssumeRoleForInstanceRequest) (*sts.AssumeRoleOutput, error) {
+ gotReq = *req
+ return &sts.AssumeRoleOutput{Credentials: &sts.Credentials{
+ AccessKeyId: aws.String("ASIATESTKEY"),
+ SecretAccessKey: aws.String("secret"),
+ SessionToken: aws.String("token"),
+ Expiration: aws.Time(time.Unix(1700000000, 0).UTC()),
+ }}, nil
+ })
+
+ assumer := NewNATSSTSAssumer(nc)
+ out, err := assumer.AssumeRoleForInstance(imdsTestAccountID, "arn:aws:iam::123456789012:role/app", "i-abc", 3600)
+ require.NoError(t, err)
+ require.NotNil(t, out.Credentials)
+
+ assert.Equal(t, "ASIATESTKEY", aws.StringValue(out.Credentials.AccessKeyId))
+ assert.Equal(t, int32(1), atomic.LoadInt32(calls))
+ // The request payload must round-trip every field the responder needs.
+ assert.Equal(t, handlers_sts.AssumeRoleForInstanceRequest{
+ AccountID: imdsTestAccountID,
+ RoleARN: "arn:aws:iam::123456789012:role/app",
+ InstanceID: "i-abc",
+ DurationSeconds: 3600,
+ }, gotReq)
+}
+
+func TestNATSProfileLookup_CachesProfile(t *testing.T) {
+ _, nc := testutil.StartTestNATS(t)
+
+ calls := serveCounted(t, nc, handlers_iam.SubjectResolveInstanceProfile,
+ func(req *handlers_iam.ResolveInstanceProfileRequest) (*handlers_iam.InstanceProfile, error) {
+ return &handlers_iam.InstanceProfile{
+ InstanceProfileName: "app",
+ AccountID: req.AccountID,
+ RoleName: "app-role",
+ }, nil
+ })
+
+ lookup := NewNATSProfileLookup(nc)
+ for range 3 {
+ prof, err := lookup.ResolveInstanceProfile(imdsTestAccountID, "app")
+ require.NoError(t, err)
+ assert.Equal(t, "app-role", prof.RoleName)
+ }
+ // Three lookups, one round-trip: the TTL cache suppressed the repeats.
+ assert.Equal(t, int32(1), atomic.LoadInt32(calls))
+}
+
+func TestNATSProfileLookup_CachesRole(t *testing.T) {
+ _, nc := testutil.StartTestNATS(t)
+
+ calls := serveCounted(t, nc, handlers_iam.SubjectGetRole,
+ func(req *handlers_iam.GetRoleRequest) (*iam.GetRoleOutput, error) {
+ return &iam.GetRoleOutput{Role: &iam.Role{
+ RoleName: req.Input.RoleName,
+ Arn: aws.String("arn:aws:iam::123456789012:role/app-role"),
+ }}, nil
+ })
+
+ lookup := NewNATSProfileLookup(nc)
+ for range 3 {
+ out, err := lookup.GetRole(imdsTestAccountID, &iam.GetRoleInput{RoleName: aws.String("app-role")})
+ require.NoError(t, err)
+ assert.Equal(t, "arn:aws:iam::123456789012:role/app-role", aws.StringValue(out.Role.Arn))
+ }
+ assert.Equal(t, int32(1), atomic.LoadInt32(calls))
+}
+
+func TestNATSProfileLookup_PropagatesError(t *testing.T) {
+ _, nc := testutil.StartTestNATS(t)
+
+ serveCounted(t, nc, handlers_iam.SubjectResolveInstanceProfile,
+ func(_ *handlers_iam.ResolveInstanceProfileRequest) (*handlers_iam.InstanceProfile, error) {
+ return nil, assert.AnError
+ })
+
+ lookup := NewNATSProfileLookup(nc)
+ _, err := lookup.ResolveInstanceProfile(imdsTestAccountID, "missing")
+ require.Error(t, err)
+}
+
+func TestTTLCache_ExpiresEntries(t *testing.T) {
+ now := time.Unix(1700000000, 0)
+ c := newTTLCache[string](time.Minute)
+ c.now = func() time.Time { return now }
+
+ c.put("k", "v")
+ got, ok := c.get("k")
+ require.True(t, ok)
+ assert.Equal(t, "v", got)
+
+ now = now.Add(2 * time.Minute) // past the TTL
+ _, ok = c.get("k")
+ assert.False(t, ok)
+}
diff --git a/spinifex/handlers/imds/resolver.go b/spinifex/handlers/imds/resolver.go
new file mode 100644
index 000000000..8400d7f13
--- /dev/null
+++ b/spinifex/handlers/imds/resolver.go
@@ -0,0 +1,189 @@
+package handlers_imds
+
+import (
+ "encoding/json"
+ "errors"
+ "fmt"
+ "log/slog"
+
+ "github.com/nats-io/nats.go"
+)
+
+// kvBucketENIs is the ENI source-of-truth bucket (handlers_ec2_vpc.KVBucketENIs),
+// duplicated as a literal rather than imported because importing handlers/ec2/vpc
+// back would close an import cycle (the veth_store record type does likewise).
+const kvBucketENIs = "spinifex-vpc-enis"
+
+// kvBucketSecurityGroups is the security-group source-of-truth bucket
+// (handlers_ec2_vpc.KVBucketSecurityGroups). Duplicated as a literal for the
+// same import-cycle reason as kvBucketENIs.
+const kvBucketSecurityGroups = "spinifex-vpc-security-groups"
+
+// eniFacts is the subset of an ENIRecord the IMDS metadata surface serves
+// directly, plus the account ID recovered from the ENI bucket key. Everything
+// here is read live off the ENI record, so a change (e.g. a re-associated EIP)
+// is reflected on the next request.
+type eniFacts struct {
+ eniID string
+ accountID string
+ instanceID string
+ vpcID string
+ subnetID string
+ privateIP string
+ publicIP string
+ mac string
+ availabilityZone string
+ securityGroupIDs []string
+}
+
+// instanceFacts carries the four metadata fields not present on the ENI record.
+// They live in the daemon's in-memory instance manager, so they are fetched via
+// the account-scoped DescribeInstances fan-out behind instanceLookup.
+type instanceFacts struct {
+ instanceType string
+ imageID string
+ iamInstanceProfileArn string
+ userData []byte
+}
+
+// instanceLookup resolves the instance-only metadata fields by instance ID. It
+// is an interface so the metadata handlers stay unit-testable without a live
+// NATS cluster; the production implementation (natsInstanceLookup) fans out
+// DescribeInstances + DescribeInstanceAttribute.
+type instanceLookup interface {
+ describe(accountID, instanceID string) (*instanceFacts, error)
+}
+
+// eniIndexValue mirrors the stored shape of a spinifex-network-eni-by-vpc-ip
+// row (handlers_ec2_vpc.eniByIPValue). Duplicated for the same cycle reason as
+// kvBucketENIs. Both fields are the ENI's immutable identity; everything mutable
+// is read live off the ENI record.
+type eniIndexValue struct {
+ ENIId string `json:"eni_id"`
+ AccountID string `json:"account_id"`
+}
+
+// sgNameRecord is the slice of handlers_ec2_vpc.SecurityGroupRecord the metadata
+// surface needs: the human-readable group name AWS serves at
+// /latest/meta-data/security-groups (not the sg-* ID).
+type sgNameRecord struct {
+ GroupName string `json:"group_name"`
+}
+
+// eniRecord is the subset of handlers_ec2_vpc.ENIRecord the resolver reads. The
+// full record is owned by the ENI controller; only these fields feed IMDS.
+type eniRecord struct {
+ NetworkInterfaceId string `json:"network_interface_id"`
+ SubnetId string `json:"subnet_id"`
+ VpcId string `json:"vpc_id"`
+ AvailabilityZone string `json:"availability_zone"`
+ PrivateIpAddress string `json:"private_ip_address"`
+ MacAddress string `json:"mac_address"`
+ InstanceId string `json:"instance_id,omitempty"`
+ PublicIpAddress string `json:"public_ip_address,omitempty"`
+ SecurityGroupIds []string `json:"security_group_ids,omitempty"`
+}
+
+// metadataResolver maps a datapath-attested (vpcID, srcIP) to the ENI + instance
+// facts the metadata surface serves. The chain is:
+//
+// (vpcID, ip) → {eniID, accountID} via the eni-by-vpc-ip reverse index
+// (account, eniID) → ENIRecord via a direct KV Get
+// (account, instanceID) → facts via the account-scoped DescribeInstances fan-out
+//
+// The index carries the ENI's immutable identity so the account resolves in one
+// Get; every mutable field is read live off the record, so there's no staleness.
+type metadataResolver struct {
+ index nats.KeyValue // spinifex-network-eni-by-vpc-ip
+ eniKV nats.KeyValue // spinifex-vpc-enis
+ sgKV nats.KeyValue // spinifex-vpc-security-groups (nil-safe: degrades to IDs)
+ lookup instanceLookup
+}
+
+var _ eniResolver = (*metadataResolver)(nil)
+
+// resolveENI returns the ENI facts for a request's (vpcID, srcIP), or (nil, nil)
+// when no mapping exists — the caller maps a miss to 404, matching AWS's
+// "eventually available during boot" posture. A non-nil error is reserved for
+// genuine backend failures.
+func (r *metadataResolver) resolveENI(vpcID, srcIP string) (*eniFacts, error) {
+ entry, err := r.index.Get(vpcID + "/" + srcIP)
+ if err != nil {
+ if errors.Is(err, nats.ErrKeyNotFound) {
+ return nil, nil
+ }
+ return nil, fmt.Errorf("get eni-by-ip index %s/%s: %w", vpcID, srcIP, err)
+ }
+
+ var idx eniIndexValue
+ if err := json.Unmarshal(entry.Value(), &idx); err != nil {
+ return nil, fmt.Errorf("unmarshal eni-by-ip index %s/%s: %w", vpcID, srcIP, err)
+ }
+ if idx.ENIId == "" || idx.AccountID == "" {
+ return nil, nil
+ }
+
+ raw, err := r.eniKV.Get(idx.AccountID + "." + idx.ENIId)
+ if err != nil {
+ if errors.Is(err, nats.ErrKeyNotFound) {
+ return nil, nil
+ }
+ return nil, fmt.Errorf("get eni record %s.%s: %w", idx.AccountID, idx.ENIId, err)
+ }
+
+ var rec eniRecord
+ if err := json.Unmarshal(raw.Value(), &rec); err != nil {
+ return nil, fmt.Errorf("unmarshal eni record %s: %w", idx.ENIId, err)
+ }
+
+ return &eniFacts{
+ eniID: rec.NetworkInterfaceId,
+ accountID: idx.AccountID,
+ instanceID: rec.InstanceId,
+ vpcID: rec.VpcId,
+ subnetID: rec.SubnetId,
+ privateIP: rec.PrivateIpAddress,
+ publicIP: rec.PublicIpAddress,
+ mac: rec.MacAddress,
+ availabilityZone: rec.AvailabilityZone,
+ securityGroupIDs: rec.SecurityGroupIds,
+ }, nil
+}
+
+// resolveInstance fetches the instance-only metadata fields for an ENI's
+// attached instance. Returns (nil, nil) when the ENI has no attached instance.
+func (r *metadataResolver) resolveInstance(eni *eniFacts) (*instanceFacts, error) {
+ if eni.instanceID == "" {
+ return nil, nil
+ }
+ return r.lookup.describe(eni.accountID, eni.instanceID)
+}
+
+// resolveSGNames maps security-group IDs to the group names AWS serves at
+// /latest/meta-data/security-groups. Best-effort and order-preserving: a missing
+// record or nil sgKV falls back to the raw ID rather than failing the request.
+func (r *metadataResolver) resolveSGNames(accountID string, sgIDs []string) []string {
+ names := make([]string, len(sgIDs))
+ for i, id := range sgIDs {
+ names[i] = id
+ if r.sgKV == nil {
+ continue
+ }
+ raw, err := r.sgKV.Get(accountID + "." + id)
+ if err != nil {
+ if !errors.Is(err, nats.ErrKeyNotFound) {
+ slog.Warn("IMDS: security-group name lookup failed", "account_id", accountID, "sg_id", id, "err", err)
+ }
+ continue
+ }
+ var rec sgNameRecord
+ if err := json.Unmarshal(raw.Value(), &rec); err != nil {
+ slog.Warn("IMDS: security-group unmarshal failed", "sg_id", id, "err", err)
+ continue
+ }
+ if rec.GroupName != "" {
+ names[i] = rec.GroupName
+ }
+ }
+ return names
+}
diff --git a/spinifex/handlers/imds/resolver_test.go b/spinifex/handlers/imds/resolver_test.go
new file mode 100644
index 000000000..5577570d7
--- /dev/null
+++ b/spinifex/handlers/imds/resolver_test.go
@@ -0,0 +1,190 @@
+package handlers_imds
+
+import (
+ "encoding/json"
+ "errors"
+ "testing"
+
+ "github.com/mulgadc/spinifex/spinifex/testutil"
+ "github.com/nats-io/nats.go"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+// fakeLookup is a programmable instanceLookup so resolver tests never need the
+// account-scoped instance fan-out.
+type fakeLookup struct {
+ facts *instanceFacts
+ err error
+ calls int
+}
+
+func (f *fakeLookup) describe(_, _ string) (*instanceFacts, error) {
+ f.calls++
+ return f.facts, f.err
+}
+
+// newTestResolver wires a metadataResolver onto a live test JetStream with the
+// three buckets it reads (eni-by-vpc-ip index, ENI source-of-truth, SG names)
+// created empty, plus a fake instance lookup the caller can program.
+func newTestResolver(t *testing.T) (*metadataResolver, *fakeLookup) {
+ t.Helper()
+ _, _, js := testutil.StartTestJetStream(t)
+ index, err := InitENIByIPBucket(js, 1)
+ require.NoError(t, err)
+ eniKV, err := js.CreateKeyValue(&nats.KeyValueConfig{Bucket: kvBucketENIs, History: 1})
+ require.NoError(t, err)
+ sgKV, err := js.CreateKeyValue(&nats.KeyValueConfig{Bucket: kvBucketSecurityGroups, History: 1})
+ require.NoError(t, err)
+
+ lookup := &fakeLookup{}
+ return &metadataResolver{index: index, eniKV: eniKV, sgKV: sgKV, lookup: lookup}, lookup
+}
+
+func putJSON(t *testing.T, kv nats.KeyValue, key string, v any) {
+ t.Helper()
+ data, err := json.Marshal(v)
+ require.NoError(t, err)
+ _, err = kv.Put(key, data)
+ require.NoError(t, err)
+}
+
+// seedENI writes a complete index → ENI chain for (testVPC, testIP) and returns
+// the resolver ready to serve it.
+func seedENI(t *testing.T, r *metadataResolver) {
+ t.Helper()
+ putJSON(t, r.index, testVPC+"/"+testIP, eniIndexValue{ENIId: "eni-aaa", AccountID: "111122223333"})
+ putJSON(t, r.eniKV, "111122223333.eni-aaa", eniRecord{
+ NetworkInterfaceId: "eni-aaa",
+ SubnetId: "subnet-1",
+ VpcId: testVPC,
+ AvailabilityZone: "ap-southeast-2a",
+ PrivateIpAddress: testIP,
+ MacAddress: "02:11:22:33:44:55",
+ InstanceId: "i-0123456789",
+ PublicIpAddress: "203.0.113.7",
+ SecurityGroupIds: []string{"sg-1", "sg-2"},
+ })
+}
+
+func TestResolveENI_Hit(t *testing.T) {
+ r, _ := newTestResolver(t)
+ seedENI(t, r)
+
+ eni, err := r.resolveENI(testVPC, testIP)
+ require.NoError(t, err)
+ require.NotNil(t, eni)
+ assert.Equal(t, "eni-aaa", eni.eniID)
+ assert.Equal(t, "111122223333", eni.accountID)
+ assert.Equal(t, "i-0123456789", eni.instanceID)
+ assert.Equal(t, testVPC, eni.vpcID)
+ assert.Equal(t, "subnet-1", eni.subnetID)
+ assert.Equal(t, testIP, eni.privateIP)
+ assert.Equal(t, "203.0.113.7", eni.publicIP)
+ assert.Equal(t, "02:11:22:33:44:55", eni.mac)
+ assert.Equal(t, "ap-southeast-2a", eni.availabilityZone)
+ assert.Equal(t, []string{"sg-1", "sg-2"}, eni.securityGroupIDs)
+}
+
+// A source IP with no reverse-index row is a miss, not an error — the caller
+// maps it to 404 per AWS's "eventually available during boot" posture.
+func TestResolveENI_IndexMissIsNilNil(t *testing.T) {
+ r, _ := newTestResolver(t)
+ eni, err := r.resolveENI(testVPC, "10.9.9.9")
+ require.NoError(t, err)
+ assert.Nil(t, eni)
+}
+
+func TestResolveENI_IndexBadJSONErrors(t *testing.T) {
+ r, _ := newTestResolver(t)
+ _, err := r.index.Put(testVPC+"/"+testIP, []byte("not json"))
+ require.NoError(t, err)
+
+ _, err = r.resolveENI(testVPC, testIP)
+ require.Error(t, err)
+}
+
+// An index row missing either identity field is treated as a miss rather than
+// being chased into a malformed ENI Get.
+func TestResolveENI_EmptyIndexFieldsIsNilNil(t *testing.T) {
+ r, _ := newTestResolver(t)
+ putJSON(t, r.index, testVPC+"/"+testIP, eniIndexValue{ENIId: "eni-aaa"}) // no account
+ eni, err := r.resolveENI(testVPC, testIP)
+ require.NoError(t, err)
+ assert.Nil(t, eni)
+}
+
+// A dangling index pointing at an ENI record that no longer exists is a miss,
+// matching a mid-teardown race.
+func TestResolveENI_ENIRecordMissIsNilNil(t *testing.T) {
+ r, _ := newTestResolver(t)
+ putJSON(t, r.index, testVPC+"/"+testIP, eniIndexValue{ENIId: "eni-gone", AccountID: "111122223333"})
+ eni, err := r.resolveENI(testVPC, testIP)
+ require.NoError(t, err)
+ assert.Nil(t, eni)
+}
+
+func TestResolveENI_ENIRecordBadJSONErrors(t *testing.T) {
+ r, _ := newTestResolver(t)
+ putJSON(t, r.index, testVPC+"/"+testIP, eniIndexValue{ENIId: "eni-aaa", AccountID: "111122223333"})
+ _, err := r.eniKV.Put("111122223333.eni-aaa", []byte("not json"))
+ require.NoError(t, err)
+
+ _, err = r.resolveENI(testVPC, testIP)
+ require.Error(t, err)
+}
+
+func TestResolveInstance_NoAttachedInstance(t *testing.T) {
+ r, lookup := newTestResolver(t)
+ inst, err := r.resolveInstance(&eniFacts{instanceID: ""})
+ require.NoError(t, err)
+ assert.Nil(t, inst)
+ assert.Equal(t, 0, lookup.calls, "lookup must be skipped when no instance is attached")
+}
+
+func TestResolveInstance_DelegatesToLookup(t *testing.T) {
+ r, lookup := newTestResolver(t)
+ lookup.facts = &instanceFacts{instanceType: "t3.micro", imageID: "ami-1"}
+
+ inst, err := r.resolveInstance(&eniFacts{accountID: "111122223333", instanceID: "i-1"})
+ require.NoError(t, err)
+ require.NotNil(t, inst)
+ assert.Equal(t, "t3.micro", inst.instanceType)
+ assert.Equal(t, 1, lookup.calls)
+}
+
+func TestResolveInstance_LookupErrorPropagates(t *testing.T) {
+ r, lookup := newTestResolver(t)
+ lookup.err = errors.New("fan-out failed")
+ _, err := r.resolveInstance(&eniFacts{accountID: "111122223333", instanceID: "i-1"})
+ require.Error(t, err)
+}
+
+// A nil SG bucket (not yet up at start) must degrade the endpoint to raw IDs
+// rather than failing the request.
+func TestResolveSGNames_NilBucketDegradesToIDs(t *testing.T) {
+ r, _ := newTestResolver(t)
+ r.sgKV = nil
+ got := r.resolveSGNames("111122223333", []string{"sg-1", "sg-2"})
+ assert.Equal(t, []string{"sg-1", "sg-2"}, got)
+}
+
+// Names resolve where a record exists; a missing record or one with an empty
+// name falls back to the ID, and order is preserved throughout.
+func TestResolveSGNames_ResolvesAndFallsBack(t *testing.T) {
+ r, _ := newTestResolver(t)
+ putJSON(t, r.sgKV, "111122223333.sg-1", sgNameRecord{GroupName: "web-sg"})
+ putJSON(t, r.sgKV, "111122223333.sg-3", sgNameRecord{GroupName: ""}) // empty → ID
+
+ got := r.resolveSGNames("111122223333", []string{"sg-1", "sg-2", "sg-3"})
+ assert.Equal(t, []string{"web-sg", "sg-2", "sg-3"}, got)
+}
+
+func TestResolveSGNames_BadJSONFallsBackToID(t *testing.T) {
+ r, _ := newTestResolver(t)
+ _, err := r.sgKV.Put("111122223333.sg-1", []byte("not json"))
+ require.NoError(t, err)
+
+ got := r.resolveSGNames("111122223333", []string{"sg-1"})
+ assert.Equal(t, []string{"sg-1"}, got)
+}
diff --git a/spinifex/handlers/imds/server.go b/spinifex/handlers/imds/server.go
new file mode 100644
index 000000000..edc2bf9e5
--- /dev/null
+++ b/spinifex/handlers/imds/server.go
@@ -0,0 +1,346 @@
+package handlers_imds
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "log/slog"
+ "net"
+ "net/http"
+ "net/netip"
+ "os"
+ "runtime"
+ "sync"
+ "syscall"
+ "time"
+
+ "github.com/mulgadc/spinifex/spinifex/utils"
+ "github.com/nats-io/nats.go"
+ "golang.org/x/sys/unix"
+)
+
+// ensureVethFunc / removeVethFunc are the per-subnet host veth lifecycle hooks,
+// injected (not imported) to avoid an import cycle via network/host. vpcd passes
+// host.EnsureIMDSVeth / host.RemoveIMDSVeth; tests pass root-free fakes. ensure
+// takes the subnet CIDR (added on-link in the netns so the reply resolves the
+// guest by ARP) and returns the per-subnet netns the listener must be created in
+// plus the host-end veth name (now living inside that netns).
+type ensureVethFunc func(ctx context.Context, subnetID string, cidr netip.Prefix) (netnsName, hostEnd string, err error)
+type removeVethFunc func(ctx context.Context, subnetID string) error
+
+// listenFunc binds a TCP listener on 169.254.169.254:80 inside the subnet's netns,
+// scoped to its host veth. Swappable in tests so bind-manager logic runs without
+// CAP_NET_ADMIN or a real netns. An empty netnsName binds in the current netns.
+type listenFunc func(ctx context.Context, netnsName, hostEnd string) (net.Listener, error)
+
+// activeBinding is the per-subnet realised state: a listener bound to the subnet's
+// host veth and the http.Server serving it.
+type activeBinding struct {
+ listener net.Listener
+ server *http.Server
+}
+
+// bindManager realises the per-chassis IMDS listener stack. It runs on every
+// chassis and eagerly materialises a veth + OVS port + listener for every subnet in
+// the subnet-veth bucket — lifecycle is "watch the bucket, ensure local state".
+type bindManager struct {
+ kv nats.KeyValue // spinifex-network-imds-subnet-veth
+ store *VethStore // reads the subnet CIDR the host on-link route needs
+ handler http.Handler
+ ensureVeth ensureVethFunc
+ removeVeth removeVethFunc
+ listen listenFunc
+
+ mu sync.Mutex
+ active map[string]*activeBinding // subnetID → binding
+}
+
+func newBindManager(kv nats.KeyValue, handler http.Handler, ensure ensureVethFunc, remove removeVethFunc, listen listenFunc) *bindManager {
+ return &bindManager{
+ kv: kv,
+ store: NewVethStore(kv),
+ handler: handler,
+ ensureVeth: ensure,
+ removeVeth: remove,
+ listen: listen,
+ active: make(map[string]*activeBinding),
+ }
+}
+
+// sync reads the full subnet-veth bucket and binds a listener for every entry. Run
+// once at startup; idempotent, so a re-sync after a transient error is safe.
+func (b *bindManager) sync(ctx context.Context) error {
+ keys, err := b.kv.Keys()
+ if err != nil {
+ if errors.Is(err, nats.ErrNoKeysFound) {
+ return nil
+ }
+ return fmt.Errorf("list subnet-veth keys: %w", err)
+ }
+ for _, subnetID := range keys {
+ if subnetID == utils.VersionKey {
+ continue // schema-version marker written by migrate.RunKV, not a subnet
+ }
+ vpcID, cidr, err := b.subnetBinding(subnetID)
+ if err != nil {
+ slog.Error("IMDS: resolve subnet binding during sync", "subnet_id", subnetID, "err", err)
+ continue
+ }
+ if err := b.bind(ctx, subnetID, vpcID, cidr); err != nil {
+ slog.Error("IMDS: bind failed during sync", "subnet_id", subnetID, "err", err)
+ }
+ }
+ return nil
+}
+
+// subnetBinding reads the persisted veth record for subnetID and returns the two
+// facts bind needs: the owning VPC (the subnet→VPC static lookup — the listener
+// identifies the subnet, the eni-by-vpc-ip index is keyed vpcID/ip) and the
+// subnet CIDR (added on-link in the netns so the reply resolves the guest by
+// ARP). A record missing either field is malformed; bind cannot proceed without
+// them, and the reconciler re-publishes a complete record to converge.
+func (b *bindManager) subnetBinding(subnetID string) (vpcID string, cidr netip.Prefix, err error) {
+ rec, err := b.store.Get(subnetID)
+ if err != nil {
+ return "", netip.Prefix{}, err
+ }
+ if rec == nil {
+ return "", netip.Prefix{}, fmt.Errorf("no veth record for %s", subnetID)
+ }
+ if rec.VPCID == "" {
+ return "", netip.Prefix{}, fmt.Errorf("veth record for %s has no vpc_id", subnetID)
+ }
+ cidr, err = netip.ParsePrefix(rec.SubnetCIDR)
+ if err != nil {
+ return "", netip.Prefix{}, fmt.Errorf("parse subnet cidr %q: %w", rec.SubnetCIDR, err)
+ }
+ return rec.VPCID, cidr, nil
+}
+
+// watch reacts to subnet-veth bucket changes for the life of ctx: a Put binds the
+// subnet (if not already bound), a Delete unbinds it and tears down the veth. Both
+// transitions are idempotent.
+func (b *bindManager) watch(ctx context.Context) {
+ watcher, err := b.kv.WatchAll(nats.Context(ctx))
+ if err != nil {
+ slog.Error("IMDS: failed to start subnet-veth watcher", "err", err)
+ return
+ }
+ defer func() { _ = watcher.Stop() }()
+
+ for {
+ select {
+ case <-ctx.Done():
+ return
+ case entry, ok := <-watcher.Updates():
+ if !ok {
+ return
+ }
+ if entry == nil {
+ // nil marks the end of the initial replay; sync() already
+ // covered those, so there is nothing extra to do.
+ continue
+ }
+ if entry.Key() == utils.VersionKey {
+ continue // schema-version marker written by migrate.RunKV, not a subnet
+ }
+ switch entry.Operation() {
+ case nats.KeyValuePut:
+ vpcID, cidr, err := b.subnetBinding(entry.Key())
+ if err != nil {
+ slog.Error("IMDS: resolve subnet binding on watch", "subnet_id", entry.Key(), "err", err)
+ continue
+ }
+ if err := b.bind(ctx, entry.Key(), vpcID, cidr); err != nil {
+ slog.Error("IMDS: bind failed on watch", "subnet_id", entry.Key(), "err", err)
+ }
+ case nats.KeyValueDelete, nats.KeyValuePurge:
+ b.unbind(ctx, entry.Key())
+ }
+ }
+ }
+}
+
+// bind ensures the subnet's host veth (with the subnet CIDR on-link for the reply
+// path) then opens a device-scoped listener for subnetID. vpcID is the subnet's
+// statically-resolved owning VPC, threaded into every request so the handler can
+// key the eni-by-vpc-ip index. A no-op if already bound.
+func (b *bindManager) bind(ctx context.Context, subnetID, vpcID string, cidr netip.Prefix) error {
+ b.mu.Lock()
+ if _, ok := b.active[subnetID]; ok {
+ b.mu.Unlock()
+ return nil
+ }
+ b.mu.Unlock()
+
+ netnsName, hostEnd, err := b.ensureVeth(ctx, subnetID, cidr)
+ if err != nil {
+ return fmt.Errorf("ensure veth: %w", err)
+ }
+
+ listener, err := b.listen(ctx, netnsName, hostEnd)
+ if err != nil {
+ return fmt.Errorf("listen on %s in netns %s: %w", hostEnd, netnsName, err)
+ }
+
+ server := &http.Server{
+ Handler: b.handler,
+ ReadHeaderTimeout: 10 * time.Second,
+ // Tag every request from this listener with the subnet it serves and that
+ // subnet's owning VPC. The handler pairs the VPC with the datapath-attested
+ // source IP to resolve the ENI; the subnet ID rides along for triage.
+ BaseContext: func(net.Listener) context.Context {
+ c := context.WithValue(ctx, ctxKeyVPCID, vpcID)
+ return context.WithValue(c, ctxKeySubnetID, subnetID)
+ },
+ }
+
+ b.mu.Lock()
+ // Re-check under lock in case a concurrent bind won the race.
+ if _, ok := b.active[subnetID]; ok {
+ b.mu.Unlock()
+ _ = listener.Close()
+ return nil
+ }
+ b.active[subnetID] = &activeBinding{listener: listener, server: server}
+ b.mu.Unlock()
+
+ go func() {
+ if err := server.Serve(listener); err != nil && !errors.Is(err, http.ErrServerClosed) {
+ slog.Error("IMDS: listener serve exited", "subnet_id", subnetID, "host_end", hostEnd, "err", err)
+ }
+ }()
+
+ slog.Info("IMDS: listener bound", "subnet_id", subnetID, "vpc_id", vpcID, "netns", netnsName, "host_end", hostEnd, "addr", listener.Addr().String())
+ return nil
+}
+
+// unbind closes the subnet's listener and removes its host veth. Idempotent.
+func (b *bindManager) unbind(ctx context.Context, subnetID string) {
+ b.mu.Lock()
+ binding := b.active[subnetID]
+ delete(b.active, subnetID)
+ b.mu.Unlock()
+
+ if binding != nil {
+ if err := binding.server.Close(); err != nil {
+ slog.Warn("IMDS: listener close failed", "subnet_id", subnetID, "err", err)
+ }
+ }
+ if err := b.removeVeth(ctx, subnetID); err != nil {
+ slog.Warn("IMDS: veth removal failed", "subnet_id", subnetID, "err", err)
+ }
+ slog.Info("IMDS: listener unbound", "subnet_id", subnetID)
+}
+
+// shutdown closes every active listener. The host veths are intentionally left
+// in place: they are cheap, idempotent to re-ensure on the next start, and
+// tearing them down on a clean vpcd restart would needlessly disrupt any other
+// chassis-local consumer.
+func (b *bindManager) shutdown() {
+ b.mu.Lock()
+ defer b.mu.Unlock()
+ for subnetID, binding := range b.active {
+ if err := binding.server.Close(); err != nil {
+ slog.Warn("IMDS: listener close failed during shutdown", "subnet_id", subnetID, "err", err)
+ }
+ delete(b.active, subnetID)
+ }
+}
+
+// bindLocalListener opens a TCP listener on 169.254.169.254:80 inside the subnet's
+// netns, where the host-end veth carries 169.254.169.254/30 with the subnet CIDR
+// on-link — so the host-served reply resolves the guest by ARP over the localport
+// on the guest's own L2 switch (SO_BINDTODEVICE alone in the root netns could not
+// route the SYN-ACK). The netns also isolates subnets whose CIDRs overlap, since
+// each is its own routing domain. The socket must be *created* in the netns, so we
+// enter it on a locked thread for the socket call and restore the root netns before
+// serving.
+// SO_BINDTODEVICE (belt-and-braces — there is only one veth in the netns) and
+// SO_REUSEADDR (fast rebind on vpcd restart) are still set on the fd.
+func bindLocalListener(ctx context.Context, netnsName, hostEnd string) (net.Listener, error) {
+ lc := net.ListenConfig{
+ Control: func(_, _ string, c syscall.RawConn) error {
+ var sockErr error
+ if err := c.Control(func(fd uintptr) {
+ if sockErr = unix.SetsockoptString(int(fd), unix.SOL_SOCKET, unix.SO_BINDTODEVICE, hostEnd); sockErr != nil {
+ return
+ }
+ sockErr = unix.SetsockoptInt(int(fd), unix.SOL_SOCKET, unix.SO_REUSEADDR, 1)
+ }); err != nil {
+ return err
+ }
+ return sockErr
+ },
+ }
+
+ bind := func() (net.Listener, error) {
+ return lc.Listen(ctx, "tcp4", net.JoinHostPort(MetaDataServerIP, "80"))
+ }
+
+ if netnsName == "" {
+ // No netns (tests, or a degraded host): bind in the current netns.
+ return bind()
+ }
+
+ var ln net.Listener
+ err := inNetns(netnsName, func() error {
+ var e error
+ ln, e = bind()
+ return e
+ })
+ if err != nil {
+ return nil, err
+ }
+ return ln, nil
+}
+
+// inNetns runs fn on a dedicated OS thread switched into the named network
+// namespace, then restores the root netns. fn's socket-creating syscalls happen
+// in the target netns, so the listener fd belongs to it; once created, the fd is
+// served normally from any thread. If the root netns cannot be restored the
+// thread is poisoned, so the goroutine returns while still locked and the Go
+// runtime retires the thread rather than returning it to the pool.
+func inNetns(netnsName string, fn func() error) error {
+ const netnsDir = "/var/run/netns/"
+ errCh := make(chan error, 1)
+
+ go func() {
+ runtime.LockOSThread()
+
+ orig, err := os.Open("/proc/thread-self/ns/net")
+ if err != nil {
+ runtime.UnlockOSThread()
+ errCh <- fmt.Errorf("open current netns: %w", err)
+ return
+ }
+ defer func() { _ = orig.Close() }()
+
+ target, err := os.Open(netnsDir + netnsName)
+ if err != nil {
+ runtime.UnlockOSThread()
+ errCh <- fmt.Errorf("open netns %s: %w", netnsName, err)
+ return
+ }
+ defer func() { _ = target.Close() }()
+
+ if err := unix.Setns(int(target.Fd()), unix.CLONE_NEWNET); err != nil {
+ runtime.UnlockOSThread()
+ errCh <- fmt.Errorf("setns into %s: %w", netnsName, err)
+ return
+ }
+
+ fnErr := fn()
+
+ if err := unix.Setns(int(orig.Fd()), unix.CLONE_NEWNET); err != nil {
+ // Cannot return to the root netns — leave the thread locked so the
+ // runtime destroys it when this goroutine exits.
+ errCh <- errors.Join(fnErr, fmt.Errorf("restore root netns: %w", err))
+ return
+ }
+ runtime.UnlockOSThread()
+ errCh <- fnErr
+ }()
+
+ return <-errCh
+}
diff --git a/spinifex/handlers/imds/server_test.go b/spinifex/handlers/imds/server_test.go
new file mode 100644
index 000000000..5fd500999
--- /dev/null
+++ b/spinifex/handlers/imds/server_test.go
@@ -0,0 +1,139 @@
+package handlers_imds
+
+import (
+ "context"
+ "io"
+ "net"
+ "net/http"
+ "net/netip"
+ "sync"
+ "sync/atomic"
+ "testing"
+ "time"
+
+ "github.com/mulgadc/spinifex/spinifex/testutil"
+ "github.com/mulgadc/spinifex/spinifex/utils"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+// loopbackListen returns a listenFunc backed by a real 127.0.0.1 listener so
+// bind-manager logic runs end-to-end (including http.Server.Serve and the
+// per-listener BaseContext) without root or the link-local address. It records
+// the bound address per host-end so the test can dial it.
+func loopbackListen(addrs *sync.Map) listenFunc {
+ return func(ctx context.Context, netnsName, hostEnd string) (net.Listener, error) {
+ ln, err := net.Listen("tcp", "127.0.0.1:0")
+ if err != nil {
+ return nil, err
+ }
+ addrs.Store(hostEnd, ln.Addr().String())
+ return ln, nil
+ }
+}
+
+func TestBindManager_BindServesWithVPCContext(t *testing.T) {
+ const (
+ hostEnd = "imds-h-abc12345"
+ subnetID = "subnet-abc12345"
+ vpcID = "vpc-abc12345"
+ )
+ var ensured, removed atomic.Int32
+ var ensuredKey, removedKey atomic.Value
+ ensure := func(_ context.Context, key string, _ netip.Prefix) (string, string, error) {
+ ensured.Add(1)
+ ensuredKey.Store(key)
+ return "", hostEnd, nil
+ }
+ remove := func(_ context.Context, key string) error {
+ removed.Add(1)
+ removedKey.Store(key)
+ return nil
+ }
+
+ // Echo the VPC + subnet the bind manager threaded into the request context.
+ // The VPC keys the eni-by-vpc-ip index — the (VPC-ID, source-IP) → identity
+ // path the security model relies on; the subnet rides along for triage.
+ handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ ctxVPC, _ := r.Context().Value(ctxKeyVPCID).(string)
+ ctxSubnet, _ := r.Context().Value(ctxKeySubnetID).(string)
+ _, _ = io.WriteString(w, ctxVPC+"|"+ctxSubnet)
+ })
+
+ var addrs sync.Map
+ bm := newBindManager(nil, handler, ensure, remove, loopbackListen(&addrs))
+ ctx := context.Background()
+ cidr := netip.MustParsePrefix("10.211.0.0/16")
+
+ require.NoError(t, bm.bind(ctx, subnetID, vpcID, cidr))
+ assert.Equal(t, int32(1), ensured.Load())
+ assert.Equal(t, subnetID, ensuredKey.Load(), "veth lifecycle is keyed by subnet")
+
+ // Second bind of the same key is a no-op (idempotent), no extra veth ensure.
+ require.NoError(t, bm.bind(ctx, subnetID, vpcID, cidr))
+ assert.Equal(t, int32(1), ensured.Load())
+
+ raw, ok := addrs.Load(hostEnd)
+ require.True(t, ok)
+ addr, ok := raw.(string)
+ require.True(t, ok)
+
+ resp, err := http.Get("http://" + addr + prefixMetaData)
+ require.NoError(t, err)
+ body, _ := io.ReadAll(resp.Body)
+ _ = resp.Body.Close()
+ assert.Equal(t, vpcID+"|"+subnetID, string(body), "handler must see the listener's VPC and subnet")
+
+ // Unbind closes the listener and tears down the veth, keyed by subnet.
+ bm.unbind(ctx, subnetID)
+ assert.Equal(t, int32(1), removed.Load())
+ assert.Equal(t, subnetID, removedKey.Load(), "veth removal is keyed by subnet")
+
+ client := http.Client{Timeout: 500 * time.Millisecond}
+ _, err = client.Get("http://" + addr + prefixMetaData)
+ assert.Error(t, err, "listener must be closed after unbind")
+}
+
+func TestBindManager_BindPropagatesVethError(t *testing.T) {
+ ensure := func(_ context.Context, _ string, _ netip.Prefix) (string, string, error) {
+ return "", "", errEnsureFailed
+ }
+ bm := newBindManager(nil, http.NewServeMux(), ensure, func(context.Context, string) error { return nil }, loopbackListen(&sync.Map{}))
+ err := bm.bind(context.Background(), "subnet-x", "vpc-x", netip.MustParsePrefix("10.211.0.0/16"))
+ require.Error(t, err)
+}
+
+var errEnsureFailed = io.ErrUnexpectedEOF
+
+// TestBindManager_SyncSkipsVersionKey guards that the schema-version marker
+// migrate.RunKV stamps into the subnet-veth bucket is not mistaken for a subnet
+// ID and made to bring up a bogus veth + listener.
+func TestBindManager_SyncSkipsVersionKey(t *testing.T) {
+ _, _, js := testutil.StartTestJetStream(t)
+ subnetVeth, _, err := InitBuckets(js, 1)
+ require.NoError(t, err)
+
+ // InitBuckets → migrate.RunKV stamps utils.VersionKey; add one real subnet
+ // record (sync resolves its CIDR from the record before binding).
+ require.NoError(t, NewVethStore(subnetVeth).Put(SubnetVethRecord{
+ SubnetID: "subnet-abcdef12",
+ VPCID: "vpc-abcdef12",
+ SubnetCIDR: "10.211.0.0/16",
+ }))
+
+ var bound sync.Map
+ ensure := func(_ context.Context, subnetID string, _ netip.Prefix) (string, string, error) {
+ bound.Store(subnetID, true)
+ return "imds-" + subnetID, "imds-h-" + subnetID, nil
+ }
+ remove := func(context.Context, string) error { return nil }
+ bm := newBindManager(subnetVeth, http.NewServeMux(), ensure, remove, loopbackListen(&sync.Map{}))
+
+ require.NoError(t, bm.sync(context.Background()))
+ defer bm.shutdown()
+
+ _, versionBound := bound.Load(utils.VersionKey)
+ assert.False(t, versionBound, "_version marker must not be bound as a subnet")
+ _, subnetBound := bound.Load("subnet-abcdef12")
+ assert.True(t, subnetBound, "real subnet entry must be bound")
+}
diff --git a/spinifex/handlers/imds/service.go b/spinifex/handlers/imds/service.go
new file mode 100644
index 000000000..0f3c6df92
--- /dev/null
+++ b/spinifex/handlers/imds/service.go
@@ -0,0 +1,161 @@
+package handlers_imds
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "log/slog"
+ "net/http"
+ "time"
+
+ "github.com/aws/aws-sdk-go/service/iam"
+ handlers_iam "github.com/mulgadc/spinifex/spinifex/handlers/iam"
+ "github.com/nats-io/nats.go"
+)
+
+// tokenSweepInterval is how often abandoned/expired IMDSv2 tokens are swept.
+const tokenSweepInterval = time.Minute
+
+// profileLookup is the slice of IAMService the IMDS credential + iam/* paths
+// need: dereference an instance-profile ARN to its record, and resolve a role
+// name to its canonical ARN. Narrowed to an interface for unit testing.
+type profileLookup interface {
+ ResolveInstanceProfile(accountID, nameOrARN string) (*handlers_iam.InstanceProfile, error)
+ GetRole(accountID string, input *iam.GetRoleInput) (*iam.GetRoleOutput, error)
+}
+
+// IMDSService is the host-served EC2 Instance Metadata Service. It runs in
+// vpcd on every chassis, serving 169.254.169.254 to local guest VMs over
+// per-subnet veths. Run owns the listener lifecycle and blocks until ctx is done.
+type IMDSService interface {
+ Run(ctx context.Context) error
+}
+
+var _ IMDSService = (*IMDSServiceImpl)(nil)
+
+// eniResolver maps a datapath-attested (vpcID, srcIP) to ENI + instance facts.
+// An interface so the HTTP handlers are unit-testable without a live KV;
+// metadataResolver is the production implementation.
+type eniResolver interface {
+ resolveENI(vpcID, srcIP string) (*eniFacts, error)
+ resolveInstance(eni *eniFacts) (*instanceFacts, error)
+ resolveSGNames(accountID string, sgIDs []string) []string
+}
+
+// IMDSServiceImpl is the in-process IMDS implementation. It is not part of the
+// HTTPS gateway dispatch surface — it owns its own per-VPC listener stack.
+type IMDSServiceImpl struct {
+ resolver eniResolver
+ tokens *tokenStore
+ creds *credCache
+ iam profileLookup
+ bind *bindManager
+ now func() time.Time
+}
+
+// NewIMDSServiceImpl wires the IMDS service. natsConn backs the KV reads and the
+// account-scoped instance fan-out (sized by expectedNodes); ensureVeth/removeVeth
+// are injected rather than imported to avoid an import cycle via network/host.
+func NewIMDSServiceImpl(natsConn *nats.Conn, sts stsAssumer, iamSvc profileLookup, expectedNodes int, ensureVeth ensureVethFunc, removeVeth removeVethFunc) (*IMDSServiceImpl, error) {
+ if natsConn == nil {
+ return nil, errors.New("nil NATS connection")
+ }
+ if sts == nil {
+ return nil, errors.New("nil STS service")
+ }
+ if iamSvc == nil {
+ return nil, errors.New("nil IAM service")
+ }
+ if ensureVeth == nil || removeVeth == nil {
+ return nil, errors.New("nil veth lifecycle hooks")
+ }
+
+ js, err := natsConn.JetStream()
+ if err != nil {
+ return nil, fmt.Errorf("get JetStream context: %w", err)
+ }
+
+ indexKV, err := js.KeyValue(KVBucketENIByVPCIP)
+ if err != nil {
+ return nil, fmt.Errorf("open %s bucket: %w", KVBucketENIByVPCIP, err)
+ }
+ eniKV, err := js.KeyValue(kvBucketENIs)
+ if err != nil {
+ return nil, fmt.Errorf("open %s bucket: %w", kvBucketENIs, err)
+ }
+ vethKV, err := js.KeyValue(KVBucketIMDSSubnetVeth)
+ if err != nil {
+ return nil, fmt.Errorf("open %s bucket: %w", KVBucketIMDSSubnetVeth, err)
+ }
+
+ // The SG bucket is owned by the VPC service and only feeds the non-critical
+ // security-groups path. Open it best-effort: if it isn't up yet, IMDS still
+ // starts and resolveSGNames degrades to serving raw IDs.
+ sgKV, err := js.KeyValue(kvBucketSecurityGroups)
+ if err != nil {
+ slog.Warn("IMDS: security-group bucket unavailable, /security-groups will serve IDs", "bucket", kvBucketSecurityGroups, "err", err)
+ sgKV = nil
+ }
+
+ svc := &IMDSServiceImpl{
+ resolver: &metadataResolver{
+ index: indexKV,
+ eniKV: eniKV,
+ sgKV: sgKV,
+ lookup: &natsInstanceLookup{nc: natsConn, expectedNodes: expectedNodes},
+ },
+ tokens: newTokenStore(),
+ creds: newCredCache(sts),
+ iam: iamSvc,
+ now: time.Now,
+ }
+ svc.bind = newBindManager(vethKV, svc.httpHandler(), ensureVeth, removeVeth, bindLocalListener)
+
+ slog.Info("IMDS service initialized",
+ "index_bucket", KVBucketENIByVPCIP,
+ "veth_bucket", KVBucketIMDSSubnetVeth)
+ return svc, nil
+}
+
+// httpHandler builds the shared mux used by every per-VPC listener. The token
+// PUT is the only path reachable without a token; everything else is gated.
+func (s *IMDSServiceImpl) httpHandler() http.Handler {
+ mux := http.NewServeMux()
+ mux.HandleFunc(pathToken, s.handleToken)
+ mux.HandleFunc("/", s.handleMetadata)
+ return rejectForwarded(mux)
+}
+
+// Run starts the bind manager (sync + watch) and the token-sweep ticker, then
+// blocks until ctx is cancelled. The initial sync failing is logged but not
+// fatal — other vpcd services must not have their readiness gated on IMDS
+// plumbing, and SDKs retry while it converges.
+func (s *IMDSServiceImpl) Run(ctx context.Context) error {
+ if err := s.bind.sync(ctx); err != nil {
+ slog.Warn("IMDS: initial bind sync failed, continuing", "err", err)
+ }
+ go s.bind.watch(ctx)
+ go s.sweepExpired(ctx)
+
+ <-ctx.Done()
+ s.bind.shutdown()
+ return ctx.Err()
+}
+
+// sweepExpired evicts expired IMDSv2 tokens and stale credential-cache entries
+// on a ticker for the life of ctx. Both stores are in-memory and accrete on VM
+// churn, so a single sweep keeps them bounded without a per-store goroutine.
+func (s *IMDSServiceImpl) sweepExpired(ctx context.Context) {
+ ticker := time.NewTicker(tokenSweepInterval)
+ defer ticker.Stop()
+ for {
+ select {
+ case <-ctx.Done():
+ return
+ case <-ticker.C:
+ now := s.now()
+ s.tokens.sweep(now)
+ s.creds.sweep(now)
+ }
+ }
+}
diff --git a/spinifex/handlers/imds/token.go b/spinifex/handlers/imds/token.go
new file mode 100644
index 000000000..5dd30280f
--- /dev/null
+++ b/spinifex/handlers/imds/token.go
@@ -0,0 +1,85 @@
+package handlers_imds
+
+import (
+ "crypto/rand"
+ "encoding/base64"
+ "sync"
+ "time"
+)
+
+const (
+ // tokenTTLMin / tokenTTLMax bound the X-aws-ec2-metadata-token-ttl-seconds
+ // header, matching AWS (1 second to 6 hours).
+ tokenTTLMin = 1
+ tokenTTLMax = 21600
+
+ // tokenBytes is the random token length before base64url encoding.
+ tokenBytes = 32
+)
+
+// tokenEntry binds an issued IMDSv2 token to the ENI that requested it (not the
+// IP — so the session survives a DHCP-renew IP change but dies on ENI detach)
+// and its expiry.
+type tokenEntry struct {
+ eniID string
+ expiresAt time.Time
+}
+
+// tokenStore holds outstanding IMDSv2 tokens in memory, per process. Tokens are
+// a CSRF defence, not a security artefact, so they are never persisted; a
+// vpcd restart drops them and SDKs reissue transparently.
+type tokenStore struct {
+ mu sync.Mutex
+ tokens map[string]tokenEntry
+}
+
+func newTokenStore() *tokenStore {
+ return &tokenStore{tokens: make(map[string]tokenEntry)}
+}
+
+// issue mints a fresh token bound to eniID, valid for ttl from now.
+func (s *tokenStore) issue(eniID string, ttl time.Duration, now time.Time) (string, error) {
+ buf := make([]byte, tokenBytes)
+ if _, err := rand.Read(buf); err != nil {
+ return "", err
+ }
+ token := base64.RawURLEncoding.EncodeToString(buf)
+
+ s.mu.Lock()
+ s.tokens[token] = tokenEntry{eniID: eniID, expiresAt: now.Add(ttl)}
+ s.mu.Unlock()
+ return token, nil
+}
+
+// validate reports whether token is currently valid AND was issued to
+// expectedENI. A wrong-ENI token is rejected exactly like an unknown one so the
+// caller cannot probe which tokens exist. Expired tokens are evicted lazily.
+func (s *tokenStore) validate(token, expectedENI string, now time.Time) bool {
+ if token == "" {
+ return false
+ }
+ s.mu.Lock()
+ defer s.mu.Unlock()
+
+ entry, ok := s.tokens[token]
+ if !ok {
+ return false
+ }
+ if now.After(entry.expiresAt) {
+ delete(s.tokens, token)
+ return false
+ }
+ return entry.eniID == expectedENI
+}
+
+// sweep deletes every token that expired at or before now. Called on a 1-minute
+// ticker so abandoned tokens (an SDK that issues then never reads) don't leak.
+func (s *tokenStore) sweep(now time.Time) {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ for token, entry := range s.tokens {
+ if now.After(entry.expiresAt) {
+ delete(s.tokens, token)
+ }
+ }
+}
diff --git a/spinifex/handlers/imds/token_test.go b/spinifex/handlers/imds/token_test.go
new file mode 100644
index 000000000..7dde4b500
--- /dev/null
+++ b/spinifex/handlers/imds/token_test.go
@@ -0,0 +1,95 @@
+package handlers_imds
+
+import (
+ "testing"
+ "time"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestTokenStore_IssueAndValidate(t *testing.T) {
+ s := newTokenStore()
+ now := time.Unix(1_700_000_000, 0)
+
+ token, err := s.issue("eni-aaa", 60*time.Second, now)
+ require.NoError(t, err)
+ require.NotEmpty(t, token)
+
+ assert.True(t, s.validate(token, "eni-aaa", now), "fresh token for its ENI must validate")
+ assert.True(t, s.validate(token, "eni-aaa", now.Add(59*time.Second)), "token valid until TTL")
+}
+
+func TestTokenStore_WrongENIRejected(t *testing.T) {
+ s := newTokenStore()
+ now := time.Unix(1_700_000_000, 0)
+ token, err := s.issue("eni-aaa", 60*time.Second, now)
+ require.NoError(t, err)
+
+ // A token issued to eni-aaa must not validate for eni-bbb — otherwise a
+ // guest could replay a peer's token to read the peer's session.
+ assert.False(t, s.validate(token, "eni-bbb", now))
+}
+
+func TestTokenStore_ExpiredRejectedAndEvicted(t *testing.T) {
+ s := newTokenStore()
+ now := time.Unix(1_700_000_000, 0)
+ token, err := s.issue("eni-aaa", 1*time.Second, now)
+ require.NoError(t, err)
+
+ assert.False(t, s.validate(token, "eni-aaa", now.Add(2*time.Second)), "expired token must be rejected")
+ // Lazy eviction: the failed validate above dropped it, so even at a valid
+ // time it no longer exists.
+ assert.False(t, s.validate(token, "eni-aaa", now))
+}
+
+func TestTokenStore_UnknownAndEmptyRejected(t *testing.T) {
+ s := newTokenStore()
+ now := time.Unix(1_700_000_000, 0)
+ assert.False(t, s.validate("", "eni-aaa", now))
+ assert.False(t, s.validate("never-issued", "eni-aaa", now))
+}
+
+func TestTokenStore_Sweep(t *testing.T) {
+ s := newTokenStore()
+ now := time.Unix(1_700_000_000, 0)
+ live, _ := s.issue("eni-live", 3600*time.Second, now)
+ dead, _ := s.issue("eni-dead", 1*time.Second, now)
+
+ s.sweep(now.Add(2 * time.Second))
+
+ assert.True(t, s.validate(live, "eni-live", now.Add(2*time.Second)), "live token survives sweep")
+ assert.False(t, s.validate(dead, "eni-dead", now), "expired token swept")
+}
+
+func TestTokenStore_IssueUnique(t *testing.T) {
+ s := newTokenStore()
+ now := time.Unix(1_700_000_000, 0)
+ a, _ := s.issue("eni-aaa", 60*time.Second, now)
+ b, _ := s.issue("eni-aaa", 60*time.Second, now)
+ assert.NotEqual(t, a, b, "each issued token must be distinct")
+}
+
+func TestParseTokenTTL(t *testing.T) {
+ cases := []struct {
+ in string
+ ok bool
+ secs int
+ }{
+ {"", false, 0},
+ {"abc", false, 0},
+ {"0", false, 0},
+ {"21601", false, 0},
+ {"-5", false, 0},
+ {"1", true, 1},
+ {"21600", true, 21600},
+ {"60", true, 60},
+ }
+ for _, c := range cases {
+ d, ok := parseTokenTTL(c.in)
+ assert.Equal(t, c.ok, ok, "ttl=%q", c.in)
+ if ok {
+ assert.Equal(t, c.secs, int(d.Seconds()), "ttl=%q", c.in)
+ }
+ }
+}
diff --git a/spinifex/handlers/imds/veth_store.go b/spinifex/handlers/imds/veth_store.go
new file mode 100644
index 000000000..4e574457c
--- /dev/null
+++ b/spinifex/handlers/imds/veth_store.go
@@ -0,0 +1,75 @@
+package handlers_imds
+
+import (
+ "encoding/json"
+ "errors"
+ "fmt"
+
+ "github.com/nats-io/nats.go"
+)
+
+// SubnetVethRecord is the persisted per-subnet IMDS plumbing record
+// (KVBucketIMDSSubnetVeth, keyed by subnet ID): the signal that a subnet's IMDS
+// localport is installed, replayed by each chassis's BindManager. It carries the
+// LSP MAC and the subnet CIDR the host can't re-derive, plus the owning VPC for
+// the subnet→VPC reverse lookup.
+type SubnetVethRecord struct {
+ SubnetID string `json:"subnet_id"`
+ ShortSubnetID string `json:"short_subnet_id"`
+ VPCID string `json:"vpc_id"`
+ IMDSPortMAC string `json:"imds_port_mac"`
+ SubnetCIDR string `json:"subnet_cidr"`
+ CreatedAt string `json:"created_at"`
+}
+
+// VethStore is the persistence surface for SubnetVethRecord rows in
+// KVBucketIMDSSubnetVeth. Backed by a NATS JetStream KV bucket opened by
+// InitBuckets in production; tests inject the handle via NewVethStore.
+type VethStore struct {
+ kv nats.KeyValue
+}
+
+// NewVethStore wraps an already-opened subnet-veth KV bucket.
+func NewVethStore(kv nats.KeyValue) *VethStore { return &VethStore{kv: kv} }
+
+// Get returns the record for subnetID, or (nil, nil) when absent.
+func (s *VethStore) Get(subnetID string) (*SubnetVethRecord, error) {
+ raw, err := s.kv.Get(subnetID)
+ if err != nil {
+ if errors.Is(err, nats.ErrKeyNotFound) {
+ return nil, nil
+ }
+ return nil, fmt.Errorf("get imds veth record %q: %w", subnetID, err)
+ }
+ var rec SubnetVethRecord
+ if err := json.Unmarshal(raw.Value(), &rec); err != nil {
+ return nil, fmt.Errorf("unmarshal imds veth record %q: %w", subnetID, err)
+ }
+ return &rec, nil
+}
+
+// Put writes the record keyed by its SubnetID, overwriting any prior entry.
+func (s *VethStore) Put(rec SubnetVethRecord) error {
+ if rec.SubnetID == "" {
+ return errors.New("imds veth store put: subnet_id is empty")
+ }
+ data, err := json.Marshal(rec)
+ if err != nil {
+ return fmt.Errorf("marshal imds veth record: %w", err)
+ }
+ if _, err := s.kv.Put(rec.SubnetID, data); err != nil {
+ return fmt.Errorf("put imds veth record %q: %w", rec.SubnetID, err)
+ }
+ return nil
+}
+
+// Delete removes the record. Idempotent: a missing key returns nil.
+func (s *VethStore) Delete(subnetID string) error {
+ if err := s.kv.Delete(subnetID); err != nil {
+ if errors.Is(err, nats.ErrKeyNotFound) {
+ return nil
+ }
+ return fmt.Errorf("delete imds veth record %q: %w", subnetID, err)
+ }
+ return nil
+}
diff --git a/spinifex/handlers/imds/veth_store_test.go b/spinifex/handlers/imds/veth_store_test.go
new file mode 100644
index 000000000..65fad3404
--- /dev/null
+++ b/spinifex/handlers/imds/veth_store_test.go
@@ -0,0 +1,58 @@
+package handlers_imds
+
+import (
+ "testing"
+
+ "github.com/mulgadc/spinifex/spinifex/testutil"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func newTestVethStore(t *testing.T) *VethStore {
+ t.Helper()
+ _, _, js := testutil.StartTestJetStream(t)
+ subnetVeth, _, err := InitBuckets(js, 1)
+ require.NoError(t, err)
+ return NewVethStore(subnetVeth)
+}
+
+func TestVethStore_PutGetRoundTrip(t *testing.T) {
+ s := newTestVethStore(t)
+ rec := SubnetVethRecord{
+ SubnetID: "subnet-abc12345",
+ ShortSubnetID: "abc12345",
+ VPCID: "vpc-abc12345",
+ IMDSPortMAC: "02:aa:bb:cc:dd:ee",
+ SubnetCIDR: "10.0.1.0/24",
+ CreatedAt: "2026-05-29T00:00:00Z",
+ }
+ require.NoError(t, s.Put(rec))
+
+ got, err := s.Get("subnet-abc12345")
+ require.NoError(t, err)
+ require.NotNil(t, got)
+ assert.Equal(t, rec, *got)
+}
+
+func TestVethStore_GetAbsentReturnsNil(t *testing.T) {
+ s := newTestVethStore(t)
+ got, err := s.Get("subnet-nope")
+ require.NoError(t, err)
+ assert.Nil(t, got)
+}
+
+func TestVethStore_PutRejectsEmptySubnetID(t *testing.T) {
+ s := newTestVethStore(t)
+ require.Error(t, s.Put(SubnetVethRecord{ShortSubnetID: "abc12345"}))
+}
+
+func TestVethStore_DeleteIsIdempotent(t *testing.T) {
+ s := newTestVethStore(t)
+ require.NoError(t, s.Delete("subnet-missing"))
+
+ require.NoError(t, s.Put(SubnetVethRecord{SubnetID: "subnet-1"}))
+ require.NoError(t, s.Delete("subnet-1"))
+ got, err := s.Get("subnet-1")
+ require.NoError(t, err)
+ assert.Nil(t, got)
+}
diff --git a/spinifex/handlers/sts/assume_role.go b/spinifex/handlers/sts/assume_role.go
index 79d387870..2014ff4e6 100644
--- a/spinifex/handlers/sts/assume_role.go
+++ b/spinifex/handlers/sts/assume_role.go
@@ -11,6 +11,7 @@ import (
"fmt"
"log/slog"
"regexp"
+ "slices"
"strings"
"time"
@@ -28,6 +29,21 @@ const (
stsActionWildcard = "sts:*"
globalWildcard = "*"
+ // principalTypeAssumedRole is the SessionCredential.PrincipalType for a role
+ // session — the default kind, also assumed when a stored value is empty.
+ principalTypeAssumedRole = "assumed-role"
+
+ // principalTypeUser is the SessionCredential.PrincipalType for a session
+ // minted by GetSessionToken, which resolves back to the calling IAM user.
+ // Mirrors the gateway's principalTypeUser so the ASIA resolve path can map
+ // the stored value straight onto the user principal context.
+ principalTypeUser = "user"
+
+ // ec2ServicePrincipal is the synthetic caller used by AssumeRoleForInstance — the
+ // only service principal that matches a trust policy in v1. The HTTPS AssumeRole
+ // path supplies an empty principalSource, so service principals never match there.
+ ec2ServicePrincipal = "ec2.amazonaws.com"
+
minDurationSeconds int64 = 900
maxDurationSeconds int64 = 43200
defaultDurationSeconds int64 = 3600
@@ -72,7 +88,62 @@ func (s *STSServiceImpl) AssumeRole(callerAccountID, callerARN, callerIdentity s
return nil, errors.New(awserrors.ErrorInvalidParameterValue)
}
- roleAccountID, roleName, err := parseRoleARN(*input.RoleArn)
+ duration := int64(0)
+ if input.DurationSeconds != nil {
+ duration = *input.DurationSeconds
+ }
+
+ out, err := s.assumeRoleForCaller(callerAccountID, callerARN, "", *input.RoleArn, sessionName, aws.StringValue(input.SourceIdentity), duration)
+ if err != nil {
+ return nil, err
+ }
+
+ slog.Info("AssumeRole success",
+ "caller_arn", callerARN,
+ "caller_identity", callerIdentity,
+ "role_arn", aws.StringValue(input.RoleArn),
+ "session_name", sessionName,
+ "akid", aws.StringValue(out.Credentials.AccessKeyId),
+ "expires_at", aws.TimeValue(out.Credentials.Expiration),
+ "source_identity", aws.StringValue(input.SourceIdentity),
+ "external_id", aws.StringValue(input.ExternalId),
+ )
+
+ return out, nil
+}
+
+// AssumeRoleForInstance is the in-process IMDS entry point, not reachable over HTTPS.
+// The caller is synthesised as the EC2 service principal (principalSource =
+// ec2.amazonaws.com); the session name is the instanceID, yielding an AWS-style ARN.
+func (s *STSServiceImpl) AssumeRoleForInstance(accountID, roleARN, instanceID string, durationSeconds int64) (*sts.AssumeRoleOutput, error) {
+ if accountID == "" || roleARN == "" || instanceID == "" {
+ return nil, errors.New(awserrors.ErrorMissingParameter)
+ }
+ if !roleSessionNameRegex.MatchString(instanceID) {
+ return nil, errors.New(awserrors.ErrorValidationError)
+ }
+
+ out, err := s.assumeRoleForCaller(accountID, "", ec2ServicePrincipal, roleARN, instanceID, "", durationSeconds)
+ if err != nil {
+ return nil, err
+ }
+
+ slog.Info("AssumeRoleForInstance success",
+ "account_id", accountID,
+ "instance_id", instanceID,
+ "role_arn", roleARN,
+ "akid", aws.StringValue(out.Credentials.AccessKeyId),
+ "expires_at", aws.TimeValue(out.Credentials.Expiration),
+ )
+
+ return out, nil
+}
+
+// assumeRoleForCaller is the shared core of AssumeRole (HTTPS) and AssumeRoleForInstance
+// (IMDS): resolves the role, validates the duration (0 → default), evaluates the trust
+// policy, and mints credentials. principalSource is "" for HTTPS, ec2.amazonaws.com for IMDS.
+func (s *STSServiceImpl) assumeRoleForCaller(callerAccountID, callerARN, principalSource, roleARN, sessionName, sourceIdentity string, requestedDuration int64) (*sts.AssumeRoleOutput, error) {
+ roleAccountID, roleName, err := parseRoleARN(roleARN)
if err != nil {
return nil, errors.New(awserrors.ErrorValidationError)
}
@@ -90,9 +161,9 @@ func (s *STSServiceImpl) AssumeRole(callerAccountID, callerARN, callerIdentity s
}
role := roleOut.Role
- duration := defaultDurationSeconds
- if input.DurationSeconds != nil {
- duration = *input.DurationSeconds
+ duration := requestedDuration
+ if duration == 0 {
+ duration = defaultDurationSeconds
}
effectiveMax := aws.Int64Value(role.MaxSessionDuration)
if effectiveMax == 0 {
@@ -105,26 +176,16 @@ func (s *STSServiceImpl) AssumeRole(callerAccountID, callerARN, callerIdentity s
return nil, errors.New(awserrors.ErrorValidationError)
}
- if err := evalTrustPolicy(aws.StringValue(role.AssumeRolePolicyDocument), callerARN); err != nil {
+ if err := evalTrustPolicy(aws.StringValue(role.AssumeRolePolicyDocument), callerARN, principalSource); err != nil {
return nil, err
}
- cred, plainSecret, plainToken, err := s.mintSessionCredential(role, roleAccountID, sessionName, aws.StringValue(input.SourceIdentity), duration)
+ env := assumedRoleEnvelope(role, roleAccountID, sessionName, sourceIdentity)
+ cred, plainSecret, plainToken, err := s.mintSession(env, duration)
if err != nil {
return nil, err
}
- slog.Info("AssumeRole success",
- "caller_arn", callerARN,
- "caller_identity", callerIdentity,
- "role_arn", aws.StringValue(role.Arn),
- "session_name", sessionName,
- "akid", cred.AccessKeyID,
- "expires_at", cred.ExpiresAt,
- "source_identity", aws.StringValue(input.SourceIdentity),
- "external_id", aws.StringValue(input.ExternalId),
- )
-
return &sts.AssumeRoleOutput{
Credentials: &sts.Credentials{
AccessKeyId: aws.String(cred.AccessKeyID),
@@ -172,7 +233,7 @@ func parseRoleARN(arnStr string) (accountID, name string, err error) {
// pass scans every Allow statement (returning nil on the first match). A
// single-pass "return on first Allow" loop would silently skip a later Deny
// and is a real authorisation bug.
-func evalTrustPolicy(docJSON, callerARN string) error {
+func evalTrustPolicy(docJSON, callerARN, principalSource string) error {
doc, err := handlers_iam.ValidateTrustPolicyDocument(docJSON)
if err != nil {
// Stored docs were validated at CreateRole / UpdateAssumeRolePolicy
@@ -187,7 +248,7 @@ func evalTrustPolicy(docJSON, callerARN string) error {
if !matchTrustAction(stmt.Action) {
continue
}
- match, err := matchTrustPrincipal(stmt.Principal, callerARN)
+ match, err := matchTrustPrincipal(stmt.Principal, callerARN, principalSource)
if err != nil {
return err
}
@@ -203,7 +264,7 @@ func evalTrustPolicy(docJSON, callerARN string) error {
if !matchTrustAction(stmt.Action) {
continue
}
- match, err := matchTrustPrincipal(stmt.Principal, callerARN)
+ match, err := matchTrustPrincipal(stmt.Principal, callerARN, principalSource)
if err != nil {
return err
}
@@ -224,11 +285,10 @@ func matchTrustAction(actions []string) bool {
return false
}
-// matchTrustPrincipal evaluates each top-level Principal key independently —
-// AWS semantics treat the map as an OR across keys, and unsupported keys
-// (Service, Federated) skip at the entry level rather than failing the whole
-// statement.
-func matchTrustPrincipal(raw json.RawMessage, callerARN string) (bool, error) {
+// matchTrustPrincipal evaluates each top-level Principal key independently (AWS
+// treats the map as an OR). AWS matches callerARN, Service matches principalSource
+// (set only by the IMDS path); unsupported keys (Federated) skip, not fail.
+func matchTrustPrincipal(raw json.RawMessage, callerARN, principalSource string) (bool, error) {
if len(raw) == 0 {
return false, nil
}
@@ -242,11 +302,45 @@ func matchTrustPrincipal(raw json.RawMessage, callerARN string) (bool, error) {
if err := json.Unmarshal(raw, &m); err != nil {
return false, fmt.Errorf("unmarshal Principal: %w", err)
}
- awsRaw, ok := m["AWS"]
- if !ok {
+ if awsRaw, ok := m["AWS"]; ok {
+ match, err := matchAWSPrincipal(awsRaw, callerARN)
+ if err != nil {
+ return false, err
+ }
+ if match {
+ return true, nil
+ }
+ }
+ if svcRaw, ok := m["Service"]; ok {
+ match, err := matchServicePrincipal(svcRaw, principalSource)
+ if err != nil {
+ return false, err
+ }
+ if match {
+ return true, nil
+ }
+ }
+ return false, nil
+}
+
+// matchServicePrincipal matches a Service principal against the synthesised caller.
+// v1 trusts only the EC2 service principal: any other principalSource (including
+// the empty one used by the HTTPS path) is rejected up front, so unsupported
+// service principals are denied even if a future caller synthesised one.
+// Expanding the whitelist (ecs-tasks, lambda) is a one-line change to this guard.
+func matchServicePrincipal(raw json.RawMessage, principalSource string) (bool, error) {
+ if principalSource != ec2ServicePrincipal {
return false, nil
}
- return matchAWSPrincipal(awsRaw, callerARN)
+ var single string
+ if err := json.Unmarshal(raw, &single); err == nil {
+ return single == principalSource, nil
+ }
+ var arr []string
+ if err := json.Unmarshal(raw, &arr); err != nil {
+ return false, fmt.Errorf("Principal.Service must be string or array: %w", err)
+ }
+ return slices.Contains(arr, principalSource), nil
}
func matchAWSPrincipal(raw json.RawMessage, callerARN string) (bool, error) {
@@ -340,11 +434,48 @@ func lastPathSegment(s string) string {
return s
}
-// mintSessionCredential generates a fresh ASIA AKID, secret, and session
-// token, encrypts the secret with the IAM master key, HMACs the session
-// token, and persists the record. Retries on AKID collision; the birthday
-// bound for 64-bit AKID entropy makes that practically unreachable.
-func (s *STSServiceImpl) mintSessionCredential(role *iam.Role, roleAccountID, sessionName, sourceIdentity string, duration int64) (*SessionCredential, string, string, error) {
+// sessionEnvelope is the resolved identity a session credential is minted for.
+// It decouples mintSession from AssumeRole's *iam.Role so GetSessionToken can
+// mint a user-bound session: the role paths fill the assumed-role fields, the
+// user path sets PrincipalType "user" + SessionName = the IAM user name and
+// leaves the assumed-role fields empty.
+type sessionEnvelope struct {
+ PrincipalType string
+ AccountID string
+ SessionName string
+ SourceIdentity string
+
+ // Assumed-role-only; empty for a user (GetSessionToken) envelope.
+ AssumedRoleARN string
+ UnderlyingRoleARN string
+ RoleID string
+ AssumedRoleID string
+}
+
+// assumedRoleEnvelope derives the session envelope for a role session, shared by
+// AssumeRole, AssumeRoleForInstance, and AssumeRoleWithWebIdentity. It owns the
+// assumed-role ARN / AssumedRoleId construction so every role path stays
+// consistent.
+func assumedRoleEnvelope(role *iam.Role, roleAccountID, sessionName, sourceIdentity string) sessionEnvelope {
+ roleID := aws.StringValue(role.RoleId)
+ roleName := aws.StringValue(role.RoleName)
+ return sessionEnvelope{
+ PrincipalType: principalTypeAssumedRole,
+ AccountID: roleAccountID,
+ SessionName: sessionName,
+ SourceIdentity: sourceIdentity,
+ AssumedRoleARN: fmt.Sprintf("arn:aws:sts::%s:assumed-role/%s/%s", roleAccountID, roleName, sessionName),
+ UnderlyingRoleARN: aws.StringValue(role.Arn),
+ RoleID: roleID,
+ AssumedRoleID: fmt.Sprintf("%s:%s", roleID, sessionName),
+ }
+}
+
+// mintSession generates a fresh ASIA AKID, secret, and session token, encrypts
+// the secret with the IAM master key, HMACs the session token, and persists the
+// record described by env. Retries on AKID collision; the birthday bound for
+// 64-bit AKID entropy makes that practically unreachable.
+func (s *STSServiceImpl) mintSession(env sessionEnvelope, duration int64) (*SessionCredential, string, string, error) {
plainSecret, err := generateRandomBase64(sessionSecretBytes)
if err != nil {
return nil, "", "", err
@@ -363,10 +494,6 @@ func (s *STSServiceImpl) mintSessionCredential(role *iam.Role, roleAccountID, se
now := time.Now().UTC()
expiresAt := now.Add(time.Duration(duration) * time.Second)
- roleID := aws.StringValue(role.RoleId)
- roleName := aws.StringValue(role.RoleName)
- underlyingRoleARN := aws.StringValue(role.Arn)
-
for range mintMaxAttempts {
akid, err := generateSessionAKID()
if err != nil {
@@ -376,13 +503,14 @@ func (s *STSServiceImpl) mintSessionCredential(role *iam.Role, roleAccountID, se
AccessKeyID: akid,
SecretEncrypted: secretEncrypted,
SessionTokenHMAC: tokenHMAC,
- AccountID: roleAccountID,
- AssumedRoleARN: fmt.Sprintf("arn:aws:sts::%s:assumed-role/%s/%s", roleAccountID, roleName, sessionName),
- UnderlyingRoleARN: underlyingRoleARN,
- RoleID: roleID,
- AssumedRoleID: fmt.Sprintf("%s:%s", roleID, sessionName),
- SessionName: sessionName,
- SourceIdentity: sourceIdentity,
+ AccountID: env.AccountID,
+ PrincipalType: env.PrincipalType,
+ AssumedRoleARN: env.AssumedRoleARN,
+ UnderlyingRoleARN: env.UnderlyingRoleARN,
+ RoleID: env.RoleID,
+ AssumedRoleID: env.AssumedRoleID,
+ SessionName: env.SessionName,
+ SourceIdentity: env.SourceIdentity,
ExpiresAt: expiresAt,
CreatedAt: now,
}
diff --git a/spinifex/handlers/sts/assume_role_test.go b/spinifex/handlers/sts/assume_role_test.go
index b3345930f..50c59d7cb 100644
--- a/spinifex/handlers/sts/assume_role_test.go
+++ b/spinifex/handlers/sts/assume_role_test.go
@@ -20,9 +20,10 @@ import (
)
const (
- testCallerAccountID = utils.GlobalAccountID
- testCallerUserName = "alice"
- testCrossAccountID = "999999999999"
+ testCallerAccountID = utils.GlobalAccountID
+ testCallerUserName = "alice"
+ testCallerAccessKeyID = "AKIAIOSFODNN7EXAMPLE"
+ testCrossAccountID = "999999999999"
)
func testCallerARN() string {
@@ -557,6 +558,109 @@ func TestAssumeRole_ServicePrincipal_NeverMatchesInV1(t *testing.T) {
assert.Equal(t, awserrors.ErrorAccessDenied, err.Error())
}
+// ----- AssumeRoleForInstance (IMDS in-process path) ----------------------
+
+const testInstanceID = "i-0123456789abcdef0"
+
+func trustPolicyAllowingEC2Service() string {
+ return `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":{"Service":"ec2.amazonaws.com"},"Action":"sts:AssumeRole"}]}`
+}
+
+func TestAssumeRoleForInstance_EC2ServicePrincipal_Allowed(t *testing.T) {
+ svc, _ := newTestSetup(t)
+ role := createRoleInAccount(t, svc, testCallerAccountID, "app-role", trustPolicyAllowingEC2Service())
+
+ out, err := svc.AssumeRoleForInstance(testCallerAccountID, *role.Arn, testInstanceID, defaultDurationSeconds)
+ require.NoError(t, err)
+ require.NotNil(t, out)
+ require.NotNil(t, out.Credentials)
+
+ akid := aws.StringValue(out.Credentials.AccessKeyId)
+ assert.True(t, strings.HasPrefix(akid, SessionAccessKeyIDPrefix))
+ assert.Len(t, akid, 20)
+
+ // The session name is the instance ID, matching AWS.
+ assert.Equal(t, fmt.Sprintf("arn:aws:sts::%s:assumed-role/app-role/%s", testCallerAccountID, testInstanceID),
+ aws.StringValue(out.AssumedRoleUser.Arn))
+
+ stored, err := svc.LookupSessionCredential(akid)
+ require.NoError(t, err)
+ require.NotNil(t, stored)
+ assert.Equal(t, testInstanceID, stored.SessionName)
+ assert.Equal(t, testCallerAccountID, stored.AccountID)
+}
+
+// Service principals other than ec2.amazonaws.com are not whitelisted in v1, so
+// even though the caller is synthesised as the EC2 service principal, a
+// lambda.amazonaws.com trust statement must not match.
+func TestAssumeRoleForInstance_NonWhitelistedService_Denied(t *testing.T) {
+ svc, _ := newTestSetup(t)
+ policy := `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":{"Service":"lambda.amazonaws.com"},"Action":"sts:AssumeRole"}]}`
+ role := createRoleInAccount(t, svc, testCallerAccountID, "lambda-role", policy)
+
+ _, err := svc.AssumeRoleForInstance(testCallerAccountID, *role.Arn, testInstanceID, defaultDurationSeconds)
+ require.Error(t, err)
+ assert.Equal(t, awserrors.ErrorAccessDenied, err.Error())
+}
+
+// A role that only trusts an AWS principal (no Service entry) must not be
+// assumable by an instance — the synthesised EC2 caller has no ARN to match.
+func TestAssumeRoleForInstance_AWSOnlyPrincipal_Denied(t *testing.T) {
+ svc, _ := newTestSetup(t)
+ role := createRoleInAccount(t, svc, testCallerAccountID, "user-role", trustPolicyAllowingUser(testCallerARN()))
+
+ _, err := svc.AssumeRoleForInstance(testCallerAccountID, *role.Arn, testInstanceID, defaultDurationSeconds)
+ require.Error(t, err)
+ assert.Equal(t, awserrors.ErrorAccessDenied, err.Error())
+}
+
+// Regression: the EC2 service principal must remain unreachable over the HTTPS
+// AssumeRole path. A role trusting only ec2.amazonaws.com is never assumable by
+// a normal SigV4 caller, no matter what — the security boundary between the two
+// entry points.
+func TestAssumeRoleForInstance_EC2ServiceUnreachableViaHTTPS(t *testing.T) {
+ svc, _ := newTestSetup(t)
+ role := createRoleInAccount(t, svc, testCallerAccountID, "ec2-only", trustPolicyAllowingEC2Service())
+
+ _, err := svc.AssumeRole(testCallerAccountID, testCallerARN(), testCallerUserName,
+ basicAssumeRoleInput(*role.Arn, "sess"))
+ require.Error(t, err)
+ assert.Equal(t, awserrors.ErrorAccessDenied, err.Error())
+}
+
+func TestAssumeRoleForInstance_RejectsMissingFields(t *testing.T) {
+ svc, _ := newTestSetup(t)
+ role := createRoleInAccount(t, svc, testCallerAccountID, "fields", trustPolicyAllowingEC2Service())
+
+ cases := []struct {
+ name string
+ accountID string
+ roleARN string
+ instanceID string
+ }{
+ {"missing_account", "", *role.Arn, testInstanceID},
+ {"missing_role_arn", testCallerAccountID, "", testInstanceID},
+ {"missing_instance_id", testCallerAccountID, *role.Arn, ""},
+ }
+ for _, tc := range cases {
+ t.Run(tc.name, func(t *testing.T) {
+ _, err := svc.AssumeRoleForInstance(tc.accountID, tc.roleARN, tc.instanceID, defaultDurationSeconds)
+ require.Error(t, err)
+ assert.Equal(t, awserrors.ErrorMissingParameter, err.Error())
+ })
+ }
+}
+
+func TestAssumeRoleForInstance_CrossAccountRoleMiss_AccessDenied(t *testing.T) {
+ svc, _ := newTestSetup(t)
+ // Instance's account differs from the role's account and the role is
+ // missing — masked to AccessDenied to prevent cross-account enumeration.
+ _, err := svc.AssumeRoleForInstance(testCallerAccountID,
+ fmt.Sprintf("arn:aws:iam::%s:role/ghost", testCrossAccountID), testInstanceID, defaultDurationSeconds)
+ require.Error(t, err)
+ assert.Equal(t, awserrors.ErrorAccessDenied, err.Error())
+}
+
func TestAssumeRole_RetriesOnAKIDCollision(t *testing.T) {
svc, _ := newTestSetup(t)
caller := testCallerARN()
@@ -643,7 +747,7 @@ func TestEvalTrustPolicy_CorruptDocReturnsError(t *testing.T) {
// Stored docs are validated upstream; reaching evalTrustPolicy with a
// malformed doc indicates corruption — must fail closed and NOT collapse
// to AccessDenied (which would hide the corruption from operators).
- err := evalTrustPolicy(`{not json`, testCallerARN())
+ err := evalTrustPolicy(`{not json`, testCallerARN(), "")
require.Error(t, err)
assert.NotEqual(t, awserrors.ErrorAccessDenied, err.Error(),
"corrupt doc must not be reported as AccessDenied — operators need to see the corruption signal")
diff --git a/spinifex/handlers/sts/assume_role_with_web_identity.go b/spinifex/handlers/sts/assume_role_with_web_identity.go
index d1499b497..12de89ea3 100644
--- a/spinifex/handlers/sts/assume_role_with_web_identity.go
+++ b/spinifex/handlers/sts/assume_role_with_web_identity.go
@@ -130,7 +130,8 @@ func (s *STSServiceImpl) AssumeRoleWithWebIdentity(input *sts.AssumeRoleWithWebI
return nil, err
}
- cred, plainSecret, plainToken, err := s.mintSessionCredential(role, roleAccountID, sessionName, claims.Subject, duration)
+ env := assumedRoleEnvelope(role, roleAccountID, sessionName, claims.Subject)
+ cred, plainSecret, plainToken, err := s.mintSession(env, duration)
if err != nil {
return nil, err
}
diff --git a/spinifex/handlers/sts/assume_role_with_web_identity_test.go b/spinifex/handlers/sts/assume_role_with_web_identity_test.go
index 367aec23e..5669d1257 100644
--- a/spinifex/handlers/sts/assume_role_with_web_identity_test.go
+++ b/spinifex/handlers/sts/assume_role_with_web_identity_test.go
@@ -56,8 +56,8 @@ func newWebIdentityFixture(t *testing.T, svc *STSServiceImpl, accountID string)
Y: base64.RawURLEncoding.EncodeToString(priv.Y.Bytes()),
}}}
- issuer := fmt.Sprintf("https://oidc.eks.%s.%s/%s/%s",
- testWebRegion, testWebSuffix, accountID, testWebClusterName)
+ issuer := fmt.Sprintf("https://gw.%s/oidc/eks/%s/%s/%s",
+ testWebSuffix, testWebRegion, accountID, testWebClusterName)
issuerHostPath := strings.TrimPrefix(issuer, "https://")
federatedARN := handlers_iam.OIDCProviderARN(accountID, issuerHostPath)
@@ -346,8 +346,8 @@ func TestAssumeRoleWithWebIdentity_ProviderNotRegistered(t *testing.T) {
priv, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
require.NoError(t, err)
const kid = "unregistered"
- issuer := fmt.Sprintf("https://oidc.eks.%s.%s/%s/%s",
- testWebRegion, testWebSuffix, testCallerAccountID, "unregistered-cluster")
+ issuer := fmt.Sprintf("https://gw.%s/oidc/eks/%s/%s/%s",
+ testWebSuffix, testWebRegion, testCallerAccountID, "unregistered-cluster")
issuerHostPath := strings.TrimPrefix(issuer, "https://")
federatedARN := handlers_iam.OIDCProviderARN(testCallerAccountID, issuerHostPath)
diff --git a/spinifex/handlers/sts/get_session_token.go b/spinifex/handlers/sts/get_session_token.go
new file mode 100644
index 000000000..fa762387d
--- /dev/null
+++ b/spinifex/handlers/sts/get_session_token.go
@@ -0,0 +1,117 @@
+package handlers_sts
+
+import (
+ "errors"
+ "log/slog"
+ "strings"
+
+ "github.com/aws/aws-sdk-go/aws"
+ "github.com/aws/aws-sdk-go/service/sts"
+ "github.com/mulgadc/spinifex/spinifex/awserrors"
+)
+
+const (
+ // GetSessionToken duration bounds differ from AssumeRole: AWS permits a
+ // session up to 36h (vs. the 12h role-session ceiling) and defaults to 12h.
+ // The floor is the STS-wide minimum (minDurationSeconds, 900s).
+ getSessionTokenMaxDuration int64 = 129600 // 36h
+ getSessionTokenDefaultDuration int64 = 43200 // 12h
+)
+
+// GetSessionToken exchanges the calling IAM user's long-lived credentials for
+// short-lived ASIA session credentials bound to the SAME user identity. Unlike
+// AssumeRole, the resulting session resolves back to arn:aws:iam::A:user/N and
+// IAM policy is evaluated against that user — see the user branch of the SigV4
+// ASIA resolve path. The caller identity is resolved by the gateway and passed
+// in as plain strings to keep the handler unit-testable.
+//
+// AWS forbids calling GetSessionToken with temporary credentials, so only a
+// long-lived user principal (AKIA → principalType "user") is accepted. Assumed
+// -role callers are rejected by principal type; session callers — including a
+// GetSessionToken session, which resolves back to principalType "user" — are
+// rejected by their ASIA access-key prefix. Root is a long-lived user and, per
+// AWS, may call GetSessionToken.
+func (s *STSServiceImpl) GetSessionToken(callerAccountID, callerUserName, callerPrincipalType, callerAccessKeyID string, input *sts.GetSessionTokenInput) (*sts.GetSessionTokenOutput, error) {
+ if input == nil {
+ // `aws sts get-session-token` with no arguments sends an empty body;
+ // an absent input is the common case, not an error.
+ input = &sts.GetSessionTokenInput{}
+ }
+
+ if callerPrincipalType != principalTypeUser {
+ slog.Warn("GetSessionToken denied: caller is not a long-lived user principal",
+ "account_id", callerAccountID, "principal_type", callerPrincipalType)
+ return nil, errors.New(awserrors.ErrorAccessDenied)
+ }
+ if strings.HasPrefix(callerAccessKeyID, SessionAccessKeyIDPrefix) {
+ // A GetSessionToken session resolves back to principalType "user", so the
+ // check above cannot catch it — the ASIA prefix is the only signal that
+ // the caller holds temporary credentials. Rejecting it stops a session
+ // from minting a fresh session and rolling its lifetime forward forever.
+ slog.Warn("GetSessionToken denied: caller is using temporary (session) credentials",
+ "account_id", callerAccountID, "akid", callerAccessKeyID)
+ return nil, errors.New(awserrors.ErrorAccessDenied)
+ }
+ if callerAccountID == "" || callerUserName == "" {
+ // A user principal with no account or name is a mis-wired caller, not a
+ // client error — fail loud rather than mint a session with an empty name.
+ slog.Error("GetSessionToken: user principal missing account or name",
+ "account_id", callerAccountID, "user_name", callerUserName)
+ return nil, errors.New(awserrors.ErrorInternalError)
+ }
+
+ // MFA (SerialNumber/TokenCode) is out of scope: reject rather than silently
+ // ignore so a caller never believes an MFA condition was enforced.
+ if aws.StringValue(input.SerialNumber) != "" || aws.StringValue(input.TokenCode) != "" {
+ return nil, errors.New(awserrors.ErrorInvalidParameterValue)
+ }
+
+ duration := getSessionTokenDefaultDuration
+ if input.DurationSeconds != nil {
+ duration = clampGetSessionTokenDuration(*input.DurationSeconds)
+ }
+
+ cred, plainSecret, plainToken, err := s.mintSession(userEnvelope(callerAccountID, callerUserName), duration)
+ if err != nil {
+ return nil, err
+ }
+
+ slog.Info("GetSessionToken success",
+ "account_id", callerAccountID,
+ "user_name", callerUserName,
+ "akid", cred.AccessKeyID,
+ "expires_at", cred.ExpiresAt,
+ )
+
+ return &sts.GetSessionTokenOutput{
+ Credentials: &sts.Credentials{
+ AccessKeyId: aws.String(cred.AccessKeyID),
+ SecretAccessKey: aws.String(plainSecret),
+ SessionToken: aws.String(plainToken),
+ Expiration: aws.Time(cred.ExpiresAt),
+ },
+ }, nil
+}
+
+// clampGetSessionTokenDuration coerces a requested duration into the permitted
+// [900, 129600] window. A nil DurationSeconds is handled by the caller (default
+// 12h); this only sees an explicit value, which AWS clamps rather than rejects.
+func clampGetSessionTokenDuration(requested int64) int64 {
+ if requested < minDurationSeconds {
+ return minDurationSeconds
+ }
+ if requested > getSessionTokenMaxDuration {
+ return getSessionTokenMaxDuration
+ }
+ return requested
+}
+
+// userEnvelope is the session envelope for a GetSessionToken user session:
+// PrincipalType "user", SessionName = the IAM user name, no assumed-role fields.
+func userEnvelope(accountID, userName string) sessionEnvelope {
+ return sessionEnvelope{
+ PrincipalType: principalTypeUser,
+ AccountID: accountID,
+ SessionName: userName,
+ }
+}
diff --git a/spinifex/handlers/sts/get_session_token_test.go b/spinifex/handlers/sts/get_session_token_test.go
new file mode 100644
index 000000000..4750bcc9e
--- /dev/null
+++ b/spinifex/handlers/sts/get_session_token_test.go
@@ -0,0 +1,167 @@
+package handlers_sts
+
+import (
+ "crypto/hmac"
+ "crypto/sha256"
+ "encoding/base64"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/aws/aws-sdk-go/aws"
+ "github.com/aws/aws-sdk-go/service/sts"
+ "github.com/mulgadc/spinifex/spinifex/awserrors"
+ handlers_iam "github.com/mulgadc/spinifex/spinifex/handlers/iam"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestGetSessionToken_HappyPath_MintsUserSession(t *testing.T) {
+ svc, _ := newTestSetup(t)
+
+ out, err := svc.GetSessionToken(testCallerAccountID, testCallerUserName, principalTypeUser, testCallerAccessKeyID,
+ &sts.GetSessionTokenInput{})
+ require.NoError(t, err)
+ require.NotNil(t, out)
+ require.NotNil(t, out.Credentials)
+
+ akid := aws.StringValue(out.Credentials.AccessKeyId)
+ secret := aws.StringValue(out.Credentials.SecretAccessKey)
+ token := aws.StringValue(out.Credentials.SessionToken)
+
+ assert.True(t, strings.HasPrefix(akid, SessionAccessKeyIDPrefix))
+ assert.Len(t, akid, 20)
+ assert.NotEmpty(t, secret)
+ assert.NotEmpty(t, token)
+ assert.True(t, out.Credentials.Expiration.After(time.Now().UTC()))
+
+ stored, err := svc.LookupSessionCredential(akid)
+ require.NoError(t, err)
+ require.NotNil(t, stored)
+
+ // The defining property of a GetSessionToken session: it is user-bound, so
+ // PrincipalType is "user", SessionName is the IAM user name, and every
+ // assumed-role field is empty. resolveSessionAKID relies on exactly this.
+ assert.Equal(t, principalTypeUser, stored.PrincipalType)
+ assert.Equal(t, testCallerUserName, stored.SessionName)
+ assert.Equal(t, testCallerAccountID, stored.AccountID)
+ assert.Empty(t, stored.AssumedRoleARN)
+ assert.Empty(t, stored.UnderlyingRoleARN)
+ assert.Empty(t, stored.RoleID)
+ assert.Empty(t, stored.AssumedRoleID)
+ assert.Empty(t, stored.SourceIdentity)
+
+ // The wire token must HMAC to the persisted record, and the persisted secret
+ // must decrypt back — same minting invariants as AssumeRole.
+ mac := hmac.New(sha256.New, svc.masterKey)
+ mac.Write([]byte(token))
+ expected := base64.StdEncoding.EncodeToString(mac.Sum(nil))
+ assert.Equal(t, expected, stored.SessionTokenHMAC)
+ assert.NotEqual(t, token, stored.SessionTokenHMAC, "raw token must not be persisted")
+
+ decrypted, err := handlers_iam.DecryptSecret(stored.SecretEncrypted, svc.masterKey)
+ require.NoError(t, err)
+ assert.Equal(t, secret, decrypted)
+}
+
+func TestGetSessionToken_NilInput_DefaultsToTwelveHours(t *testing.T) {
+ svc, _ := newTestSetup(t)
+
+ out, err := svc.GetSessionToken(testCallerAccountID, testCallerUserName, principalTypeUser, testCallerAccessKeyID, nil)
+ require.NoError(t, err)
+ require.NotNil(t, out.Credentials)
+
+ assertStoredDuration(t, svc, out, getSessionTokenDefaultDuration)
+}
+
+func TestGetSessionToken_DurationClamp(t *testing.T) {
+ svc, _ := newTestSetup(t)
+
+ cases := []struct {
+ name string
+ requested int64
+ want int64
+ }{
+ {"below_minimum_clamps_up", minDurationSeconds - 1, minDurationSeconds},
+ {"at_minimum", minDurationSeconds, minDurationSeconds},
+ {"in_range", 7200, 7200},
+ {"at_maximum", getSessionTokenMaxDuration, getSessionTokenMaxDuration},
+ {"above_maximum_clamps_down", getSessionTokenMaxDuration + 1, getSessionTokenMaxDuration},
+ }
+ for _, tc := range cases {
+ t.Run(tc.name, func(t *testing.T) {
+ out, err := svc.GetSessionToken(testCallerAccountID, testCallerUserName, principalTypeUser, testCallerAccessKeyID,
+ &sts.GetSessionTokenInput{DurationSeconds: aws.Int64(tc.requested)})
+ require.NoError(t, err)
+ assertStoredDuration(t, svc, out, tc.want)
+ })
+ }
+}
+
+func TestGetSessionToken_RejectsNonUserAndSessionCallers(t *testing.T) {
+ svc, _ := newTestSetup(t)
+
+ // Only a long-lived user (AKIA + principalType "user") may call
+ // GetSessionToken. Assumed-role callers are denied by principal type;
+ // session callers are denied by their ASIA access-key prefix — including a
+ // GetSessionToken session, which resolves back to principalType "user" and
+ // would otherwise roll its own lifetime forward indefinitely.
+ cases := []struct {
+ name string
+ principalType string
+ accessKeyID string
+ }{
+ {"assumed_role_session", principalTypeAssumedRole, "ASIAEXAMPLEAAAAAAAAA"},
+ {"user_session", principalTypeUser, "ASIAEXAMPLEAAAAAAAAA"},
+ {"empty_principal_type", "", testCallerAccessKeyID},
+ }
+ for _, tc := range cases {
+ t.Run(tc.name, func(t *testing.T) {
+ out, err := svc.GetSessionToken(testCallerAccountID, testCallerUserName, tc.principalType, tc.accessKeyID,
+ &sts.GetSessionTokenInput{})
+ require.Error(t, err)
+ assert.Equal(t, awserrors.ErrorAccessDenied, err.Error())
+ assert.Nil(t, out)
+ })
+ }
+}
+
+func TestGetSessionToken_RejectsMFAParameters(t *testing.T) {
+ svc, _ := newTestSetup(t)
+
+ cases := []struct {
+ name string
+ input *sts.GetSessionTokenInput
+ }{
+ {"serial_number", &sts.GetSessionTokenInput{SerialNumber: aws.String("arn:aws:iam::000000000000:mfa/alice")}},
+ {"token_code", &sts.GetSessionTokenInput{TokenCode: aws.String("123456")}},
+ }
+ for _, tc := range cases {
+ t.Run(tc.name, func(t *testing.T) {
+ out, err := svc.GetSessionToken(testCallerAccountID, testCallerUserName, principalTypeUser, testCallerAccessKeyID, tc.input)
+ require.Error(t, err)
+ assert.Equal(t, awserrors.ErrorInvalidParameterValue, err.Error())
+ assert.Nil(t, out)
+ })
+ }
+}
+
+func TestGetSessionToken_RejectsMissingUserName(t *testing.T) {
+ svc, _ := newTestSetup(t)
+
+ out, err := svc.GetSessionToken(testCallerAccountID, "", principalTypeUser, testCallerAccessKeyID, &sts.GetSessionTokenInput{})
+ require.Error(t, err)
+ assert.Equal(t, awserrors.ErrorInternalError, err.Error())
+ assert.Nil(t, out)
+}
+
+// assertStoredDuration looks up the minted credential and asserts the persisted
+// lifetime (ExpiresAt - CreatedAt) equals the expected number of seconds. This
+// reads the clamp through the stored record rather than re-deriving it.
+func assertStoredDuration(t *testing.T, svc *STSServiceImpl, out *sts.GetSessionTokenOutput, wantSeconds int64) {
+ t.Helper()
+ stored, err := svc.LookupSessionCredential(aws.StringValue(out.Credentials.AccessKeyId))
+ require.NoError(t, err)
+ require.NotNil(t, stored)
+ assert.Equal(t, time.Duration(wantSeconds)*time.Second, stored.ExpiresAt.Sub(stored.CreatedAt))
+}
diff --git a/spinifex/handlers/sts/imds_rpc.go b/spinifex/handlers/sts/imds_rpc.go
new file mode 100644
index 000000000..7bb54e265
--- /dev/null
+++ b/spinifex/handlers/sts/imds_rpc.go
@@ -0,0 +1,36 @@
+package handlers_sts
+
+import (
+ "github.com/aws/aws-sdk-go/service/sts"
+ "github.com/mulgadc/spinifex/spinifex/utils"
+ "github.com/nats-io/nats.go"
+)
+
+// imdsResponderQueue load-balances the IMDS control-plane RPCs across every
+// awsgw in the cluster, matching the cluster-wide worker convention.
+const imdsResponderQueue = "spinifex-workers"
+
+// SubjectAssumeRoleForInstance is the internal, ACL-gated subject the IMDS handler
+// (in vpcd) calls to mint instance-role credentials. STS keeps the IAM master key
+// in-process inside awsgw; only this narrow RPC is surfaced, never to a guest.
+const SubjectAssumeRoleForInstance = "imds.sts.assume_role_for_instance"
+
+// AssumeRoleForInstanceRequest is the SubjectAssumeRoleForInstance payload. The
+// account ID is data here (resolved from the requesting ENI), not an
+// authenticated caller identity.
+type AssumeRoleForInstanceRequest struct {
+ AccountID string `json:"account_id"`
+ RoleARN string `json:"role_arn"`
+ InstanceID string `json:"instance_id"`
+ DurationSeconds int64 `json:"duration_seconds"`
+}
+
+// SubscribeIMDSResponder wires the AssumeRoleForInstance responder onto nc,
+// queue-grouped so any awsgw can answer.
+func (s *STSServiceImpl) SubscribeIMDSResponder(nc *nats.Conn) (*nats.Subscription, error) {
+ return nc.QueueSubscribe(SubjectAssumeRoleForInstance, imdsResponderQueue, func(msg *nats.Msg) {
+ utils.ServeNATSRequest(msg, func(req *AssumeRoleForInstanceRequest) (*sts.AssumeRoleOutput, error) {
+ return s.AssumeRoleForInstance(req.AccountID, req.RoleARN, req.InstanceID, req.DurationSeconds)
+ })
+ })
+}
diff --git a/spinifex/handlers/sts/invariants_test.go b/spinifex/handlers/sts/invariants_test.go
index abce32a2b..65016e657 100644
--- a/spinifex/handlers/sts/invariants_test.go
+++ b/spinifex/handlers/sts/invariants_test.go
@@ -49,6 +49,39 @@ func TestInvariant_SessionCredentialsBucket_AcceptsASIAPrefix(t *testing.T) {
require.NoError(t, putSessionCredential(bucket, cred))
}
+// User sessions (minted by GetSessionToken) write to the same bucket through
+// the same putSessionCredential helper as assumed-role sessions, so the
+// ASIA-prefix invariant must be principal-type-agnostic. This pins that: a
+// PrincipalType="user" credential is accepted iff its AKID is ASIA-prefixed,
+// exactly like a role session — the writer guard must never special-case the
+// principal type, or a user session could land in the wrong bucket and bypass
+// the ASIA dispatch path's invariants.
+func TestInvariant_SessionCredentialsBucket_UserSessionPrefixEnforced(t *testing.T) {
+ bucket := setupBucket(t)
+
+ userCred := func(akid string) *SessionCredential {
+ now := time.Now().UTC()
+ return &SessionCredential{
+ AccessKeyID: akid,
+ SecretEncrypted: "ciphertext-base64",
+ SessionTokenHMAC: "hmac-base64",
+ AccountID: "000000000000",
+ PrincipalType: principalTypeUser,
+ SessionName: "alice",
+ ExpiresAt: now.Add(time.Hour),
+ CreatedAt: now,
+ }
+ }
+
+ // A non-ASIA user AKID is rejected at the writer, same as any other prefix.
+ err := putSessionCredential(bucket, userCred("AKIA0123456789ABCDEF"))
+ require.Error(t, err, "putSessionCredential accepted a non-ASIA user session")
+ assert.Contains(t, err.Error(), SessionAccessKeyIDPrefix)
+
+ // An ASIA user AKID is accepted, just like an assumed-role session.
+ require.NoError(t, putSessionCredential(bucket, userCred(SessionAccessKeyIDPrefix+"USERSESSION00001")))
+}
+
// Cross-cluster anti-replay invariant. A presigned sts:GetCallerIdentity URL
// signed under cluster-A's name MUST NOT verify when presented to cluster-B.
// The defence is the X-K8s-Aws-Id header participating in SigV4
diff --git a/spinifex/handlers/sts/oidc_jwks.go b/spinifex/handlers/sts/oidc_jwks.go
index 32bddb6a5..175b745cd 100644
--- a/spinifex/handlers/sts/oidc_jwks.go
+++ b/spinifex/handlers/sts/oidc_jwks.go
@@ -5,6 +5,7 @@ import (
"errors"
"fmt"
"net/url"
+ "slices"
"strings"
handlers_eks "github.com/mulgadc/spinifex/spinifex/handlers/eks"
@@ -47,15 +48,34 @@ func (s *JWKS) FindByKID(kid string) *JWK {
return nil
}
-// ParseEKSIssuerURL extracts the (accountID, clusterName) pair encoded in an
-// EKS OIDC issuer URL of the form:
+// eksIssuerPathSegments is the fixed path layout of a spinifex EKS OIDC issuer:
//
-// https://oidc.eks.{region}.{suffix}/{accountID}/{clusterName}
+// oidc / eks / {region} / {accountID} / {clusterName}
+const (
+ eksIssuerPathSegments = 5
+ eksIssuerSegOIDC = 0
+ eksIssuerSegEKS = 1
+ eksIssuerSegRegion = 2
+ eksIssuerSegAccountID = 3
+ eksIssuerSegCluster = 4
+)
+
+// ParseEKSIssuerURL extracts the (accountID, clusterName) pair encoded in a
+// spinifex EKS OIDC issuer URL of the form:
//
-// Returns an error for any URL that does not match that exact two-segment
-// path structure under an `oidc.eks.` host prefix — defensive parsing prevents
-// a maliciously crafted `iss` claim from steering the JWKS lookup at an
-// attacker-controlled cluster bucket.
+// https://{gateway-host}/oidc/eks/{region}/{accountID}/{clusterName}
+//
+// This matches the URL ClusterOIDCIssuer (handlers/eks) emits and that awsgw
+// serves /.well-known/openid-configuration + /keys under. The host is the
+// awsgw gateway (an IP:port on bare-metal/on-prem, not an `oidc.eks.*` vhost),
+// so host trust is established by the IAM OIDC-provider registration — the full
+// issuer string is the registry key in verifyOIDCProviderRegistered — and by
+// the JWKS signature check, not by a host name pattern.
+//
+// Returns an error for any URL that does not match this exact 5-segment path
+// structure: defensive parsing pins accountID and clusterName at fixed
+// positions so a maliciously crafted `iss` claim cannot steer the JWKS lookup
+// at a different cluster bucket.
func ParseEKSIssuerURL(issuer string) (accountID, clusterName string, err error) {
u, err := url.Parse(issuer)
if err != nil {
@@ -64,14 +84,18 @@ func ParseEKSIssuerURL(issuer string) (accountID, clusterName string, err error)
if u.Scheme != "https" {
return "", "", errors.New("issuer URL must use https scheme")
}
- if !strings.HasPrefix(u.Host, "oidc.eks.") {
- return "", "", errors.New("issuer host must start with oidc.eks")
+ if u.Host == "" {
+ return "", "", errors.New("issuer URL missing host")
}
segments := strings.Split(strings.Trim(u.Path, "/"), "/")
- if len(segments) != 2 || segments[0] == "" || segments[1] == "" {
- return "", "", errors.New("issuer path must be /{accountID}/{clusterName}")
+ if len(segments) != eksIssuerPathSegments ||
+ segments[eksIssuerSegOIDC] != "oidc" || segments[eksIssuerSegEKS] != "eks" {
+ return "", "", errors.New("issuer path must be /oidc/eks/{region}/{accountID}/{clusterName}")
}
- return segments[0], segments[1], nil
+ if slices.Contains(segments, "") {
+ return "", "", errors.New("issuer path has empty segment")
+ }
+ return segments[eksIssuerSegAccountID], segments[eksIssuerSegCluster], nil
}
// FetchClusterJWKS reads the JWKS document for an EKS cluster from the
diff --git a/spinifex/handlers/sts/oidc_jwks_test.go b/spinifex/handlers/sts/oidc_jwks_test.go
index d3a465861..c4ec3b5e4 100644
--- a/spinifex/handlers/sts/oidc_jwks_test.go
+++ b/spinifex/handlers/sts/oidc_jwks_test.go
@@ -12,7 +12,20 @@ import (
)
func TestParseEKSIssuerURL_Valid(t *testing.T) {
- accountID, clusterName, err := ParseEKSIssuerURL("https://oidc.eks.au-mel-1.mulga.local/123456789012/my-cluster")
+ accountID, clusterName, err := ParseEKSIssuerURL("https://10.0.0.1:9999/oidc/eks/au-mel-1/123456789012/my-cluster")
+ require.NoError(t, err)
+ assert.Equal(t, "123456789012", accountID)
+ assert.Equal(t, "my-cluster", clusterName)
+}
+
+// Cross-component contract: a token whose iss is built by ClusterOIDCIssuer
+// must parse cleanly via ParseEKSIssuerURL and resolve to the same
+// (accountID, clusterName). This is the collision that bead 165.11 fixes.
+func TestParseEKSIssuerURL_RoundTripsClusterOIDCIssuer(t *testing.T) {
+ issuer, err := handlers_eks.ClusterOIDCIssuer("https://10.0.0.1:9999", "au-mel-1", "123456789012", "my-cluster")
+ require.NoError(t, err)
+
+ accountID, clusterName, err := ParseEKSIssuerURL(issuer)
require.NoError(t, err)
assert.Equal(t, "123456789012", accountID)
assert.Equal(t, "my-cluster", clusterName)
@@ -23,13 +36,15 @@ func TestParseEKSIssuerURL_Invalid(t *testing.T) {
name string
issuer string
}{
- {"http scheme", "http://oidc.eks.au-mel-1.mulga.local/123456789012/c1"},
- {"wrong host prefix", "https://attacker.example.com/123456789012/c1"},
- {"single segment", "https://oidc.eks.au-mel-1.mulga.local/123456789012"},
- {"three segments", "https://oidc.eks.au-mel-1.mulga.local/123/c/extra"},
- {"empty cluster segment", "https://oidc.eks.au-mel-1.mulga.local/123456789012/"},
- {"empty account segment", "https://oidc.eks.au-mel-1.mulga.local//c1"},
- {"empty path", "https://oidc.eks.au-mel-1.mulga.local"},
+ {"http scheme", "http://10.0.0.1:9999/oidc/eks/au-mel-1/123456789012/c1"},
+ {"missing host", "https:///oidc/eks/au-mel-1/123456789012/c1"},
+ {"wrong path prefix", "https://10.0.0.1:9999/foo/eks/au-mel-1/123456789012/c1"},
+ {"too few segments", "https://10.0.0.1:9999/oidc/eks/au-mel-1/123456789012"},
+ {"too many segments", "https://10.0.0.1:9999/oidc/eks/au-mel-1/123456789012/c1/extra"},
+ {"empty account segment", "https://10.0.0.1:9999/oidc/eks/au-mel-1//c1"},
+ {"empty cluster segment", "https://10.0.0.1:9999/oidc/eks/au-mel-1/123456789012/"},
+ {"empty path", "https://10.0.0.1:9999"},
+ {"legacy aws two-segment shape", "https://oidc.eks.au-mel-1.mulga.local/123456789012/c1"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
diff --git a/spinifex/handlers/sts/presigned_url_verify.go b/spinifex/handlers/sts/presigned_url_verify.go
index 1414149b3..c605c58f4 100644
--- a/spinifex/handlers/sts/presigned_url_verify.go
+++ b/spinifex/handlers/sts/presigned_url_verify.go
@@ -143,7 +143,11 @@ func (s *STSServiceImpl) VerifyPresignedGetCallerIdentity(presignedURL, expected
canonicalQuery,
canonicalHeaders,
signedHeadersList,
- "UNSIGNED-PAYLOAD",
+ // botocore / aws-sdk presign `sts:GetCallerIdentity` (a non-S3, empty-body
+ // request) with the SHA256 of the empty string as the payload hash.
+ // "UNSIGNED-PAYLOAD" is an S3-only convention and would mismatch every
+ // real `aws eks get-token`.
+ emptyStringSHA256,
}, "\n")
stringToSign := strings.Join([]string{
@@ -360,6 +364,10 @@ func hexSHA256(s string) string {
return hex.EncodeToString(sum[:])
}
+// emptyStringSHA256 is the SigV4 payload hash for an empty body — the value
+// botocore/aws-sdk use when presigning non-S3 requests like GetCallerIdentity.
+var emptyStringSHA256 = hexSHA256("")
+
func hmacSHA256(key []byte, data string) []byte {
mac := hmac.New(sha256.New, key)
mac.Write([]byte(data))
diff --git a/spinifex/handlers/sts/presigned_url_verify_test.go b/spinifex/handlers/sts/presigned_url_verify_test.go
index ed0b5af05..e7c05b7dd 100644
--- a/spinifex/handlers/sts/presigned_url_verify_test.go
+++ b/spinifex/handlers/sts/presigned_url_verify_test.go
@@ -2,6 +2,7 @@ package handlers_sts
import (
"encoding/hex"
+ "net/http"
"net/url"
"sort"
"strconv"
@@ -10,6 +11,8 @@ import (
"time"
"github.com/aws/aws-sdk-go/aws"
+ "github.com/aws/aws-sdk-go/aws/credentials"
+ v4 "github.com/aws/aws-sdk-go/aws/signer/v4"
"github.com/aws/aws-sdk-go/service/iam"
"github.com/mulgadc/spinifex/spinifex/awserrors"
"github.com/stretchr/testify/assert"
@@ -52,7 +55,7 @@ func presignTestURL(t *testing.T, accessKeyID, secret, clusterName string, signe
canonHeaders := "host:" + host + "\n" + "x-k8s-aws-id:" + clusterName + "\n"
signedHeadersList := "host;x-k8s-aws-id"
canonRequest := strings.Join([]string{
- "GET", "/", canonQ, canonHeaders, signedHeadersList, "UNSIGNED-PAYLOAD",
+ "GET", "/", canonQ, canonHeaders, signedHeadersList, emptyStringSHA256,
}, "\n")
stringToSign := strings.Join([]string{
@@ -204,7 +207,7 @@ func TestVerifyPresignedGetCallerIdentity_MissingXK8sAwsIdInSignedHeaders(t *tes
canonQ := canonicalQueryStringForTest(q)
canonHeaders := "host:" + testPresignedHost + "\n"
canonReq := strings.Join([]string{
- "GET", "/", canonQ, canonHeaders, "host", "UNSIGNED-PAYLOAD",
+ "GET", "/", canonQ, canonHeaders, "host", emptyStringSHA256,
}, "\n")
stringToSign := strings.Join([]string{
"AWS4-HMAC-SHA256", amzDate, credentialScope, hexSHA256(canonReq),
@@ -383,3 +386,38 @@ func TestDeriveSigningKey_KnownVector(t *testing.T) {
const wantHex = "f4780e2d9f65fa895f9c67b32ce1baf0b0d8a43505a000a1a9e090d414db404d"
assert.Equal(t, wantHex, hex.EncodeToString(key))
}
+
+// TestVerifyPresignedGetCallerIdentity_RealSDKPresign signs the URL with the
+// actual aws-sdk-go v4 presigner — the same canonicalisation `aws eks
+// get-token` uses — rather than the hand-rolled presignTestURL. This is the
+// faithful end-to-end guard: it caught the payload-hash bug (verify used the
+// S3-only "UNSIGNED-PAYLOAD" instead of the empty-string SHA256 that botocore
+// signs non-S3 requests with), which every hand-rolled test masked by using
+// the same wrong constant.
+func TestVerifyPresignedGetCallerIdentity_RealSDKPresign(t *testing.T) {
+ svc, _ := newTestSetup(t)
+ akid, secret := seedAccessKey(t, svc, testCallerAccountID, "carol")
+
+ signedAt := time.Now().UTC().Truncate(time.Second)
+ withFrozenTime(t, signedAt)
+
+ const cluster = "prod-cluster"
+ req, err := http.NewRequest(http.MethodGet,
+ "https://"+testPresignedHost+"/?Action=GetCallerIdentity&Version=2011-06-15", nil)
+ require.NoError(t, err)
+ req.Header.Set("X-K8s-Aws-Id", cluster)
+
+ signer := v4.NewSigner(credentials.NewStaticCredentials(akid, secret, ""))
+ _, err = signer.Presign(req, nil, "sts", testPresignedRegion, 900*time.Second, signedAt)
+ require.NoError(t, err)
+
+ got, err := svc.VerifyPresignedGetCallerIdentity(req.URL.String(), cluster)
+ require.NoError(t, err)
+ require.NotNil(t, got)
+ assert.Equal(t, cluster, got.XK8sAwsID)
+ assert.Contains(t, got.ARN, ":user/carol")
+
+ // Anti-replay: the SDK-signed URL must NOT verify against a different cluster.
+ _, err = svc.VerifyPresignedGetCallerIdentity(req.URL.String(), "other-cluster")
+ require.Error(t, err)
+}
diff --git a/spinifex/handlers/sts/service.go b/spinifex/handlers/sts/service.go
index 3a65d502f..acc65f8d3 100644
--- a/spinifex/handlers/sts/service.go
+++ b/spinifex/handlers/sts/service.go
@@ -17,6 +17,11 @@ type STSService interface {
// strings to keep the handler unit-testable.
AssumeRole(callerAccountID, callerARN, callerIdentity string, input *sts.AssumeRoleInput) (*sts.AssumeRoleOutput, error)
+ // AssumeRoleForInstance mints role-bound temporary credentials for an EC2 instance.
+ // It is the in-process IMDS entry point, NOT reachable over HTTPS: the caller is the
+ // synthesised EC2 service principal (trust must allow Service ec2.amazonaws.com).
+ AssumeRoleForInstance(accountID, roleARN, instanceID string, durationSeconds int64) (*sts.AssumeRoleOutput, error)
+
// AssumeRoleWithWebIdentity exchanges an OIDC ID token (typically a
// projected K8s ServiceAccount token signed by an EKS cluster's
// per-cluster signing key) for short-lived AWS credentials bound to the
@@ -29,6 +34,14 @@ type STSService interface {
// gateway does not gate it with checkPolicy.
GetCallerIdentity(callerAccountID, callerARN, callerUserID string, input *sts.GetCallerIdentityInput) (*sts.GetCallerIdentityOutput, error)
+ // GetSessionToken exchanges the calling IAM user's long-lived credentials for
+ // short-lived session credentials bound to the SAME user identity — resolving
+ // back to arn:aws:iam::A:user/N, unlike AssumeRole. callerPrincipalType and
+ // callerAccessKeyID let the handler enforce that only a long-lived user
+ // (never a session) may call it. The gateway resolves these from the SigV4
+ // context.
+ GetSessionToken(callerAccountID, callerUserName, callerPrincipalType, callerAccessKeyID string, input *sts.GetSessionTokenInput) (*sts.GetSessionTokenOutput, error)
+
// VerifyPresignedGetCallerIdentity validates a SigV4-presigned URL for
// the sts:GetCallerIdentity action — the token shape produced by `aws
// eks get-token` — and resolves the calling principal. The EKS token
diff --git a/spinifex/handlers/sts/session_credentials.go b/spinifex/handlers/sts/session_credentials.go
index 29914eac9..8023cba09 100644
--- a/spinifex/handlers/sts/session_credentials.go
+++ b/spinifex/handlers/sts/session_credentials.go
@@ -43,10 +43,22 @@ const (
// persisted; only an HMAC of it is stored, so a bucket read by a process that
// lacks the master key cannot recover the token.
type SessionCredential struct {
- AccessKeyID string `json:"access_key_id"`
- SecretEncrypted string `json:"secret_encrypted"`
- SessionTokenHMAC string `json:"session_token_hmac"`
- AccountID string `json:"account_id"`
+ AccessKeyID string `json:"access_key_id"`
+ SecretEncrypted string `json:"secret_encrypted"`
+ SessionTokenHMAC string `json:"session_token_hmac"`
+ AccountID string `json:"account_id"`
+
+ // PrincipalType records what kind of identity this session represents:
+ // "assumed-role" (minted by AssumeRole) or "user" (minted by
+ // GetSessionToken, resolving back to the calling IAM user). It governs how
+ // the SigV4 ASIA path rebuilds the caller identity and evaluates policy.
+ //
+ // Backward compat: an empty/absent value is treated as "assumed-role".
+ // Records are write-once and expire within hours (default 12h, max 36h),
+ // so no bucket-version bump or data migration is needed — the empty-string
+ // default covers any in-flight records minted before this field existed.
+ PrincipalType string `json:"principal_type,omitempty"`
+
AssumedRoleARN string `json:"assumed_role_arn"`
UnderlyingRoleARN string `json:"underlying_role_arn"`
RoleID string `json:"role_id"`
diff --git a/spinifex/handlers/sysinstance/launcher.go b/spinifex/handlers/sysinstance/launcher.go
new file mode 100644
index 000000000..7e6cb1f52
--- /dev/null
+++ b/spinifex/handlers/sysinstance/launcher.go
@@ -0,0 +1,158 @@
+// Package sysinstance holds the boot-agnostic contract for launching
+// system-managed VMs. System instances are hidden from customer
+// DescribeInstances calls — BootDirect VMs (LB) via system-account ownership,
+// BootAMI VMs (EKS control plane, owned by the customer account so their ENI
+// can live in the customer VPC) via their ManagedBy tag. Two boot styles are
+// supported:
+//
+// - BootDirect: a direct-boot microVM (bundled vmlinuz+initramfs, per-VM
+// config delivered via QEMU fw_cfg blobs; no AMI, volume, or cloud-init).
+// Used by the ELBv2 LB agent.
+// - BootAMI: a full AMI boot (root volume cloned from an AMI, cloud-init
+// user-data + network-config seed, q35 machine). Used by the EKS K3s
+// control-plane VM.
+//
+// The types live here (not in handlers/elbv2) so both ELBv2 and EKS can launch
+// system instances without an eks→elbv2 dependency. handlers/elbv2 re-exports
+// them as type aliases for source compatibility.
+package sysinstance
+
+import "errors"
+
+// ErrSystemInstanceNotFound is returned by TerminateSystemInstance when the
+// target instance is unknown to the daemon — already terminated, or never
+// present on this node. Teardown callers treat it as idempotent success so a
+// retried delete (e.g. EKS DeleteCluster after the VM already drained) does not
+// wedge forever on a VM that is legitimately gone.
+var ErrSystemInstanceNotFound = errors.New("sysinstance: instance not found")
+
+// BootMode selects how a system instance boots.
+type BootMode string
+
+const (
+ // BootDirect is the direct-boot microVM path (fw_cfg-delivered config).
+ BootDirect BootMode = "direct"
+ // BootAMI is the AMI + cloud-init path (root volume + seed ISO).
+ BootAMI BootMode = "ami"
+)
+
+// SystemInstanceLauncher is the subset of daemon functionality needed to launch
+// and terminate system-managed VMs. Defined here to avoid a circular import
+// between the launching handlers (elbv2, eks) and daemon.
+type SystemInstanceLauncher interface {
+ // LaunchSystemInstance creates and starts a system-managed VM. The VM is
+ // assigned to the system account and not visible to customers. Returns the
+ // instance ID and private IP once the VM is running.
+ LaunchSystemInstance(input *SystemInstanceInput) (*SystemInstanceOutput, error)
+
+ // TerminateSystemInstance stops and cleans up a system-managed VM.
+ TerminateSystemInstance(instanceID string) error
+}
+
+// SystemInstanceInput describes a system VM to launch. The BootMode field
+// selects which subset of fields is consumed:
+//
+// - BootDirect uses NICs, LBAgentEnv, CACert (fw_cfg blobs); ImageID/UserData
+// are ignored.
+// - BootAMI uses ImageID and UserData (cloud-init); NICs/LBAgentEnv/CACert are
+// ignored. The mgmt NIC is allocated by the daemon and rendered into the
+// cloud-init network-config from the VM record.
+//
+// JSON tags are explicit because this struct crosses the
+// system.LaunchInstance.* NATS subject root when a launch is handed off to the
+// cluster — see daemon/daemon_system_dispatch.go.
+type SystemInstanceInput struct {
+ // BootMode selects the boot style. Empty defaults to BootDirect for
+ // backward compatibility with the original ELBv2-only contract.
+ BootMode BootMode `json:"boot_mode,omitempty"`
+
+ // ManagedBy is the spinifex:managed-by tag value stamped on the instance so
+ // the UI/customer listings hide it (e.g. "elbv2", "eks").
+ ManagedBy string `json:"managed_by,omitempty"`
+
+ InstanceType string `json:"instance_type"` // e.g. "sys.micro"
+ SubnetID string `json:"subnet_id"` // VPC subnet for networking
+
+ // ENI fields — the VM always uses a pre-created ENI (the primary ENI).
+ ENIID string `json:"eni_id"`
+ ENIMac string `json:"eni_mac"`
+ ENIIP string `json:"eni_ip"`
+
+ // ExtraENIs lists additional pre-created ENIs to attach alongside the
+ // primary ENI (e.g. multi-subnet ALBs).
+ ExtraENIs []ExtraENIInput `json:"extra_enis,omitempty"`
+
+ // Scheme is the ALB scheme ("internet-facing" or "internal"). BootDirect
+ // only; ignored for BootAMI.
+ Scheme string `json:"scheme,omitempty"`
+
+ // AccountID is the owner account of the primary ENI. Required so the daemon
+ // can look up and update the ENI record (which is keyed by account).
+ AccountID string `json:"account_id"`
+
+ // HostfwdPorts forwards additional guest ports from the host via the QEMU
+ // user-mode dev NIC (dev_networking mode only).
+ HostfwdPorts []int `json:"hostfwd_ports,omitempty"`
+
+ // NICs defines the network interfaces for a BootDirect microVM. Index 0 is
+ // the primary VPC ENI, index 1 the mgmt NIC, index 2+ extra ENIs.
+ NICs []NICConfig `json:"nics,omitempty"`
+
+ // LBAgentEnv is a KEY=value blob written to /etc/conf.d/lb-agent inside a
+ // BootDirect guest via fw_cfg.
+ LBAgentEnv string `json:"lb_agent_env,omitempty"`
+
+ // CACert holds PEM CA bytes delivered to a BootDirect guest via fw_cfg.
+ CACert string `json:"ca_cert,omitempty"`
+
+ // ImageID is the AMI to clone the root volume from (BootAMI only).
+ ImageID string `json:"image_id,omitempty"`
+
+ // UserData is the raw (un-encoded) cloud-init user-data for a BootAMI guest.
+ // The launcher base64-encodes it for the RunInstances path.
+ UserData string `json:"user_data,omitempty"`
+}
+
+// ExtraENIInput describes an additional pre-created ENI to attach to a system
+// instance. The primary ENI is still passed via ENIID/ENIMac/ENIIP.
+type ExtraENIInput struct {
+ ENIID string `json:"eni_id"`
+ ENIMac string `json:"eni_mac"`
+ ENIIP string `json:"eni_ip"`
+ SubnetID string `json:"subnet_id"`
+}
+
+// NICConfig describes a single network interface for a BootDirect microVM.
+// Index 0 is the primary VPC ENI, index 1 the management NIC, index 2+ extra.
+type NICConfig struct {
+ MAC string `json:"mac"` // e.g. "02:0a:01:23:45:67"
+ CIDR string `json:"cidr"` // e.g. "10.0.1.5/24"
+ Gateway string `json:"gateway,omitempty"` // e.g. "10.0.1.1"; empty for mgmt NIC
+ IsDefault bool `json:"is_default"` // true for exactly one NIC (primary VPC ENI)
+ RouteDst string `json:"route_dst,omitempty"` // specific host route dst, e.g. "10.20.0.5/32"
+ RouteVia string `json:"route_via,omitempty"` // next-hop for RouteDst
+}
+
+// RecoveryContext carries the per-VM fields needed to reconstruct a
+// SystemInstanceInput during host-reboot recovery without importing the vm
+// package. MgmtMAC / MgmtIP are the values the daemon already allocated on a
+// prior launch; the service injects them back into NIC[1] rather than
+// re-allocating from the mgmt IPAM.
+type RecoveryContext struct {
+ InstanceID string
+ InstanceType string
+ ENIMac string
+ MgmtMAC string
+ MgmtIP string
+}
+
+// SystemInstanceOutput contains the result of a successful launch.
+type SystemInstanceOutput struct {
+ InstanceID string `json:"instance_id"` // e.g. "i-xxxxx"
+ PrivateIP string `json:"private_ip"` // VPC private IP
+ PublicIP string `json:"public_ip,omitempty"` // only for internet-facing scheme
+ MgmtIP string `json:"mgmt_ip,omitempty"` // br-mgmt NIC IP, reachable from the host
+
+ // HostfwdMap maps guest port → host port for any forwarded ports.
+ HostfwdMap map[int]int `json:"hostfwd_map,omitempty"`
+}
diff --git a/spinifex/lbagent/agent.go b/spinifex/lbagent/agent.go
index 4fffb401b..648004f43 100644
--- a/spinifex/lbagent/agent.go
+++ b/spinifex/lbagent/agent.go
@@ -5,7 +5,9 @@ package lbagent
import (
"bytes"
+ "crypto/sha256"
"crypto/tls"
+ "encoding/hex"
"encoding/xml"
"fmt"
"io"
@@ -20,8 +22,7 @@ import (
"syscall"
"time"
- "github.com/aws/aws-sdk-go/aws/credentials"
- v4 "github.com/aws/aws-sdk-go/aws/signer/v4"
+ "github.com/mulgadc/predastore/auth"
"github.com/mulgadc/spinifex/internal/tlsconfig"
)
@@ -30,10 +31,33 @@ const (
DefaultConfigPath = "/etc/haproxy/haproxy.cfg"
DefaultPIDPath = "/run/haproxy.pid"
+ // CertDir is the fixed directory holding TLS certificate PEM files for
+ // HTTPS listeners. The daemon renders `ssl crt` paths under this dir; the
+ // agent writes the delivered PEM files here (0600) before reload and
+ // rejects any delivered path that escapes it.
+ CertDir = "/etc/haproxy/certs"
+
+ // Nginx paths for the NLB (L4) data plane. NLBs render an nginx `stream`
+ // config because HAProxy cannot load-balance UDP; ALBs keep using the
+ // HAProxy paths above. NginxCertDir holds TLS PEM files for TLS listeners
+ // (referenced by both ssl_certificate and ssl_certificate_key).
+ NginxConfigPath = "/etc/nginx/nginx.conf"
+ NginxPIDPath = "/run/nginx.pid"
+ NginxCertDir = "/etc/nginx/certs"
+
// pollInterval is how often the agent sends a heartbeat.
pollInterval = 5 * time.Second
)
+// Data-plane engines, duplicated from handlers/elbv2 to avoid an import cycle
+// (handlers/elbv2 imports this package for path constants). The agent runs
+// HAProxy for ALBs and nginx `stream` for NLBs, selected by the GetLBConfig
+// response.
+const (
+ EngineHAProxy = "haproxy"
+ EngineNginx = "nginx"
+)
+
// Agent manages HAProxy configuration inside an LB VM by polling the gateway.
type Agent struct {
lbID string
@@ -41,18 +65,25 @@ type Agent struct {
region string // SigV4 signing region
configPath string
pidPath string
+ certDir string // dir for TLS cert PEM files; defaults to CertDir (overridable in tests)
socketPath string // HAProxy stats socket
- signer *v4.Signer
- client *http.Client
+ accessKey string
+ secretKey string
+ client *http.Client
localConfigHash string
+ engine string // data-plane engine of the last applied config
+ healthTargets []HealthTarget // active probe targets (nginx/NLB only)
stopCh chan struct{}
- // For testing: override the reload function.
- reloadFn func(configPath, pidPath string) error
+ // For testing: override the reload functions (HAProxy / nginx).
+ reloadFn func(configPath, pidPath string) error
+ reloadNginxFn func(configPath, pidPath string) error
// For testing: override the stats query function.
statsFn func(socketPath string) ([]ServerStatus, error)
+ // For testing: override the active health prober (nginx/NLB).
+ probeFn func(targets []HealthTarget) []ServerStatus
}
// New creates a new LB agent for the given load balancer.
@@ -70,9 +101,6 @@ func New(lbID, gatewayURL, accessKey, secretKey, region string) (*Agent, error)
return nil, fmt.Errorf("region is required")
}
- creds := credentials.NewStaticCredentials(accessKey, secretKey, "")
- signer := v4.NewSigner(creds)
-
// Use system CA trust store (CA cert injected via cloud-init ca_certs).
client := &http.Client{
Timeout: 10 * time.Second,
@@ -87,23 +115,27 @@ func New(lbID, gatewayURL, accessKey, secretKey, region string) (*Agent, error)
}
return &Agent{
- lbID: lbID,
- gatewayURL: strings.TrimRight(gatewayURL, "/"),
- region: region,
- configPath: DefaultConfigPath,
- pidPath: DefaultPIDPath,
- socketPath: fmt.Sprintf("/tmp/spinifex-haproxy/lb-%s.sock", lbID),
- signer: signer,
- client: client,
- stopCh: make(chan struct{}),
- reloadFn: reloadHAProxy,
- statsFn: queryHAProxyStats,
+ lbID: lbID,
+ gatewayURL: strings.TrimRight(gatewayURL, "/"),
+ region: region,
+ configPath: DefaultConfigPath,
+ pidPath: DefaultPIDPath,
+ certDir: CertDir,
+ socketPath: fmt.Sprintf("/tmp/spinifex-haproxy/lb-%s.sock", lbID),
+ accessKey: accessKey,
+ secretKey: secretKey,
+ client: client,
+ stopCh: make(chan struct{}),
+ reloadFn: reloadHAProxy,
+ reloadNginxFn: reloadNginx,
+ statsFn: queryHAProxyStats,
+ probeFn: probeHealthTargets,
}, nil
}
// Start runs the poll loop. It blocks until Stop is called.
func (a *Agent) Start() error {
- slog.Info("Agent started", "lbId", a.lbID, "gateway", a.gatewayURL)
+ slog.Info("Agent started", "lbId", a.lbID, "gateway", a.gatewayURL, "region", a.region)
// Run first tick immediately.
a.tick()
@@ -133,14 +165,25 @@ func (a *Agent) Stop() {
// tick runs one heartbeat cycle: send health, check config hash, fetch if changed.
func (a *Agent) tick() {
- servers, err := a.statsFn(a.socketPath)
- if err != nil {
- slog.Warn("HAProxy stats unavailable", "err", err)
+ // nginx (NLB) has no per-server stats socket — the agent actively probes
+ // the delivered health targets instead of reading HAProxy stats.
+ var servers []ServerStatus
+ if a.engine == EngineNginx {
+ servers = a.probeFn(a.healthTargets)
+ } else {
+ var statsErr error
+ servers, statsErr = a.statsFn(a.socketPath)
+ if statsErr != nil {
+ slog.Warn("HAProxy stats unavailable", "err", statsErr)
+ }
}
resp, err := a.sendHeartbeat(servers)
if err != nil {
- slog.Error("Heartbeat failed", "err", err)
+ // Include the dial target so the serial console disambiguates a
+ // transport failure (never reached AWSGW — "send request: dial ...")
+ // from an HTTP-level rejection ("gateway returned NNN: ...").
+ slog.Error("Heartbeat failed", "err", err, "gateway", a.gatewayURL, "region", a.region)
return
}
@@ -162,11 +205,30 @@ type heartbeatResponse struct {
ConfigHash string `xml:"LBAgentHeartbeatResult>ConfigHash"`
}
+// certFile is one delivered TLS certificate PEM parsed from the GetLBConfig
+// response. Path is the absolute destination under CertDir.
+type certFile struct {
+ Path string `xml:"Path"`
+ PEM string `xml:"PEM"`
+}
+
// configResponse is the parsed XML response from GetLBConfig.
type configResponse struct {
- XMLName xml.Name `xml:"GetLBConfigResponse"`
- ConfigText string `xml:"GetLBConfigResult>ConfigText"`
- ConfigHash string `xml:"GetLBConfigResult>ConfigHash"`
+ XMLName xml.Name `xml:"GetLBConfigResponse"`
+ ConfigText string `xml:"GetLBConfigResult>ConfigText"`
+ ConfigHash string `xml:"GetLBConfigResult>ConfigHash"`
+ Engine string `xml:"GetLBConfigResult>Engine"`
+ CertFiles []certFile `xml:"GetLBConfigResult>CertFiles>member"`
+ HealthTargets []HealthTarget `xml:"GetLBConfigResult>HealthTargets>member"`
+}
+
+// enginePaths returns the data-plane file paths and reload function for the
+// given engine, defaulting to HAProxy when the gateway omits Engine.
+func (a *Agent) enginePaths(engine string) (configPath, pidPath, certDir string, reload func(string, string) error) {
+ if engine == EngineNginx {
+ return NginxConfigPath, NginxPIDPath, NginxCertDir, a.reloadNginxFn
+ }
+ return a.configPath, a.pidPath, a.certDir, a.reloadFn
}
// sendHeartbeat sends a heartbeat with health report to the gateway.
@@ -216,16 +278,31 @@ func (a *Agent) fetchAndApplyConfig() error {
return fmt.Errorf("empty config returned")
}
- if err := WriteConfig(a.configPath, resp.ConfigText); err != nil {
+ engine := resp.Engine
+ if engine == "" {
+ engine = EngineHAProxy
+ }
+ configPath, pidPath, certDir, reload := a.enginePaths(engine)
+
+ // Cert files must land before the config that references them (via
+ // `ssl crt` / `ssl_certificate`), otherwise the engine fails to
+ // start/reload on a missing path.
+ if err := a.writeCertFiles(certDir, resp.CertFiles); err != nil {
+ return fmt.Errorf("write cert files: %w", err)
+ }
+
+ if err := WriteConfig(configPath, resp.ConfigText); err != nil {
return fmt.Errorf("write config: %w", err)
}
- if err := a.reloadFn(a.configPath, a.pidPath); err != nil {
- return fmt.Errorf("reload haproxy: %w", err)
+ if err := reload(configPath, pidPath); err != nil {
+ return fmt.Errorf("reload %s: %w", engine, err)
}
+ a.engine = engine
+ a.healthTargets = resp.HealthTargets
a.localConfigHash = resp.ConfigHash
- slog.Info("Config applied", "hash", resp.ConfigHash)
+ slog.Info("Config applied", "engine", engine, "hash", resp.ConfigHash, "healthTargets", len(resp.HealthTargets))
return nil
}
@@ -238,8 +315,9 @@ func (a *Agent) signedPost(params url.Values) ([]byte, error) {
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
- _, err = a.signer.Sign(req, bytes.NewReader([]byte(body)), "elasticloadbalancing", a.region, time.Now())
- if err != nil {
+ sum := sha256.Sum256([]byte(body))
+ payloadHash := hex.EncodeToString(sum[:])
+ if err := auth.SignReq(req, a.accessKey, a.secretKey, payloadHash, "elasticloadbalancing", a.region); err != nil {
return nil, fmt.Errorf("sign request: %w", err)
}
@@ -261,6 +339,34 @@ func (a *Agent) signedPost(params url.Values) ([]byte, error) {
return respBody, nil
}
+// writeCertFiles writes each delivered TLS certificate PEM to its path 0600,
+// creating certDir as needed. Every path is constrained to certDir — a
+// delivered path that escapes it (traversal, absolute elsewhere) is rejected
+// rather than written, so a malformed/hostile response can't drop files across
+// the filesystem.
+func (a *Agent) writeCertFiles(certDir string, certs []certFile) error {
+ if len(certs) == 0 {
+ return nil
+ }
+ if err := os.MkdirAll(certDir, 0o750); err != nil {
+ return fmt.Errorf("create cert dir: %w", err)
+ }
+ for _, c := range certs {
+ clean := filepath.Clean(c.Path)
+ if clean != filepath.Join(certDir, filepath.Base(clean)) {
+ return fmt.Errorf("cert path %q escapes %s", c.Path, certDir)
+ }
+ if c.PEM == "" {
+ return fmt.Errorf("cert %q has empty PEM", c.Path)
+ }
+ if err := os.WriteFile(clean, []byte(c.PEM), 0o600); err != nil {
+ return fmt.Errorf("write cert %q: %w", clean, err)
+ }
+ slog.Info("Wrote TLS cert", "path", clean, "bytes", len(c.PEM))
+ }
+ return nil
+}
+
// WriteConfig atomically writes an HAProxy config file.
// It writes to a temp file first, then renames for atomicity.
func WriteConfig(path, content string) error {
@@ -306,6 +412,56 @@ func reloadHAProxy(configPath, pidPath string) error {
return nil
}
+// reloadNginx validates then starts or reloads nginx for the NLB data plane.
+// `nginx -t` fails closed before any reload, mirroring HAProxy's
+// validate-before-bind guarantee (a bad config never replaces a good one).
+func reloadNginx(configPath, pidPath string) error {
+ // On a stripped Alpine microvm initramfs the nginx runtime prefix dirs may
+ // be absent, so `nginx -t`/start fails closed on a missing pid dir or temp
+ // path — and the agent never enters nginx mode, never probes, and reports
+ // empty health (NLB targets stuck at 0/N). Pre-create them, mirroring
+ // reloadHAProxy's socket-dir pre-create.
+ for _, dir := range []string{filepath.Dir(pidPath), "/var/lib/nginx", "/var/lib/nginx/tmp", "/var/log/nginx"} {
+ _ = os.MkdirAll(dir, 0o750)
+ }
+
+ test := exec.Command("nginx", "-t", "-c", configPath)
+ if out, err := test.CombinedOutput(); err != nil {
+ return fmt.Errorf("nginx -t: %s: %w", strings.TrimSpace(string(out)), err)
+ }
+
+ var cmd *exec.Cmd
+ if readPID(pidPath) > 0 {
+ // Master running: signal a graceful reload.
+ cmd = exec.Command("nginx", "-s", "reload", "-c", configPath)
+ } else {
+ // Fresh start (nginx daemonizes; pid path comes from the config).
+ cmd = exec.Command("nginx", "-c", configPath)
+ }
+
+ // nginx daemonizes on start, and the rendered config uses `error_log stderr`,
+ // so the forked master and workers inherit and keep this command's stderr
+ // open. CombinedOutput pipes stderr and blocks on Wait until EOF — which the
+ // long-lived daemon never sends — hanging the agent here forever before it
+ // ever reports health, leaving NLB targets stuck at 0/N. Capture to a temp
+ // file instead: Wait then blocks only on the short-lived foreground process,
+ // which exits once it has daemonized.
+ logFile, err := os.CreateTemp("", "nginx-reload-*.log")
+ if err != nil {
+ return fmt.Errorf("create nginx log: %w", err)
+ }
+ defer os.Remove(logFile.Name())
+ defer logFile.Close()
+ cmd.Stdout = logFile
+ cmd.Stderr = logFile
+
+ if err := cmd.Run(); err != nil {
+ out, _ := os.ReadFile(logFile.Name())
+ return fmt.Errorf("nginx: %s: %w", strings.TrimSpace(string(out)), err)
+ }
+ return nil
+}
+
// readPID reads the HAProxy PID from the PID file. Returns 0 if unavailable.
func readPID(pidPath string) int {
data, err := os.ReadFile(pidPath)
diff --git a/spinifex/lbagent/agent_certs_test.go b/spinifex/lbagent/agent_certs_test.go
new file mode 100644
index 000000000..8c343c16a
--- /dev/null
+++ b/spinifex/lbagent/agent_certs_test.go
@@ -0,0 +1,63 @@
+package lbagent
+
+import (
+ "os"
+ "path/filepath"
+ "runtime"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func newCertTestAgent(t *testing.T) *Agent {
+ t.Helper()
+ a, err := New("lb-1", "https://gw.local", "AK", "SK", "us-east-1")
+ require.NoError(t, err)
+ a.certDir = t.TempDir()
+ return a
+}
+
+func TestWriteCertFiles_WritesPEM0600(t *testing.T) {
+ a := newCertTestAgent(t)
+ path := filepath.Join(a.certDir, "lb-1-lst.pem")
+
+ err := a.writeCertFiles(a.certDir, []certFile{{Path: path, PEM: "LEAF\nKEY\n"}})
+ require.NoError(t, err)
+
+ data, err := os.ReadFile(path)
+ require.NoError(t, err)
+ assert.Equal(t, "LEAF\nKEY\n", string(data))
+
+ if runtime.GOOS != "windows" {
+ fi, err := os.Stat(path)
+ require.NoError(t, err)
+ assert.Equal(t, os.FileMode(0o600), fi.Mode().Perm())
+ }
+}
+
+func TestWriteCertFiles_Empty(t *testing.T) {
+ a := newCertTestAgent(t)
+ assert.NoError(t, a.writeCertFiles(a.certDir, nil))
+}
+
+func TestWriteCertFiles_RejectsTraversal(t *testing.T) {
+ a := newCertTestAgent(t)
+ cases := []string{
+ filepath.Join(a.certDir, "../escape.pem"),
+ "/etc/passwd",
+ filepath.Join(a.certDir, "sub/dir/x.pem"),
+ }
+ for _, p := range cases {
+ err := a.writeCertFiles(a.certDir, []certFile{{Path: p, PEM: "X"}})
+ require.Error(t, err, "path %q must be rejected", p)
+ assert.Contains(t, err.Error(), "escapes")
+ }
+}
+
+func TestWriteCertFiles_RejectsEmptyPEM(t *testing.T) {
+ a := newCertTestAgent(t)
+ err := a.writeCertFiles(a.certDir, []certFile{{Path: filepath.Join(a.certDir, "x.pem"), PEM: ""}})
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), "empty PEM")
+}
diff --git a/spinifex/lbagent/agent_test.go b/spinifex/lbagent/agent_test.go
index 54540087e..38125975c 100644
--- a/spinifex/lbagent/agent_test.go
+++ b/spinifex/lbagent/agent_test.go
@@ -1,14 +1,20 @@
package lbagent
import (
+ "crypto/sha256"
+ "encoding/hex"
"fmt"
+ "io"
"net/http"
"net/http/httptest"
+ "net/url"
"os"
"path/filepath"
"sync/atomic"
"testing"
"time"
+
+ "github.com/mulgadc/predastore/auth"
)
func TestNew(t *testing.T) {
@@ -68,6 +74,53 @@ func TestNew_SocketPath(t *testing.T) {
}
}
+// TestSignedPost_ProducesVerifiableSignature confirms the migrated signing path
+// (predastore/auth.SignReq over aws-sdk-go-v2) produces a request the gateway's
+// SigV4 verifier accepts, including a body-hash that matches X-Amz-Content-Sha256.
+func TestSignedPost_ProducesVerifiableSignature(t *testing.T) {
+ const (
+ accessKey = "AKIAIOSFODNN7EXAMPLE"
+ secretKey = "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY"
+ region = "us-east-1"
+ )
+
+ var parsed bool
+ var verifyErr error
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ body, _ := io.ReadAll(r.Body)
+ sum := sha256.Sum256(body)
+ req, err := auth.ParseReq(r)
+ if err != nil {
+ verifyErr = err
+ http.Error(w, err.Error(), http.StatusBadRequest)
+ return
+ }
+ parsed = true
+ verifyErr = req.Verify(secretKey, "elasticloadbalancing", region,
+ auth.WithBodyHash(hex.EncodeToString(sum[:])))
+ fmt.Fprint(w, "ok")
+ }))
+ defer srv.Close()
+
+ agent, err := New("lb-sig", srv.URL, accessKey, secretKey, region)
+ if err != nil {
+ t.Fatalf("New: %v", err)
+ }
+
+ if _, err := agent.signedPost(url.Values{
+ "Action": {"LBAgentHeartbeat"},
+ "LoadBalancerId": {"lb-sig"},
+ }); err != nil {
+ t.Fatalf("signedPost: %v", err)
+ }
+ if !parsed {
+ t.Fatal("gateway did not parse a SigV4 Authorization header")
+ }
+ if verifyErr != nil {
+ t.Fatalf("signed request failed server-side verification: %v", verifyErr)
+ }
+}
+
// fakeGateway returns a test server that responds to LBAgentHeartbeat and GetLBConfig.
func fakeGateway(t *testing.T, configHash, configText, status string) *httptest.Server {
t.Helper()
@@ -300,6 +353,100 @@ func TestHeartbeat_StatsError(t *testing.T) {
agent.tick()
}
+func TestEnginePaths_Nginx(t *testing.T) {
+ agent := newTestAgent(t, "http://example.invalid")
+ var nginxCalled, haproxyCalled bool
+ agent.reloadNginxFn = func(_, _ string) error { nginxCalled = true; return nil }
+ agent.reloadFn = func(_, _ string) error { haproxyCalled = true; return nil }
+
+ cfg, pid, certDir, reload := agent.enginePaths(EngineNginx)
+ if cfg != NginxConfigPath || pid != NginxPIDPath || certDir != NginxCertDir {
+ t.Errorf("nginx paths = %q,%q,%q want %q,%q,%q", cfg, pid, certDir, NginxConfigPath, NginxPIDPath, NginxCertDir)
+ }
+ _ = reload("", "")
+ if !nginxCalled || haproxyCalled {
+ t.Errorf("nginx engine routed to wrong reload (nginx=%v haproxy=%v)", nginxCalled, haproxyCalled)
+ }
+}
+
+func TestEnginePaths_HAProxyDefault(t *testing.T) {
+ agent := newTestAgent(t, "http://example.invalid")
+ var nginxCalled, haproxyCalled bool
+ agent.reloadNginxFn = func(_, _ string) error { nginxCalled = true; return nil }
+ agent.reloadFn = func(_, _ string) error { haproxyCalled = true; return nil }
+
+ // Both explicit haproxy and an empty engine fall through to HAProxy.
+ for _, engine := range []string{EngineHAProxy, ""} {
+ cfg, pid, certDir, reload := agent.enginePaths(engine)
+ if cfg != agent.configPath || pid != agent.pidPath || certDir != agent.certDir {
+ t.Errorf("engine %q paths = %q,%q,%q want haproxy defaults", engine, cfg, pid, certDir)
+ }
+ _ = reload("", "")
+ }
+ if nginxCalled || !haproxyCalled {
+ t.Errorf("haproxy/empty engine routed to wrong reload (nginx=%v haproxy=%v)", nginxCalled, haproxyCalled)
+ }
+}
+
+func TestTick_NginxSkipsStats(t *testing.T) {
+ gw := fakeGateway(t, "h1", "", "active")
+ defer gw.Close()
+
+ agent := newTestAgent(t, gw.URL)
+ agent.localConfigHash = "h1"
+ agent.engine = EngineNginx // stats poll is HAProxy-only
+ statsCalled := false
+ agent.statsFn = func(_ string) ([]ServerStatus, error) {
+ statsCalled = true
+ return nil, nil
+ }
+
+ agent.tick()
+
+ if statsCalled {
+ t.Error("nginx engine must not poll HAProxy stats")
+ }
+}
+
+func TestTick_NginxProbesHealthTargets(t *testing.T) {
+ var receivedServer, receivedStatus string
+ gw := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ r.ParseForm()
+ if r.FormValue("Action") == "LBAgentHeartbeat" {
+ receivedServer = r.FormValue("Servers.member.1.Server")
+ receivedStatus = r.FormValue("Servers.member.1.Status")
+ fmt.Fprint(w, `active h1 `)
+ return
+ }
+ http.Error(w, "unexpected", http.StatusBadRequest)
+ }))
+ defer gw.Close()
+
+ agent := newTestAgent(t, gw.URL)
+ agent.localConfigHash = "h1" // match to avoid config fetch
+ agent.engine = EngineNginx
+ agent.healthTargets = []HealthTarget{{ServerName: "srv_i-web1", Address: "10.0.0.5:80", Protocol: "TCP"}}
+ statsCalled := false
+ agent.statsFn = func(_ string) ([]ServerStatus, error) { statsCalled = true; return nil, nil }
+ probedTargets := 0
+ agent.probeFn = func(targets []HealthTarget) []ServerStatus {
+ probedTargets = len(targets)
+ return []ServerStatus{{Server: "srv_i-web1", Status: "UP"}}
+ }
+
+ agent.tick()
+
+ if statsCalled {
+ t.Error("nginx engine must not poll HAProxy stats")
+ }
+ if probedTargets != 1 {
+ t.Errorf("probeFn saw %d targets, want 1", probedTargets)
+ }
+ if receivedServer != "srv_i-web1" || receivedStatus != "UP" {
+ t.Errorf("heartbeat server/status = %q/%q, want srv_i-web1/UP", receivedServer, receivedStatus)
+ }
+}
+
func TestHeartbeat_EmptyConfigHash(t *testing.T) {
// Gateway returns empty config hash (no config stored yet)
gw := fakeGateway(t, "", "", "provisioning")
diff --git a/spinifex/lbagent/healthcheck.go b/spinifex/lbagent/healthcheck.go
new file mode 100644
index 000000000..94ad0371e
--- /dev/null
+++ b/spinifex/lbagent/healthcheck.go
@@ -0,0 +1,94 @@
+package lbagent
+
+import (
+ "crypto/tls"
+ "fmt"
+ "log/slog"
+ "net"
+ "net/http"
+ "strings"
+ "time"
+)
+
+// HealthTarget is one backend the agent must actively health-check, delivered
+// by GetLBConfig for the nginx (NLB) data plane. OSS nginx `stream` has no
+// active upstream probing, so the agent probes each target every tick and
+// reports the result. ServerName is the daemon-computed name the health checker
+// keys on — echoed back verbatim so the agent needs no ELBv2 naming logic.
+type HealthTarget struct {
+ ServerName string `xml:"ServerName"`
+ Address string `xml:"Address"` // ip:port to probe
+ Protocol string `xml:"Protocol"` // TCP | HTTP | HTTPS
+ Path string `xml:"Path"` // HTTP(S) path
+}
+
+// probeTimeout bounds each individual target probe.
+const probeTimeout = 3 * time.Second
+
+// probeHealthTargets actively probes every delivered health target and returns
+// one ServerStatus per target (Status "UP"/"DOWN"). The Server field carries
+// the daemon-supplied ServerName so the daemon health checker matches it
+// directly. An empty target list yields a nil report (the daemon early-returns
+// on empty reports, leaving health untouched).
+func probeHealthTargets(targets []HealthTarget) []ServerStatus {
+ if len(targets) == 0 {
+ return nil
+ }
+ out := make([]ServerStatus, 0, len(targets))
+ for _, t := range targets {
+ status := "DOWN"
+ if probeOne(t) {
+ status = "UP"
+ }
+ out = append(out, ServerStatus{Server: t.ServerName, Status: status})
+ }
+ return out
+}
+
+// probeOne performs a single health check against one target. TCP succeeds on a
+// completed dial; HTTP/HTTPS succeeds on a response status < 400 (HTTPS skips
+// certificate verification, matching how AWS NLB HTTPS health checks treat the
+// target's self-signed cert).
+func probeOne(t HealthTarget) bool {
+ switch strings.ToUpper(t.Protocol) {
+ case "HTTP", "HTTPS":
+ return probeHTTP(t)
+ default: // TCP and anything else falls back to a connect probe
+ conn, err := net.DialTimeout("tcp", t.Address, probeTimeout)
+ if err != nil {
+ slog.Warn("TCP probe failed", "addr", t.Address, "err", err)
+ return false
+ }
+ _ = conn.Close()
+ return true
+ }
+}
+
+// probeHTTP issues a GET to the target's health-check path and reports whether
+// the response status is below 400.
+func probeHTTP(t HealthTarget) bool {
+ scheme := "http"
+ client := &http.Client{Timeout: probeTimeout}
+ if strings.EqualFold(t.Protocol, "HTTPS") {
+ scheme = "https"
+ client.Transport = &http.Transport{
+ TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, //nolint:gosec // target uses a self-signed cert; NLB HTTPS checks don't verify it
+ }
+ }
+
+ path := t.Path
+ if path == "" {
+ path = "/"
+ } else if !strings.HasPrefix(path, "/") {
+ path = "/" + path
+ }
+
+ url := fmt.Sprintf("%s://%s%s", scheme, t.Address, path)
+ resp, err := client.Get(url) //nolint:noctx // short fixed-timeout probe
+ if err != nil {
+ slog.Warn("HTTP probe failed", "url", url, "err", err)
+ return false
+ }
+ defer resp.Body.Close()
+ return resp.StatusCode < 400
+}
diff --git a/spinifex/lbagent/healthcheck_test.go b/spinifex/lbagent/healthcheck_test.go
new file mode 100644
index 000000000..2a237be06
--- /dev/null
+++ b/spinifex/lbagent/healthcheck_test.go
@@ -0,0 +1,96 @@
+package lbagent
+
+import (
+ "net"
+ "net/http"
+ "net/http/httptest"
+ "testing"
+)
+
+func TestProbeHealthTargets_Empty(t *testing.T) {
+ if got := probeHealthTargets(nil); got != nil {
+ t.Errorf("empty targets = %v, want nil (daemon early-returns on empty report)", got)
+ }
+}
+
+func TestProbeOne_TCP(t *testing.T) {
+ ln, err := net.Listen("tcp", "127.0.0.1:0")
+ if err != nil {
+ t.Fatalf("listen: %v", err)
+ }
+ defer ln.Close()
+ go func() {
+ for {
+ c, err := ln.Accept()
+ if err != nil {
+ return
+ }
+ c.Close()
+ }
+ }()
+
+ up := HealthTarget{ServerName: "srv_i-up", Address: ln.Addr().String(), Protocol: "TCP"}
+ if !probeOne(up) {
+ t.Error("open TCP port probed DOWN, want UP")
+ }
+
+ // Closed port: pick the listener's port after closing it.
+ addr := ln.Addr().String()
+ ln.Close()
+ down := HealthTarget{ServerName: "srv_i-down", Address: addr, Protocol: "TCP"}
+ if probeOne(down) {
+ t.Error("closed TCP port probed UP, want DOWN")
+ }
+}
+
+func TestProbeOne_HTTP(t *testing.T) {
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.URL.Path == "/healthz" {
+ w.WriteHeader(http.StatusOK)
+ return
+ }
+ w.WriteHeader(http.StatusInternalServerError)
+ }))
+ defer srv.Close()
+
+ addr := srv.Listener.Addr().String()
+ up := HealthTarget{ServerName: "srv_i-h", Address: addr, Protocol: "HTTP", Path: "/healthz"}
+ if !probeOne(up) {
+ t.Error("200 health path probed DOWN, want UP")
+ }
+
+ down := HealthTarget{ServerName: "srv_i-h", Address: addr, Protocol: "HTTP", Path: "/bad"}
+ if probeOne(down) {
+ t.Error("500 health path probed UP, want DOWN")
+ }
+}
+
+func TestProbeHealthTargets_ReportsServerName(t *testing.T) {
+ ln, err := net.Listen("tcp", "127.0.0.1:0")
+ if err != nil {
+ t.Fatalf("listen: %v", err)
+ }
+ defer ln.Close()
+ go func() {
+ for {
+ c, err := ln.Accept()
+ if err != nil {
+ return
+ }
+ c.Close()
+ }
+ }()
+
+ out := probeHealthTargets([]HealthTarget{
+ {ServerName: "srv_i-abc", Address: ln.Addr().String(), Protocol: "TCP"},
+ })
+ if len(out) != 1 {
+ t.Fatalf("got %d statuses, want 1", len(out))
+ }
+ if out[0].Server != "srv_i-abc" {
+ t.Errorf("Server = %q, want %q (daemon health checker keys on it)", out[0].Server, "srv_i-abc")
+ }
+ if out[0].Status != "UP" {
+ t.Errorf("Status = %q, want UP", out[0].Status)
+ }
+}
diff --git a/spinifex/network/external/eip.go b/spinifex/network/external/eip.go
index 03e0fc8f5..567f1b556 100644
--- a/spinifex/network/external/eip.go
+++ b/spinifex/network/external/eip.go
@@ -12,7 +12,7 @@ import (
// that adds the flows-barrier so callers see the EIP reachable on return.
type EIPManager interface {
AttachEIP(ctx context.Context, eip policy.EIPSpec) error
- DetachEIP(ctx context.Context, vpcID, logicalIP string) error
+ DetachEIP(ctx context.Context, vpcID, externalIP, logicalIP string) error
}
type eipManager struct {
@@ -44,6 +44,6 @@ func (m *eipManager) AttachEIP(ctx context.Context, eip policy.EIPSpec) error {
return nil
}
-func (m *eipManager) DetachEIP(ctx context.Context, vpcID, logicalIP string) error {
- return m.nat.DeleteEIP(ctx, vpcID, logicalIP)
+func (m *eipManager) DetachEIP(ctx context.Context, vpcID, externalIP, logicalIP string) error {
+ return m.nat.DeleteEIP(ctx, vpcID, externalIP, logicalIP)
}
diff --git a/spinifex/network/external/eip_test.go b/spinifex/network/external/eip_test.go
index a838953be..ba128425f 100644
--- a/spinifex/network/external/eip_test.go
+++ b/spinifex/network/external/eip_test.go
@@ -41,7 +41,7 @@ func TestEIPManager_AttachAndDetach(t *testing.T) {
require.NotNil(t, found)
assert.Equal(t, 1, barrierCalls)
- require.NoError(t, mgr.DetachEIP(ctx, "vpc-1", "10.0.0.5"))
+ require.NoError(t, mgr.DetachEIP(ctx, "vpc-1", "1.2.3.4", "10.0.0.5"))
for _, n := range m.NATs {
assert.NotEqual(t, "10.0.0.5", n.LogicalIP)
}
@@ -59,5 +59,5 @@ func TestEIPManager_DetachIdempotent(t *testing.T) {
require.NoError(t, m.CreateLogicalRouter(ctx, &nbdb.LogicalRouter{Name: topology.VPCRouter("vpc-1")}))
nm, _ := policy.NewNATManager(m, policy.NATModeDistributed)
mgr, _ := NewEIPManager(nm, nil)
- require.NoError(t, mgr.DetachEIP(ctx, "vpc-1", "10.0.0.99"))
+ require.NoError(t, mgr.DetachEIP(ctx, "vpc-1", "9.9.9.9", "10.0.0.99"))
}
diff --git a/spinifex/network/external/igw.go b/spinifex/network/external/igw.go
index 7c9375ae9..62075025b 100644
--- a/spinifex/network/external/igw.go
+++ b/spinifex/network/external/igw.go
@@ -47,6 +47,15 @@ type IGWManager interface {
EnsureSubnetEgressDrop(ctx context.Context, vpcID, subnetID string, prefix netip.Prefix) error
// RemoveSubnetEgressDrop is the inverse. Idempotent.
RemoveSubnetEgressDrop(ctx context.Context, vpcID, subnetID string, prefix netip.Prefix) error
+ // EnsureSystemInstanceEgress wires egress-only internet access for a
+ // single system instance (e.g. an EKS K3s server VM) sourced from
+ // instanceIP in subnetID, SNAT'd to externalIP. Installs a /32 reroute
+ // policy at SystemInstanceEgressPriority (above the drop gate so it works
+ // even on a drop-gated subnet) plus a plain snat (no DNAT, no inbound).
+ // Requires AttachIGW to have run first. Idempotent.
+ EnsureSystemInstanceEgress(ctx context.Context, vpcID, subnetID, instanceIP, externalIP string) error
+ // RemoveSystemInstanceEgress is the inverse. Idempotent.
+ RemoveSystemInstanceEgress(ctx context.Context, vpcID, subnetID, instanceIP, externalIP string) error
}
// IGWManagerConfig is the construction-time bag for igwManager.
@@ -289,7 +298,7 @@ func (m *igwManager) RemoveSubnetEgress(ctx context.Context, vpcID, subnetID str
if vpcID == "" || subnetID == "" {
return errors.New("RemoveSubnetEgress: vpcID and subnetID required")
}
- return m.routes.DeleteSubnetEgress(ctx, vpcID, subnetID, prefix, m.vpcExcludeCIDRs(ctx, vpcID))
+ return m.routes.DeleteSubnetEgress(ctx, vpcID, subnetID, prefix, m.rerouteExcludeCIDRs(ctx, vpcID))
}
// EnsureNATGatewaySubnetEgress is the NATGW priority sibling of
@@ -306,7 +315,7 @@ func (m *igwManager) RemoveNATGatewaySubnetEgress(ctx context.Context, vpcID, su
if vpcID == "" || subnetID == "" {
return errors.New("RemoveNATGatewaySubnetEgress: vpcID and subnetID required")
}
- return m.routes.DeleteSubnetEgress(ctx, vpcID, subnetID, prefix, m.vpcExcludeCIDRs(ctx, vpcID))
+ return m.routes.DeleteSubnetEgress(ctx, vpcID, subnetID, prefix, m.rerouteExcludeCIDRs(ctx, vpcID))
}
// EnsureSubnetEgressDrop installs a drop policy at SubnetEgressPriorityDrop
@@ -352,23 +361,85 @@ func (m *igwManager) ensureSubnetEgressAtPriority(ctx context.Context, vpcID, su
Nexthop: nexthop,
OutputPort: topology.GatewayRouterPort(vpcID),
Priority: priority,
- ExcludeCIDRs: m.vpcExcludeCIDRs(ctx, vpcID),
+ ExcludeCIDRs: m.rerouteExcludeCIDRs(ctx, vpcID),
})
}
+// EnsureSystemInstanceEgress installs an egress-only path for one system
+// instance: a /32 reroute policy out the gateway port plus a plain snat
+// (instanceIP -> externalIP). The reroute sits above the subnet drop gate so
+// it works even where the instance's subnet is otherwise private; where the
+// subnet already has a 1000 reroute it is harmless (same nexthop). The snat is
+// not dnat_and_snat, so the instance is never reachable inbound.
+func (m *igwManager) EnsureSystemInstanceEgress(ctx context.Context, vpcID, subnetID, instanceIP, externalIP string) error {
+ if vpcID == "" || subnetID == "" {
+ return errors.New("EnsureSystemInstanceEgress: vpcID and subnetID required")
+ }
+ srcIP, err := netip.ParseAddr(instanceIP)
+ if err != nil {
+ return fmt.Errorf("EnsureSystemInstanceEgress: parse instance IP %q: %w", instanceIP, err)
+ }
+ if externalIP == "" {
+ return errors.New("EnsureSystemInstanceEgress: externalIP required")
+ }
+
+ _, nexthop, _, err := m.resolveGatewayNetwork(ctx, vpcID)
+ if err != nil {
+ return fmt.Errorf("resolve gateway network for %s: %w", vpcID, err)
+ }
+ if nexthop == "" {
+ return fmt.Errorf("EnsureSystemInstanceEgress: no gateway nexthop for %s (IGW not attached?)", vpcID)
+ }
+
+ if err := m.routes.AddSystemInstanceEgress(ctx, vpcID, policy.SystemInstanceEgressSpec{
+ SubnetID: subnetID,
+ SrcIP: srcIP,
+ Prefix: defaultRoutePrefix,
+ Nexthop: nexthop,
+ OutputPort: topology.GatewayRouterPort(vpcID),
+ ExcludeCIDRs: m.vpcExcludeCIDRs(ctx, vpcID),
+ }); err != nil {
+ return fmt.Errorf("add system instance egress reroute: %w", err)
+ }
+ if err := m.nat.AddSystemInstanceSNAT(ctx, vpcID, instanceIP+"/32", externalIP); err != nil {
+ return fmt.Errorf("add system instance egress snat: %w", err)
+ }
+ return nil
+}
+
+// RemoveSystemInstanceEgress deletes the policy + snat installed by
+// EnsureSystemInstanceEgress. Idempotent.
+func (m *igwManager) RemoveSystemInstanceEgress(ctx context.Context, vpcID, subnetID, instanceIP, externalIP string) error {
+ if vpcID == "" || subnetID == "" {
+ return errors.New("RemoveSystemInstanceEgress: vpcID and subnetID required")
+ }
+ srcIP, err := netip.ParseAddr(instanceIP)
+ if err != nil {
+ return fmt.Errorf("RemoveSystemInstanceEgress: parse instance IP %q: %w", instanceIP, err)
+ }
+ var firstErr error
+ if err := m.routes.DeleteSystemInstanceEgress(ctx, vpcID, subnetID, srcIP, defaultRoutePrefix, m.vpcExcludeCIDRs(ctx, vpcID)); err != nil {
+ firstErr = fmt.Errorf("delete system instance egress reroute: %w", err)
+ }
+ if err := m.nat.DeleteSystemInstanceSNAT(ctx, vpcID, instanceIP+"/32"); err != nil && firstErr == nil {
+ firstErr = fmt.Errorf("delete system instance egress snat: %w", err)
+ }
+ return firstErr
+}
+
// linkLocalCIDR / multicastCIDR are appended to drop-policy excludes so
-// link-local (DHCP, metadata, 169.254.169.254) and multicast destinations
-// are not killed by the per-subnet gate.
+// link-local (DHCP and friends — traffic that should never egress the gateway)
+// and multicast destinations are not killed by the per-subnet gate.
var (
- linkLocalCIDR = netip.MustParsePrefix("169.254.0.0/16")
- multicastCIDR = netip.MustParsePrefix("224.0.0.0/4")
+ linkLocalCIDR = netip.MustParsePrefix("169.254.0.0/16")
+ multicastCIDR = netip.MustParsePrefix("224.0.0.0/4")
+ defaultRoutePrefix = netip.MustParsePrefix("0.0.0.0/0")
)
// dropExcludeCIDRs returns the exclusion list for a per-subnet drop policy:
-// VPC CIDR (if discoverable) plus link-local and multicast. Reroute policies
-// only need the VPC CIDR exclusion because their match scope is narrower; the
-// drop policy is a default-deny within the subnet's egress so it must spare
-// any class the platform legitimately uses outside the VPC.
+// VPC CIDR (if discoverable) plus link-local and multicast. The drop policy is
+// a default-deny within the subnet's egress so it must spare any class the
+// platform legitimately uses outside the VPC.
func (m *igwManager) dropExcludeCIDRs(ctx context.Context, vpcID string) []netip.Prefix {
excludes := []netip.Prefix{linkLocalCIDR, multicastCIDR}
if vpc := m.vpcExcludeCIDRs(ctx, vpcID); len(vpc) > 0 {
@@ -377,6 +448,18 @@ func (m *igwManager) dropExcludeCIDRs(ctx context.Context, vpcID string) []netip
return excludes
}
+// rerouteExcludeCIDRs returns the exclusion list for a per-subnet egress
+// reroute policy: VPC CIDR (if discoverable) plus link-local. The reroute
+// matches 0.0.0.0/0 — the widest possible scope — so it would otherwise divert
+// link-local (169.254.0.0/16) out the gateway, where it has no business going.
+// Multicast is not excluded here (unlike the drop policy): a reroute only
+// diverts traffic that already has somewhere to go, so leaking multicast to the
+// gateway is harmless, whereas the drop policy would kill it outright.
+func (m *igwManager) rerouteExcludeCIDRs(ctx context.Context, vpcID string) []netip.Prefix {
+ excludes := m.vpcExcludeCIDRs(ctx, vpcID)
+ return append(excludes, linkLocalCIDR)
+}
+
// vpcExcludeCIDRs fetches the VPC's primary CIDR from the LR ExternalIDs
// label so per-subnet egress reroute policies skip in-VPC destinations.
// Returns nil on lookup failure or unset label — caller installs a policy
diff --git a/spinifex/network/external/igw_test.go b/spinifex/network/external/igw_test.go
index 1a6802ded..1e46e85d9 100644
--- a/spinifex/network/external/igw_test.go
+++ b/spinifex/network/external/igw_test.go
@@ -375,6 +375,114 @@ func TestRemoveNATGatewaySubnetEgress_PropagatesDeleteError(t *testing.T) {
require.Error(t, err)
}
+// TestEnsureSystemInstanceEgress installs the /32 reroute plus the snat-only
+// NAT, and asserts no inbound dnat_and_snat row exists (egress-only).
+func TestEnsureSystemInstanceEgress(t *testing.T) {
+ ctx := context.Background()
+ m := mock.New()
+ seedVPCRouter(t, m, "vpc-1", "10.0.0.0/16")
+ pool := &ExternalPoolConfig{Name: "p", Gateway: "192.168.1.1", PrefixLen: 24}
+ mgr, _ := newTestIGWManager(t, m, policy.NATModeDistributed, pool, LinkLocalAllocator{}, []string{"chassis-a"})
+
+ require.NoError(t, mgr.AttachIGW(ctx, IGWSpec{VPCID: "vpc-1", InternetGatewayID: "igw-1"}))
+ require.NoError(t, mgr.EnsureSystemInstanceEgress(ctx, "vpc-1", "subnet-k3s", "10.0.4.10", "203.0.113.7"))
+
+ policies, err := m.ListLogicalRouterPolicies(ctx, topology.VPCRouter("vpc-1"))
+ require.NoError(t, err)
+ require.Len(t, policies, 1)
+ assert.Equal(t, policy.SystemInstanceEgressPriority, policies[0].Priority)
+ assert.Contains(t, policies[0].Match, "ip4.src == 10.0.4.10/32")
+
+ var snat, dnat *nbdb.NAT
+ for _, n := range m.NATs {
+ switch n.Type {
+ case "snat":
+ snat = n
+ case "dnat_and_snat":
+ dnat = n
+ }
+ }
+ require.NotNil(t, snat, "egress snat must be installed")
+ assert.Equal(t, "203.0.113.7", snat.ExternalIP)
+ assert.Equal(t, "10.0.4.10/32", snat.LogicalIP)
+ assert.Nil(t, dnat, "egress-only: no inbound dnat_and_snat may be installed")
+}
+
+// TestEnsureSystemInstanceEgress_IsIdempotent re-runs install; row counts stay 1.
+func TestEnsureSystemInstanceEgress_IsIdempotent(t *testing.T) {
+ ctx := context.Background()
+ m := mock.New()
+ seedVPCRouter(t, m, "vpc-1", "10.0.0.0/16")
+ pool := &ExternalPoolConfig{Name: "p", Gateway: "192.168.1.1", PrefixLen: 24}
+ mgr, _ := newTestIGWManager(t, m, policy.NATModeDistributed, pool, LinkLocalAllocator{}, nil)
+
+ require.NoError(t, mgr.AttachIGW(ctx, IGWSpec{VPCID: "vpc-1", InternetGatewayID: "igw-1"}))
+ require.NoError(t, mgr.EnsureSystemInstanceEgress(ctx, "vpc-1", "subnet-k3s", "10.0.4.10", "203.0.113.7"))
+ require.NoError(t, mgr.EnsureSystemInstanceEgress(ctx, "vpc-1", "subnet-k3s", "10.0.4.10", "203.0.113.7"))
+
+ policies, err := m.ListLogicalRouterPolicies(ctx, topology.VPCRouter("vpc-1"))
+ require.NoError(t, err)
+ assert.Len(t, policies, 1)
+ snatCount := 0
+ for _, n := range m.NATs {
+ if n.Type == "snat" {
+ snatCount++
+ }
+ }
+ assert.Equal(t, 1, snatCount)
+}
+
+// TestRemoveSystemInstanceEgress tears down both the reroute and the snat and
+// is idempotent on a second call.
+func TestRemoveSystemInstanceEgress(t *testing.T) {
+ ctx := context.Background()
+ m := mock.New()
+ seedVPCRouter(t, m, "vpc-1", "10.0.0.0/16")
+ pool := &ExternalPoolConfig{Name: "p", Gateway: "192.168.1.1", PrefixLen: 24}
+ mgr, _ := newTestIGWManager(t, m, policy.NATModeDistributed, pool, LinkLocalAllocator{}, nil)
+
+ require.NoError(t, mgr.AttachIGW(ctx, IGWSpec{VPCID: "vpc-1", InternetGatewayID: "igw-1"}))
+ require.NoError(t, mgr.EnsureSystemInstanceEgress(ctx, "vpc-1", "subnet-k3s", "10.0.4.10", "203.0.113.7"))
+ require.NoError(t, mgr.RemoveSystemInstanceEgress(ctx, "vpc-1", "subnet-k3s", "10.0.4.10", "203.0.113.7"))
+
+ policies, err := m.ListLogicalRouterPolicies(ctx, topology.VPCRouter("vpc-1"))
+ require.NoError(t, err)
+ assert.Empty(t, policies)
+ for _, n := range m.NATs {
+ assert.NotEqual(t, "snat", n.Type, "snat must be removed")
+ }
+
+ require.NoError(t, mgr.RemoveSystemInstanceEgress(ctx, "vpc-1", "subnet-k3s", "10.0.4.10", "203.0.113.7"))
+}
+
+// TestSystemInstanceEgress_RejectsBadArgs guards validation for malformed events.
+func TestSystemInstanceEgress_RejectsBadArgs(t *testing.T) {
+ ctx := context.Background()
+ m := mock.New()
+ seedVPCRouter(t, m, "vpc-1", "10.0.0.0/16")
+ pool := &ExternalPoolConfig{Name: "p", Gateway: "192.168.1.1", PrefixLen: 24}
+ mgr, _ := newTestIGWManager(t, m, policy.NATModeDistributed, pool, LinkLocalAllocator{}, nil)
+ require.NoError(t, mgr.AttachIGW(ctx, IGWSpec{VPCID: "vpc-1", InternetGatewayID: "igw-1"}))
+
+ require.Error(t, mgr.EnsureSystemInstanceEgress(ctx, "", "subnet-k3s", "10.0.4.10", "203.0.113.7"))
+ require.Error(t, mgr.EnsureSystemInstanceEgress(ctx, "vpc-1", "", "10.0.4.10", "203.0.113.7"))
+ require.Error(t, mgr.EnsureSystemInstanceEgress(ctx, "vpc-1", "subnet-k3s", "not-an-ip", "203.0.113.7"))
+ require.Error(t, mgr.EnsureSystemInstanceEgress(ctx, "vpc-1", "subnet-k3s", "10.0.4.10", ""))
+}
+
+// TestEnsureSystemInstanceEgress_RequiresAttachedIGW asserts a clear error when
+// no gateway nexthop exists — without it the reroute would be unreachable.
+func TestEnsureSystemInstanceEgress_RequiresAttachedIGW(t *testing.T) {
+ ctx := context.Background()
+ m := mock.New()
+ seedVPCRouter(t, m, "vpc-1", "10.0.0.0/16")
+ pool := &ExternalPoolConfig{Name: "p", PrefixLen: 24} // no Gateway → no centralized nexthop
+ mgr, _ := newTestIGWManager(t, m, policy.NATModeCentralized, pool, failingAllocator{}, nil)
+
+ err := mgr.EnsureSystemInstanceEgress(ctx, "vpc-1", "subnet-k3s", "10.0.4.10", "203.0.113.7")
+ require.Error(t, err)
+}
+
// TestRemoveSubnetEgress_RejectsEmptyArgs covers the IGW-priority sibling of
// the NATGW empty-arg guard. Both vpcID and subnetID are mandatory.
func TestRemoveSubnetEgress_RejectsEmptyArgs(t *testing.T) {
diff --git a/spinifex/network/external/imds_datapath_contract_test.go b/spinifex/network/external/imds_datapath_contract_test.go
new file mode 100644
index 000000000..d126cfdb1
--- /dev/null
+++ b/spinifex/network/external/imds_datapath_contract_test.go
@@ -0,0 +1,246 @@
+package external
+
+import (
+ "context"
+ "net/netip"
+ "sort"
+ "strings"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+
+ handlers_imds "github.com/mulgadc/spinifex/spinifex/handlers/imds"
+ "github.com/mulgadc/spinifex/spinifex/network/ovn/mock"
+ "github.com/mulgadc/spinifex/spinifex/network/policy"
+ "github.com/mulgadc/spinifex/spinifex/network/topology"
+)
+
+// IMDS datapath contract — desk-speed oracle.
+//
+// Every IMDS datapath bug in the v1 grind (the egress-reroute hijack of the
+// /32, the multi-hop reply break) lived inside the VPC logical-router pipeline.
+// The L2 datapath takes IMDS off the router entirely: the localport that claims
+// 169.254.169.254 sits directly on the guest's subnet switch, so the request is
+// answered over a single L2 hop and never enters lr_in_*. This test asserts that
+// structural property — the localport is on the subnet LS and NO IMDS object
+// (LRP, /32 route, dedicated switch) exists on the VPC router — for two VPCs that
+// share the *identical* CIDR (the overlapping-CIDR case per-subnet identity is
+// built for).
+//
+// The LR-pipeline simulator below is retained for TestIMDSDatapathContract_OracleHasTeeth,
+// which proves a broad lr_in_policy reroute WOULD have hijacked IMDS had it ever
+// been routed — documenting exactly why moving IMDS to L2 removes the bug class.
+
+const imdsContractCIDR = "10.211.0.0/16"
+
+func TestIMDSDatapathContract_AnsweredAtL2_OverlappingCIDRs(t *testing.T) {
+ ctx := context.Background()
+ m := mock.New()
+
+ // Two VPCs on the IDENTICAL CIDR, each with internet egress (the broad
+ // 0.0.0.0/0 reroute + drop that historically threatened IMDS) and a subnet
+ // switch carrying the IMDS localport.
+ vpcs := []struct{ vpcID, subnetID string }{
+ {"vpc-aaaa1111", "subnet-aaaa1111"},
+ {"vpc-bbbb2222", "subnet-bbbb2222"},
+ }
+
+ imdsMgr, _ := newIMDSManager(t, m)
+ igwMgr, _ := newTestIGWManager(t, m, policy.NATModeDistributed,
+ &ExternalPoolConfig{Name: "p", Gateway: "192.168.1.1", PrefixLen: 24},
+ LinkLocalAllocator{}, []string{"chassis-a"})
+
+ defaultRoute := netip.MustParsePrefix("0.0.0.0/0")
+
+ for _, v := range vpcs {
+ seedVPCRouter(t, m, v.vpcID, imdsContractCIDR)
+ seedSubnetSwitch(t, m, v.subnetID)
+ require.NoError(t, igwMgr.AttachIGW(ctx, IGWSpec{VPCID: v.vpcID, InternetGatewayID: "igw-" + v.vpcID}))
+ // The broad 0.0.0.0/0 egress reroute + drop — the pipeline stages that
+ // hijacked / killed the IMDS /32 in v1 before #19 excluded link-local.
+ require.NoError(t, igwMgr.EnsureSubnetEgress(ctx, v.vpcID, v.subnetID, defaultRoute))
+ require.NoError(t, igwMgr.EnsureSubnetEgressDrop(ctx, v.vpcID, v.subnetID, defaultRoute))
+ // The IMDS localport on the subnet switch.
+ _, err := imdsMgr.EnsureForSubnet(ctx, v.subnetID, v.vpcID, netip.MustParsePrefix(imdsContractCIDR))
+ require.NoError(t, err)
+ }
+
+ for _, v := range vpcs {
+ assertIMDSAnsweredAtL2(t, m, v.vpcID, v.subnetID)
+ }
+}
+
+// TestIMDSDatapathContract_OracleHasTeeth proves the LR-pipeline simulator
+// actually detects the regression it documents: a 0.0.0.0/0 egress reroute that
+// does NOT exclude link-local (the pre-#19 bug) must be reported as diverting
+// IMDS. Under L2 this can no longer reach IMDS — but the simulator keeps the
+// historical hazard legible and the oracle non-vacuous.
+func TestIMDSDatapathContract_OracleHasTeeth(t *testing.T) {
+ ctx := context.Background()
+ m := mock.New()
+ const vpcID, subnetID = "vpc-cccc3333", "subnet-cccc3333"
+ seedVPCRouter(t, m, vpcID, imdsContractCIDR)
+
+ rm := policy.NewRouteManager(m)
+ gw := "169.254.0.2"
+ // Reroute excluding ONLY the VPC CIDR — link-local omitted, reproducing the
+ // bug #19 fixed. IMDS (169.254.169.254) is in 0.0.0.0/0 and not in the VPC
+ // CIDR, so this policy would hijack it had IMDS ever been routed.
+ require.NoError(t, rm.AddSubnetEgress(ctx, vpcID, policy.SubnetEgressSpec{
+ SubnetID: subnetID,
+ Prefix: netip.MustParsePrefix("0.0.0.0/0"),
+ Nexthop: gw,
+ OutputPort: topology.GatewayRouterPort(vpcID),
+ Priority: policy.SubnetEgressPriorityIGW,
+ ExcludeCIDRs: []netip.Prefix{netip.MustParsePrefix(imdsContractCIDR)},
+ }))
+
+ res := resolveEgress(t, m, vpcID, subnetID, handlers_imds.MetaDataServerIP)
+ assert.True(t, res.diverted,
+ "simulator must detect a link-local-unaware reroute as hijacking IMDS; if this fails the oracle is blind")
+}
+
+// assertIMDSAnsweredAtL2 is the contract: 169.254.169.254 is claimed by a
+// localport on the guest's subnet switch (so it is answered over one L2 hop),
+// and NO IMDS object exists on the VPC router (so the LR pipeline cannot shadow
+// it — the entire v1 bug class is structurally impossible).
+func assertIMDSAnsweredAtL2(t *testing.T, m *mock.Client, vpcID, subnetID string) {
+ t.Helper()
+ spec := IMDSSpecForSubnet(subnetID)
+
+ lsp, err := m.GetLogicalSwitchPort(context.Background(), spec.LSPName)
+ require.NoErrorf(t, err, "IMDS contract (%s): no localport on the subnet switch %s", vpcID, spec.LSName)
+ assert.Equalf(t, "localport", lsp.Type,
+ "IMDS contract (%s): the IMDS port must be a localport so every chassis self-serves it over L2", vpcID)
+ assert.Emptyf(t, lsp.PortSecurity,
+ "IMDS contract (%s): the IMDS localport must carry no port_security (the host sources the frames)", vpcID)
+ assert.Equalf(t, []string{spec.LSPMAC + " " + handlers_imds.MetaDataServerIP}, lsp.Addresses,
+ "IMDS contract (%s): the localport must claim %s on the subnet switch", vpcID, handlers_imds.MetaDataServerIP)
+
+ // No IMDS object on the router: the request never enters lr_in_*.
+ assert.Nilf(t, findIMDSRoute(m, handlers_imds.MetaDataServerIP+"/32"),
+ "IMDS contract (%s): no %s/32 static route may exist — IMDS must not transit the VPC router",
+ vpcID, handlers_imds.MetaDataServerIP)
+ for name := range m.RouterPorts {
+ assert.Falsef(t, strings.HasPrefix(name, "imds-"),
+ "IMDS contract (%s): found IMDS LRP %q — IMDS must not transit the VPC router", vpcID, name)
+ }
+}
+
+// --- minimal OVN LR ingress-pipeline model (used by OracleHasTeeth) -----------
+
+type egressResult struct {
+ diverted bool // a reroute lr_in_policy matched
+ dropped bool // a drop lr_in_policy matched
+ outputPort string // lr_in_ip_routing winner's output port (empty if a policy already decided)
+ routePrefix string // lr_in_ip_routing winner's prefix
+}
+
+// resolveEgress simulates the VPC LR ingress pipeline for a packet arriving on
+// the subnet LRP destined to dstIP.
+func resolveEgress(t *testing.T, m *mock.Client, vpcID, subnetID, dstIP string) egressResult {
+ t.Helper()
+ return resolveEgressFrom(t, m, vpcID, topology.SubnetRouterPort(subnetID), dstIP)
+}
+
+// resolveEgressFrom models OVN's two ingress stages in order:
+//
+// lr_in_policy — Logical_Router_Policy, highest Priority first, first
+// applicable wins (reroute => diverted, drop => dropped).
+// lr_in_ip_routing — longest-prefix-match over static routes.
+func resolveEgressFrom(t *testing.T, m *mock.Client, vpcID, inport, dstIP string) egressResult {
+ t.Helper()
+ dst, err := netip.ParseAddr(dstIP)
+ require.NoError(t, err)
+ router := topology.VPCRouter(vpcID)
+
+ // Stage 1: lr_in_policy.
+ policies, err := m.ListLogicalRouterPolicies(context.Background(), router)
+ require.NoError(t, err)
+ sort.SliceStable(policies, func(i, j int) bool { return policies[i].Priority > policies[j].Priority })
+ for _, p := range policies {
+ mt, ok := parsePolicyMatch(p.Match)
+ if !ok || !mt.applies(inport, dst) {
+ continue
+ }
+ switch p.Action {
+ case "reroute":
+ return egressResult{diverted: true}
+ case "drop":
+ return egressResult{dropped: true}
+ case "allow":
+ // falls through to routing
+ }
+ break
+ }
+
+ // Stage 2: lr_in_ip_routing (longest-prefix-match).
+ lr, err := m.GetLogicalRouter(context.Background(), router)
+ require.NoError(t, err)
+ var best egressResult
+ bestBits := -1
+ for _, uuid := range lr.StaticRoutes {
+ r := m.StaticRoutes[uuid]
+ if r == nil {
+ continue
+ }
+ pfx, err := netip.ParsePrefix(r.IPPrefix)
+ if err != nil || !pfx.Contains(dst) {
+ continue
+ }
+ if pfx.Bits() > bestBits {
+ bestBits = pfx.Bits()
+ out := ""
+ if r.OutputPort != nil {
+ out = *r.OutputPort
+ }
+ best = egressResult{outputPort: out, routePrefix: r.IPPrefix}
+ }
+ }
+ return best
+}
+
+type policyMatch struct {
+ inport string // "" => no inport constraint
+ dst netip.Prefix
+ excludes []netip.Prefix
+}
+
+func (mt policyMatch) applies(inport string, dst netip.Addr) bool {
+ if mt.inport != "" && mt.inport != inport {
+ return false
+ }
+ if mt.dst.IsValid() && !mt.dst.Contains(dst) {
+ return false
+ }
+ for _, ex := range mt.excludes {
+ if ex.Contains(dst) {
+ return false
+ }
+ }
+ return true
+}
+
+// parsePolicyMatch parses the match strings produced by subnetEgressMatch:
+//
+// inport == "rtr-subnet-x" && ip4.dst == 0.0.0.0/0 && ip4.dst != 10.0.0.0/16 && ip4.dst != 169.254.0.0/16
+func parsePolicyMatch(match string) (policyMatch, bool) {
+ var mt policyMatch
+ for clause := range strings.SplitSeq(match, "&&") {
+ clause = strings.TrimSpace(clause)
+ switch {
+ case strings.HasPrefix(clause, "inport =="):
+ mt.inport = strings.Trim(strings.TrimSpace(strings.TrimPrefix(clause, "inport ==")), `"`)
+ case strings.HasPrefix(clause, "ip4.dst =="):
+ if p, err := netip.ParsePrefix(strings.TrimSpace(strings.TrimPrefix(clause, "ip4.dst =="))); err == nil {
+ mt.dst = p
+ }
+ case strings.HasPrefix(clause, "ip4.dst !="):
+ if p, err := netip.ParsePrefix(strings.TrimSpace(strings.TrimPrefix(clause, "ip4.dst !="))); err == nil {
+ mt.excludes = append(mt.excludes, p)
+ }
+ }
+ }
+ return mt, true
+}
diff --git a/spinifex/network/external/imds_topology.go b/spinifex/network/external/imds_topology.go
new file mode 100644
index 000000000..7d65af0a1
--- /dev/null
+++ b/spinifex/network/external/imds_topology.go
@@ -0,0 +1,157 @@
+package external
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "log/slog"
+ "net/netip"
+ "time"
+
+ handlers_imds "github.com/mulgadc/spinifex/spinifex/handlers/imds"
+ "github.com/mulgadc/spinifex/spinifex/network/host"
+ "github.com/mulgadc/spinifex/spinifex/network/ovn"
+ "github.com/mulgadc/spinifex/spinifex/network/ovn/nbdb"
+ "github.com/mulgadc/spinifex/spinifex/network/topology"
+ "github.com/mulgadc/spinifex/spinifex/utils"
+)
+
+// IMDSSubnetSpec is the resolved set of OVN + host names for a subnet's IMDS
+// localport. Every field is a deterministic function of the subnet ID, so
+// callers can rebuild it from the subnet ID alone (BindManager does this per
+// bucket entry).
+type IMDSSubnetSpec struct {
+ SubnetID string
+ ShortSubnetID string // last 8 chars of SubnetID
+ LSName string // subnet-{subnetID} — the guest's own switch
+ LSPName string // imds-port-{subnetID} (localport; host veth binds here)
+ LSPMAC string // 02:.. — HashMAC("imds-"+subnetID)
+ OVSEndName string // imds-o-{shortSubnetID}
+ HostEndName string // imds-h-{shortSubnetID}
+}
+
+// IMDSTopologyManager installs and removes the per-subnet localport that claims
+// 169.254.169.254 directly on the guest's subnet switch, so metadata is reached
+// over a single L2 hop on the guest's own broadcast domain — no router in the
+// path. Idempotent.
+type IMDSTopologyManager interface {
+ EnsureForSubnet(ctx context.Context, subnetID, vpcID string, cidr netip.Prefix) (IMDSSubnetSpec, error)
+ RemoveForSubnet(ctx context.Context, subnetID string) error
+}
+
+type imdsTopologyManager struct {
+ ovn ovn.Client
+ store *handlers_imds.VethStore
+}
+
+var _ IMDSTopologyManager = (*imdsTopologyManager)(nil)
+
+// NewIMDSTopologyManager constructs an IMDSTopologyManager. store is the
+// subnet-veth KV bucket the installer publishes records to.
+func NewIMDSTopologyManager(client ovn.Client, store *handlers_imds.VethStore) (IMDSTopologyManager, error) {
+ switch {
+ case client == nil:
+ return nil, errors.New("IMDSTopologyManager: OVN client required")
+ case store == nil:
+ return nil, errors.New("IMDSTopologyManager: VethStore required")
+ }
+ return &imdsTopologyManager{ovn: client, store: store}, nil
+}
+
+// IMDSSpecForSubnet returns the deterministic name/MAC set for a subnet's IMDS
+// localport without touching OVN or the bucket.
+func IMDSSpecForSubnet(subnetID string) IMDSSubnetSpec {
+ return IMDSSubnetSpec{
+ SubnetID: subnetID,
+ ShortSubnetID: host.IMDSShortSubnetID(subnetID),
+ LSName: topology.SubnetSwitch(subnetID),
+ LSPName: topology.IMDSPort(subnetID),
+ LSPMAC: utils.HashMAC("imds-" + subnetID),
+ OVSEndName: host.IMDSOVSPortName(subnetID),
+ HostEndName: host.IMDSHostVethName(subnetID),
+ }
+}
+
+// EnsureForSubnet installs the IMDS localport on subnet-{subnetID} and publishes
+// the subnet-veth record. Idempotent: a present record short-circuits, and the
+// localport is created only when absent so a lost record still converges. The
+// subnet switch must already exist (the subnet lifecycle owns it). cidr is
+// persisted in the record so the host reply path resolves the guest on-link;
+// vpcID is persisted so the IMDS handler can resolve the subnet→VPC mapping the
+// eni-by-vpc-ip index needs (the index stays keyed vpcID/ip).
+func (m *imdsTopologyManager) EnsureForSubnet(ctx context.Context, subnetID, vpcID string, cidr netip.Prefix) (IMDSSubnetSpec, error) {
+ if subnetID == "" {
+ return IMDSSubnetSpec{}, errors.New("EnsureForSubnet: subnetID required")
+ }
+ if vpcID == "" {
+ return IMDSSubnetSpec{}, errors.New("EnsureForSubnet: vpcID required")
+ }
+ if !cidr.IsValid() {
+ return IMDSSubnetSpec{}, errors.New("EnsureForSubnet: cidr required")
+ }
+ spec := IMDSSpecForSubnet(subnetID)
+
+ // The bucket record is the publish gate: present means the localport is
+ // installed and BindManagers have (or will) materialise host state.
+ rec, err := m.store.Get(subnetID)
+ if err != nil {
+ return IMDSSubnetSpec{}, fmt.Errorf("read imds veth record for %s: %w", subnetID, err)
+ }
+ if rec != nil {
+ return spec, nil
+ }
+
+ extIDs := map[string]string{"spinifex:subnet_id": subnetID, "spinifex:role": "imds"}
+
+ // Host-owned localport on the guest's subnet switch: no port_security (the
+ // host sources 169.254.169.254 frames, and port_security would make
+ // ovn-controller drop reply frames from the host veth's MAC) and no SG
+ // membership (so the guest's SG ACLs never match it). localport binds on
+ // every chassis.
+ if _, err := m.ovn.GetLogicalSwitchPort(ctx, spec.LSPName); err != nil {
+ if cErr := m.ovn.CreateLogicalSwitchPort(ctx, spec.LSName, &nbdb.LogicalSwitchPort{
+ Name: spec.LSPName,
+ Type: "localport",
+ Addresses: []string{spec.LSPMAC + " " + handlers_imds.MetaDataServerIP},
+ ExternalIDs: extIDs,
+ }); cErr != nil {
+ return IMDSSubnetSpec{}, fmt.Errorf("create imds localport %s on %s: %w", spec.LSPName, spec.LSName, cErr)
+ }
+ }
+
+ if err := m.store.Put(handlers_imds.SubnetVethRecord{
+ SubnetID: subnetID,
+ ShortSubnetID: spec.ShortSubnetID,
+ VPCID: vpcID,
+ IMDSPortMAC: spec.LSPMAC,
+ SubnetCIDR: cidr.String(),
+ CreatedAt: time.Now().UTC().Format(time.RFC3339),
+ }); err != nil {
+ return IMDSSubnetSpec{}, fmt.Errorf("publish imds veth record for %s: %w", subnetID, err)
+ }
+
+ slog.Info("external: installed IMDS localport",
+ "subnet_id", subnetID, "subnet_switch", spec.LSName, "imds_port", spec.LSPName)
+ return spec, nil
+}
+
+// RemoveForSubnet deletes the IMDS localport and the subnet-veth record. The
+// subnet switch is left in place (the subnet lifecycle owns it). Idempotent and
+// drift-tolerant: a missing localport is logged and skipped so the record delete
+// always runs. Must run before the subnet switch goes away.
+func (m *imdsTopologyManager) RemoveForSubnet(ctx context.Context, subnetID string) error {
+ if subnetID == "" {
+ return errors.New("RemoveForSubnet: subnetID required")
+ }
+ spec := IMDSSpecForSubnet(subnetID)
+
+ if err := m.ovn.DeleteLogicalSwitchPort(ctx, spec.LSName, spec.LSPName); err != nil {
+ slog.Warn("external: delete imds localport failed", "port", spec.LSPName, "err", err)
+ }
+ if err := m.store.Delete(subnetID); err != nil {
+ return fmt.Errorf("delete imds veth record for %s: %w", subnetID, err)
+ }
+
+ slog.Info("external: removed IMDS localport", "subnet_id", subnetID)
+ return nil
+}
diff --git a/spinifex/network/external/imds_topology_test.go b/spinifex/network/external/imds_topology_test.go
new file mode 100644
index 000000000..7cfeaa5ad
--- /dev/null
+++ b/spinifex/network/external/imds_topology_test.go
@@ -0,0 +1,194 @@
+package external
+
+import (
+ "context"
+ "net/netip"
+ "slices"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+
+ handlers_imds "github.com/mulgadc/spinifex/spinifex/handlers/imds"
+ "github.com/mulgadc/spinifex/spinifex/network/ovn/mock"
+ "github.com/mulgadc/spinifex/spinifex/network/ovn/nbdb"
+ "github.com/mulgadc/spinifex/spinifex/network/topology"
+ "github.com/mulgadc/spinifex/spinifex/testutil"
+)
+
+const (
+ imdsTestSubnetID = "subnet-0a1b2c3d4e5f6789"
+ imdsTestVPCID = "vpc-0a1b2c3d4e5f6789"
+ imdsTestCIDR = "10.0.1.0/24"
+)
+
+func newIMDSManager(t *testing.T, m *mock.Client) (IMDSTopologyManager, *handlers_imds.VethStore) {
+ t.Helper()
+ _, _, js := testutil.StartTestJetStream(t)
+ subnetVeth, _, err := handlers_imds.InitBuckets(js, 1)
+ require.NoError(t, err)
+ store := handlers_imds.NewVethStore(subnetVeth)
+ mgr, err := NewIMDSTopologyManager(m, store)
+ require.NoError(t, err)
+ return mgr, store
+}
+
+// seedSubnetSwitch creates the subnet logical switch the IMDS localport attaches
+// to. The subnet lifecycle owns the switch; EnsureForSubnet only adds the localport.
+func seedSubnetSwitch(t *testing.T, m *mock.Client, subnetID string) {
+ t.Helper()
+ _, err := m.EnsureLogicalSwitch(context.Background(), &nbdb.LogicalSwitch{
+ Name: topology.SubnetSwitch(subnetID),
+ ExternalIDs: map[string]string{"spinifex:subnet_id": subnetID},
+ })
+ require.NoError(t, err)
+}
+
+func findIMDSRoute(m *mock.Client, prefix string) *nbdb.LogicalRouterStaticRoute {
+ for _, r := range m.StaticRoutes {
+ if r.IPPrefix == prefix {
+ return r
+ }
+ }
+ return nil
+}
+
+func TestNewIMDSTopologyManager_RejectsMissingDeps(t *testing.T) {
+ m := mock.New()
+ _, _, js := testutil.StartTestJetStream(t)
+ subnetVeth, _, err := handlers_imds.InitBuckets(js, 1)
+ require.NoError(t, err)
+ store := handlers_imds.NewVethStore(subnetVeth)
+
+ _, err = NewIMDSTopologyManager(nil, store)
+ require.Error(t, err)
+ _, err = NewIMDSTopologyManager(m, nil)
+ require.Error(t, err)
+}
+
+func TestIMDSEnsureForSubnet_InstallsLocalportOnSubnetLS(t *testing.T) {
+ ctx := context.Background()
+ m := mock.New()
+ seedSubnetSwitch(t, m, imdsTestSubnetID)
+ mgr, store := newIMDSManager(t, m)
+
+ spec, err := mgr.EnsureForSubnet(ctx, imdsTestSubnetID, imdsTestVPCID, netip.MustParsePrefix(imdsTestCIDR))
+ require.NoError(t, err)
+
+ // Resolved names are deterministic functions of the subnet ID.
+ assert.Equal(t, "subnet-"+imdsTestSubnetID, spec.LSName)
+ assert.Equal(t, "imds-port-"+imdsTestSubnetID, spec.LSPName)
+ assert.Equal(t, "4e5f6789", spec.ShortSubnetID)
+ assert.Equal(t, "imds-o-4e5f6789", spec.OVSEndName)
+ assert.Equal(t, "imds-h-4e5f6789", spec.HostEndName)
+
+ // The localport is created directly on the guest's subnet switch — no IMDS
+ // switch, LRP, router-LSP, or static route exists.
+ assert.Len(t, m.Switches, 1, "only the subnet switch should exist; no dedicated IMDS switch")
+ assert.Empty(t, m.RouterPorts, "no IMDS LRP under the L2 datapath")
+ assert.Empty(t, m.StaticRoutes, "no 169.254.169.254/32 static route under the L2 datapath")
+ assert.Nil(t, findIMDSRoute(m, handlers_imds.MetaDataServerIP+"/32"))
+
+ // Host-owned localport: claims the MetaData IP, no port_security, no SG.
+ lsp, err := m.GetLogicalSwitchPort(ctx, spec.LSPName)
+ require.NoError(t, err)
+ assert.Equal(t, "localport", lsp.Type)
+ assert.Empty(t, lsp.PortSecurity)
+ assert.Equal(t, []string{spec.LSPMAC + " " + handlers_imds.MetaDataServerIP}, lsp.Addresses)
+
+ // ...and it is a member of the subnet switch.
+ ls, err := m.GetLogicalSwitch(ctx, spec.LSName)
+ require.NoError(t, err)
+ assert.True(t, slices.Contains(ls.Ports, lsp.UUID), "localport must be a port on the subnet switch")
+
+ // Record published to the bucket, keyed by subnet, carrying the owning VPC
+ // (the subnet→VPC static lookup the IMDS handler needs) and the CIDR.
+ rec, err := store.Get(imdsTestSubnetID)
+ require.NoError(t, err)
+ require.NotNil(t, rec)
+ assert.Equal(t, spec.ShortSubnetID, rec.ShortSubnetID)
+ assert.Equal(t, imdsTestVPCID, rec.VPCID)
+ assert.Equal(t, spec.LSPMAC, rec.IMDSPortMAC)
+ assert.Equal(t, imdsTestCIDR, rec.SubnetCIDR)
+ assert.NotEmpty(t, rec.CreatedAt)
+}
+
+func TestIMDSEnsureForSubnet_RequiresCIDR(t *testing.T) {
+ ctx := context.Background()
+ m := mock.New()
+ seedSubnetSwitch(t, m, imdsTestSubnetID)
+ mgr, _ := newIMDSManager(t, m)
+
+ _, err := mgr.EnsureForSubnet(ctx, imdsTestSubnetID, imdsTestVPCID, netip.Prefix{})
+ require.Error(t, err)
+}
+
+func TestIMDSEnsureForSubnet_RequiresVPCID(t *testing.T) {
+ ctx := context.Background()
+ m := mock.New()
+ seedSubnetSwitch(t, m, imdsTestSubnetID)
+ mgr, _ := newIMDSManager(t, m)
+
+ // vpcID is persisted in the record for the IMDS handler's subnet→VPC lookup;
+ // without it the eni-by-vpc-ip index can't be keyed, so install must fail loud.
+ _, err := mgr.EnsureForSubnet(ctx, imdsTestSubnetID, "", netip.MustParsePrefix(imdsTestCIDR))
+ require.Error(t, err)
+}
+
+func TestIMDSEnsureForSubnet_Idempotent(t *testing.T) {
+ ctx := context.Background()
+ m := mock.New()
+ seedSubnetSwitch(t, m, "subnet-1")
+ mgr, _ := newIMDSManager(t, m)
+
+ for range 3 {
+ _, err := mgr.EnsureForSubnet(ctx, "subnet-1", "vpc-1", netip.MustParsePrefix(imdsTestCIDR))
+ require.NoError(t, err)
+ }
+
+ // Exactly one switch (the subnet) and exactly one localport.
+ assert.Len(t, m.Switches, 1)
+ assert.Len(t, m.Ports, 1)
+ lsp, err := m.GetLogicalSwitchPort(ctx, "imds-port-subnet-1")
+ require.NoError(t, err)
+ assert.Equal(t, "localport", lsp.Type)
+}
+
+func TestIMDSEnsureForSubnet_MissingSwitchErrors(t *testing.T) {
+ ctx := context.Background()
+ m := mock.New()
+ // No subnet switch seeded — the localport create has nowhere to land.
+ mgr, store := newIMDSManager(t, m)
+
+ _, err := mgr.EnsureForSubnet(ctx, "subnet-missing", "vpc-1", netip.MustParsePrefix(imdsTestCIDR))
+ require.Error(t, err)
+
+ // No record published when install fails.
+ rec, err := store.Get("subnet-missing")
+ require.NoError(t, err)
+ assert.Nil(t, rec)
+}
+
+func TestIMDSRemoveForSubnet_TearsDownLocalportKeepsSwitch(t *testing.T) {
+ ctx := context.Background()
+ m := mock.New()
+ seedSubnetSwitch(t, m, "subnet-1")
+ mgr, store := newIMDSManager(t, m)
+
+ spec, err := mgr.EnsureForSubnet(ctx, "subnet-1", "vpc-1", netip.MustParsePrefix(imdsTestCIDR))
+ require.NoError(t, err)
+
+ require.NoError(t, mgr.RemoveForSubnet(ctx, "subnet-1"))
+
+ // Localport and record gone; the subnet switch survives (subnet lifecycle owns it).
+ _, err = m.GetLogicalSwitchPort(ctx, spec.LSPName)
+ require.Error(t, err)
+ _, err = m.GetLogicalSwitch(ctx, spec.LSName)
+ require.NoError(t, err)
+ rec, err := store.Get("subnet-1")
+ require.NoError(t, err)
+ assert.Nil(t, rec)
+
+ // Second remove is a no-op.
+ require.NoError(t, mgr.RemoveForSubnet(ctx, "subnet-1"))
+}
diff --git a/spinifex/network/external/invariants_test.go b/spinifex/network/external/invariants_test.go
new file mode 100644
index 000000000..e0f89001e
--- /dev/null
+++ b/spinifex/network/external/invariants_test.go
@@ -0,0 +1,110 @@
+package external
+
+import (
+ "context"
+ "fmt"
+ "net/netip"
+ "slices"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+
+ handlers_imds "github.com/mulgadc/spinifex/spinifex/handlers/imds"
+ "github.com/mulgadc/spinifex/spinifex/network/ovn/mock"
+ "github.com/mulgadc/spinifex/spinifex/network/policy"
+ "github.com/mulgadc/spinifex/spinifex/network/topology"
+)
+
+// IMDS-datapath invariants. These guard the OVN topology
+// the IMDS handler trusts as a security + availability boundary. A regression
+// in either is a privilege-escalation or single-point-of-failure vector, so the
+// failure messages quote the offending design clause verbatim.
+
+// TestI2_IMDSLocalportOnSubnetSwitchNoPortSecurity asserts the host-owned
+// imds-port-{subnetID} LSP is a localport on the guest's subnet switch with no
+// port_security. The host — not a guest — sources 169.254.169.254 frames on this
+// port, so port_security would be actively harmful (it pins the allowed src MAC
+// to the LSP's claimed MAC, and ovn-controller would drop reply frames egressing
+// from the host veth's MAC). A regular (non-localport) LSP would bind to a single
+// chassis, defeating the per-chassis self-serve design. Living on the subnet
+// switch is the L2 datapath itself: the guest reaches metadata in one hop on its
+// own broadcast domain, with no router in the path to shadow it.
+func TestI2_IMDSLocalportOnSubnetSwitchNoPortSecurity(t *testing.T) {
+ ctx := context.Background()
+ m := mock.New()
+ seedSubnetSwitch(t, m, imdsTestSubnetID)
+ mgr, _ := newIMDSManager(t, m)
+
+ spec, err := mgr.EnsureForSubnet(ctx, imdsTestSubnetID, imdsTestVPCID, netip.MustParsePrefix(imdsTestCIDR))
+ require.NoError(t, err)
+
+ lsp, err := m.GetLogicalSwitchPort(ctx, spec.LSPName)
+ require.NoError(t, err)
+
+ if lsp.Type != "localport" {
+ t.Errorf("imds LSP %s Type = %q, want \"localport\": a regular LSP binds to "+
+ "one chassis only, forcing IMDS through Geneve and creating a single point "+
+ "of failure per subnet", spec.LSPName, lsp.Type)
+ }
+ if len(lsp.PortSecurity) != 0 {
+ t.Errorf("imds LSP %s PortSecurity = %v, want empty: port_security set on the "+
+ "host-owned LSP would cause ovn-controller to drop reply frames from the "+
+ "host veth's MAC", spec.LSPName, lsp.PortSecurity)
+ }
+ assert.Equal(t, []string{spec.LSPMAC + " " + handlers_imds.MetaDataServerIP}, lsp.Addresses,
+ "the localport must claim %s on the subnet switch", handlers_imds.MetaDataServerIP)
+
+ // It must be a port on the subnet switch — that is what makes the datapath L2.
+ ls, err := m.GetLogicalSwitch(ctx, spec.LSName)
+ require.NoError(t, err)
+ if !slices.Contains(ls.Ports, lsp.UUID) {
+ t.Errorf("imds localport %s is not a port on subnet switch %s: the L2 datapath "+
+ "requires the localport on the guest's own broadcast domain, not a dedicated "+
+ "IMDS switch reached through the VPC router", spec.LSPName, spec.LSName)
+ }
+}
+
+// TestI4_SubnetEgressRerouteMustExcludeLinkLocal asserts every per-subnet
+// internet-egress reroute policy excludes 169.254.0.0/16. The reroute matches
+// 0.0.0.0/0 — the widest possible scope — so without this exclusion it would
+// SNAT-reroute link-local traffic out the WAN. Link-local is host-scoped by
+// definition and must never egress to the provider network. Both the IGW- and
+// NATGW-priority reroutes carry the gate, so both must spare link-local. A
+// drifted exclude list silently leaks 169.254.0.0/16 onto the internet.
+func TestI4_SubnetEgressRerouteMustExcludeLinkLocal(t *testing.T) {
+ linkLocal := fmt.Sprintf("ip4.dst != %s", linkLocalCIDR.String())
+
+ cases := []struct {
+ name string
+ install func(mgr IGWManager, ctx context.Context) error
+ }{
+ {"IGW", func(mgr IGWManager, ctx context.Context) error {
+ return mgr.EnsureSubnetEgress(ctx, "vpc-1", "subnet-pub", netip.MustParsePrefix("0.0.0.0/0"))
+ }},
+ {"NATGW", func(mgr IGWManager, ctx context.Context) error {
+ return mgr.EnsureNATGatewaySubnetEgress(ctx, "vpc-1", "subnet-priv", netip.MustParsePrefix("0.0.0.0/0"))
+ }},
+ }
+
+ for _, tc := range cases {
+ t.Run(tc.name, func(t *testing.T) {
+ ctx := context.Background()
+ m := mock.New()
+ seedVPCRouter(t, m, "vpc-1", "10.0.0.0/16")
+ pool := &ExternalPoolConfig{Name: "p", Gateway: "192.168.1.1", PrefixLen: 24}
+ mgr, _ := newTestIGWManager(t, m, policy.NATModeDistributed, pool, LinkLocalAllocator{}, []string{"chassis-a"})
+
+ require.NoError(t, mgr.AttachIGW(ctx, IGWSpec{VPCID: "vpc-1", InternetGatewayID: "igw-1"}))
+ require.NoError(t, tc.install(mgr, ctx))
+
+ policies, err := m.ListLogicalRouterPolicies(ctx, topology.VPCRouter("vpc-1"))
+ require.NoError(t, err)
+ require.Len(t, policies, 1)
+ assert.Contains(t, policies[0].Match, linkLocal,
+ "%s egress reroute %q does not exclude %s — it will SNAT-reroute "+
+ "link-local traffic out the WAN; link-local must never egress",
+ tc.name, policies[0].Match, linkLocalCIDR)
+ })
+ }
+}
diff --git a/spinifex/network/external/static_pool.go b/spinifex/network/external/static_pool.go
index cdcf2a513..bc00d13c8 100644
--- a/spinifex/network/external/static_pool.go
+++ b/spinifex/network/external/static_pool.go
@@ -52,6 +52,12 @@ type PoolRecord struct {
GwLrpRangeStart string `json:"gw_lrp_range_start,omitempty"`
GwLrpRangeEnd string `json:"gw_lrp_range_end,omitempty"`
Allocated map[string]ExternalIPAllocation `json:"allocated"`
+ // Cursor is the last address handed out. Allocation resumes just past it
+ // and wraps at RangeEnd, so a released IP is not reused until the cursor
+ // cycles the whole range — by which time the host ARP cache for the prior
+ // owner has decayed and its OVN gateway state is gone. Empty on a fresh
+ // pool (older records unmarshal with no cursor) → first cycle is lowest-free.
+ Cursor string `json:"cursor,omitempty"`
}
// StaticPoolAllocator implements Allocator backed by a NATS JetStream KV
@@ -114,6 +120,7 @@ func (a *StaticPoolAllocator) Allocate(_ context.Context, req AllocateRequest) (
ENIId: req.ENIID,
InstanceId: req.InstanceID,
}
+ record.Cursor = ip
data, err := json.Marshal(record)
if err != nil {
@@ -274,8 +281,12 @@ func (a *StaticPoolAllocator) getRecord(poolName string) (*PoolRecord, uint64, e
}
// nextAvailableIP picks the next unallocated address in the pool's range,
-// skipping addresses inside [GwLrpRangeStart, GwLrpRangeEnd]. Carved from
-// the pre-Q1 handlers/ec2/vpc.nextAvailableExternalIP.
+// skipping addresses inside [GwLrpRangeStart, GwLrpRangeEnd]. Allocation
+// resumes one past record.Cursor and wraps at RangeEnd so a just-released IP
+// is not handed straight back — it only becomes a candidate again after the
+// cursor cycles the whole range. An empty cursor (fresh pool, or an older KV
+// record) starts at RangeStart, preserving lowest-free order for the first
+// cycle. Carved from the pre-Q1 handlers/ec2/vpc.nextAvailableExternalIP.
func nextAvailableIP(record *PoolRecord) (string, error) {
startIP := net.ParseIP(record.RangeStart).To4()
endIP := net.ParseIP(record.RangeEnd).To4()
@@ -297,16 +308,28 @@ func nextAvailableIP(record *PoolRecord) (string, error) {
startInt := ipv4ToUint32(startIP)
endInt := ipv4ToUint32(endIP)
+ if endInt < startInt {
+ return "", fmt.Errorf("invalid IP range: %s - %s", record.RangeStart, record.RangeEnd)
+ }
+ total := endInt - startInt + 1
- for i := startInt; ; i++ {
- if !hasGwLrp || i < gwLrpStart || i > gwLrpEnd {
- candidate := uint32ToIPv4(i).String()
- if _, taken := record.Allocated[candidate]; !taken {
- return candidate, nil
- }
+ // Resume just past the cursor; empty/out-of-range cursor → start of range.
+ offset := uint32(0)
+ if c := net.ParseIP(record.Cursor).To4(); c != nil {
+ ci := ipv4ToUint32(c)
+ if ci >= startInt && ci <= endInt {
+ offset = ((ci - startInt) + 1) % total
}
- if i == endInt {
- break
+ }
+
+ for k := range total {
+ i := startInt + (offset+k)%total
+ if hasGwLrp && i >= gwLrpStart && i <= gwLrpEnd {
+ continue
+ }
+ candidate := uint32ToIPv4(i).String()
+ if _, taken := record.Allocated[candidate]; !taken {
+ return candidate, nil
}
}
diff --git a/spinifex/network/external/static_pool_test.go b/spinifex/network/external/static_pool_test.go
index 2c5196c94..fd85992b8 100644
--- a/spinifex/network/external/static_pool_test.go
+++ b/spinifex/network/external/static_pool_test.go
@@ -62,20 +62,38 @@ func TestStaticPool_GatewayReserved(t *testing.T) {
assert.ErrorContains(t, err, "cannot release gateway IP")
}
-func TestStaticPool_Release(t *testing.T) {
+// TestStaticPool_NoInstantReuseAfterRelease pins the round-robin reuse policy:
+// a released IP must not be handed straight back (the recycle that left a
+// freshly-reassigned EIP unreachable from the same host — siv-246). It is only
+// reusable after the cursor cycles the range.
+func TestStaticPool_NoInstantReuseAfterRelease(t *testing.T) {
a := newStaticAllocator(t, []ExternalPoolConfig{wanPool()})
ctx := context.Background()
ip1, err := a.Allocate(ctx, AllocateRequest{PoolName: "wan", Purpose: "eni-public", ENIID: "eni-1"})
require.NoError(t, err)
+ require.Equal(t, "192.168.1.151", ip1.String())
_, err = a.Allocate(ctx, AllocateRequest{PoolName: "wan", Purpose: "eni-public", ENIID: "eni-2"})
require.NoError(t, err)
+ // Release the first IP; the very next allocation must NOT reuse it.
require.NoError(t, a.Release(ctx, "wan", ip1))
-
ip3, err := a.Allocate(ctx, AllocateRequest{PoolName: "wan", Purpose: "eni-public", ENIID: "eni-3"})
require.NoError(t, err)
- assert.Equal(t, ip1, ip3)
+ assert.NotEqual(t, ip1, ip3, "released IP must not be reused immediately")
+ assert.Equal(t, "192.168.1.153", ip3.String(), "allocation resumes past the cursor")
+
+ // The released IP becomes reusable only after the cursor wraps the range.
+ var reused bool
+ for range 10 {
+ ip, err := a.Allocate(ctx, AllocateRequest{PoolName: "wan"})
+ require.NoError(t, err)
+ if ip == ip1 {
+ reused = true
+ break
+ }
+ }
+ assert.True(t, reused, "released IP reused only after a full cycle")
}
func TestStaticPool_Exhaustion(t *testing.T) {
diff --git a/spinifex/network/host/imds_names.go b/spinifex/network/host/imds_names.go
new file mode 100644
index 000000000..fea55b4c5
--- /dev/null
+++ b/spinifex/network/host/imds_names.go
@@ -0,0 +1,36 @@
+package host
+
+// IMDS per-subnet veth naming. The names live here (not in network/topology) so
+// the IMDS BindManager can derive them without importing topology, which would
+// form an import cycle (topology imports handlers/imds for MetaDataServerIP).
+
+// IMDSShortSubnetID is the last 8 chars of the subnet ID. Linux IFNAMSIZ caps
+// interface names at 15 chars, so the IMDS veth names key off this short form.
+// Deterministic and stable: every chassis serving IMDS for subnet X derives the
+// same names.
+func IMDSShortSubnetID(subnetID string) string {
+ if len(subnetID) <= 8 {
+ return subnetID
+ }
+ return subnetID[len(subnetID)-8:]
+}
+
+// IMDSOVSPortName is the br-int side of the per-subnet IMDS veth pair, bound to the
+// imds-port LSP via external_ids:iface-id. The 7-char prefix keeps "imds-o-" + the
+// 8-char short subnet ID at exactly IFNAMSIZ-1 (15); longer fails `ip link add`.
+func IMDSOVSPortName(subnetID string) string { return "imds-o-" + IMDSShortSubnetID(subnetID) }
+
+// IMDSHostVethName is the host-end side of the per-subnet IMDS veth pair — the
+// device the IMDS listener binds to via SO_BINDTODEVICE. Same 7-char prefix
+// budget as IMDSOVSPortName: "imds-h-" + 8-char short subnet ID = 15 chars. It
+// lives inside the subnet's netns (see IMDSNetnsName), not the root netns.
+func IMDSHostVethName(subnetID string) string { return "imds-h-" + IMDSShortSubnetID(subnetID) }
+
+// IMDSNetnsName is the per-subnet network namespace the IMDS host-end veth and its
+// listener live in. The netns gives the reply path a real L3 next-hop — the host
+// end carries 169.254.169.254/30 with the subnet CIDR on-link, so the reply to the
+// guest resolves by ARP over the localport — and structurally isolates subnets
+// with overlapping CIDRs, since each netns is its own routing domain. Netns names
+// are filesystem-scoped, not IFNAMSIZ-bound, so "imds-" + 8-char short subnet ID
+// (13 chars) is comfortably within budget.
+func IMDSNetnsName(subnetID string) string { return "imds-" + IMDSShortSubnetID(subnetID) }
diff --git a/spinifex/network/host/imds_netns_unit_test.go b/spinifex/network/host/imds_netns_unit_test.go
new file mode 100644
index 000000000..47fd99cb3
--- /dev/null
+++ b/spinifex/network/host/imds_netns_unit_test.go
@@ -0,0 +1,97 @@
+package host
+
+import (
+ "os"
+ "path/filepath"
+ "runtime"
+ "strings"
+ "testing"
+)
+
+// repoRoot resolves the spinifex submodule root from this test file's location
+// (spinifex/network/host/ → three levels up) so the systemd-unit invariants can
+// read build/ artifacts regardless of the test's working directory.
+func repoRoot(t *testing.T) string {
+ t.Helper()
+ _, file, _, ok := runtime.Caller(0)
+ if !ok {
+ t.Fatal("runtime.Caller failed")
+ }
+ root := filepath.Clean(filepath.Join(filepath.Dir(file), "..", "..", ".."))
+ if _, err := os.Stat(filepath.Join(root, "build", "systemd")); err != nil {
+ t.Fatalf("could not locate build/systemd from %s: %v", root, err)
+ }
+ return root
+}
+
+func readUnit(t *testing.T, root, rel string) string {
+ t.Helper()
+ b, err := os.ReadFile(filepath.Join(root, rel))
+ if err != nil {
+ t.Fatalf("read %s: %v", rel, err)
+ }
+ return string(b)
+}
+
+// TestIMDS_NetnsHostMountNSInvariant pins the host-mount-namespace contract for
+// per-VPC IMDS netns (see spinifex-vpcd.service + bead js-133): vpcd creates the
+// netns via `ip netns add` and binds the listener via in-process setns, so the
+// /run/netns/ bind-mount must live in the HOST mount namespace — host-visible
+// for diagnostics and durable across a vpcd restart. Any filesystem-sandbox
+// directive forks a PRIVATE mount ns that traps the bind-mount inside vpcd, so the
+// host sees only a stale handle that fails setns(2) EINVAL (CI runs 26920490454,
+// 26922587471). The earlier MountFlags=shared + spinifex-netns.service shared-mount
+// workaround was proven insufficient and removed; vpcd now simply shares the host
+// mount ns. Guard that shape: re-adding any mount-ns directive reintroduces the bug.
+func TestIMDS_NetnsHostMountNSInvariant(t *testing.T) {
+ root := repoRoot(t)
+
+ vpcd := readUnit(t, root, "build/systemd/spinifex-vpcd.service")
+ target := readUnit(t, root, "build/systemd/spinifex.target")
+
+ // No filesystem-sandbox directive may be set on vpcd: each forks a private
+ // mount namespace and re-traps the netns bind-mount.
+ for _, banned := range []string{
+ "ProtectSystem", "ProtectHome", "PrivateTmp", "ProtectKernelTunables",
+ "ProtectKernelModules", "ProtectKernelLogs", "ProtectControlGroups",
+ "ProtectProc", "ReadOnlyPaths", "ReadWritePaths", "MountFlags",
+ } {
+ if directiveSet(vpcd, banned) {
+ t.Errorf("spinifex-vpcd.service must share the HOST mount ns but sets %s= — a private mount ns traps /run/netns/ and host setns(2) fails EINVAL", banned)
+ }
+ }
+
+ // vpcd still needs CAP_SYS_ADMIN for setns(CLONE_NEWNET) and the net+mnt
+ // namespace allowance for the in-netns `ip` plumbing.
+ mustContain(t, "spinifex-vpcd.service", vpcd, "CAP_SYS_ADMIN")
+ mustContain(t, "spinifex-vpcd.service", vpcd, "RestrictNamespaces=net mnt")
+
+ // The removed shared-mount workaround must not reappear by reference.
+ if strings.Contains(vpcd, "spinifex-netns.service") && !strings.Contains(vpcd, "# ") {
+ t.Errorf("spinifex-vpcd.service must not depend on spinifex-netns.service (removed)")
+ }
+ if directiveSet(target, "Wants") && strings.Contains(target, "spinifex-netns.service") {
+ t.Errorf("spinifex.target must not pull in spinifex-netns.service (removed)")
+ }
+ if _, err := os.Stat(filepath.Join(root, "build", "systemd", "spinifex-netns.service")); err == nil {
+ t.Errorf("build/systemd/spinifex-netns.service must be removed — the shared-mount workaround was proven insufficient")
+ }
+}
+
+// directiveSet reports whether key is set as an actual unit directive (a line
+// `key=...`), ignoring comment prose that merely names it.
+func directiveSet(body, key string) bool {
+ for line := range strings.SplitSeq(body, "\n") {
+ if strings.HasPrefix(strings.TrimSpace(line), key+"=") {
+ return true
+ }
+ }
+ return false
+}
+
+func mustContain(t *testing.T, name, body, want string) {
+ t.Helper()
+ if !strings.Contains(body, want) {
+ t.Errorf("%s: missing %q", name, want)
+ }
+}
diff --git a/spinifex/network/host/imds_veth.go b/spinifex/network/host/imds_veth.go
new file mode 100644
index 000000000..94a8679b5
--- /dev/null
+++ b/spinifex/network/host/imds_veth.go
@@ -0,0 +1,220 @@
+package host
+
+import (
+ "context"
+ "fmt"
+ "log/slog"
+ "net/netip"
+ "strings"
+
+ "github.com/mulgadc/spinifex/spinifex/utils"
+)
+
+// imdsNetnsAddr is the host/listener side of the IMDS address, assigned to the
+// veth host end inside the per-subnet netns. .254 is MetaDataServerIP; the /30 is
+// kept (no longer load-bearing for a next-hop) and the reply path resolves the
+// guest via the subnet CIDR added on-link out the same veth.
+const imdsNetnsAddr = "169.254.169.254/30"
+
+// imdsPortLSPName is the OVN logical-switch-port the OVS-side veth binds to via
+// external_ids:iface-id. It mirrors topology.IMDSPort but is duplicated here to
+// avoid an import cycle (host ← handlers/imds ← topology).
+func imdsPortLSPName(subnetID string) string { return "imds-port-" + subnetID }
+
+// imdsLSPMAC is the logical MAC OVN advertises for 169.254.169.254 (the localport
+// address). The netns veth host end MUST carry this exact MAC: OVN resolves .254
+// to it and delivers IMDS request frames with dl_dst set to it, so a kernel-random
+// veth MAC makes the netns drop every frame not-for-me and the listener never sees
+// a SYN. Mirrors external.IMDSSpecForSubnet's LSPMAC, duplicated here (like
+// imdsPortLSPName) to avoid the import cycle.
+func imdsLSPMAC(subnetID string) string { return utils.HashMAC("imds-" + subnetID) }
+
+// EnsureIMDSVeth idempotently creates the per-subnet IMDS veth pair, moves the host
+// end into a dedicated netns with 169.254.169.254/30 + the subnet CIDR on-link, and
+// attaches the OVS end to br-int so ovn-controller binds the localport here. The
+// netns gives the host-served reply a real L3 next-hop (SO_BINDTODEVICE alone cannot
+// route the SYN-ACK): the on-link subnet route makes the guest directly reachable, so
+// the reply resolves by ARP over the localport on the guest's own L2 switch. The netns
+// also isolates overlapping subnet CIDRs into separate routing domains. Returns the
+// netns name and host-end name the listener enters and binds.
+func EnsureIMDSVeth(ctx context.Context, subnetID string, cidr netip.Prefix) (netnsName, hostEndName string, err error) {
+ if !cidr.IsValid() {
+ return "", "", fmt.Errorf("EnsureIMDSVeth: subnet %s requires a valid CIDR", subnetID)
+ }
+ ovsEnd := IMDSOVSPortName(subnetID)
+ hostEnd := IMDSHostVethName(subnetID)
+ netns := IMDSNetnsName(subnetID)
+
+ // Idempotency probe: the full plumbing (veth pair, netns, host-end address +
+ // route) exists from a prior boot only when the OVS end is a port on br-int
+ // AND the netns is enterable. A live OVS port over a stale netns (name
+ // present but setns(2) fails EINVAL — e.g. a crash between umount and unlink,
+ // or a tmpfs remount across reboot) leaves the listener permanently
+ // unbindable, so that case must NOT short-circuit: tear the inconsistent
+ // plumbing down and rebuild it below.
+ if imdsOVSPortOnBrInt(ovsEnd) {
+ if netnsEnterable(netns) {
+ slog.Debug("IMDS veth already present", "subnet", subnetID, "ovs_end", ovsEnd, "host_end", hostEnd, "netns", netns)
+ return netns, hostEnd, nil
+ }
+ slog.Warn("IMDS netns unenterable behind a live OVS port (stale handle), rebuilding plumbing",
+ "subnet", subnetID, "netns", netns, "ovs_end", ovsEnd)
+ if err := removeIMDSPlumbing(ovsEnd, hostEnd, netns); err != nil {
+ slog.Warn("Failed to tear down stale IMDS plumbing before rebuild", "subnet", subnetID, "err", err)
+ }
+ }
+
+ if err := ensureNetns(netns); err != nil {
+ return "", "", err
+ }
+
+ if out, err := utils.SudoCommand("ip", "link", "add", ovsEnd, "type", "veth", "peer", "name", hostEnd).CombinedOutput(); err != nil {
+ if cleanErr := removeIMDSPlumbing(ovsEnd, hostEnd, netns); cleanErr != nil {
+ slog.Warn("Failed to clean up IMDS plumbing after veth-create failure", "subnet", subnetID, "err", cleanErr)
+ }
+ return "", "", fmt.Errorf("create IMDS veth pair %s/%s: %s: %w", ovsEnd, hostEnd, strings.TrimSpace(string(out)), err)
+ }
+
+ if err := configureIMDSNetns(netns, hostEnd, ovsEnd, imdsLSPMAC(subnetID), cidr); err != nil {
+ if cleanErr := removeIMDSPlumbing(ovsEnd, hostEnd, netns); cleanErr != nil {
+ slog.Warn("Failed to clean up IMDS plumbing after netns config failure", "subnet", subnetID, "err", cleanErr)
+ }
+ return "", "", err
+ }
+
+ ifaceID := imdsPortLSPName(subnetID)
+ if out, err := utils.SudoCommand("ovs-vsctl", "add-port", "br-int", ovsEnd,
+ "--", "set", "Interface", ovsEnd, "external_ids:iface-id="+ifaceID).CombinedOutput(); err != nil {
+ if cleanErr := removeIMDSPlumbing(ovsEnd, hostEnd, netns); cleanErr != nil {
+ slog.Warn("Failed to clean up IMDS plumbing after OVS failure", "subnet", subnetID, "err", cleanErr)
+ }
+ return "", "", fmt.Errorf("add IMDS veth %s to br-int: %s: %w", ovsEnd, strings.TrimSpace(string(out)), err)
+ }
+
+ slog.Info("IMDS veth plumbing complete", "subnet", subnetID, "ovs_end", ovsEnd, "host_end", hostEnd, "netns", netns, "iface_id", ifaceID)
+ return netns, hostEnd, nil
+}
+
+// configureIMDSNetns moves the host end into the netns, sets its MAC to the
+// localport's logical MAC, brings it (and lo) up, and assigns the IMDS address +
+// the subnet CIDR on-link so the host-served reply resolves the guest by ARP over
+// the localport (a single L2 hop on the guest's own switch — no router next-hop).
+// The OVS end stays in the root netns for ovn-controller to bind. The MAC set is
+// the request-path contract: OVN delivers frames with dl_dst = the localport MAC,
+// so the veth host end must own that MAC or the netns silently drops them. The
+// addr/route adds tolerate "File exists" so a re-run after a partial failure
+// converges rather than wedging.
+func configureIMDSNetns(netns, hostEnd, ovsEnd, hostEndMAC string, cidr netip.Prefix) error {
+ if out, err := utils.SudoCommand("ip", "link", "set", ovsEnd, "up").CombinedOutput(); err != nil {
+ return fmt.Errorf("bring up IMDS OVS end %s: %s: %w", ovsEnd, strings.TrimSpace(string(out)), err)
+ }
+ if out, err := utils.SudoCommand("ip", "link", "set", hostEnd, "netns", netns).CombinedOutput(); err != nil {
+ return fmt.Errorf("move IMDS host end %s into netns %s: %s: %w", hostEnd, netns, strings.TrimSpace(string(out)), err)
+ }
+ if out, err := utils.SudoCommand("ip", "-n", netns, "link", "set", hostEnd, "address", hostEndMAC).CombinedOutput(); err != nil {
+ return fmt.Errorf("set IMDS host end %s MAC %s in netns %s: %s: %w", hostEnd, hostEndMAC, netns, strings.TrimSpace(string(out)), err)
+ }
+ for _, dev := range []string{"lo", hostEnd} {
+ if out, err := utils.SudoCommand("ip", "-n", netns, "link", "set", dev, "up").CombinedOutput(); err != nil {
+ return fmt.Errorf("bring up %s in netns %s: %s: %w", dev, netns, strings.TrimSpace(string(out)), err)
+ }
+ }
+ if err := ipNetnsTolerate(netns, "File exists", "addr", "add", imdsNetnsAddr, "dev", hostEnd); err != nil {
+ return err
+ }
+ return ipNetnsTolerate(netns, "File exists", "route", "add", cidr.String(), "dev", hostEnd)
+}
+
+// ensureNetns creates the netns, treating "already exists" as success — but
+// only when the pre-existing handle is actually enterable. A stale
+// /run/netns/ bind-mount (name resolves yet setns(2) fails EINVAL) returns
+// "File exists" from `ip netns add` while being unusable, which would leave the
+// IMDS listener permanently unbindable; that handle is torn down and recreated.
+func ensureNetns(netns string) error {
+ out, err := utils.SudoCommand("ip", "netns", "add", netns).CombinedOutput()
+ if err == nil {
+ return nil
+ }
+ if !strings.Contains(string(out), "File exists") {
+ return fmt.Errorf("create IMDS netns %s: %s: %w", netns, strings.TrimSpace(string(out)), err)
+ }
+ if netnsEnterable(netns) {
+ return nil
+ }
+ slog.Warn("IMDS netns present but unenterable (stale handle), recreating", "netns", netns)
+ if out, err := utils.SudoCommand("ip", "netns", "del", netns).CombinedOutput(); err != nil {
+ if msg := strings.TrimSpace(string(out)); !strings.Contains(msg, "No such file") {
+ return fmt.Errorf("delete stale IMDS netns %s: %s: %w", netns, msg, err)
+ }
+ }
+ if out, err := utils.SudoCommand("ip", "netns", "add", netns).CombinedOutput(); err != nil {
+ return fmt.Errorf("recreate IMDS netns %s: %s: %w", netns, strings.TrimSpace(string(out)), err)
+ }
+ return nil
+}
+
+// imdsOVSPortOnBrInt reports whether the IMDS OVS-end veth is currently a port
+// on br-int — the cheap signal that prior plumbing exists.
+func imdsOVSPortOnBrInt(ovsEnd string) bool {
+ out, err := utils.SudoCommand("ovs-vsctl", "port-to-br", ovsEnd).CombinedOutput()
+ return err == nil && strings.TrimSpace(string(out)) == "br-int"
+}
+
+// netnsEnterable reports whether netns can be entered via setns(2). A stale
+// bind-mount resolves by name but fails EINVAL; `ip -n link show lo`
+// performs the setns and surfaces it. A truly-absent netns also reports false,
+// which is the correct signal for both callers (recreate / rebuild).
+func netnsEnterable(netns string) bool {
+ return utils.SudoCommand("ip", "-n", netns, "link", "show", "lo").Run() == nil
+}
+
+// ipNetnsTolerate runs `ip -n `, treating output containing
+// tolerate as success (for idempotent re-runs of addr/route adds).
+func ipNetnsTolerate(netns, tolerate string, args ...string) error {
+ full := append([]string{"-n", netns}, args...)
+ if out, err := utils.SudoCommand("ip", full...).CombinedOutput(); err != nil {
+ if strings.Contains(string(out), tolerate) {
+ return nil
+ }
+ return fmt.Errorf("ip %s: %s: %w", strings.Join(full, " "), strings.TrimSpace(string(out)), err)
+ }
+ return nil
+}
+
+// RemoveIMDSVeth detaches the OVS port, deletes the netns (which destroys the
+// host end and thus the veth pair), and clears any root-netns remnant. Idempotent:
+// safe to call for a subnet that never had a veth on this chassis.
+func RemoveIMDSVeth(ctx context.Context, subnetID string) error {
+ return removeIMDSPlumbing(IMDSOVSPortName(subnetID), IMDSHostVethName(subnetID), IMDSNetnsName(subnetID))
+}
+
+// removeIMDSPlumbing tears down the OVS port, the netns, and any leftover veth.
+// Deleting the netns destroys the host end (and its peer); the trailing link del
+// covers the partial state where the host end was never moved into the netns.
+func removeIMDSPlumbing(ovsEnd, hostEnd, netns string) error {
+ if out, err := utils.SudoCommand("ovs-vsctl", "--if-exists", "del-port", ovsEnd).CombinedOutput(); err != nil {
+ slog.Warn("Failed to remove IMDS veth from OVS", "ovs_end", ovsEnd, "err", err, "out", strings.TrimSpace(string(out)))
+ }
+
+ if out, err := utils.SudoCommand("ip", "netns", "del", netns).CombinedOutput(); err != nil {
+ msg := strings.TrimSpace(string(out))
+ // "No such file or directory" — already gone. Anything else is logged
+ // but not fatal: the link del below still runs.
+ if !strings.Contains(msg, "No such file") {
+ slog.Warn("Failed to delete IMDS netns", "netns", netns, "err", err, "out", msg)
+ }
+ }
+
+ if out, err := utils.SudoCommand("ip", "link", "del", ovsEnd).CombinedOutput(); err != nil {
+ // "Cannot find device" — the pair was already destroyed with the netns.
+ msg := strings.TrimSpace(string(out))
+ if strings.Contains(msg, "Cannot find device") {
+ slog.Debug("IMDS veth already absent", "ovs_end", ovsEnd)
+ return nil
+ }
+ return fmt.Errorf("delete IMDS veth %s: %s: %w", ovsEnd, msg, err)
+ }
+
+ slog.Info("IMDS veth removed", "ovs_end", ovsEnd, "host_end", hostEnd, "netns", netns)
+ return nil
+}
diff --git a/spinifex/network/host/imds_veth_test.go b/spinifex/network/host/imds_veth_test.go
new file mode 100644
index 000000000..55f846b07
--- /dev/null
+++ b/spinifex/network/host/imds_veth_test.go
@@ -0,0 +1,354 @@
+package host
+
+import (
+ "context"
+ "net/netip"
+ "os/exec"
+ "slices"
+ "strings"
+ "testing"
+
+ "github.com/mulgadc/spinifex/spinifex/utils"
+)
+
+const testSubnetID = "subnet-0123456789abcdef" // short form: "89abcdef"
+
+var testSubnetCIDR = netip.MustParsePrefix("10.211.0.0/16")
+
+func recordSudo(t *testing.T, fail func(name string, args []string) *exec.Cmd) *[][]string {
+ t.Helper()
+ var calls [][]string
+ t.Cleanup(utils.SetSudoCommandForTest(func(name string, args ...string) *exec.Cmd {
+ calls = append(calls, append([]string{name}, args...))
+ if fail != nil {
+ if c := fail(name, args); c != nil {
+ return c
+ }
+ }
+ return exec.Command("/bin/true")
+ }))
+ return &calls
+}
+
+func TestEnsureIMDSVeth_HappyPath(t *testing.T) {
+ // Probe must report "not a port" so creation proceeds.
+ calls := recordSudo(t, func(name string, args []string) *exec.Cmd {
+ if name == "ovs-vsctl" && len(args) >= 1 && args[0] == "port-to-br" {
+ return exec.Command("/bin/false")
+ }
+ return nil
+ })
+
+ netns, hostEnd, err := EnsureIMDSVeth(context.Background(), testSubnetID, testSubnetCIDR)
+ if err != nil {
+ t.Fatalf("EnsureIMDSVeth: %v", err)
+ }
+ if netns != "imds-89abcdef" {
+ t.Errorf("netns = %q, want imds-89abcdef", netns)
+ }
+ if hostEnd != "imds-h-89abcdef" {
+ t.Errorf("hostEnd = %q, want imds-h-89abcdef", hostEnd)
+ }
+
+ want := [][]string{
+ {"ovs-vsctl", "port-to-br", "imds-o-89abcdef"},
+ {"ip", "netns", "add", "imds-89abcdef"},
+ {"ip", "link", "add", "imds-o-89abcdef", "type", "veth", "peer", "name", "imds-h-89abcdef"},
+ {"ip", "link", "set", "imds-o-89abcdef", "up"},
+ {"ip", "link", "set", "imds-h-89abcdef", "netns", "imds-89abcdef"},
+ {"ip", "-n", "imds-89abcdef", "link", "set", "imds-h-89abcdef", "address", utils.HashMAC("imds-" + testSubnetID)},
+ {"ip", "-n", "imds-89abcdef", "link", "set", "lo", "up"},
+ {"ip", "-n", "imds-89abcdef", "link", "set", "imds-h-89abcdef", "up"},
+ {"ip", "-n", "imds-89abcdef", "addr", "add", "169.254.169.254/30", "dev", "imds-h-89abcdef"},
+ {"ip", "-n", "imds-89abcdef", "route", "add", "10.211.0.0/16", "dev", "imds-h-89abcdef"},
+ {"ovs-vsctl", "add-port", "br-int", "imds-o-89abcdef",
+ "--", "set", "Interface", "imds-o-89abcdef",
+ "external_ids:iface-id=imds-port-" + testSubnetID},
+ }
+ if len(*calls) != len(want) {
+ t.Fatalf("got %d calls, want %d: %v", len(*calls), len(want), *calls)
+ }
+ for i := range want {
+ if !slices.Equal((*calls)[i], want[i]) {
+ t.Errorf("call %d = %v, want %v", i, (*calls)[i], want[i])
+ }
+ }
+}
+
+func TestEnsureIMDSVeth_Idempotent(t *testing.T) {
+ // Probe reports the OVS end is already on br-int and the netns is enterable
+ // (default mock = /bin/true) → early return, no mutation.
+ calls := recordSudo(t, func(name string, args []string) *exec.Cmd {
+ if name == "ovs-vsctl" && len(args) >= 1 && args[0] == "port-to-br" {
+ return exec.Command("echo", "br-int")
+ }
+ return nil
+ })
+
+ netns, hostEnd, err := EnsureIMDSVeth(context.Background(), testSubnetID, testSubnetCIDR)
+ if err != nil {
+ t.Fatalf("EnsureIMDSVeth: %v", err)
+ }
+ if netns != "imds-89abcdef" {
+ t.Errorf("netns = %q, want imds-89abcdef", netns)
+ }
+ if hostEnd != "imds-h-89abcdef" {
+ t.Errorf("hostEnd = %q, want imds-h-89abcdef", hostEnd)
+ }
+ want := [][]string{
+ {"ovs-vsctl", "port-to-br", "imds-o-89abcdef"},
+ {"ip", "-n", "imds-89abcdef", "link", "show", "lo"},
+ }
+ if len(*calls) != len(want) {
+ t.Fatalf("expected only the two probe calls, got %v", *calls)
+ }
+ for i := range want {
+ if !slices.Equal((*calls)[i], want[i]) {
+ t.Errorf("call %d = %v, want %v", i, (*calls)[i], want[i])
+ }
+ }
+}
+
+// TestEnsureIMDSVeth_StaleNetnsBehindLivePort_Rebuilds guards Fix #2: a live
+// OVS port over an unenterable netns (stale /run/netns handle, setns EINVAL)
+// must NOT short-circuit. The inconsistent plumbing is torn down and rebuilt so
+// the IMDS listener becomes bindable again, rather than wedging forever.
+func TestEnsureIMDSVeth_StaleNetnsBehindLivePort_Rebuilds(t *testing.T) {
+ calls := recordSudo(t, func(name string, args []string) *exec.Cmd {
+ if name == "ovs-vsctl" && len(args) >= 1 && args[0] == "port-to-br" {
+ return exec.Command("echo", "br-int")
+ }
+ // Netns enterability probe fails with the kernel's EINVAL message.
+ if name == "ip" && len(args) >= 5 && args[0] == "-n" && args[2] == "link" && args[3] == "show" {
+ return exec.Command("sh", "-c", `echo 'setting the network namespace "imds-89abcdef" failed: Invalid argument' >&2; exit 1`)
+ }
+ return nil
+ })
+
+ if _, _, err := EnsureIMDSVeth(context.Background(), testSubnetID, testSubnetCIDR); err != nil {
+ t.Fatalf("EnsureIMDSVeth: %v", err)
+ }
+
+ // Teardown (removeIMDSPlumbing) must run before the rebuild, and the rebuild
+ // must re-add the OVS port — proving we did not short-circuit.
+ var sawDelPort, sawNetnsDel, sawAddPort bool
+ for _, c := range *calls {
+ if c[0] == "ovs-vsctl" && slices.Contains(c, "del-port") {
+ sawDelPort = true
+ }
+ if c[0] == "ip" && len(c) >= 3 && c[1] == "netns" && c[2] == "del" {
+ sawNetnsDel = true
+ }
+ if c[0] == "ovs-vsctl" && slices.Contains(c, "add-port") {
+ sawAddPort = true
+ }
+ }
+ if !sawDelPort || !sawNetnsDel || !sawAddPort {
+ t.Errorf("expected stale plumbing torn down and rebuilt (del-port=%v, netns del=%v, add-port=%v); calls=%v",
+ sawDelPort, sawNetnsDel, sawAddPort, *calls)
+ }
+}
+
+// TestEnsureIMDSVeth_StaleNetnsRecreated guards Fix #1: when `ip netns add`
+// reports "File exists" but the handle is unenterable, ensureNetns deletes the
+// stale handle and recreates it, then plumbing proceeds to completion.
+func TestEnsureIMDSVeth_StaleNetnsRecreated(t *testing.T) {
+ netnsAdds := 0
+ calls := recordSudo(t, func(name string, args []string) *exec.Cmd {
+ if name == "ovs-vsctl" && len(args) >= 1 && args[0] == "port-to-br" {
+ return exec.Command("/bin/false")
+ }
+ if name == "ip" && len(args) >= 2 && args[0] == "netns" && args[1] == "add" {
+ netnsAdds++
+ if netnsAdds == 1 {
+ // First add: name already present as a stale handle.
+ return exec.Command("sh", "-c", "echo 'Cannot create namespace file: File exists' >&2; exit 1")
+ }
+ return nil // recreate succeeds
+ }
+ // Enterability probe fails → stale handle detected.
+ if name == "ip" && len(args) >= 5 && args[0] == "-n" && args[2] == "link" && args[3] == "show" {
+ return exec.Command("sh", "-c", `echo 'setting the network namespace "imds-89abcdef" failed: Invalid argument' >&2; exit 1`)
+ }
+ return nil
+ })
+
+ if _, _, err := EnsureIMDSVeth(context.Background(), testSubnetID, testSubnetCIDR); err != nil {
+ t.Fatalf("EnsureIMDSVeth: %v", err)
+ }
+
+ // Stale handle deleted then re-added, and the rebuild ran to the add-port.
+ var sawNetnsDel, sawAddPort bool
+ for _, c := range *calls {
+ if c[0] == "ip" && len(c) >= 3 && c[1] == "netns" && c[2] == "del" {
+ sawNetnsDel = true
+ }
+ if c[0] == "ovs-vsctl" && slices.Contains(c, "add-port") {
+ sawAddPort = true
+ }
+ }
+ if !sawNetnsDel {
+ t.Errorf("expected stale netns to be deleted before recreate; calls=%v", *calls)
+ }
+ if !sawAddPort {
+ t.Errorf("expected plumbing to complete after recreate; calls=%v", *calls)
+ }
+ if netnsAdds != 2 {
+ t.Errorf("expected two netns add attempts (stale + recreate), got %d", netnsAdds)
+ }
+}
+
+// TestEnsureIMDSVeth_NetnsExistsTolerated guards idempotent re-runs after a
+// partial prior attempt: `ip netns add` reporting "File exists" must not abort.
+func TestEnsureIMDSVeth_NetnsExistsTolerated(t *testing.T) {
+ recordSudo(t, func(name string, args []string) *exec.Cmd {
+ if name == "ovs-vsctl" && len(args) >= 1 && args[0] == "port-to-br" {
+ return exec.Command("/bin/false")
+ }
+ if name == "ip" && len(args) >= 2 && args[0] == "netns" && args[1] == "add" {
+ return exec.Command("sh", "-c", "echo 'Cannot create namespace file: File exists' >&2; exit 1")
+ }
+ return nil
+ })
+
+ if _, _, err := EnsureIMDSVeth(context.Background(), testSubnetID, testSubnetCIDR); err != nil {
+ t.Fatalf("expected nil for pre-existing netns, got %v", err)
+ }
+}
+
+func TestEnsureIMDSVeth_AddPortFailureCleansUp(t *testing.T) {
+ calls := recordSudo(t, func(name string, args []string) *exec.Cmd {
+ if name == "ovs-vsctl" && len(args) >= 1 && args[0] == "port-to-br" {
+ return exec.Command("/bin/false")
+ }
+ if name == "ovs-vsctl" && len(args) >= 1 && args[0] == "add-port" {
+ return exec.Command("/bin/false")
+ }
+ return nil
+ })
+
+ _, _, err := EnsureIMDSVeth(context.Background(), testSubnetID, testSubnetCIDR)
+ if err == nil || !strings.Contains(err.Error(), "add IMDS veth") {
+ t.Fatalf("expected add-port error, got %v", err)
+ }
+ // Cleanup must have run: del-port, netns del, then link del.
+ var sawDelPort, sawNetnsDel, sawLinkDel bool
+ for _, c := range *calls {
+ if c[0] == "ovs-vsctl" && slices.Contains(c, "del-port") {
+ sawDelPort = true
+ }
+ if c[0] == "ip" && len(c) >= 3 && c[1] == "netns" && c[2] == "del" {
+ sawNetnsDel = true
+ }
+ if c[0] == "ip" && len(c) >= 3 && c[1] == "link" && c[2] == "del" {
+ sawLinkDel = true
+ }
+ }
+ if !sawDelPort || !sawNetnsDel || !sawLinkDel {
+ t.Errorf("expected cleanup (del-port=%v, netns del=%v, link del=%v); calls=%v", sawDelPort, sawNetnsDel, sawLinkDel, *calls)
+ }
+}
+
+func TestEnsureIMDSVeth_LinkAddFailureSurfaces(t *testing.T) {
+ recordSudo(t, func(name string, args []string) *exec.Cmd {
+ if name == "ovs-vsctl" && len(args) >= 1 && args[0] == "port-to-br" {
+ return exec.Command("/bin/false")
+ }
+ if name == "ip" && len(args) >= 3 && args[0] == "link" && args[1] == "add" {
+ return exec.Command("/bin/false")
+ }
+ return nil
+ })
+
+ _, _, err := EnsureIMDSVeth(context.Background(), testSubnetID, testSubnetCIDR)
+ if err == nil || !strings.Contains(err.Error(), "create IMDS veth pair") {
+ t.Fatalf("expected create error, got %v", err)
+ }
+}
+
+// TestEnsureIMDSVeth_AddrFailureCleansUp asserts a failure assigning the IMDS
+// address inside the netns surfaces and tears the half-built plumbing down.
+func TestEnsureIMDSVeth_AddrFailureCleansUp(t *testing.T) {
+ calls := recordSudo(t, func(name string, args []string) *exec.Cmd {
+ if name == "ovs-vsctl" && len(args) >= 1 && args[0] == "port-to-br" {
+ return exec.Command("/bin/false")
+ }
+ if name == "ip" && len(args) >= 4 && args[0] == "-n" && args[2] == "addr" && args[3] == "add" {
+ return exec.Command("/bin/false")
+ }
+ return nil
+ })
+
+ _, _, err := EnsureIMDSVeth(context.Background(), testSubnetID, testSubnetCIDR)
+ if err == nil || !strings.Contains(err.Error(), "addr add") {
+ t.Fatalf("expected addr-add error, got %v", err)
+ }
+ var sawNetnsDel bool
+ for _, c := range *calls {
+ if c[0] == "ip" && len(c) >= 3 && c[1] == "netns" && c[2] == "del" {
+ sawNetnsDel = true
+ }
+ }
+ if !sawNetnsDel {
+ t.Errorf("expected netns cleanup after addr failure; calls=%v", *calls)
+ }
+}
+
+func TestRemoveIMDSVeth(t *testing.T) {
+ calls := recordSudo(t, nil)
+
+ if err := RemoveIMDSVeth(context.Background(), testSubnetID); err != nil {
+ t.Fatalf("RemoveIMDSVeth: %v", err)
+ }
+
+ want := [][]string{
+ {"ovs-vsctl", "--if-exists", "del-port", "imds-o-89abcdef"},
+ {"ip", "netns", "del", "imds-89abcdef"},
+ {"ip", "link", "del", "imds-o-89abcdef"},
+ }
+ if len(*calls) != len(want) {
+ t.Fatalf("got %d calls, want %d: %v", len(*calls), len(want), *calls)
+ }
+ for i := range want {
+ if !slices.Equal((*calls)[i], want[i]) {
+ t.Errorf("call %d = %v, want %v", i, (*calls)[i], want[i])
+ }
+ }
+}
+
+func TestRemoveIMDSVeth_MissingDeviceIsNotError(t *testing.T) {
+ recordSudo(t, func(name string, args []string) *exec.Cmd {
+ if name == "ip" && len(args) >= 2 && args[0] == "netns" && args[1] == "del" {
+ return exec.Command("sh", "-c", "echo 'Cannot remove namespace file \"/var/run/netns/imds-89abcdef\": No such file or directory' >&2; exit 1")
+ }
+ if name == "ip" && len(args) >= 2 && args[0] == "link" && args[1] == "del" {
+ // Emit the kernel's "absent device" message on stderr and fail.
+ return exec.Command("sh", "-c", "echo 'Cannot find device \"imds-o-89abcdef\"' >&2; exit 1")
+ }
+ return nil
+ })
+
+ if err := RemoveIMDSVeth(context.Background(), testSubnetID); err != nil {
+ t.Fatalf("expected nil for absent device, got %v", err)
+ }
+}
+
+// TestIMDSVethNamesWithinIFNAMSIZ guards the load-bearing constraint that both
+// veth-end names fit Linux's IFNAMSIZ-1 (15 chars). A longer name makes
+// `ip link add` fail with "name too long" for every subnet, silently taking IMDS
+// offline cluster-wide — and the SudoCommand mock would never catch it.
+func TestIMDSVethNamesWithinIFNAMSIZ(t *testing.T) {
+ const ifnamsizMax = 15
+ for _, subnetID := range []string{
+ "subnet-0123456789abcdef",
+ "subnet-0a1b2c3d4e5f6a7b8",
+ "subnet-deadbeef",
+ "short",
+ } {
+ for _, name := range []string{IMDSOVSPortName(subnetID), IMDSHostVethName(subnetID)} {
+ if len(name) > ifnamsizMax {
+ t.Errorf("veth name %q (%d chars) exceeds IFNAMSIZ-1 (%d) for subnet %q", name, len(name), ifnamsizMax, subnetID)
+ }
+ }
+ }
+}
diff --git a/spinifex/network/host/neigh.go b/spinifex/network/host/neigh.go
new file mode 100644
index 000000000..de403fd42
--- /dev/null
+++ b/spinifex/network/host/neigh.go
@@ -0,0 +1,63 @@
+package host
+
+import (
+ "context"
+ "fmt"
+ "strings"
+)
+
+// FlushNeigh removes the kernel neighbour (ARP) entry for ip on dev so the next
+// packet to ip re-resolves L2. Wraps `ip neigh flush to dev `, which
+// is idempotent — flushing zero entries still succeeds.
+//
+// inject-garp-independent ARP refresh for EIP recycle: ovn-controller emits its
+// automatic GARP only on LSP binding-chassis migration, and the explicit
+// `ovn-appctl inject-garp` refresh (see InjectGARP) is unavailable on OVN builds
+// without that appctl. Without either, a same-chassis external_ip rebind leaves
+// the host neighbour cache pointed at the prior owner's MAC until the kernel ARP
+// timeout (60-300s). Flushing the entry on EIP attach and detach forces a fresh
+// resolution against the current owner.
+//
+// L0 method (ADR-0006 S2) — only network/host/ may shell out to host tools.
+// Best-effort: callers must treat errors as warnings, not failures.
+func FlushNeigh(ctx context.Context, runner Runner, dev, ip string) error {
+ if dev == "" || ip == "" {
+ return fmt.Errorf("host.FlushNeigh: dev and ip required")
+ }
+ if runner == nil {
+ runner = NewExecRunner()
+ }
+ out, err := runner.Run(ctx, "ip", "neigh", "flush", "to", ip, "dev", dev)
+ if err != nil {
+ return fmt.Errorf("ip neigh flush to %s dev %s: %s: %w", ip, dev, strings.TrimSpace(string(out)), err)
+ }
+ return nil
+}
+
+// ReplaceNeigh installs (or overwrites) the kernel neighbour entry mapping ip to
+// mac on dev in NUD_REACHABLE state. Wraps `ip neigh replace lladdr
+// dev nud reachable`.
+//
+// This is the deterministic counterpart to FlushNeigh for EIP attach: when an
+// external IP is recycled onto a new owner, OVN advertises the new MAC only via
+// a GARP it emits on LSP binding-chassis migration — a same-chassis rebind
+// emits none, and `ovn-appctl inject-garp` is absent on older OVN builds, so no
+// node answers the host's re-ARP. Flushing then waits for an ARP reply that
+// never comes; programming the known external_mac directly skips the round-trip,
+// and inbound traffic refreshes the entry from there on.
+//
+// L0 method (ADR-0006 S2) — only network/host/ may shell out to host tools.
+// Best-effort: callers must treat errors as warnings, not failures.
+func ReplaceNeigh(ctx context.Context, runner Runner, dev, ip, mac string) error {
+ if dev == "" || ip == "" || mac == "" {
+ return fmt.Errorf("host.ReplaceNeigh: dev, ip and mac required")
+ }
+ if runner == nil {
+ runner = NewExecRunner()
+ }
+ out, err := runner.Run(ctx, "ip", "neigh", "replace", ip, "lladdr", mac, "dev", dev, "nud", "reachable")
+ if err != nil {
+ return fmt.Errorf("ip neigh replace %s lladdr %s dev %s: %s: %w", ip, mac, dev, strings.TrimSpace(string(out)), err)
+ }
+ return nil
+}
diff --git a/spinifex/network/host/neigh_test.go b/spinifex/network/host/neigh_test.go
new file mode 100644
index 000000000..3309c0402
--- /dev/null
+++ b/spinifex/network/host/neigh_test.go
@@ -0,0 +1,73 @@
+package host
+
+import (
+ "context"
+ "errors"
+ "strings"
+ "testing"
+)
+
+func TestFlushNeigh_ShellOutShape(t *testing.T) {
+ r := &recordingRunner{}
+ if err := FlushNeigh(context.Background(), r, "br-wan", "192.168.0.231"); err != nil {
+ t.Fatalf("FlushNeigh: %v", err)
+ }
+ if len(r.args) != 1 {
+ t.Fatalf("expected 1 call, got %d", len(r.args))
+ }
+ got := strings.Join(r.args[0], " ")
+ want := "ip neigh flush to 192.168.0.231 dev br-wan"
+ if got != want {
+ t.Fatalf("argv mismatch\n got: %s\nwant: %s", got, want)
+ }
+}
+
+func TestFlushNeigh_MissingArgs(t *testing.T) {
+ if err := FlushNeigh(context.Background(), &recordingRunner{}, "", "192.168.0.231"); err == nil {
+ t.Fatal("expected error for empty dev")
+ }
+ if err := FlushNeigh(context.Background(), &recordingRunner{}, "br-wan", ""); err == nil {
+ t.Fatal("expected error for empty ip")
+ }
+}
+
+func TestFlushNeigh_PropagatesRunnerError(t *testing.T) {
+ r := &recordingRunner{out: []byte("Cannot find device \"br-wan\""), err: errors.New("exit 1")}
+ if err := FlushNeigh(context.Background(), r, "br-wan", "192.168.0.231"); err == nil {
+ t.Fatal("expected error")
+ }
+}
+
+func TestReplaceNeigh_ShellOutShape(t *testing.T) {
+ r := &recordingRunner{}
+ if err := ReplaceNeigh(context.Background(), r, "br-wan", "192.168.0.231", "02:00:0a:d2:01:04"); err != nil {
+ t.Fatalf("ReplaceNeigh: %v", err)
+ }
+ if len(r.args) != 1 {
+ t.Fatalf("expected 1 call, got %d", len(r.args))
+ }
+ got := strings.Join(r.args[0], " ")
+ want := "ip neigh replace 192.168.0.231 lladdr 02:00:0a:d2:01:04 dev br-wan nud reachable"
+ if got != want {
+ t.Fatalf("argv mismatch\n got: %s\nwant: %s", got, want)
+ }
+}
+
+func TestReplaceNeigh_MissingArgs(t *testing.T) {
+ if err := ReplaceNeigh(context.Background(), &recordingRunner{}, "", "192.168.0.231", "02:00:0a:d2:01:04"); err == nil {
+ t.Fatal("expected error for empty dev")
+ }
+ if err := ReplaceNeigh(context.Background(), &recordingRunner{}, "br-wan", "", "02:00:0a:d2:01:04"); err == nil {
+ t.Fatal("expected error for empty ip")
+ }
+ if err := ReplaceNeigh(context.Background(), &recordingRunner{}, "br-wan", "192.168.0.231", ""); err == nil {
+ t.Fatal("expected error for empty mac")
+ }
+}
+
+func TestReplaceNeigh_PropagatesRunnerError(t *testing.T) {
+ r := &recordingRunner{out: []byte("Cannot find device \"br-wan\""), err: errors.New("exit 1")}
+ if err := ReplaceNeigh(context.Background(), r, "br-wan", "192.168.0.231", "02:00:0a:d2:01:04"); err == nil {
+ t.Fatal("expected error")
+ }
+}
diff --git a/spinifex/network/policy/nat.go b/spinifex/network/policy/nat.go
index dd6d82993..40e8541a5 100644
--- a/spinifex/network/policy/nat.go
+++ b/spinifex/network/policy/nat.go
@@ -36,8 +36,10 @@ type NATManager interface {
// same ExternalIP on any router first (pool reuse across VPCs).
AddEIP(ctx context.Context, eip EIPSpec) error
- // DeleteEIP removes the rule by LogicalIP; idempotent.
- DeleteEIP(ctx context.Context, vpcID, logicalIP string) error
+ // DeleteEIP removes the rule by LogicalIP and flushes the host ARP entry
+ // for ExternalIP so a recycled IP is not shadowed by the stale MAC;
+ // idempotent.
+ DeleteEIP(ctx context.Context, vpcID, externalIP, logicalIP string) error
// AddNATGateway installs the (snat, SubnetCIDR) rule; rejects overlap.
AddNATGateway(ctx context.Context, gw NATGWSpec) error
@@ -51,6 +53,16 @@ type NATManager interface {
// DeleteSNAT removes the IGW default-outbound snat for vpcCIDR.
DeleteSNAT(ctx context.Context, vpcID, vpcCIDR string) error
+
+ // AddSystemInstanceSNAT installs an egress-only snat rewriting a single
+ // system instance's /32 (logicalIP) to externalIP. Unlike AddEIP it is a
+ // plain snat, not dnat_and_snat, so there is no inbound path to the
+ // instance. Idempotent; tagged role=system-instance-egress.
+ AddSystemInstanceSNAT(ctx context.Context, vpcID, logicalIP, externalIP string) error
+
+ // DeleteSystemInstanceSNAT removes the egress-only snat by logicalIP;
+ // idempotent.
+ DeleteSystemInstanceSNAT(ctx context.Context, vpcID, logicalIP string) error
}
// FlowsBarrier blocks until ovn-northd has compiled NB → SB and every
@@ -69,6 +81,27 @@ type FlowsBarrier func() error
// Best-effort: implementations return errors but callers warn and proceed.
type GARPEmitter func(EIPSpec) error
+// NeighFlusher invalidates the host neighbour (ARP) entry for externalIP so the
+// next dial re-resolves L2. Called on both EIP attach and detach: it is the
+// inject-garp-independent path that keeps a recycled external IP reachable on
+// OVN builds where the GARPEmitter (ovn-appctl inject-garp) is a no-op,
+// preventing the host ARP cache from pointing at the prior owner's MAC until
+// the kernel ARP timeout expires (60-300s).
+//
+// Best-effort: implementations return errors but callers warn and proceed.
+type NeighFlusher func(externalIP string) error
+
+// NeighPrimer installs the host neighbour entry for a distributed EIP directly,
+// mapping ExternalIP to its external_mac. Preferred over NeighFlusher on attach
+// when the MAC is known (distributed mode): a flush merely re-arms ARP, but on
+// OVN builds without inject-garp no node answers the host's re-ARP for a
+// same-chassis recycled IP, so the entry would just decay back to FAILED.
+// Programming the known MAC makes the recycled IP reachable without an ARP
+// round-trip.
+//
+// Best-effort: implementations return errors but callers warn and proceed.
+type NeighPrimer func(eip EIPSpec) error
+
type Option func(*natManager)
// WithFlowsBarrier injects the post-write flow-install barrier so callers
@@ -92,11 +125,37 @@ func WithGARPEmitter(g GARPEmitter) Option {
}
}
+// WithNeighFlusher injects the host neighbour-flush hook fired on EIP detach (and
+// on attach when the external_mac is unknown). Without it, recycled external IPs
+// rely solely on the GARPEmitter, which is a no-op on OVN builds lacking
+// inject-garp.
+func WithNeighFlusher(f NeighFlusher) Option {
+ return func(m *natManager) {
+ if f != nil {
+ m.neigh = f
+ }
+ }
+}
+
+// WithNeighPrimer injects the host neighbour-prime hook fired on EIP attach in
+// distributed mode (external_mac known). Preferred over the flusher there: it
+// programs the recycled IP's MAC directly instead of waiting on an ARP reply
+// that no node sends when inject-garp is unavailable.
+func WithNeighPrimer(p NeighPrimer) Option {
+ return func(m *natManager) {
+ if p != nil {
+ m.neighPrime = p
+ }
+ }
+}
+
type natManager struct {
- ovn ovn.Client
- mode NATMode
- barrier FlowsBarrier
- garp GARPEmitter
+ ovn ovn.Client
+ mode NATMode
+ barrier FlowsBarrier
+ garp GARPEmitter
+ neigh NeighFlusher
+ neighPrime NeighPrimer
}
var _ NATManager = (*natManager)(nil)
@@ -108,10 +167,12 @@ func NewNATManager(client ovn.Client, mode NATMode, opts ...Option) (NATManager,
return nil, fmt.Errorf("NAT mode is unknown; resolve from host.Wiring.UplinkMode()")
}
m := &natManager{
- ovn: client,
- mode: mode,
- barrier: func() error { return nil },
- garp: func(EIPSpec) error { return nil },
+ ovn: client,
+ mode: mode,
+ barrier: func() error { return nil },
+ garp: func(EIPSpec) error { return nil },
+ neigh: func(string) error { return nil },
+ neighPrime: func(EIPSpec) error { return nil },
}
for _, opt := range opts {
opt(m)
@@ -170,16 +231,34 @@ func (m *natManager) AddEIP(ctx context.Context, eip EIPSpec) error {
if err := m.garp(eip); err != nil {
slog.Warn("policy: AddEIP gratuitous-ARP emission failed", "external_ip", eip.ExternalIP, "logical_ip", eip.LogicalIP, "err", err)
}
+ // Distributed mode: the external_mac is known, so program the host
+ // neighbour entry directly instead of flushing and waiting on an ARP reply
+ // no node sends when inject-garp is unavailable. Fall back to a flush when
+ // the MAC is absent (centralised shape).
+ if distributed {
+ if err := m.neighPrime(eip); err != nil {
+ slog.Warn("policy: AddEIP neighbour prime failed", "external_ip", eip.ExternalIP, "logical_ip", eip.LogicalIP, "err", err)
+ }
+ } else if err := m.neigh(eip.ExternalIP); err != nil {
+ slog.Warn("policy: AddEIP neighbour flush failed", "external_ip", eip.ExternalIP, "logical_ip", eip.LogicalIP, "err", err)
+ }
return nil
}
-func (m *natManager) DeleteEIP(ctx context.Context, vpcID, logicalIP string) error {
+func (m *natManager) DeleteEIP(ctx context.Context, vpcID, externalIP, logicalIP string) error {
router := topology.VPCRouter(vpcID)
if err := m.ovn.DeleteNAT(ctx, router, "dnat_and_snat", logicalIP); err != nil {
- if errors.Is(err, ovn.ErrNATNotFound) {
- return nil
+ if !errors.Is(err, ovn.ErrNATNotFound) {
+ return fmt.Errorf("delete dnat_and_snat %s on %s: %w", logicalIP, router, err)
+ }
+ }
+ // Flush the host ARP entry for the released IP so the next owner is not
+ // shadowed by this rule's stale MAC. Best-effort, and safe on the
+ // not-found path (the entry may still be cached from a prior binding).
+ if externalIP != "" {
+ if err := m.neigh(externalIP); err != nil {
+ slog.Warn("policy: DeleteEIP neighbour flush failed", "external_ip", externalIP, "logical_ip", logicalIP, "err", err)
}
- return fmt.Errorf("delete dnat_and_snat %s on %s: %w", logicalIP, router, err)
}
return nil
}
@@ -246,3 +325,48 @@ func (m *natManager) DeleteSNAT(ctx context.Context, vpcID, vpcCIDR string) erro
}
return nil
}
+
+func (m *natManager) AddSystemInstanceSNAT(ctx context.Context, vpcID, logicalIP, externalIP string) error {
+ router := topology.VPCRouter(vpcID)
+ snatRule := &nbdb.NAT{
+ Type: "snat",
+ ExternalIP: externalIP,
+ LogicalIP: logicalIP,
+ ExternalIDs: map[string]string{
+ "spinifex:vpc_id": vpcID,
+ "spinifex:public_ip": externalIP,
+ "spinifex:role": "system-instance-egress",
+ },
+ }
+ // Skip when the existing row already matches; avoids the duplicate-append
+ // on a re-published add (the snat is keyed by its unique pool external IP).
+ if existing, err := m.ovn.FindNATByExternalIP(ctx, "snat", externalIP); err != nil {
+ slog.Warn("policy: AddSystemInstanceSNAT idempotency lookup failed", "external_ip", externalIP, "err", err)
+ } else if existing != nil && existing.LogicalIP == logicalIP {
+ slog.Info("policy: AddSystemInstanceSNAT idempotent skip — rule already current",
+ "router", router, "external_ip", externalIP, "logical_ip", logicalIP)
+ return nil
+ }
+
+ if err := m.ovn.AddNAT(ctx, router, snatRule); err != nil {
+ return fmt.Errorf("add system-instance snat %s -> %s on %s: %w", logicalIP, externalIP, router, err)
+ }
+ // Block until SB + chassis have the SNAT flow; otherwise first egress
+ // packets from the instance drop.
+ if err := m.barrier(); err != nil {
+ slog.Warn("policy: AddSystemInstanceSNAT flows barrier failed",
+ "logical_ip", logicalIP, "external_ip", externalIP, "err", err)
+ }
+ return nil
+}
+
+func (m *natManager) DeleteSystemInstanceSNAT(ctx context.Context, vpcID, logicalIP string) error {
+ router := topology.VPCRouter(vpcID)
+ if err := m.ovn.DeleteNAT(ctx, router, "snat", logicalIP); err != nil {
+ if errors.Is(err, ovn.ErrNATNotFound) {
+ return nil
+ }
+ return fmt.Errorf("delete system-instance snat %s on %s: %w", logicalIP, router, err)
+ }
+ return nil
+}
diff --git a/spinifex/network/policy/nat_test.go b/spinifex/network/policy/nat_test.go
index 9b065d832..9720899d5 100644
--- a/spinifex/network/policy/nat_test.go
+++ b/spinifex/network/policy/nat_test.go
@@ -140,6 +140,78 @@ func TestNATManager_AddEIP_GARP_FailureNonFatal(t *testing.T) {
assert.NotNil(t, findNAT(m, "dnat_and_snat", "10.0.0.5"), "NAT row must persist despite GARP failure")
}
+func TestNATManager_NeighPrime_OnDistributedAttach_FlushOnDetach(t *testing.T) {
+ ctx := context.Background()
+ m := mock.New()
+ seedRouter(t, m, "vpc-1")
+ var primed []EIPSpec
+ var flushed []string
+ nm, err := NewNATManager(m, NATModeDistributed,
+ WithNeighPrimer(func(eip EIPSpec) error {
+ primed = append(primed, eip)
+ return nil
+ }),
+ WithNeighFlusher(func(externalIP string) error {
+ flushed = append(flushed, externalIP)
+ return nil
+ }))
+ require.NoError(t, err)
+
+ spec := EIPSpec{
+ VPCID: "vpc-1", ExternalIP: "1.2.3.4", LogicalIP: "10.0.0.5",
+ PortName: "port-eni-abc", MAC: "aa:bb:cc:dd:ee:ff",
+ }
+ require.NoError(t, nm.AddEIP(ctx, spec))
+ require.NoError(t, nm.DeleteEIP(ctx, "vpc-1", "1.2.3.4", "10.0.0.5"))
+
+ require.Equal(t, []EIPSpec{spec}, primed,
+ "distributed attach must prime the host neighbour with the external_mac, not flush")
+ assert.Equal(t, []string{"1.2.3.4"}, flushed,
+ "detach must still flush the released external IP")
+}
+
+func TestNATManager_NeighFlush_OnCentralizedAttach(t *testing.T) {
+ ctx := context.Background()
+ m := mock.New()
+ seedRouter(t, m, "vpc-1")
+ var primed []EIPSpec
+ var flushed []string
+ nm, err := NewNATManager(m, NATModeCentralized,
+ WithNeighPrimer(func(eip EIPSpec) error {
+ primed = append(primed, eip)
+ return nil
+ }),
+ WithNeighFlusher(func(externalIP string) error {
+ flushed = append(flushed, externalIP)
+ return nil
+ }))
+ require.NoError(t, err)
+
+ require.NoError(t, nm.AddEIP(ctx, EIPSpec{
+ VPCID: "vpc-1", ExternalIP: "1.2.3.4", LogicalIP: "10.0.0.5",
+ }))
+ assert.Empty(t, primed, "centralized attach has no external_mac to prime")
+ assert.Equal(t, []string{"1.2.3.4"}, flushed,
+ "centralized attach must fall back to a neighbour flush")
+}
+
+func TestNATManager_NeighHooks_FailureNonFatal(t *testing.T) {
+ ctx := context.Background()
+ m := mock.New()
+ seedRouter(t, m, "vpc-1")
+ nm, err := NewNATManager(m, NATModeDistributed,
+ WithNeighPrimer(func(EIPSpec) error { return assert.AnError }),
+ WithNeighFlusher(func(string) error { return assert.AnError }))
+ require.NoError(t, err)
+
+ require.NoError(t, nm.AddEIP(ctx, EIPSpec{
+ VPCID: "vpc-1", ExternalIP: "1.2.3.4", LogicalIP: "10.0.0.5",
+ PortName: "port-eni-abc", MAC: "aa:bb:cc:dd:ee:ff",
+ }), "neigh prime failure must not propagate from AddEIP")
+ require.NoError(t, nm.DeleteEIP(ctx, "vpc-1", "1.2.3.4", "10.0.0.5"),
+ "neigh flush failure must not propagate from DeleteEIP")
+}
+
func TestNATManager_AddEIP_NoBarrier_Default(t *testing.T) {
ctx := context.Background()
m := mock.New()
@@ -237,7 +309,7 @@ func TestNATManager_DeleteEIP_IdempotentOnMissing(t *testing.T) {
nm, _ := NewNATManager(m, NATModeDistributed)
// No prior AddEIP — delete should still succeed.
- require.NoError(t, nm.DeleteEIP(ctx, "vpc-1", "10.0.0.5"))
+ require.NoError(t, nm.DeleteEIP(ctx, "vpc-1", "1.2.3.4", "10.0.0.5"))
}
func TestNATManager_AddNATGateway_FlowsBarrier_Fires(t *testing.T) {
@@ -342,7 +414,7 @@ func TestNATManager_DeleteEIP_CrossRouterIsolation(t *testing.T) {
PortName: "port-b", MAC: "bb:bb:bb:bb:bb:bb",
}))
- require.NoError(t, nm.DeleteEIP(ctx, "vpc-a", "10.0.0.5"))
+ require.NoError(t, nm.DeleteEIP(ctx, "vpc-a", "1.1.1.1", "10.0.0.5"))
var survived *nbdb.NAT
for _, n := range m.NATs {
@@ -370,3 +442,23 @@ func TestNATManager_AddSNAT_AndDelete(t *testing.T) {
assert.Nil(t, findNAT(m, "snat", "10.0.0.0/16"))
require.NoError(t, nm.DeleteSNAT(ctx, "vpc-1", "10.0.0.0/16"))
}
+
+func TestNATManager_AddSystemInstanceSNAT_AndDelete(t *testing.T) {
+ ctx := context.Background()
+ m := mock.New()
+ seedRouter(t, m, "vpc-1")
+ nm, _ := NewNATManager(m, NATModeDistributed)
+
+ require.NoError(t, nm.AddSystemInstanceSNAT(ctx, "vpc-1", "172.31.4.10/32", "1.2.3.4"))
+ got := findNAT(m, "snat", "172.31.4.10/32")
+ require.NotNil(t, got)
+ assert.Equal(t, "1.2.3.4", got.ExternalIP)
+ assert.Equal(t, "system-instance-egress", got.ExternalIDs["spinifex:role"])
+ // snat-only: no dnat_and_snat row, so the instance stays unreachable inbound.
+ assert.Nil(t, findNAT(m, "dnat_and_snat", "172.31.4.10/32"))
+
+ require.NoError(t, nm.DeleteSystemInstanceSNAT(ctx, "vpc-1", "172.31.4.10/32"))
+ assert.Nil(t, findNAT(m, "snat", "172.31.4.10/32"))
+ // Idempotent on a missing rule.
+ require.NoError(t, nm.DeleteSystemInstanceSNAT(ctx, "vpc-1", "172.31.4.10/32"))
+}
diff --git a/spinifex/network/policy/route.go b/spinifex/network/policy/route.go
index 7893e8595..fcf5a0fac 100644
--- a/spinifex/network/policy/route.go
+++ b/spinifex/network/policy/route.go
@@ -24,6 +24,13 @@ const (
SubnetEgressPriorityDrop = 1100
SubnetEgressPriorityIGW = 1000
SubnetEgressPriorityNATGW = 900
+
+ // SystemInstanceEgressPriority sits above the drop gate (1100) so a single
+ // system instance (e.g. an EKS K3s server VM) egresses even when its subnet
+ // is otherwise drop-gated. The policy is scoped to the instance's /32
+ // source, so peer instances in the same subnet are unaffected — they still
+ // hit the 1100 drop (or the subnet's own 1000 reroute where one exists).
+ SystemInstanceEgressPriority = 1200
)
// RouteSpec is a static route on a VPC's LogicalRouter. OutputPort is
@@ -52,6 +59,20 @@ type SubnetEgressSpec struct {
ExcludeCIDRs []netip.Prefix
}
+// SystemInstanceEgressSpec describes a per-instance egress override installed
+// as an OVN Logical_Router_Policy at SystemInstanceEgressPriority. The match
+// adds an `ip4.src == /32` clause to the subnet-egress shape so the
+// reroute is confined to a single system instance; peer instances in the same
+// subnet are untouched. Action is "reroute" via Nexthop out OutputPort.
+type SystemInstanceEgressSpec struct {
+ SubnetID string
+ SrcIP netip.Addr
+ Prefix netip.Prefix
+ Nexthop string
+ OutputPort string
+ ExcludeCIDRs []netip.Prefix
+}
+
// RouteManager owns static routes on VPC LogicalRouters. Adds are
// idempotent (no-op on match, delete-then-add on drift) so the reconciler
// can replay safely.
@@ -64,6 +85,8 @@ type RouteManager interface {
DeleteSubnetEgress(ctx context.Context, vpcID, subnetID string, prefix netip.Prefix, excludeCIDRs []netip.Prefix) error
AddSubnetEgressDrop(ctx context.Context, vpcID string, spec SubnetEgressDropSpec) error
DeleteSubnetEgressDrop(ctx context.Context, vpcID, subnetID string, prefix netip.Prefix, excludeCIDRs []netip.Prefix) error
+ AddSystemInstanceEgress(ctx context.Context, vpcID string, spec SystemInstanceEgressSpec) error
+ DeleteSystemInstanceEgress(ctx context.Context, vpcID, subnetID string, srcIP netip.Addr, prefix netip.Prefix, excludeCIDRs []netip.Prefix) error
}
// SubnetEgressDropSpec describes a per-subnet egress DROP override installed
@@ -263,6 +286,62 @@ func (m *routeManager) DeleteSubnetEgressDrop(ctx context.Context, vpcID, subnet
return nil
}
+func (m *routeManager) AddSystemInstanceEgress(ctx context.Context, vpcID string, spec SystemInstanceEgressSpec) error {
+ if spec.SubnetID == "" {
+ return fmt.Errorf("system instance egress: SubnetID required")
+ }
+ if !spec.SrcIP.IsValid() {
+ return fmt.Errorf("system instance egress: SrcIP required")
+ }
+ if spec.OutputPort == "" {
+ return fmt.Errorf("system instance egress: OutputPort required (ovn-northd drops policy reroute otherwise)")
+ }
+ router := topology.VPCRouter(vpcID)
+ match := systemInstanceEgressMatch(spec.SubnetID, spec.SrcIP, spec.Prefix, spec.ExcludeCIDRs)
+
+ existing, err := m.ovn.FindLogicalRouterPolicy(ctx, router, SystemInstanceEgressPriority, match)
+ if err != nil {
+ return fmt.Errorf("find LR policy %q on %s: %w", match, router, err)
+ }
+ if existing != nil {
+ if existing.Action == "reroute" && existing.Nexthop != nil && *existing.Nexthop == spec.Nexthop &&
+ existing.ExternalIDs["spinifex:output_port"] == spec.OutputPort {
+ return nil
+ }
+ if err := m.ovn.DeleteLogicalRouterPolicy(ctx, router, SystemInstanceEgressPriority, match); err != nil {
+ return fmt.Errorf("delete drifted LR policy %q on %s: %w", match, router, err)
+ }
+ }
+
+ nexthop := spec.Nexthop
+ row := &nbdb.LogicalRouterPolicy{
+ Priority: SystemInstanceEgressPriority,
+ Match: match,
+ Action: "reroute",
+ Nexthop: &nexthop,
+ Options: map[string]string{},
+ ExternalIDs: map[string]string{
+ "spinifex:subnet": spec.SubnetID,
+ "spinifex:src_ip": spec.SrcIP.String(),
+ "spinifex:output_port": spec.OutputPort,
+ "spinifex:role": "system-instance-egress",
+ },
+ }
+ if err := m.ovn.AddLogicalRouterPolicy(ctx, router, row); err != nil {
+ return fmt.Errorf("add LR policy %q -> %s on %s: %w", match, spec.Nexthop, router, err)
+ }
+ return nil
+}
+
+func (m *routeManager) DeleteSystemInstanceEgress(ctx context.Context, vpcID, subnetID string, srcIP netip.Addr, prefix netip.Prefix, excludeCIDRs []netip.Prefix) error {
+ router := topology.VPCRouter(vpcID)
+ match := systemInstanceEgressMatch(subnetID, srcIP, prefix, excludeCIDRs)
+ if err := m.ovn.DeleteLogicalRouterPolicy(ctx, router, SystemInstanceEgressPriority, match); err != nil {
+ return fmt.Errorf("delete system instance egress LR policy %q on %s: %w", match, router, err)
+ }
+ return nil
+}
+
func subnetEgressMatch(subnetID string, prefix netip.Prefix, excludeCIDRs []netip.Prefix) string {
match := fmt.Sprintf(`inport == %q && ip4.dst == %s`, topology.SubnetRouterPort(subnetID), prefix.String())
for _, ex := range excludeCIDRs {
@@ -274,6 +353,18 @@ func subnetEgressMatch(subnetID string, prefix netip.Prefix, excludeCIDRs []neti
return match
}
+func systemInstanceEgressMatch(subnetID string, srcIP netip.Addr, prefix netip.Prefix, excludeCIDRs []netip.Prefix) string {
+ match := fmt.Sprintf(`inport == %q && ip4.src == %s/32 && ip4.dst == %s`,
+ topology.SubnetRouterPort(subnetID), srcIP.String(), prefix.String())
+ for _, ex := range excludeCIDRs {
+ if !ex.IsValid() {
+ continue
+ }
+ match += fmt.Sprintf(` && ip4.dst != %s`, ex.String())
+ }
+ return match
+}
+
func policyMatches(existing *nbdb.LogicalRouterPolicy, want SubnetEgressSpec) bool {
if existing.Action != "reroute" {
return false
diff --git a/spinifex/network/policy/route_test.go b/spinifex/network/policy/route_test.go
index 6fa5a4a33..0ba25dfe0 100644
--- a/spinifex/network/policy/route_test.go
+++ b/spinifex/network/policy/route_test.go
@@ -131,6 +131,128 @@ func TestRouteManager_AddSubnetEgress_InstallsScopedPolicy(t *testing.T) {
assert.Equal(t, "gw-vpc-1", p.ExternalIDs["spinifex:output_port"])
}
+func TestRouteManager_AddSystemInstanceEgress_InstallsScopedPolicy(t *testing.T) {
+ ctx := context.Background()
+ m := mock.New()
+ seedRouter(t, m, "vpc-1")
+ rm := NewRouteManager(m)
+
+ require.NoError(t, rm.AddSystemInstanceEgress(ctx, "vpc-1", SystemInstanceEgressSpec{
+ SubnetID: "subnet-k3s",
+ SrcIP: netip.MustParseAddr("172.31.4.10"),
+ Prefix: netip.MustParsePrefix("0.0.0.0/0"),
+ Nexthop: "192.168.1.1",
+ OutputPort: "gw-vpc-1",
+ }))
+
+ policies, err := m.ListLogicalRouterPolicies(ctx, topology.VPCRouter("vpc-1"))
+ require.NoError(t, err)
+ require.Len(t, policies, 1)
+ p := policies[0]
+ assert.Equal(t, "reroute", p.Action)
+ assert.Equal(t, SystemInstanceEgressPriority, p.Priority)
+ require.NotNil(t, p.Nexthop)
+ assert.Equal(t, "192.168.1.1", *p.Nexthop)
+ // Scoped to the single instance /32 source so peers in the subnet are untouched.
+ assert.Contains(t, p.Match, "ip4.src == 172.31.4.10/32")
+ assert.Contains(t, p.Match, topology.SubnetRouterPort("subnet-k3s"))
+ assert.Equal(t, "172.31.4.10", p.ExternalIDs["spinifex:src_ip"])
+ assert.Equal(t, "system-instance-egress", p.ExternalIDs["spinifex:role"])
+}
+
+func TestRouteManager_AddSystemInstanceEgress_IsIdempotent(t *testing.T) {
+ ctx := context.Background()
+ m := mock.New()
+ seedRouter(t, m, "vpc-1")
+ rm := NewRouteManager(m)
+
+ spec := SystemInstanceEgressSpec{
+ SubnetID: "subnet-k3s",
+ SrcIP: netip.MustParseAddr("172.31.4.10"),
+ Prefix: netip.MustParsePrefix("0.0.0.0/0"),
+ Nexthop: "192.168.1.1",
+ OutputPort: "gw-vpc-1",
+ }
+ require.NoError(t, rm.AddSystemInstanceEgress(ctx, "vpc-1", spec))
+ require.NoError(t, rm.AddSystemInstanceEgress(ctx, "vpc-1", spec))
+ require.NoError(t, rm.AddSystemInstanceEgress(ctx, "vpc-1", spec))
+
+ policies, err := m.ListLogicalRouterPolicies(ctx, topology.VPCRouter("vpc-1"))
+ require.NoError(t, err)
+ assert.Len(t, policies, 1)
+}
+
+func TestRouteManager_AddSystemInstanceEgress_DriftReplacesNexthop(t *testing.T) {
+ ctx := context.Background()
+ m := mock.New()
+ seedRouter(t, m, "vpc-1")
+ rm := NewRouteManager(m)
+
+ spec := SystemInstanceEgressSpec{
+ SubnetID: "subnet-k3s",
+ SrcIP: netip.MustParseAddr("172.31.4.10"),
+ Prefix: netip.MustParsePrefix("0.0.0.0/0"),
+ Nexthop: "192.168.1.1",
+ OutputPort: "gw-vpc-1",
+ }
+ require.NoError(t, rm.AddSystemInstanceEgress(ctx, "vpc-1", spec))
+ spec.Nexthop = "192.168.1.254"
+ require.NoError(t, rm.AddSystemInstanceEgress(ctx, "vpc-1", spec))
+
+ policies, err := m.ListLogicalRouterPolicies(ctx, topology.VPCRouter("vpc-1"))
+ require.NoError(t, err)
+ require.Len(t, policies, 1)
+ require.NotNil(t, policies[0].Nexthop)
+ assert.Equal(t, "192.168.1.254", *policies[0].Nexthop)
+}
+
+func TestRouteManager_AddSystemInstanceEgress_RejectsMissingFields(t *testing.T) {
+ ctx := context.Background()
+ m := mock.New()
+ seedRouter(t, m, "vpc-1")
+ rm := NewRouteManager(m)
+
+ valid := SystemInstanceEgressSpec{
+ SubnetID: "subnet-k3s",
+ SrcIP: netip.MustParseAddr("172.31.4.10"),
+ Prefix: netip.MustParsePrefix("0.0.0.0/0"),
+ Nexthop: "192.168.1.1",
+ OutputPort: "gw-vpc-1",
+ }
+
+ noSubnet := valid
+ noSubnet.SubnetID = ""
+ assert.Error(t, rm.AddSystemInstanceEgress(ctx, "vpc-1", noSubnet))
+
+ noSrc := valid
+ noSrc.SrcIP = netip.Addr{}
+ assert.Error(t, rm.AddSystemInstanceEgress(ctx, "vpc-1", noSrc))
+
+ noPort := valid
+ noPort.OutputPort = ""
+ assert.Error(t, rm.AddSystemInstanceEgress(ctx, "vpc-1", noPort))
+}
+
+func TestRouteManager_DeleteSystemInstanceEgress_RemovesAndIdempotent(t *testing.T) {
+ ctx := context.Background()
+ m := mock.New()
+ seedRouter(t, m, "vpc-1")
+ rm := NewRouteManager(m)
+
+ src := netip.MustParseAddr("172.31.4.10")
+ prefix := netip.MustParsePrefix("0.0.0.0/0")
+ require.NoError(t, rm.AddSystemInstanceEgress(ctx, "vpc-1", SystemInstanceEgressSpec{
+ SubnetID: "subnet-k3s", SrcIP: src, Prefix: prefix, Nexthop: "192.168.1.1", OutputPort: "gw-vpc-1",
+ }))
+
+ require.NoError(t, rm.DeleteSystemInstanceEgress(ctx, "vpc-1", "subnet-k3s", src, prefix, nil))
+ policies, err := m.ListLogicalRouterPolicies(ctx, topology.VPCRouter("vpc-1"))
+ require.NoError(t, err)
+ assert.Empty(t, policies)
+ // Idempotent on a missing policy.
+ require.NoError(t, rm.DeleteSystemInstanceEgress(ctx, "vpc-1", "subnet-k3s", src, prefix, nil))
+}
+
func TestRouteManager_AddSubnetEgress_IsIdempotent(t *testing.T) {
ctx := context.Background()
m := mock.New()
diff --git a/spinifex/network/reconcile/apply.go b/spinifex/network/reconcile/apply.go
index ff904e45f..31eeaf9fb 100644
--- a/spinifex/network/reconcile/apply.go
+++ b/spinifex/network/reconcile/apply.go
@@ -15,22 +15,21 @@ import (
func (r *reconciler) applyVPCs(ctx context.Context, intent IntentState, actual ActualState) {
for vpcID, spec := range intent.VPCs {
routerName := topology.VPCRouter(vpcID)
- if _, ok := actual.Routers[routerName]; ok {
- continue
+ if _, ok := actual.Routers[routerName]; !ok {
+ lr := &nbdb.LogicalRouter{
+ Name: routerName,
+ ExternalIDs: map[string]string{
+ "spinifex:vpc_id": vpcID,
+ "spinifex:cidr": spec.CIDR.String(),
+ },
+ }
+ if _, err := r.ovn.EnsureLogicalRouter(ctx, lr); err != nil {
+ slog.Error("reconcile/apply: ensure VPC router failed", "vpc_id", vpcID, "err", err)
+ continue
+ }
+ actual.Routers[routerName] = struct{}{}
+ slog.Info("reconcile/apply: ensured VPC router", "vpc_id", vpcID, "router", routerName)
}
- lr := &nbdb.LogicalRouter{
- Name: routerName,
- ExternalIDs: map[string]string{
- "spinifex:vpc_id": vpcID,
- "spinifex:cidr": spec.CIDR.String(),
- },
- }
- if _, err := r.ovn.EnsureLogicalRouter(ctx, lr); err != nil {
- slog.Error("reconcile/apply: ensure VPC router failed", "vpc_id", vpcID, "err", err)
- continue
- }
- actual.Routers[routerName] = struct{}{}
- slog.Info("reconcile/apply: ensured VPC router", "vpc_id", vpcID, "router", routerName)
}
}
@@ -88,7 +87,9 @@ func (r *reconciler) applySubnets(ctx context.Context, intent IntentState, actua
Name: switchRouterPortName,
Type: "router",
Addresses: []string{"router"},
- Options: map[string]string{"router-port": routerPortName},
+ Options: map[string]string{
+ "router-port": routerPortName,
+ },
ExternalIDs: map[string]string{
"spinifex:subnet_id": subnetID,
"spinifex:vpc_id": spec.VPCID,
@@ -100,6 +101,14 @@ func (r *reconciler) applySubnets(ctx context.Context, intent IntentState, actua
}
}
+ // The IMDS localport rides on the subnet switch (idempotent via the
+ // subnet-veth bucket gate); the switch must exist first.
+ if r.imds != nil {
+ if _, err := r.imds.EnsureForSubnet(ctx, subnetID, spec.VPCID, spec.CIDR); err != nil {
+ slog.Error("reconcile/apply: IMDS EnsureForSubnet failed", "subnet_id", subnetID, "err", err)
+ }
+ }
+
if existing, err := r.ovn.FindDHCPOptionsByExternalID(ctx, "spinifex:subnet_id", subnetID); err != nil || existing == nil {
opts := &nbdb.DHCPOptions{
CIDR: spec.CIDR.String(),
diff --git a/spinifex/network/reconcile/invariants_test.go b/spinifex/network/reconcile/invariants_test.go
new file mode 100644
index 000000000..8afa80b77
--- /dev/null
+++ b/spinifex/network/reconcile/invariants_test.go
@@ -0,0 +1,85 @@
+package reconcile
+
+import (
+ "context"
+ "net"
+ "net/netip"
+ "testing"
+
+ "github.com/mulgadc/spinifex/spinifex/network/ovn/mock"
+ "github.com/mulgadc/spinifex/spinifex/network/topology"
+)
+
+// IMDS-datapath invariants. A guest LSP is created by two
+// independent paths — the live topology manager (EnsurePort) and the reconciler
+// (applyPorts) — and the per-subnet DHCPOptions row likewise. Both paths are
+// exercised here so neither can drift away from the contract the IMDS handler
+// trusts. These live in the reconcile package because it is the only network
+// package that can import both topology (the live path) and itself (the
+// reconciler path) without a cycle.
+
+// TestI1_GuestLSPMustHavePortSecurity asserts that every guest-attached LSP,
+// created by either path, carries port_security equal to its addresses
+// (" "). This is the load-bearing security boundary: ovn-controller
+// drops any frame whose eth.src/ip4.src doesn't match port_security at the
+// ingress LSP, so a compromised guest cannot forge a peer's source IP. Without
+// it the IMDS (VPC-ID, source-IP) → ENI mapping is forgeable.
+func TestI1_GuestLSPMustHavePortSecurity(t *testing.T) {
+ ctx := context.Background()
+
+ // Live path: topology.EnsurePort.
+ t.Run("live", func(t *testing.T) {
+ m := mock.New()
+ mgr := topology.NewLiveManager(m)
+ vpc := topology.VPCSpec{VPCID: "vpc-a", CIDR: netip.MustParsePrefix("10.0.0.0/16")}
+ sub := topology.SubnetSpec{SubnetID: "subnet-a", VPCID: "vpc-a", CIDR: netip.MustParsePrefix("10.0.1.0/24")}
+ mac, _ := net.ParseMAC("02:00:00:00:00:01")
+ port := topology.PortSpec{
+ PortID: "eni-a", SubnetID: "subnet-a", VPCID: "vpc-a",
+ PrivateIP: netip.MustParseAddr("10.0.1.5"), MAC: mac,
+ }
+ if err := mgr.EnsureVPC(ctx, vpc); err != nil {
+ t.Fatalf("EnsureVPC: %v", err)
+ }
+ if err := mgr.EnsureSubnet(ctx, sub); err != nil {
+ t.Fatalf("EnsureSubnet: %v", err)
+ }
+ if err := mgr.EnsurePort(ctx, port); err != nil {
+ t.Fatalf("EnsurePort: %v", err)
+ }
+ assertPortSecurity(t, m, topology.Port(port.PortID), "02:00:00:00:00:01 10.0.1.5")
+ })
+
+ // Reconciler path: applyPorts.
+ t.Run("reconciler", func(t *testing.T) {
+ rec, m := newTestReconciler(t)
+ intent := freshIntent(t)
+ if err := rec.Reconcile(ctx, intent); err != nil {
+ t.Fatalf("Reconcile: %v", err)
+ }
+ port := intent.Ports["eni-a"]
+ want := port.MAC.String() + " " + port.PrivateIP.String()
+ assertPortSecurity(t, m, topology.Port(port.PortID), want)
+ })
+}
+
+// assertPortSecurity fails with the ADR clause when the LSP lacks port_security
+// equal to its addresses.
+func assertPortSecurity(t *testing.T, m *mock.Client, portName, want string) {
+ t.Helper()
+ lsp, err := m.GetLogicalSwitchPort(context.Background(), portName)
+ if err != nil {
+ t.Fatalf("guest LSP %s missing: %v", portName, err)
+ }
+ if len(lsp.PortSecurity) != 1 || lsp.PortSecurity[0] != want {
+ t.Errorf("guest LSP %s PortSecurity = %v, want [%q]: guest LSPs without "+
+ "port_security allow source-IP spoofing, breaking the IMDS "+
+ "(VPC-ID, source-IP) → ENI mapping", portName, lsp.PortSecurity, want)
+ }
+ // port_security must mirror addresses exactly, or the enforced identity and
+ // the advertised identity diverge.
+ if len(lsp.Addresses) != 1 || lsp.Addresses[0] != lsp.PortSecurity[0] {
+ t.Errorf("guest LSP %s Addresses %v != PortSecurity %v: enforced and advertised "+
+ "identity must match", portName, lsp.Addresses, lsp.PortSecurity)
+ }
+}
diff --git a/spinifex/network/reconcile/lock.go b/spinifex/network/reconcile/lock.go
index 07da0f5c1..37b4b5c19 100644
--- a/spinifex/network/reconcile/lock.go
+++ b/spinifex/network/reconcile/lock.go
@@ -1,6 +1,7 @@
package reconcile
import (
+ "errors"
"log/slog"
"time"
@@ -32,11 +33,18 @@ func AcquireLeader(nc *nats.Conn, holder string) (func(), bool) {
)
deadline := time.Now().Add(leaderRetryFor)
for {
- kv, err = js.CreateKeyValue(&nats.KeyValueConfig{
- Bucket: reconcileLeaderBucket,
- History: 1,
- TTL: reconcileLeaderTTL,
- })
+ // Get-or-create: CreateKeyValue alone returns "stream name already in
+ // use" once the bucket exists, so every reconcile after the first would
+ // hit the deadline and never elect. Attach to the existing bucket first,
+ // create only when it is genuinely absent.
+ kv, err = js.KeyValue(reconcileLeaderBucket)
+ if errors.Is(err, nats.ErrBucketNotFound) {
+ kv, err = js.CreateKeyValue(&nats.KeyValueConfig{
+ Bucket: reconcileLeaderBucket,
+ History: 1,
+ TTL: reconcileLeaderTTL,
+ })
+ }
if err == nil {
break
}
diff --git a/spinifex/network/reconcile/lock_test.go b/spinifex/network/reconcile/lock_test.go
new file mode 100644
index 000000000..2c2a48d44
--- /dev/null
+++ b/spinifex/network/reconcile/lock_test.go
@@ -0,0 +1,35 @@
+package reconcile
+
+import (
+ "testing"
+
+ "github.com/mulgadc/spinifex/spinifex/testutil"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+// TestAcquireLeader_GetOrCreateAcrossCalls pins the get-or-create fix: the
+// first acquire creates the bucket and wins, a second acquire while the lock is
+// held must attach to the now-existing bucket (not hang on "stream name already
+// in use") and lose cleanly, and once the winner releases a later acquire wins
+// again.
+func TestAcquireLeader_GetOrCreateAcrossCalls(t *testing.T) {
+ _, nc, _ := testutil.StartTestJetStream(t)
+
+ release, ok := AcquireLeader(nc, "node-1")
+ require.True(t, ok, "first acquire must win and create the bucket")
+ require.NotNil(t, release)
+
+ // Bucket now exists; the second acquire must reach the Create-key contention
+ // path (and lose) rather than dead-ending on CreateKeyValue.
+ loserRelease, ok := AcquireLeader(nc, "node-2")
+ assert.False(t, ok, "second acquire must lose while the lock is held")
+ assert.Nil(t, loserRelease)
+
+ // Releasing frees the key so a subsequent acquire can take over.
+ release()
+ release2, ok := AcquireLeader(nc, "node-3")
+ require.True(t, ok, "acquire after release must win")
+ require.NotNil(t, release2)
+ release2()
+}
diff --git a/spinifex/network/reconcile/reconcile.go b/spinifex/network/reconcile/reconcile.go
index 02946b490..37078949a 100644
--- a/spinifex/network/reconcile/reconcile.go
+++ b/spinifex/network/reconcile/reconcile.go
@@ -36,7 +36,11 @@ type Config struct {
Routes policy.RouteManager
IGW external.IGWManager
Topology topology.Manager
- LocalAZ string
+ // IMDS installs per-VPC IMDS OVN topology during the VPC apply pass.
+ // Optional: nil skips IMDS plumbing (focused reconcile tests leave it
+ // unset; production always wires it).
+ IMDS external.IMDSTopologyManager
+ LocalAZ string
// NodeHostname is the holder identity for leader-election CAS.
NodeHostname string
// Chassis is the SBDB-discovered chassis list for gateway LRP rebinding.
@@ -54,6 +58,7 @@ type reconciler struct {
routes policy.RouteManager
igw external.IGWManager
topology topology.Manager
+ imds external.IMDSTopologyManager
localAZ string
host string
chassis []string
@@ -92,6 +97,7 @@ func New(cfg Config) (Reconciler, error) {
routes: cfg.Routes,
igw: cfg.IGW,
topology: cfg.Topology,
+ imds: cfg.IMDS,
localAZ: cfg.LocalAZ,
host: cfg.NodeHostname,
chassis: cfg.Chassis,
diff --git a/spinifex/network/subscribers/events.go b/spinifex/network/subscribers/events.go
index de3bb0442..6e12c8198 100644
--- a/spinifex/network/subscribers/events.go
+++ b/spinifex/network/subscribers/events.go
@@ -19,6 +19,8 @@ const (
TopicDeleteIGWRoute = "vpc.delete-igw-route"
TopicGateSubnetEgress = "vpc.gate-subnet-egress"
TopicUngateSubnetEgress = "vpc.ungate-subnet-egress"
+ TopicAddSystemEgress = "vpc.add-system-egress"
+ TopicDeleteSystemEgress = "vpc.delete-system-egress"
TopicCreateSG = "vpc.create-sg"
TopicDeleteSG = "vpc.delete-sg"
TopicUpdateSG = "vpc.update-sg"
@@ -113,6 +115,19 @@ type SubnetEgressUngateEvent struct {
DestinationCidr string `json:"destination_cidr"`
}
+// SystemEgressEvent is published on vpc.add-system-egress /
+// vpc.delete-system-egress to give a single hidden system instance (e.g. an
+// EKS K3s server VM) egress-only internet access. The subscriber installs (or
+// removes) a /32 reroute policy at SystemInstanceEgressPriority plus a plain
+// snat (InstanceIp -> ExternalIp) — no DNAT, so the instance stays unreachable
+// inbound.
+type SystemEgressEvent struct {
+ VpcId string `json:"vpc_id"`
+ SubnetId string `json:"subnet_id"`
+ InstanceIp string `json:"instance_ip"`
+ ExternalIp string `json:"external_ip"`
+}
+
// SGRule mirrors handlers/ec2/vpc.SGRule on the wire (kept local to avoid
// the handler import).
type SGRule struct {
diff --git a/spinifex/network/subscribers/subscribers.go b/spinifex/network/subscribers/subscribers.go
index fc3368bc5..ccd785b4e 100644
--- a/spinifex/network/subscribers/subscribers.go
+++ b/spinifex/network/subscribers/subscribers.go
@@ -23,15 +23,20 @@ type Subscriber struct {
eip external.EIPManager
natgw external.NATGWManager
igw external.IGWManager
+ imds external.IMDSTopologyManager
}
-// Config: all fields required.
+// Config: all fields required except IMDS.
type Config struct {
Topology topology.Manager
SG policy.SecurityGroupManager
EIP external.EIPManager
NATGW external.NATGWManager
IGW external.IGWManager
+ // IMDS installs per-VPC IMDS OVN topology on vpc.create / removes it on
+ // vpc.delete. Optional: nil disables IMDS plumbing (focused topology tests
+ // leave it unset; production always wires it).
+ IMDS external.IMDSTopologyManager
}
// New constructs a Subscriber, returning an error when any manager is nil.
@@ -54,6 +59,7 @@ func New(cfg Config) (*Subscriber, error) {
eip: cfg.EIP,
natgw: cfg.NATGW,
igw: cfg.IGW,
+ imds: cfg.IMDS,
}, nil
}
@@ -82,6 +88,8 @@ func (s *Subscriber) Subscribe(nc *nats.Conn) ([]*nats.Subscription, error) {
{TopicDeleteIGWRoute, s.handleDeleteIGWRoute},
{TopicGateSubnetEgress, s.handleGateSubnetEgress},
{TopicUngateSubnetEgress, s.handleUngateSubnetEgress},
+ {TopicAddSystemEgress, s.handleAddSystemEgress},
+ {TopicDeleteSystemEgress, s.handleDeleteSystemEgress},
{TopicCreateSG, s.handleCreateSG},
{TopicDeleteSG, s.handleDeleteSG},
{TopicUpdateSG, s.handleUpdateSG},
diff --git a/spinifex/network/subscribers/subscribers_test.go b/spinifex/network/subscribers/subscribers_test.go
index 1d2f3c106..e528cb285 100644
--- a/spinifex/network/subscribers/subscribers_test.go
+++ b/spinifex/network/subscribers/subscribers_test.go
@@ -98,6 +98,7 @@ func TestSubscribe_RegistersAllTopics(t *testing.T) {
TopicAddNATGateway, TopicDeleteNATGateway,
TopicAddIGWRoute, TopicDeleteIGWRoute,
TopicGateSubnetEgress, TopicUngateSubnetEgress,
+ TopicAddSystemEgress, TopicDeleteSystemEgress,
TopicCreateSG, TopicDeleteSG, TopicUpdateSG,
}
if len(subs) != len(wantTopics) {
@@ -173,6 +174,66 @@ func TestHandleAddNATGateway_InstallsPerSubnetEgress(t *testing.T) {
}, 2*time.Second, 20*time.Millisecond, "NATGW egress policy must be removed on delete event")
}
+// TestHandleSystemEgress_InstallsAndRemoves drives the full event path: a
+// vpc.add-system-egress event installs the /32 reroute + snat-only NAT, and
+// vpc.delete-system-egress tears both down.
+func TestHandleSystemEgress_InstallsAndRemoves(t *testing.T) {
+ ctx := context.Background()
+ _, nc := testutil.StartTestNATS(t)
+ sub, m := newTestSubscriber(t)
+ subs, err := sub.Subscribe(nc)
+ require.NoError(t, err)
+ defer func() {
+ for _, s := range subs {
+ _ = s.Unsubscribe()
+ }
+ }()
+
+ require.NoError(t, m.CreateLogicalRouter(ctx, &nbdb.LogicalRouter{
+ Name: topology.VPCRouter("vpc-1"),
+ ExternalIDs: map[string]string{"spinifex:vpc_id": "vpc-1"},
+ }))
+ require.NoError(t, sub.igw.AttachIGW(ctx, external.IGWSpec{VPCID: "vpc-1", InternetGatewayID: "igw-1"}))
+
+ evt := SystemEgressEvent{
+ VpcId: "vpc-1",
+ SubnetId: "subnet-k3s",
+ InstanceIp: "10.0.4.10",
+ ExternalIp: "192.168.1.60",
+ }
+ data, err := json.Marshal(evt)
+ require.NoError(t, err)
+ require.NoError(t, nc.Publish(TopicAddSystemEgress, data))
+
+ require.Eventually(t, func() bool {
+ policies, perr := m.ListLogicalRouterPolicies(ctx, topology.VPCRouter("vpc-1"))
+ if perr != nil || len(policies) != 1 {
+ return false
+ }
+ return policies[0].Priority == policy.SystemInstanceEgressPriority
+ }, 2*time.Second, 20*time.Millisecond, "system egress reroute must be installed")
+
+ policies, err := m.ListLogicalRouterPolicies(ctx, topology.VPCRouter("vpc-1"))
+ require.NoError(t, err)
+ require.Len(t, policies, 1)
+ assert.Contains(t, policies[0].Match, "ip4.src == 10.0.4.10/32")
+
+ snat, err := m.FindNATByExternalIP(ctx, "snat", "192.168.1.60")
+ require.NoError(t, err)
+ require.NotNil(t, snat)
+ assert.Equal(t, "10.0.4.10/32", snat.LogicalIP)
+
+ require.NoError(t, nc.Publish(TopicDeleteSystemEgress, data))
+ require.Eventually(t, func() bool {
+ policies, perr := m.ListLogicalRouterPolicies(ctx, topology.VPCRouter("vpc-1"))
+ if perr != nil || len(policies) != 0 {
+ return false
+ }
+ got, gerr := m.FindNATByExternalIP(ctx, "snat", "192.168.1.60")
+ return gerr == nil && got == nil
+ }, 2*time.Second, 20*time.Millisecond, "system egress reroute + snat must be removed on delete")
+}
+
// TestHandleAddNATGateway_InvalidCIDRSkipsPolicy: the SNAT install must still
// happen, but a malformed DestinationCidr must not install any LR policy.
func TestHandleAddNATGateway_InvalidCIDRSkipsPolicy(t *testing.T) {
diff --git a/spinifex/network/subscribers/topology.go b/spinifex/network/subscribers/topology.go
index 0db002c9d..18d44639e 100644
--- a/spinifex/network/subscribers/topology.go
+++ b/spinifex/network/subscribers/topology.go
@@ -31,7 +31,8 @@ func (s *Subscriber) handleVPCCreate(msg *nats.Msg) {
}
spec.CIDR = cidr
}
- if err := s.topology.EnsureVPC(context.Background(), spec); err != nil {
+ ctx := context.Background()
+ if err := s.topology.EnsureVPC(ctx, spec); err != nil {
slog.Error("subscribers: EnsureVPC failed", "vpc_id", evt.VpcId, "err", err)
respond(msg, err)
return
@@ -46,7 +47,8 @@ func (s *Subscriber) handleVPCDelete(msg *nats.Msg) {
respond(msg, err)
return
}
- if err := s.topology.DeleteVPC(context.Background(), evt.VpcId); err != nil {
+ ctx := context.Background()
+ if err := s.topology.DeleteVPC(ctx, evt.VpcId); err != nil {
slog.Error("subscribers: DeleteVPC failed", "vpc_id", evt.VpcId, "err", err)
respond(msg, err)
return
@@ -85,6 +87,14 @@ func (s *Subscriber) handleSubnetCreate(msg *nats.Msg) {
respond(msg, err)
return
}
+ // The IMDS localport lives on every subnet switch (guests reach metadata over
+ // one L2 hop). Install is best-effort — the reconciler re-ensures it
+ // idempotently, so a transient OVN failure here must not fail CreateSubnet.
+ if s.imds != nil {
+ if _, err := s.imds.EnsureForSubnet(ctx, evt.SubnetId, evt.VpcId, cidr); err != nil {
+ slog.Warn("subscribers: IMDS EnsureForSubnet failed; reconciler will converge", "subnet_id", evt.SubnetId, "err", err)
+ }
+ }
respond(msg, nil)
}
@@ -101,7 +111,17 @@ func (s *Subscriber) handleSubnetDelete(msg *nats.Msg) {
spec.CIDR = cidr
}
}
- if err := s.topology.DeleteSubnet(context.Background(), spec); err != nil {
+ ctx := context.Background()
+ // Remove the IMDS localport before the subnet switch goes away — the
+ // localport lives on subnet-{subnetID}.
+ if s.imds != nil {
+ if err := s.imds.RemoveForSubnet(ctx, evt.SubnetId); err != nil {
+ slog.Error("subscribers: IMDS RemoveForSubnet failed", "subnet_id", evt.SubnetId, "err", err)
+ respond(msg, err)
+ return
+ }
+ }
+ if err := s.topology.DeleteSubnet(ctx, spec); err != nil {
slog.Error("subscribers: DeleteSubnet failed", "subnet_id", evt.SubnetId, "err", err)
respond(msg, err)
return
@@ -249,7 +269,7 @@ func (s *Subscriber) handleDeleteNAT(msg *nats.Msg) {
respond(msg, err)
return
}
- if err := s.eip.DetachEIP(context.Background(), evt.VpcId, evt.LogicalIP); err != nil {
+ if err := s.eip.DetachEIP(context.Background(), evt.VpcId, evt.ExternalIP, evt.LogicalIP); err != nil {
slog.Error("subscribers: DeleteEIP failed",
"vpc_id", evt.VpcId, "logical_ip", evt.LogicalIP, "err", err)
respond(msg, err)
@@ -310,6 +330,41 @@ func (s *Subscriber) handleAddNATGateway(msg *nats.Msg) {
"natgw_id", evt.NatGatewayId)
}
+func (s *Subscriber) handleAddSystemEgress(msg *nats.Msg) {
+ var evt SystemEgressEvent
+ if err := json.Unmarshal(msg.Data, &evt); err != nil {
+ slog.Error("subscribers: failed to unmarshal vpc.add-system-egress event", "err", err)
+ return
+ }
+ ctx := context.Background()
+ if err := s.igw.EnsureSystemInstanceEgress(ctx, evt.VpcId, evt.SubnetId, evt.InstanceIp, evt.ExternalIp); err != nil {
+ slog.Error("subscribers: EnsureSystemInstanceEgress failed",
+ "vpc_id", evt.VpcId, "subnet_id", evt.SubnetId,
+ "instance_ip", evt.InstanceIp, "external_ip", evt.ExternalIp, "err", err)
+ return
+ }
+ slog.Info("subscribers: system instance egress installed",
+ "vpc_id", evt.VpcId, "subnet_id", evt.SubnetId,
+ "instance_ip", evt.InstanceIp, "external_ip", evt.ExternalIp)
+}
+
+func (s *Subscriber) handleDeleteSystemEgress(msg *nats.Msg) {
+ var evt SystemEgressEvent
+ if err := json.Unmarshal(msg.Data, &evt); err != nil {
+ slog.Error("subscribers: failed to unmarshal vpc.delete-system-egress event", "err", err)
+ return
+ }
+ ctx := context.Background()
+ if err := s.igw.RemoveSystemInstanceEgress(ctx, evt.VpcId, evt.SubnetId, evt.InstanceIp, evt.ExternalIp); err != nil {
+ slog.Error("subscribers: RemoveSystemInstanceEgress failed",
+ "vpc_id", evt.VpcId, "subnet_id", evt.SubnetId,
+ "instance_ip", evt.InstanceIp, "external_ip", evt.ExternalIp, "err", err)
+ return
+ }
+ slog.Info("subscribers: system instance egress removed",
+ "vpc_id", evt.VpcId, "subnet_id", evt.SubnetId, "instance_ip", evt.InstanceIp)
+}
+
func (s *Subscriber) handleDeleteNATGateway(msg *nats.Msg) {
var evt NATGatewayEvent
if err := json.Unmarshal(msg.Data, &evt); err != nil {
diff --git a/spinifex/network/topology/dhcp_options.go b/spinifex/network/topology/dhcp_options.go
index 553c4f61e..b1c323539 100644
--- a/spinifex/network/topology/dhcp_options.go
+++ b/spinifex/network/topology/dhcp_options.go
@@ -4,6 +4,14 @@ package topology
// subnet's DHCPOptions row. Both the live topology manager and the reconciler
// call it so the two paths cannot drift — they previously hard-coded divergent
// dns_server values (live used the configured server, the reconciler "8.8.8.8").
+//
+// IMDS reachability (169.254.169.254) is not steered via DHCP option 121: the
+// default gateway stays option 3 (router), and the localport on the subnet
+// switch answers ARP for the metadata IP directly (one L2 hop, no router). The
+// guest's on-link route to 169.254.169.254 is delivered out-of-band by the
+// cloud-init network-config the instance handler renders (NoCloud seed) — see
+// generateNetworkConfig in handlers/ec2/instance — so it reaches DHCP and static
+// guests alike without folding the default route into option 121 (RFC 3442).
func BuildSubnetDHCPOptions(gwIP, routerMAC, dnsServer string) map[string]string {
return map[string]string{
"server_id": gwIP,
diff --git a/spinifex/network/topology/dhcp_options_test.go b/spinifex/network/topology/dhcp_options_test.go
new file mode 100644
index 000000000..2c72051f9
--- /dev/null
+++ b/spinifex/network/topology/dhcp_options_test.go
@@ -0,0 +1,24 @@
+package topology
+
+import "testing"
+
+// IMDS reachability is served by a localport on the subnet switch (answered over
+// L2), not via DHCP option 121, so BuildSubnetDHCPOptions must no longer emit a
+// classless_static_route key. The default gateway is still carried by option 3
+// (router), and dns_server/server_id remain present.
+func TestBuildSubnetDHCPOptions_NoClasslessStaticRoute(t *testing.T) {
+ opts := BuildSubnetDHCPOptions("10.0.1.1", "02:00:00:00:00:01", "{8.8.8.8, 1.1.1.1}")
+
+ if _, ok := opts["classless_static_route"]; ok {
+ t.Errorf("classless_static_route must be absent (IMDS is served by a subnet-switch localport over L2, not DHCP option 121); got %q", opts["classless_static_route"])
+ }
+ if got := opts["router"]; got != "10.0.1.1" {
+ t.Errorf("router = %q, want %q", got, "10.0.1.1")
+ }
+ if got := opts["dns_server"]; got != "{8.8.8.8, 1.1.1.1}" {
+ t.Errorf("dns_server = %q, want %q", got, "{8.8.8.8, 1.1.1.1}")
+ }
+ if got := opts["server_id"]; got != "10.0.1.1" {
+ t.Errorf("server_id = %q, want %q", got, "10.0.1.1")
+ }
+}
diff --git a/spinifex/network/topology/names.go b/spinifex/network/topology/names.go
index ce9e8aa84..62fa12488 100644
--- a/spinifex/network/topology/names.go
+++ b/spinifex/network/topology/names.go
@@ -36,6 +36,12 @@ func ExternalSwitch(vpcID string) string { return "ext-" + vpcID }
// external switch onto the host uplink.
func ExternalLocalnetPort(vpcID string) string { return "ext-port-" + vpcID }
+// IMDSPort is the host-owned localport LSP that claims 169.254.169.254 directly
+// on the subnet switch (SubnetSwitch). ovn-controller binds it on every chassis
+// with a matching iface-id OVS port, so each chassis self-serves IMDS for its
+// local VMs over a single L2 hop on the guest's own broadcast domain.
+func IMDSPort(subnetID string) string { return "imds-port-" + subnetID }
+
// SecurityGroupPortGroup is the OVN port group name for an SG.
// OVN names match [a-zA-Z_][a-zA-Z0-9_]*, so hyphens become underscores.
func SecurityGroupPortGroup(sgID string) string {
diff --git a/spinifex/services/awsgw/awsgw.go b/spinifex/services/awsgw/awsgw.go
index ac7f49c1e..29daa81a1 100644
--- a/spinifex/services/awsgw/awsgw.go
+++ b/spinifex/services/awsgw/awsgw.go
@@ -146,6 +146,29 @@ func launchService(config *config.ClusterConfig) error {
defer cancelJanitor()
go stsService.RunJanitor(janitorCtx)
+ // IMDS serves 169.254.169.254 to guest VMs from vpcd, which holds the network
+ // capabilities the hardened awsgw sandbox can't grant. awsgw stays the home of
+ // STS + IAM, answering the IMDS handler's control-plane RPCs over NATS.
+ if _, err := stsService.SubscribeIMDSResponder(natsConn); err != nil {
+ return fmt.Errorf("subscribe IMDS STS responder: %w", err)
+ }
+ if _, err := iamService.SubscribeIMDSResponders(natsConn); err != nil {
+ return fmt.Errorf("subscribe IMDS IAM responders: %w", err)
+ }
+
+ // Expose the in-process STS presigned-URL verify over NATS for the
+ // in-cluster eks-token-webhook (STS is gateway-local, not otherwise on the
+ // bus). Bound to the process lifetime via natsConn.Close on return.
+ tokenVerifySub, err := registerEKSTokenVerify(natsConn, stsService)
+ if err != nil {
+ return fmt.Errorf("subscribe EKS token verify: %w", err)
+ }
+ defer func() {
+ if err := tokenVerifySub.Unsubscribe(); err != nil {
+ slog.Warn("EKS token verify: unsubscribe failed", "err", err)
+ }
+ }()
+
// First boot: consume bootstrap.json → seed IAM users into NATS KV → delete file.
// Check data directory first (production: /var/lib/spinifex/awsgw/), then
// awsgw subdir (dev: ~/spinifex/awsgw/), then legacy config dir.
diff --git a/spinifex/services/awsgw/eks_token_verify.go b/spinifex/services/awsgw/eks_token_verify.go
new file mode 100644
index 000000000..730fc8e9c
--- /dev/null
+++ b/spinifex/services/awsgw/eks_token_verify.go
@@ -0,0 +1,76 @@
+package awsgw
+
+import (
+ "encoding/json"
+ "log/slog"
+
+ "github.com/mulgadc/spinifex/spinifex/awserrors"
+ handlers_eks "github.com/mulgadc/spinifex/spinifex/handlers/eks"
+ handlers_sts "github.com/mulgadc/spinifex/spinifex/handlers/sts"
+ "github.com/mulgadc/spinifex/spinifex/utils"
+ "github.com/nats-io/nats.go"
+)
+
+// eksTokenVerifyQueue is the NATS queue group for the EKS token-verify
+// responders so the request is load-balanced across awsgw nodes.
+const eksTokenVerifyQueue = "awsgw-eks-token-verify" //nolint:gosec // queue-group name, not a credential
+
+// presignedVerifier is the slice of the STS service the token-verify responder
+// needs: resolve an `aws eks get-token` presigned URL into a caller principal,
+// binding the signature to the expected cluster name (anti-replay, Q10).
+type presignedVerifier interface {
+ VerifyPresignedGetCallerIdentity(presignedURL, expectedClusterName string) (*handlers_sts.PresignedCallerIdentity, error)
+}
+
+var _ presignedVerifier = (*handlers_sts.STSServiceImpl)(nil)
+
+// registerEKSTokenVerify subscribes the awsgw service to TokenVerifySubject so
+// the in-cluster eks-token-webhook can resolve get-token presigned URLs over
+// NATS (STS is hosted in-process here and is not otherwise on the bus).
+func registerEKSTokenVerify(nc *nats.Conn, verifier presignedVerifier) (*nats.Subscription, error) {
+ return nc.QueueSubscribe(handlers_eks.TokenVerifySubject, eksTokenVerifyQueue, handleEKSTokenVerify(verifier))
+}
+
+func handleEKSTokenVerify(verifier presignedVerifier) nats.MsgHandler {
+ return func(msg *nats.Msg) {
+ var req handlers_eks.TokenVerifyRequest
+ if err := json.Unmarshal(msg.Data, &req); err != nil {
+ respondTokenVerifyErr(msg, awserrors.ErrorInvalidParameterValue)
+ return
+ }
+ if req.PresignedURL == "" || req.ClusterName == "" {
+ respondTokenVerifyErr(msg, awserrors.ErrorInvalidParameterValue)
+ return
+ }
+
+ ident, err := verifier.VerifyPresignedGetCallerIdentity(req.PresignedURL, req.ClusterName)
+ if err != nil {
+ // A verify failure is the common, expected case for a forged/replayed
+ // token — log at debug so a port-scan does not flood the error log.
+ slog.Debug("EKS token verify rejected", "cluster", req.ClusterName, "err", err)
+ respondTokenVerifyErr(msg, awserrors.ValidErrorCode(err.Error()))
+ return
+ }
+
+ resp := handlers_eks.TokenVerifyResponse{
+ AccountID: ident.AccountID,
+ ARN: ident.ARN,
+ UserID: ident.UserID,
+ PrincipalType: ident.PrincipalType,
+ }
+ data, err := json.Marshal(resp)
+ if err != nil {
+ respondTokenVerifyErr(msg, awserrors.ErrorServerInternal)
+ return
+ }
+ if err := msg.Respond(data); err != nil {
+ slog.Error("EKS token verify: failed to respond", "err", err)
+ }
+ }
+}
+
+func respondTokenVerifyErr(msg *nats.Msg, code string) {
+ if err := msg.Respond(utils.GenerateErrorPayload(code)); err != nil {
+ slog.Error("EKS token verify: failed to respond with error", "err", err)
+ }
+}
diff --git a/spinifex/services/awsgw/eks_token_verify_test.go b/spinifex/services/awsgw/eks_token_verify_test.go
new file mode 100644
index 000000000..069193fed
--- /dev/null
+++ b/spinifex/services/awsgw/eks_token_verify_test.go
@@ -0,0 +1,79 @@
+package awsgw
+
+import (
+ "errors"
+ "testing"
+ "time"
+
+ handlers_eks "github.com/mulgadc/spinifex/spinifex/handlers/eks"
+ handlers_sts "github.com/mulgadc/spinifex/spinifex/handlers/sts"
+ "github.com/mulgadc/spinifex/spinifex/testutil"
+ "github.com/mulgadc/spinifex/spinifex/utils"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+type fakeVerifier struct {
+ ident *handlers_sts.PresignedCallerIdentity
+ err error
+ gotURL string
+ gotName string
+}
+
+func (f *fakeVerifier) VerifyPresignedGetCallerIdentity(presignedURL, expectedClusterName string) (*handlers_sts.PresignedCallerIdentity, error) {
+ f.gotURL = presignedURL
+ f.gotName = expectedClusterName
+ return f.ident, f.err
+}
+
+func TestEKSTokenVerify_ResolvesPrincipal(t *testing.T) {
+ _, nc := testutil.StartTestNATS(t)
+ fake := &fakeVerifier{ident: &handlers_sts.PresignedCallerIdentity{
+ AccountID: "111122223333",
+ ARN: "arn:aws:iam::111122223333:role/admin",
+ UserID: "AROAEXAMPLE:sess",
+ PrincipalType: "AssumedRole",
+ }}
+ sub, err := registerEKSTokenVerify(nc, fake)
+ require.NoError(t, err)
+ defer func() { _ = sub.Unsubscribe() }()
+
+ req := handlers_eks.TokenVerifyRequest{PresignedURL: "https://sts/?x=1", ClusterName: "alpha"}
+ resp, err := utils.NATSRequest[handlers_eks.TokenVerifyResponse](
+ nc, handlers_eks.TokenVerifySubject, req, 2*time.Second, "")
+ require.NoError(t, err)
+
+ assert.Equal(t, "arn:aws:iam::111122223333:role/admin", resp.ARN)
+ assert.Equal(t, "AROAEXAMPLE:sess", resp.UserID)
+ assert.Equal(t, "AssumedRole", resp.PrincipalType)
+ // Cluster name is forwarded for the STS anti-replay pin.
+ assert.Equal(t, "alpha", fake.gotName)
+}
+
+func TestEKSTokenVerify_RejectedTokenIsError(t *testing.T) {
+ _, nc := testutil.StartTestNATS(t)
+ fake := &fakeVerifier{err: errors.New("signature mismatch (cluster name replay)")}
+ sub, err := registerEKSTokenVerify(nc, fake)
+ require.NoError(t, err)
+ defer func() { _ = sub.Unsubscribe() }()
+
+ req := handlers_eks.TokenVerifyRequest{PresignedURL: "https://sts/?x=1", ClusterName: "alpha"}
+ _, err = utils.NATSRequest[handlers_eks.TokenVerifyResponse](
+ nc, handlers_eks.TokenVerifySubject, req, 2*time.Second, "")
+ require.Error(t, err, "a verify failure must surface as a NATS error payload")
+}
+
+func TestEKSTokenVerify_RejectsEmptyFields(t *testing.T) {
+ _, nc := testutil.StartTestNATS(t)
+ fake := &fakeVerifier{ident: &handlers_sts.PresignedCallerIdentity{ARN: "arn:..."}}
+ sub, err := registerEKSTokenVerify(nc, fake)
+ require.NoError(t, err)
+ defer func() { _ = sub.Unsubscribe() }()
+
+ // Missing ClusterName — responder rejects before calling the verifier.
+ req := handlers_eks.TokenVerifyRequest{PresignedURL: "https://sts/?x=1"}
+ _, err = utils.NATSRequest[handlers_eks.TokenVerifyResponse](
+ nc, handlers_eks.TokenVerifySubject, req, 2*time.Second, "")
+ require.Error(t, err)
+ assert.Empty(t, fake.gotURL, "verifier must not run on an invalid request")
+}
diff --git a/spinifex/services/awsgw/imds_absent_test.go b/spinifex/services/awsgw/imds_absent_test.go
new file mode 100644
index 000000000..6640e703e
--- /dev/null
+++ b/spinifex/services/awsgw/imds_absent_test.go
@@ -0,0 +1,23 @@
+package awsgw
+
+import (
+ "os"
+ "strings"
+ "testing"
+)
+
+// TestIMDSRuntimeAbsentFromAWSGW guards against re-introducing the privileged
+// IMDS listener stack into awsgw, whose systemd unit is the most hardened in
+// the fleet (NoNewPrivileges=yes, empty CapabilityBoundingSet). The veth +
+// privileged-socket bind cannot succeed there; the runtime lives in vpcd, and
+// awsgw only answers the IMDS control-plane RPCs (STS + IAM).
+func TestIMDSRuntimeAbsentFromAWSGW(t *testing.T) {
+ src, err := os.ReadFile("awsgw.go")
+ if err != nil {
+ t.Fatalf("read awsgw.go: %v", err)
+ }
+ if strings.Contains(string(src), "NewIMDSServiceImpl") {
+ t.Fatal("awsgw must not construct the IMDS runtime (NewIMDSServiceImpl) — " +
+ "the privileged listener stack belongs in vpcd.")
+ }
+}
diff --git a/spinifex/services/spinifexui/frontend/dist/assets/SignatureV4.js b/spinifex/services/spinifexui/frontend/dist/assets/SignatureV4.js
new file mode 100644
index 000000000..1125bf20a
--- /dev/null
+++ b/spinifex/services/spinifexui/frontend/dist/assets/SignatureV4.js
@@ -0,0 +1,75 @@
+import{A as e,D as t,F as n,P as r,c as i,d as a,f as o,k as s,l as c}from"./index.browser.js";function l(e){var t,n,r=``;if(typeof e==`string`||typeof e==`number`)r+=e;else if(typeof e==`object`)if(Array.isArray(e)){var i=e.length;for(t=0;t{let n=Array(e.length+t.length);for(let t=0;t({classGroupId:e,validator:t}),p=(e=new Map,t=null,n)=>({nextPart:e,validators:t,classGroupId:n}),m=`-`,h=[],g=`arbitrary..`,ee=e=>{let t=_(e),{conflictingClassGroups:n,conflictingClassGroupModifiers:r}=e;return{getClassGroupId:e=>{if(e.startsWith(`[`)&&e.endsWith(`]`))return ne(e);let n=e.split(m);return te(n,+(n[0]===``&&n.length>1),t)},getConflictingClassGroupIds:(e,t)=>{if(t){let t=r[e],i=n[e];return t?i?d(i,t):t:i||h}return n[e]||h}}},te=(e,t,n)=>{if(e.length-t===0)return n.classGroupId;let r=e[t],i=n.nextPart.get(r);if(i){let n=te(e,t+1,i);if(n)return n}let a=n.validators;if(a===null)return;let o=t===0?e.join(m):e.slice(t).join(m),s=a.length;for(let e=0;ee.slice(1,-1).indexOf(`:`)===-1?void 0:(()=>{let t=e.slice(1,-1),n=t.indexOf(`:`),r=t.slice(0,n);return r?g+r:void 0})(),_=e=>{let{theme:t,classGroups:n}=e;return re(n,t)},re=(e,t)=>{let n=p();for(let r in e){let i=e[r];ie(i,n,r,t)}return n},ie=(e,t,n,r)=>{let i=e.length;for(let a=0;a{if(typeof e==`string`){v(e,t,n);return}if(typeof e==`function`){y(e,t,n,r);return}oe(e,t,n,r)},v=(e,t,n)=>{let r=e===``?t:se(t,e);r.classGroupId=n},y=(e,t,n,r)=>{if(ce(e)){ie(e(r),t,n,r);return}t.validators===null&&(t.validators=[]),t.validators.push(f(n,e))},oe=(e,t,n,r)=>{let i=Object.entries(e),a=i.length;for(let e=0;e{let n=e,r=t.split(m),i=r.length;for(let e=0;e`isThemeGetter`in e&&e.isThemeGetter===!0,le=e=>{if(e<1)return{get:()=>void 0,set:()=>{}};let t=0,n=Object.create(null),r=Object.create(null),i=(i,a)=>{n[i]=a,t++,t>e&&(t=0,r=n,n=Object.create(null))};return{get(e){let t=n[e];if(t!==void 0)return t;if((t=r[e])!==void 0)return i(e,t),t},set(e,t){e in n?n[e]=t:i(e,t)}}},ue=`!`,b=`:`,x=[],S=(e,t,n,r,i)=>({modifiers:e,hasImportantModifier:t,baseClassName:n,maybePostfixModifierPosition:r,isExternal:i}),de=e=>{let{prefix:t,experimentalParseClassName:n}=e,r=e=>{let t=[],n=0,r=0,i=0,a,o=e.length;for(let s=0;si?a-i:void 0;return S(t,l,c,u)};if(t){let e=t+b,n=r;r=t=>t.startsWith(e)?n(t.slice(e.length)):S(x,!1,t,void 0,!0)}if(n){let e=r;r=t=>n({className:t,parseClassName:e})}return r},fe=e=>{let t=new Map;return e.orderSensitiveModifiers.forEach((e,n)=>{t.set(e,1e6+n)}),e=>{let n=[],r=[];for(let i=0;i0&&(r.sort(),n.push(...r),r=[]),n.push(a)):r.push(a)}return r.length>0&&(r.sort(),n.push(...r)),n}},C=e=>({cache:le(e.cacheSize),parseClassName:de(e),sortModifiers:fe(e),postfixLookupClassGroupIds:pe(e),...ee(e)}),pe=e=>{let t=Object.create(null),n=e.postfixLookupClassGroups;if(n)for(let e=0;e{let{parseClassName:n,getClassGroupId:r,getConflictingClassGroupIds:i,sortModifiers:a,postfixLookupClassGroupIds:o}=t,s=[],c=e.trim().split(me),l=``;for(let e=c.length-1;e>=0;--e){let t=c[e],{isExternal:u,modifiers:d,hasImportantModifier:f,baseClassName:p,maybePostfixModifierPosition:m}=n(t);if(u){l=t+(l.length>0?` `+l:l);continue}let h=!!m,g;if(h){g=r(p.substring(0,m));let e=g&&o[g]?r(p):void 0;e&&e!==g&&(g=e,h=!1)}else g=r(p);if(!g){if(!h){l=t+(l.length>0?` `+l:l);continue}if(g=r(p),!g){l=t+(l.length>0?` `+l:l);continue}h=!1}let ee=d.length===0?``:d.length===1?d[0]:a(d).join(`:`),te=f?ee+ue:ee,ne=te+g;if(s.indexOf(ne)>-1)continue;s.push(ne);let _=i(g,h);for(let e=0;e<_.length;++e){let t=_[e];s.push(te+t)}l=t+(l.length>0?` `+l:l)}return l},ge=(...e)=>{let t=0,n,r,i=``;for(;t{if(typeof e==`string`)return e;let t,n=``;for(let r=0;r{let n,r,i,a,o=o=>(n=C(t.reduce((e,t)=>t(e),e())),r=n.cache.get,i=n.cache.set,a=s,s(o)),s=e=>{let t=r(e);if(t)return t;let a=he(e,n);return i(e,a),a};return a=o,(...e)=>a(ge(...e))},_e=[],E=e=>{let t=t=>t[e]||_e;return t.isThemeGetter=!0,t},ve=/^\[(?:(\w[\w-]*):)?(.+)\]$/i,D=/^\((?:(\w[\w-]*):)?(.+)\)$/i,ye=/^\d+(?:\.\d+)?\/\d+(?:\.\d+)?$/,be=/^(\d+(\.\d+)?)?(xs|sm|md|lg|xl)$/,xe=/\d+(%|px|r?em|[sdl]?v([hwib]|min|max)|pt|pc|in|cm|mm|cap|ch|ex|r?lh|cq(w|h|i|b|min|max))|\b(calc|min|max|clamp)\(.+\)|^0$/,Se=/^(rgba?|hsla?|hwb|(ok)?(lab|lch)|color-mix)\(.+\)$/,Ce=/^(inset_)?-?((\d+)?\.?(\d+)[a-z]+|0)_-?((\d+)?\.?(\d+)[a-z]+|0)/,we=/^(url|image|image-set|cross-fade|element|(repeating-)?(linear|radial|conic)-gradient)\(.+\)$/,O=e=>ye.test(e),k=e=>!!e&&!Number.isNaN(Number(e)),A=e=>!!e&&Number.isInteger(Number(e)),Te=e=>e.endsWith(`%`)&&k(e.slice(0,-1)),j=e=>be.test(e),Ee=()=>!0,De=e=>xe.test(e)&&!Se.test(e),Oe=()=>!1,ke=e=>Ce.test(e),Ae=e=>we.test(e),je=e=>!M(e)&&!P(e),Me=e=>e.startsWith(`@container`)&&(e[10]===`/`&&e[11]!==void 0||e[11]===`s`&&e[16]!==void 0&&e.startsWith(`-size/`,10)||e[11]===`n`&&e[18]!==void 0&&e.startsWith(`-normal/`,10)),Ne=e=>F(e,Ye,Oe),M=e=>ve.test(e),N=e=>F(e,Xe,De),Pe=e=>F(e,Ze,k),Fe=e=>F(e,$e,Ee),Ie=e=>F(e,Qe,Oe),Le=e=>F(e,qe,Oe),Re=e=>F(e,Je,Ae),ze=e=>F(e,et,ke),P=e=>D.test(e),Be=e=>I(e,Xe),Ve=e=>I(e,Qe),He=e=>I(e,qe),Ue=e=>I(e,Ye),We=e=>I(e,Je),Ge=e=>I(e,et,!0),Ke=e=>I(e,$e,!0),F=(e,t,n)=>{let r=ve.exec(e);return r?r[1]?t(r[1]):n(r[2]):!1},I=(e,t,n=!1)=>{let r=D.exec(e);return r?r[1]?t(r[1]):n:!1},qe=e=>e===`position`||e===`percentage`,Je=e=>e===`image`||e===`url`,Ye=e=>e===`length`||e===`size`||e===`bg-size`,Xe=e=>e===`length`,Ze=e=>e===`number`,Qe=e=>e===`family-name`,$e=e=>e===`number`||e===`weight`,et=e=>e===`shadow`,tt=T(()=>{let e=E(`color`),t=E(`font`),n=E(`text`),r=E(`font-weight`),i=E(`tracking`),a=E(`leading`),o=E(`breakpoint`),s=E(`container`),c=E(`spacing`),l=E(`radius`),u=E(`shadow`),d=E(`inset-shadow`),f=E(`text-shadow`),p=E(`drop-shadow`),m=E(`blur`),h=E(`perspective`),g=E(`aspect`),ee=E(`ease`),te=E(`animate`),ne=()=>[`auto`,`avoid`,`all`,`avoid-page`,`page`,`left`,`right`,`column`],_=()=>[`center`,`top`,`bottom`,`left`,`right`,`top-left`,`left-top`,`top-right`,`right-top`,`bottom-right`,`right-bottom`,`bottom-left`,`left-bottom`],re=()=>[..._(),P,M],ie=()=>[`auto`,`hidden`,`clip`,`visible`,`scroll`],ae=()=>[`auto`,`contain`,`none`],v=()=>[P,M,c],y=()=>[O,`full`,`auto`,...v()],oe=()=>[A,`none`,`subgrid`,P,M],se=()=>[`auto`,{span:[`full`,A,P,M]},A,P,M],ce=()=>[A,`auto`,P,M],le=()=>[`auto`,`min`,`max`,`fr`,P,M],ue=()=>[`start`,`end`,`center`,`between`,`around`,`evenly`,`stretch`,`baseline`,`center-safe`,`end-safe`],b=()=>[`start`,`end`,`center`,`stretch`,`center-safe`,`end-safe`],x=()=>[`auto`,...v()],S=()=>[O,`auto`,`full`,`dvw`,`dvh`,`lvw`,`lvh`,`svw`,`svh`,`min`,`max`,`fit`,...v()],de=()=>[O,`screen`,`full`,`dvw`,`lvw`,`svw`,`min`,`max`,`fit`,...v()],fe=()=>[O,`screen`,`full`,`lh`,`dvh`,`lvh`,`svh`,`min`,`max`,`fit`,...v()],C=()=>[e,P,M],pe=()=>[..._(),He,Le,{position:[P,M]}],me=()=>[`no-repeat`,{repeat:[``,`x`,`y`,`space`,`round`]}],he=()=>[`auto`,`cover`,`contain`,Ue,Ne,{size:[P,M]}],ge=()=>[Te,Be,N],w=()=>[``,`none`,`full`,l,P,M],T=()=>[``,k,Be,N],_e=()=>[`solid`,`dashed`,`dotted`,`double`],ve=()=>[`normal`,`multiply`,`screen`,`overlay`,`darken`,`lighten`,`color-dodge`,`color-burn`,`hard-light`,`soft-light`,`difference`,`exclusion`,`hue`,`saturation`,`color`,`luminosity`],D=()=>[k,Te,He,Le],ye=()=>[``,`none`,m,P,M],be=()=>[`none`,k,P,M],xe=()=>[`none`,k,P,M],Se=()=>[k,P,M],Ce=()=>[O,`full`,...v()];return{cacheSize:500,theme:{animate:[`spin`,`ping`,`pulse`,`bounce`],aspect:[`video`],blur:[j],breakpoint:[j],color:[Ee],container:[j],"drop-shadow":[j],ease:[`in`,`out`,`in-out`],font:[je],"font-weight":[`thin`,`extralight`,`light`,`normal`,`medium`,`semibold`,`bold`,`extrabold`,`black`],"inset-shadow":[j],leading:[`none`,`tight`,`snug`,`normal`,`relaxed`,`loose`],perspective:[`dramatic`,`near`,`normal`,`midrange`,`distant`,`none`],radius:[j],shadow:[j],spacing:[`px`,k],text:[j],"text-shadow":[j],tracking:[`tighter`,`tight`,`normal`,`wide`,`wider`,`widest`]},classGroups:{aspect:[{aspect:[`auto`,`square`,O,M,P,g]}],container:[`container`],"container-type":[{"@container":[``,`normal`,`size`,P,M]}],"container-named":[Me],columns:[{columns:[k,M,P,s]}],"break-after":[{"break-after":ne()}],"break-before":[{"break-before":ne()}],"break-inside":[{"break-inside":[`auto`,`avoid`,`avoid-page`,`avoid-column`]}],"box-decoration":[{"box-decoration":[`slice`,`clone`]}],box:[{box:[`border`,`content`]}],display:[`block`,`inline-block`,`inline`,`flex`,`inline-flex`,`table`,`inline-table`,`table-caption`,`table-cell`,`table-column`,`table-column-group`,`table-footer-group`,`table-header-group`,`table-row-group`,`table-row`,`flow-root`,`grid`,`inline-grid`,`contents`,`list-item`,`hidden`],sr:[`sr-only`,`not-sr-only`],float:[{float:[`right`,`left`,`none`,`start`,`end`]}],clear:[{clear:[`left`,`right`,`both`,`none`,`start`,`end`]}],isolation:[`isolate`,`isolation-auto`],"object-fit":[{object:[`contain`,`cover`,`fill`,`none`,`scale-down`]}],"object-position":[{object:re()}],overflow:[{overflow:ie()}],"overflow-x":[{"overflow-x":ie()}],"overflow-y":[{"overflow-y":ie()}],overscroll:[{overscroll:ae()}],"overscroll-x":[{"overscroll-x":ae()}],"overscroll-y":[{"overscroll-y":ae()}],position:[`static`,`fixed`,`absolute`,`relative`,`sticky`],inset:[{inset:y()}],"inset-x":[{"inset-x":y()}],"inset-y":[{"inset-y":y()}],start:[{"inset-s":y(),start:y()}],end:[{"inset-e":y(),end:y()}],"inset-bs":[{"inset-bs":y()}],"inset-be":[{"inset-be":y()}],top:[{top:y()}],right:[{right:y()}],bottom:[{bottom:y()}],left:[{left:y()}],visibility:[`visible`,`invisible`,`collapse`],z:[{z:[A,`auto`,P,M]}],basis:[{basis:[O,`full`,`auto`,s,...v()]}],"flex-direction":[{flex:[`row`,`row-reverse`,`col`,`col-reverse`]}],"flex-wrap":[{flex:[`nowrap`,`wrap`,`wrap-reverse`]}],flex:[{flex:[k,O,`auto`,`initial`,`none`,M]}],grow:[{grow:[``,k,P,M]}],shrink:[{shrink:[``,k,P,M]}],order:[{order:[A,`first`,`last`,`none`,P,M]}],"grid-cols":[{"grid-cols":oe()}],"col-start-end":[{col:se()}],"col-start":[{"col-start":ce()}],"col-end":[{"col-end":ce()}],"grid-rows":[{"grid-rows":oe()}],"row-start-end":[{row:se()}],"row-start":[{"row-start":ce()}],"row-end":[{"row-end":ce()}],"grid-flow":[{"grid-flow":[`row`,`col`,`dense`,`row-dense`,`col-dense`]}],"auto-cols":[{"auto-cols":le()}],"auto-rows":[{"auto-rows":le()}],gap:[{gap:v()}],"gap-x":[{"gap-x":v()}],"gap-y":[{"gap-y":v()}],"justify-content":[{justify:[...ue(),`normal`]}],"justify-items":[{"justify-items":[...b(),`normal`]}],"justify-self":[{"justify-self":[`auto`,...b()]}],"align-content":[{content:[`normal`,...ue()]}],"align-items":[{items:[...b(),{baseline:[``,`last`]}]}],"align-self":[{self:[`auto`,...b(),{baseline:[``,`last`]}]}],"place-content":[{"place-content":ue()}],"place-items":[{"place-items":[...b(),`baseline`]}],"place-self":[{"place-self":[`auto`,...b()]}],p:[{p:v()}],px:[{px:v()}],py:[{py:v()}],ps:[{ps:v()}],pe:[{pe:v()}],pbs:[{pbs:v()}],pbe:[{pbe:v()}],pt:[{pt:v()}],pr:[{pr:v()}],pb:[{pb:v()}],pl:[{pl:v()}],m:[{m:x()}],mx:[{mx:x()}],my:[{my:x()}],ms:[{ms:x()}],me:[{me:x()}],mbs:[{mbs:x()}],mbe:[{mbe:x()}],mt:[{mt:x()}],mr:[{mr:x()}],mb:[{mb:x()}],ml:[{ml:x()}],"space-x":[{"space-x":v()}],"space-x-reverse":[`space-x-reverse`],"space-y":[{"space-y":v()}],"space-y-reverse":[`space-y-reverse`],size:[{size:S()}],"inline-size":[{inline:[`auto`,...de()]}],"min-inline-size":[{"min-inline":[`auto`,...de()]}],"max-inline-size":[{"max-inline":[`none`,...de()]}],"block-size":[{block:[`auto`,...fe()]}],"min-block-size":[{"min-block":[`auto`,...fe()]}],"max-block-size":[{"max-block":[`none`,...fe()]}],w:[{w:[s,`screen`,...S()]}],"min-w":[{"min-w":[s,`screen`,`none`,...S()]}],"max-w":[{"max-w":[s,`screen`,`none`,`prose`,{screen:[o]},...S()]}],h:[{h:[`screen`,`lh`,...S()]}],"min-h":[{"min-h":[`screen`,`lh`,`none`,...S()]}],"max-h":[{"max-h":[`screen`,`lh`,...S()]}],"font-size":[{text:[`base`,n,Be,N]}],"font-smoothing":[`antialiased`,`subpixel-antialiased`],"font-style":[`italic`,`not-italic`],"font-weight":[{font:[r,Ke,Fe]}],"font-stretch":[{"font-stretch":[`ultra-condensed`,`extra-condensed`,`condensed`,`semi-condensed`,`normal`,`semi-expanded`,`expanded`,`extra-expanded`,`ultra-expanded`,Te,M]}],"font-family":[{font:[Ve,Ie,t]}],"font-features":[{"font-features":[M]}],"fvn-normal":[`normal-nums`],"fvn-ordinal":[`ordinal`],"fvn-slashed-zero":[`slashed-zero`],"fvn-figure":[`lining-nums`,`oldstyle-nums`],"fvn-spacing":[`proportional-nums`,`tabular-nums`],"fvn-fraction":[`diagonal-fractions`,`stacked-fractions`],tracking:[{tracking:[i,P,M]}],"line-clamp":[{"line-clamp":[k,`none`,P,Pe]}],leading:[{leading:[a,...v()]}],"list-image":[{"list-image":[`none`,P,M]}],"list-style-position":[{list:[`inside`,`outside`]}],"list-style-type":[{list:[`disc`,`decimal`,`none`,P,M]}],"text-alignment":[{text:[`left`,`center`,`right`,`justify`,`start`,`end`]}],"placeholder-color":[{placeholder:C()}],"text-color":[{text:C()}],"text-decoration":[`underline`,`overline`,`line-through`,`no-underline`],"text-decoration-style":[{decoration:[..._e(),`wavy`]}],"text-decoration-thickness":[{decoration:[k,`from-font`,`auto`,P,N]}],"text-decoration-color":[{decoration:C()}],"underline-offset":[{"underline-offset":[k,`auto`,P,M]}],"text-transform":[`uppercase`,`lowercase`,`capitalize`,`normal-case`],"text-overflow":[`truncate`,`text-ellipsis`,`text-clip`],"text-wrap":[{text:[`wrap`,`nowrap`,`balance`,`pretty`]}],indent:[{indent:v()}],"tab-size":[{tab:[A,P,M]}],"vertical-align":[{align:[`baseline`,`top`,`middle`,`bottom`,`text-top`,`text-bottom`,`sub`,`super`,P,M]}],whitespace:[{whitespace:[`normal`,`nowrap`,`pre`,`pre-line`,`pre-wrap`,`break-spaces`]}],break:[{break:[`normal`,`words`,`all`,`keep`]}],wrap:[{wrap:[`break-word`,`anywhere`,`normal`]}],hyphens:[{hyphens:[`none`,`manual`,`auto`]}],content:[{content:[`none`,P,M]}],"bg-attachment":[{bg:[`fixed`,`local`,`scroll`]}],"bg-clip":[{"bg-clip":[`border`,`padding`,`content`,`text`]}],"bg-origin":[{"bg-origin":[`border`,`padding`,`content`]}],"bg-position":[{bg:pe()}],"bg-repeat":[{bg:me()}],"bg-size":[{bg:he()}],"bg-image":[{bg:[`none`,{linear:[{to:[`t`,`tr`,`r`,`br`,`b`,`bl`,`l`,`tl`]},A,P,M],radial:[``,P,M],conic:[A,P,M]},We,Re]}],"bg-color":[{bg:C()}],"gradient-from-pos":[{from:ge()}],"gradient-via-pos":[{via:ge()}],"gradient-to-pos":[{to:ge()}],"gradient-from":[{from:C()}],"gradient-via":[{via:C()}],"gradient-to":[{to:C()}],rounded:[{rounded:w()}],"rounded-s":[{"rounded-s":w()}],"rounded-e":[{"rounded-e":w()}],"rounded-t":[{"rounded-t":w()}],"rounded-r":[{"rounded-r":w()}],"rounded-b":[{"rounded-b":w()}],"rounded-l":[{"rounded-l":w()}],"rounded-ss":[{"rounded-ss":w()}],"rounded-se":[{"rounded-se":w()}],"rounded-ee":[{"rounded-ee":w()}],"rounded-es":[{"rounded-es":w()}],"rounded-tl":[{"rounded-tl":w()}],"rounded-tr":[{"rounded-tr":w()}],"rounded-br":[{"rounded-br":w()}],"rounded-bl":[{"rounded-bl":w()}],"border-w":[{border:T()}],"border-w-x":[{"border-x":T()}],"border-w-y":[{"border-y":T()}],"border-w-s":[{"border-s":T()}],"border-w-e":[{"border-e":T()}],"border-w-bs":[{"border-bs":T()}],"border-w-be":[{"border-be":T()}],"border-w-t":[{"border-t":T()}],"border-w-r":[{"border-r":T()}],"border-w-b":[{"border-b":T()}],"border-w-l":[{"border-l":T()}],"divide-x":[{"divide-x":T()}],"divide-x-reverse":[`divide-x-reverse`],"divide-y":[{"divide-y":T()}],"divide-y-reverse":[`divide-y-reverse`],"border-style":[{border:[..._e(),`hidden`,`none`]}],"divide-style":[{divide:[..._e(),`hidden`,`none`]}],"border-color":[{border:C()}],"border-color-x":[{"border-x":C()}],"border-color-y":[{"border-y":C()}],"border-color-s":[{"border-s":C()}],"border-color-e":[{"border-e":C()}],"border-color-bs":[{"border-bs":C()}],"border-color-be":[{"border-be":C()}],"border-color-t":[{"border-t":C()}],"border-color-r":[{"border-r":C()}],"border-color-b":[{"border-b":C()}],"border-color-l":[{"border-l":C()}],"divide-color":[{divide:C()}],"outline-style":[{outline:[..._e(),`none`,`hidden`]}],"outline-offset":[{"outline-offset":[k,P,M]}],"outline-w":[{outline:[``,k,Be,N]}],"outline-color":[{outline:C()}],shadow:[{shadow:[``,`none`,u,Ge,ze]}],"shadow-color":[{shadow:C()}],"inset-shadow":[{"inset-shadow":[`none`,d,Ge,ze]}],"inset-shadow-color":[{"inset-shadow":C()}],"ring-w":[{ring:T()}],"ring-w-inset":[`ring-inset`],"ring-color":[{ring:C()}],"ring-offset-w":[{"ring-offset":[k,N]}],"ring-offset-color":[{"ring-offset":C()}],"inset-ring-w":[{"inset-ring":T()}],"inset-ring-color":[{"inset-ring":C()}],"text-shadow":[{"text-shadow":[`none`,f,Ge,ze]}],"text-shadow-color":[{"text-shadow":C()}],opacity:[{opacity:[k,P,M]}],"mix-blend":[{"mix-blend":[...ve(),`plus-darker`,`plus-lighter`]}],"bg-blend":[{"bg-blend":ve()}],"mask-clip":[{"mask-clip":[`border`,`padding`,`content`,`fill`,`stroke`,`view`]},`mask-no-clip`],"mask-composite":[{mask:[`add`,`subtract`,`intersect`,`exclude`]}],"mask-image-linear-pos":[{"mask-linear":[k]}],"mask-image-linear-from-pos":[{"mask-linear-from":D()}],"mask-image-linear-to-pos":[{"mask-linear-to":D()}],"mask-image-linear-from-color":[{"mask-linear-from":C()}],"mask-image-linear-to-color":[{"mask-linear-to":C()}],"mask-image-t-from-pos":[{"mask-t-from":D()}],"mask-image-t-to-pos":[{"mask-t-to":D()}],"mask-image-t-from-color":[{"mask-t-from":C()}],"mask-image-t-to-color":[{"mask-t-to":C()}],"mask-image-r-from-pos":[{"mask-r-from":D()}],"mask-image-r-to-pos":[{"mask-r-to":D()}],"mask-image-r-from-color":[{"mask-r-from":C()}],"mask-image-r-to-color":[{"mask-r-to":C()}],"mask-image-b-from-pos":[{"mask-b-from":D()}],"mask-image-b-to-pos":[{"mask-b-to":D()}],"mask-image-b-from-color":[{"mask-b-from":C()}],"mask-image-b-to-color":[{"mask-b-to":C()}],"mask-image-l-from-pos":[{"mask-l-from":D()}],"mask-image-l-to-pos":[{"mask-l-to":D()}],"mask-image-l-from-color":[{"mask-l-from":C()}],"mask-image-l-to-color":[{"mask-l-to":C()}],"mask-image-x-from-pos":[{"mask-x-from":D()}],"mask-image-x-to-pos":[{"mask-x-to":D()}],"mask-image-x-from-color":[{"mask-x-from":C()}],"mask-image-x-to-color":[{"mask-x-to":C()}],"mask-image-y-from-pos":[{"mask-y-from":D()}],"mask-image-y-to-pos":[{"mask-y-to":D()}],"mask-image-y-from-color":[{"mask-y-from":C()}],"mask-image-y-to-color":[{"mask-y-to":C()}],"mask-image-radial":[{"mask-radial":[P,M]}],"mask-image-radial-from-pos":[{"mask-radial-from":D()}],"mask-image-radial-to-pos":[{"mask-radial-to":D()}],"mask-image-radial-from-color":[{"mask-radial-from":C()}],"mask-image-radial-to-color":[{"mask-radial-to":C()}],"mask-image-radial-shape":[{"mask-radial":[`circle`,`ellipse`]}],"mask-image-radial-size":[{"mask-radial":[{closest:[`side`,`corner`],farthest:[`side`,`corner`]}]}],"mask-image-radial-pos":[{"mask-radial-at":_()}],"mask-image-conic-pos":[{"mask-conic":[k]}],"mask-image-conic-from-pos":[{"mask-conic-from":D()}],"mask-image-conic-to-pos":[{"mask-conic-to":D()}],"mask-image-conic-from-color":[{"mask-conic-from":C()}],"mask-image-conic-to-color":[{"mask-conic-to":C()}],"mask-mode":[{mask:[`alpha`,`luminance`,`match`]}],"mask-origin":[{"mask-origin":[`border`,`padding`,`content`,`fill`,`stroke`,`view`]}],"mask-position":[{mask:pe()}],"mask-repeat":[{mask:me()}],"mask-size":[{mask:he()}],"mask-type":[{"mask-type":[`alpha`,`luminance`]}],"mask-image":[{mask:[`none`,P,M]}],filter:[{filter:[``,`none`,P,M]}],blur:[{blur:ye()}],brightness:[{brightness:[k,P,M]}],contrast:[{contrast:[k,P,M]}],"drop-shadow":[{"drop-shadow":[``,`none`,p,Ge,ze]}],"drop-shadow-color":[{"drop-shadow":C()}],grayscale:[{grayscale:[``,k,P,M]}],"hue-rotate":[{"hue-rotate":[k,P,M]}],invert:[{invert:[``,k,P,M]}],saturate:[{saturate:[k,P,M]}],sepia:[{sepia:[``,k,P,M]}],"backdrop-filter":[{"backdrop-filter":[``,`none`,P,M]}],"backdrop-blur":[{"backdrop-blur":ye()}],"backdrop-brightness":[{"backdrop-brightness":[k,P,M]}],"backdrop-contrast":[{"backdrop-contrast":[k,P,M]}],"backdrop-grayscale":[{"backdrop-grayscale":[``,k,P,M]}],"backdrop-hue-rotate":[{"backdrop-hue-rotate":[k,P,M]}],"backdrop-invert":[{"backdrop-invert":[``,k,P,M]}],"backdrop-opacity":[{"backdrop-opacity":[k,P,M]}],"backdrop-saturate":[{"backdrop-saturate":[k,P,M]}],"backdrop-sepia":[{"backdrop-sepia":[``,k,P,M]}],"border-collapse":[{border:[`collapse`,`separate`]}],"border-spacing":[{"border-spacing":v()}],"border-spacing-x":[{"border-spacing-x":v()}],"border-spacing-y":[{"border-spacing-y":v()}],"table-layout":[{table:[`auto`,`fixed`]}],caption:[{caption:[`top`,`bottom`]}],transition:[{transition:[``,`all`,`colors`,`opacity`,`shadow`,`transform`,`none`,P,M]}],"transition-behavior":[{transition:[`normal`,`discrete`]}],duration:[{duration:[k,`initial`,P,M]}],ease:[{ease:[`linear`,`initial`,ee,P,M]}],delay:[{delay:[k,P,M]}],animate:[{animate:[`none`,te,P,M]}],backface:[{backface:[`hidden`,`visible`]}],perspective:[{perspective:[h,P,M]}],"perspective-origin":[{"perspective-origin":re()}],rotate:[{rotate:be()}],"rotate-x":[{"rotate-x":be()}],"rotate-y":[{"rotate-y":be()}],"rotate-z":[{"rotate-z":be()}],scale:[{scale:xe()}],"scale-x":[{"scale-x":xe()}],"scale-y":[{"scale-y":xe()}],"scale-z":[{"scale-z":xe()}],"scale-3d":[`scale-3d`],skew:[{skew:Se()}],"skew-x":[{"skew-x":Se()}],"skew-y":[{"skew-y":Se()}],transform:[{transform:[P,M,``,`none`,`gpu`,`cpu`]}],"transform-origin":[{origin:re()}],"transform-style":[{transform:[`3d`,`flat`]}],translate:[{translate:Ce()}],"translate-x":[{"translate-x":Ce()}],"translate-y":[{"translate-y":Ce()}],"translate-z":[{"translate-z":Ce()}],"translate-none":[`translate-none`],zoom:[{zoom:[A,P,M]}],accent:[{accent:C()}],appearance:[{appearance:[`none`,`auto`]}],"caret-color":[{caret:C()}],"color-scheme":[{scheme:[`normal`,`dark`,`light`,`light-dark`,`only-dark`,`only-light`]}],cursor:[{cursor:[`auto`,`default`,`pointer`,`wait`,`text`,`move`,`help`,`not-allowed`,`none`,`context-menu`,`progress`,`cell`,`crosshair`,`vertical-text`,`alias`,`copy`,`no-drop`,`grab`,`grabbing`,`all-scroll`,`col-resize`,`row-resize`,`n-resize`,`e-resize`,`s-resize`,`w-resize`,`ne-resize`,`nw-resize`,`se-resize`,`sw-resize`,`ew-resize`,`ns-resize`,`nesw-resize`,`nwse-resize`,`zoom-in`,`zoom-out`,P,M]}],"field-sizing":[{"field-sizing":[`fixed`,`content`]}],"pointer-events":[{"pointer-events":[`auto`,`none`]}],resize:[{resize:[`none`,``,`y`,`x`]}],"scroll-behavior":[{scroll:[`auto`,`smooth`]}],"scrollbar-thumb-color":[{"scrollbar-thumb":C()}],"scrollbar-track-color":[{"scrollbar-track":C()}],"scrollbar-gutter":[{"scrollbar-gutter":[`auto`,`stable`,`both`]}],"scrollbar-w":[{scrollbar:[`auto`,`thin`,`none`]}],"scroll-m":[{"scroll-m":v()}],"scroll-mx":[{"scroll-mx":v()}],"scroll-my":[{"scroll-my":v()}],"scroll-ms":[{"scroll-ms":v()}],"scroll-me":[{"scroll-me":v()}],"scroll-mbs":[{"scroll-mbs":v()}],"scroll-mbe":[{"scroll-mbe":v()}],"scroll-mt":[{"scroll-mt":v()}],"scroll-mr":[{"scroll-mr":v()}],"scroll-mb":[{"scroll-mb":v()}],"scroll-ml":[{"scroll-ml":v()}],"scroll-p":[{"scroll-p":v()}],"scroll-px":[{"scroll-px":v()}],"scroll-py":[{"scroll-py":v()}],"scroll-ps":[{"scroll-ps":v()}],"scroll-pe":[{"scroll-pe":v()}],"scroll-pbs":[{"scroll-pbs":v()}],"scroll-pbe":[{"scroll-pbe":v()}],"scroll-pt":[{"scroll-pt":v()}],"scroll-pr":[{"scroll-pr":v()}],"scroll-pb":[{"scroll-pb":v()}],"scroll-pl":[{"scroll-pl":v()}],"snap-align":[{snap:[`start`,`end`,`center`,`align-none`]}],"snap-stop":[{snap:[`normal`,`always`]}],"snap-type":[{snap:[`none`,`x`,`y`,`both`]}],"snap-strictness":[{snap:[`mandatory`,`proximity`]}],touch:[{touch:[`auto`,`none`,`manipulation`]}],"touch-x":[{"touch-pan":[`x`,`left`,`right`]}],"touch-y":[{"touch-pan":[`y`,`up`,`down`]}],"touch-pz":[`touch-pinch-zoom`],select:[{select:[`none`,`text`,`all`,`auto`]}],"will-change":[{"will-change":[`auto`,`scroll`,`contents`,`transform`,P,M]}],fill:[{fill:[`none`,...C()]}],"stroke-w":[{stroke:[k,Be,N,Pe]}],stroke:[{stroke:[`none`,...C()]}],"forced-color-adjust":[{"forced-color-adjust":[`auto`,`none`]}]},conflictingClassGroups:{"container-named":[`container-type`],overflow:[`overflow-x`,`overflow-y`],overscroll:[`overscroll-x`,`overscroll-y`],inset:[`inset-x`,`inset-y`,`inset-bs`,`inset-be`,`start`,`end`,`top`,`right`,`bottom`,`left`],"inset-x":[`right`,`left`],"inset-y":[`top`,`bottom`],flex:[`basis`,`grow`,`shrink`],gap:[`gap-x`,`gap-y`],p:[`px`,`py`,`ps`,`pe`,`pbs`,`pbe`,`pt`,`pr`,`pb`,`pl`],px:[`pr`,`pl`],py:[`pt`,`pb`],m:[`mx`,`my`,`ms`,`me`,`mbs`,`mbe`,`mt`,`mr`,`mb`,`ml`],mx:[`mr`,`ml`],my:[`mt`,`mb`],size:[`w`,`h`],"font-size":[`leading`],"fvn-normal":[`fvn-ordinal`,`fvn-slashed-zero`,`fvn-figure`,`fvn-spacing`,`fvn-fraction`],"fvn-ordinal":[`fvn-normal`],"fvn-slashed-zero":[`fvn-normal`],"fvn-figure":[`fvn-normal`],"fvn-spacing":[`fvn-normal`],"fvn-fraction":[`fvn-normal`],"line-clamp":[`display`,`overflow`],rounded:[`rounded-s`,`rounded-e`,`rounded-t`,`rounded-r`,`rounded-b`,`rounded-l`,`rounded-ss`,`rounded-se`,`rounded-ee`,`rounded-es`,`rounded-tl`,`rounded-tr`,`rounded-br`,`rounded-bl`],"rounded-s":[`rounded-ss`,`rounded-es`],"rounded-e":[`rounded-se`,`rounded-ee`],"rounded-t":[`rounded-tl`,`rounded-tr`],"rounded-r":[`rounded-tr`,`rounded-br`],"rounded-b":[`rounded-br`,`rounded-bl`],"rounded-l":[`rounded-tl`,`rounded-bl`],"border-spacing":[`border-spacing-x`,`border-spacing-y`],"border-w":[`border-w-x`,`border-w-y`,`border-w-s`,`border-w-e`,`border-w-bs`,`border-w-be`,`border-w-t`,`border-w-r`,`border-w-b`,`border-w-l`],"border-w-x":[`border-w-r`,`border-w-l`],"border-w-y":[`border-w-t`,`border-w-b`],"border-color":[`border-color-x`,`border-color-y`,`border-color-s`,`border-color-e`,`border-color-bs`,`border-color-be`,`border-color-t`,`border-color-r`,`border-color-b`,`border-color-l`],"border-color-x":[`border-color-r`,`border-color-l`],"border-color-y":[`border-color-t`,`border-color-b`],translate:[`translate-x`,`translate-y`,`translate-none`],"translate-none":[`translate`,`translate-x`,`translate-y`,`translate-z`],"scroll-m":[`scroll-mx`,`scroll-my`,`scroll-ms`,`scroll-me`,`scroll-mbs`,`scroll-mbe`,`scroll-mt`,`scroll-mr`,`scroll-mb`,`scroll-ml`],"scroll-mx":[`scroll-mr`,`scroll-ml`],"scroll-my":[`scroll-mt`,`scroll-mb`],"scroll-p":[`scroll-px`,`scroll-py`,`scroll-ps`,`scroll-pe`,`scroll-pbs`,`scroll-pbe`,`scroll-pt`,`scroll-pr`,`scroll-pb`,`scroll-pl`],"scroll-px":[`scroll-pr`,`scroll-pl`],"scroll-py":[`scroll-pt`,`scroll-pb`],touch:[`touch-x`,`touch-y`,`touch-pz`],"touch-x":[`touch`],"touch-y":[`touch`],"touch-pz":[`touch`]},conflictingClassGroupModifiers:{"font-size":[`leading`]},postfixLookupClassGroups:[`container-type`],orderSensitiveModifiers:[`*`,`**`,`after`,`backdrop`,`before`,`details-content`,`file`,`first-letter`,`first-line`,`marker`,`placeholder`,`selection`]}});function nt(...e){return tt(u(e))}var rt=new Intl.DateTimeFormat(void 0,{year:`numeric`,month:`long`,day:`numeric`,hour:`2-digit`,minute:`2-digit`,second:`2-digit`});function it(e){return e?rt.format(new Date(e)):`Unknown`}function at(e){return`${Math.round(e/1024)} GiB`}var ot=[`B`,`KB`,`MB`,`GB`,`TB`];function st(e){if(e===0)return`0 B`;let t=Math.floor(Math.log(e)/Math.log(1024));return`${(e/1024**t).toFixed(2)} ${ot[t]}`}function ct(e){return e?.find(e=>e.Key===`Name`)?.Value}var lt=/\/$/;function ut(e){return e.replace(lt,``)}function dt(e){return e&&(e.endsWith(`/`)?e:`${e}/`)}function ft(e,t){return e.startsWith(t)?e:`${t}${e}`}function pt(e,t){return e.startsWith(t)?e.slice(t.length):e}var mt;function L(e,t,n){function r(n,r){if(n._zod||Object.defineProperty(n,"_zod",{value:{def:r,constr:o,traits:new Set},enumerable:!1}),n._zod.traits.has(e))return;n._zod.traits.add(e),t(n,r);let i=o.prototype,a=Object.keys(i);for(let e=0;en?.Parent&&t instanceof n.Parent?!0:t?._zod?.traits?.has(e)}),Object.defineProperty(o,"name",{value:e}),o}var ht=class extends Error{constructor(){super(`Encountered Promise during synchronous parse. Use .parseAsync() instead.`)}},gt=class extends Error{constructor(e){super(`Encountered unidirectional transform during encode: ${e}`),this.name=`ZodEncodeError`}};(mt=globalThis).__zod_globalConfig??(mt.__zod_globalConfig={});var _t=globalThis.__zod_globalConfig;function R(e){return e&&Object.assign(_t,e),_t}function vt(e){let t=Object.values(e).filter(e=>typeof e==`number`);return Object.entries(e).filter(([e,n])=>t.indexOf(+e)===-1).map(([e,t])=>t)}function yt(e,t){return typeof t==`bigint`?t.toString():t}function bt(e){return{get value(){{let t=e();return Object.defineProperty(this,"value",{value:t}),t}throw Error(`cached value already set`)}}}function xt(e){return e==null}function St(e){let t=+!!e.startsWith(`^`),n=e.endsWith(`$`)?e.length-1:e.length;return e.slice(t,n)}function Ct(e,t){let n=e/t,r=Math.round(n),i=2**-52*Math.max(Math.abs(n),1);return Math.abs(n-r){};function Ot(e){return typeof e==`object`&&!!e&&!Array.isArray(e)}var kt=bt(()=>{if(_t.jitless||typeof navigator<`u`&&navigator?.userAgent?.includes(`Cloudflare`))return!1;try{return Function(``),!0}catch{return!1}});function At(e){if(Ot(e)===!1)return!1;let t=e.constructor;if(t===void 0||typeof t!=`function`)return!0;let n=t.prototype;return!(Ot(n)===!1||Object.prototype.hasOwnProperty.call(n,`isPrototypeOf`)===!1)}function jt(e){return At(e)?{...e}:Array.isArray(e)?[...e]:e instanceof Map?new Map(e):e instanceof Set?new Set(e):e}var Mt=new Set([`string`,`number`,`symbol`]);function Nt(e){return e.replace(/[.*+?^${}()|[\]\\]/g,`\\$&`)}function H(e,t,n){let r=new e._zod.constr(t??e._zod.def);return(!t||n?.parent)&&(r._zod.parent=e),r}function U(e){let t=e;if(!t)return{};if(typeof t==`string`)return{error:()=>t};if(t?.message!==void 0){if(t?.error!==void 0)throw Error("Cannot specify both `message` and `error` params");t.error=t.message}return delete t.message,typeof t.error==`string`?{...t,error:()=>t.error}:t}function Pt(e){return Object.keys(e).filter(t=>e[t]._zod.optin===`optional`&&e[t]._zod.optout===`optional`)}var Ft={safeint:[-(2**53-1),2**53-1],int32:[-2147483648,2147483647],uint32:[0,4294967295],float32:[-34028234663852886e22,34028234663852886e22],float64:[-Number.MAX_VALUE,Number.MAX_VALUE]};function It(e,t){let n=e._zod.def,r=n.checks;if(r&&r.length>0)throw Error(`.pick() cannot be used on object schemas containing refinements`);return H(e,V(e._zod.def,{get shape(){let e={};for(let r in t){if(!(r in n.shape))throw Error(`Unrecognized key: "${r}"`);t[r]&&(e[r]=n.shape[r])}return B(this,`shape`,e),e},checks:[]}))}function Lt(e,t){let n=e._zod.def,r=n.checks;if(r&&r.length>0)throw Error(`.omit() cannot be used on object schemas containing refinements`);return H(e,V(e._zod.def,{get shape(){let r={...e._zod.def.shape};for(let e in t){if(!(e in n.shape))throw Error(`Unrecognized key: "${e}"`);t[e]&&delete r[e]}return B(this,`shape`,r),r},checks:[]}))}function Rt(e,t){if(!At(t))throw Error(`Invalid input to extend: expected a plain object`);let n=e._zod.def.checks;if(n&&n.length>0){let n=e._zod.def.shape;for(let e in t)if(Object.getOwnPropertyDescriptor(n,e)!==void 0)throw Error("Cannot overwrite keys on object schemas containing refinements. Use `.safeExtend()` instead.")}return H(e,V(e._zod.def,{get shape(){let n={...e._zod.def.shape,...t};return B(this,`shape`,n),n}}))}function zt(e,t){if(!At(t))throw Error(`Invalid input to safeExtend: expected a plain object`);return H(e,V(e._zod.def,{get shape(){let n={...e._zod.def.shape,...t};return B(this,`shape`,n),n}}))}function Bt(e,t){if(e._zod.def.checks?.length)throw Error(`.merge() cannot be used on object schemas containing refinements. Use .safeExtend() instead.`);return H(e,V(e._zod.def,{get shape(){let n={...e._zod.def.shape,...t._zod.def.shape};return B(this,`shape`,n),n},get catchall(){return t._zod.def.catchall},checks:t._zod.def.checks??[]}))}function Vt(e,t,n){let r=t._zod.def.checks;if(r&&r.length>0)throw Error(`.partial() cannot be used on object schemas containing refinements`);return H(t,V(t._zod.def,{get shape(){let r=t._zod.def.shape,i={...r};if(n)for(let t in n){if(!(t in r))throw Error(`Unrecognized key: "${t}"`);n[t]&&(i[t]=e?new e({type:`optional`,innerType:r[t]}):r[t])}else for(let t in r)i[t]=e?new e({type:`optional`,innerType:r[t]}):r[t];return B(this,`shape`,i),i},checks:[]}))}function Ht(e,t,n){return H(t,V(t._zod.def,{get shape(){let r=t._zod.def.shape,i={...r};if(n)for(let t in n){if(!(t in i))throw Error(`Unrecognized key: "${t}"`);n[t]&&(i[t]=new e({type:`nonoptional`,innerType:r[t]}))}else for(let t in r)i[t]=new e({type:`nonoptional`,innerType:r[t]});return B(this,`shape`,i),i}}))}function Ut(e,t=0){if(e.aborted===!0)return!0;for(let n=t;n{var n;return(n=t).path??(n.path=[]),t.path.unshift(e),t})}function Kt(e){return typeof e==`string`?e:e?.message}function W(e,t,n){let r=e.message?e.message:Kt(e.inst?._zod.def?.error?.(e))??Kt(t?.error?.(e))??Kt(n.customError?.(e))??Kt(n.localeError?.(e))??`Invalid input`,{inst:i,continue:a,input:o,...s}=e;return s.path??=[],s.message=r,t?.reportInput&&(s.input=o),s}function qt(e){return Array.isArray(e)?`array`:typeof e==`string`?`string`:`unknown`}function Jt(...e){let[t,n,r]=e;return typeof t==`string`?{message:t,code:`custom`,input:n,inst:r}:{...t}}var Yt=(e,t)=>{e.name=`$ZodError`,Object.defineProperty(e,"_zod",{value:e._zod,enumerable:!1}),Object.defineProperty(e,"issues",{value:t,enumerable:!1}),e.message=JSON.stringify(t,yt,2),Object.defineProperty(e,"toString",{value:()=>e.message,enumerable:!1})},Xt=L(`$ZodError`,Yt),Zt=L(`$ZodError`,Yt,{Parent:Error});function Qt(e,t=e=>e.message){let n={},r=[];for(let i of e.issues)i.path.length>0?(n[i.path[0]]=n[i.path[0]]||[],n[i.path[0]].push(t(i))):r.push(t(i));return{formErrors:r,fieldErrors:n}}function $t(e,t=e=>e.message){let n={_errors:[]},r=(e,i=[])=>{for(let a of e.issues)if(a.code===`invalid_union`&&a.errors.length)a.errors.map(e=>r({issues:e},[...i,...a.path]));else if(a.code===`invalid_key`)r({issues:a.issues},[...i,...a.path]);else if(a.code===`invalid_element`)r({issues:a.issues},[...i,...a.path]);else{let e=[...i,...a.path];if(e.length===0)n._errors.push(t(a));else{let r=n,i=0;for(;i(t,n,r,i)=>{let a=r?{...r,async:!1}:{async:!1},o=t._zod.run({value:n,issues:[]},a);if(o instanceof Promise)throw new ht;if(o.issues.length){let t=new(i?.Err??e)(o.issues.map(e=>W(e,a,R())));throw Dt(t,i?.callee),t}return o.value},tn=en(Zt),nn=e=>async(t,n,r,i)=>{let a=r?{...r,async:!0}:{async:!0},o=t._zod.run({value:n,issues:[]},a);if(o instanceof Promise&&(o=await o),o.issues.length){let t=new(i?.Err??e)(o.issues.map(e=>W(e,a,R())));throw Dt(t,i?.callee),t}return o.value},rn=nn(Zt),an=e=>(t,n,r)=>{let i=r?{...r,async:!1}:{async:!1},a=t._zod.run({value:n,issues:[]},i);if(a instanceof Promise)throw new ht;return a.issues.length?{success:!1,error:new(e??Xt)(a.issues.map(e=>W(e,i,R())))}:{success:!0,data:a.value}},on=an(Zt),sn=e=>async(t,n,r)=>{let i=r?{...r,async:!0}:{async:!0},a=t._zod.run({value:n,issues:[]},i);return a instanceof Promise&&(a=await a),a.issues.length?{success:!1,error:new e(a.issues.map(e=>W(e,i,R())))}:{success:!0,data:a.value}},cn=sn(Zt),ln=e=>(t,n,r)=>{let i=r?{...r,direction:`backward`}:{direction:`backward`};return en(e)(t,n,i)},un=e=>(t,n,r)=>en(e)(t,n,r),dn=e=>async(t,n,r)=>{let i=r?{...r,direction:`backward`}:{direction:`backward`};return nn(e)(t,n,i)},fn=e=>async(t,n,r)=>nn(e)(t,n,r),pn=e=>(t,n,r)=>{let i=r?{...r,direction:`backward`}:{direction:`backward`};return an(e)(t,n,i)},mn=e=>(t,n,r)=>an(e)(t,n,r),hn=e=>async(t,n,r)=>{let i=r?{...r,direction:`backward`}:{direction:`backward`};return sn(e)(t,n,i)},gn=e=>async(t,n,r)=>sn(e)(t,n,r),_n=/^[cC][0-9a-z]{6,}$/,vn=/^[0-9a-z]+$/,yn=/^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$/,bn=/^[0-9a-vA-V]{20}$/,xn=/^[A-Za-z0-9]{27}$/,Sn=/^[a-zA-Z0-9_-]{21}$/,Cn=/^P(?:(\d+W)|(?!.*W)(?=\d|T\d)(\d+Y)?(\d+M)?(\d+D)?(T(?=\d)(\d+H)?(\d+M)?(\d+([.,]\d+)?S)?)?)$/,wn=/^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12})$/,Tn=e=>e?RegExp(`^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-${e}[0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12})$`):/^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$/,En=/^(?!\.)(?!.*\.\.)([A-Za-z0-9_'+\-\.]*)[A-Za-z0-9_+-]@([A-Za-z0-9][A-Za-z0-9\-]*\.)+[A-Za-z]{2,}$/,Dn=`^(\\p{Extended_Pictographic}|\\p{Emoji_Component})+$`;function On(){return new RegExp(Dn,`u`)}var kn=/^(?:(?:25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9][0-9]|[0-9])\.){3}(?:25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9][0-9]|[0-9])$/,An=/^(([0-9a-fA-F]{1,4}:){7}[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,7}:|([0-9a-fA-F]{1,4}:){1,6}:[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,5}(:[0-9a-fA-F]{1,4}){1,2}|([0-9a-fA-F]{1,4}:){1,4}(:[0-9a-fA-F]{1,4}){1,3}|([0-9a-fA-F]{1,4}:){1,3}(:[0-9a-fA-F]{1,4}){1,4}|([0-9a-fA-F]{1,4}:){1,2}(:[0-9a-fA-F]{1,4}){1,5}|[0-9a-fA-F]{1,4}:((:[0-9a-fA-F]{1,4}){1,6})|:((:[0-9a-fA-F]{1,4}){1,7}|:))$/,jn=/^((25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9][0-9]|[0-9])\.){3}(25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9][0-9]|[0-9])\/([0-9]|[1-2][0-9]|3[0-2])$/,Mn=/^(([0-9a-fA-F]{1,4}:){7}[0-9a-fA-F]{1,4}|::|([0-9a-fA-F]{1,4})?::([0-9a-fA-F]{1,4}:?){0,6})\/(12[0-8]|1[01][0-9]|[1-9]?[0-9])$/,Nn=/^$|^(?:[0-9a-zA-Z+/]{4})*(?:(?:[0-9a-zA-Z+/]{2}==)|(?:[0-9a-zA-Z+/]{3}=))?$/,Pn=/^[A-Za-z0-9_-]*$/,Fn=/^https?$/,In=/^\+[1-9]\d{6,14}$/,Ln=`(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))`,Rn=RegExp(`^${Ln}$`);function zn(e){let t=`(?:[01]\\d|2[0-3]):[0-5]\\d`;return typeof e.precision==`number`?e.precision===-1?`${t}`:e.precision===0?`${t}:[0-5]\\d`:`${t}:[0-5]\\d\\.\\d{${e.precision}}`:`${t}(?::[0-5]\\d(?:\\.\\d+)?)?`}function Bn(e){return RegExp(`^${zn(e)}$`)}function Vn(e){let t=zn({precision:e.precision}),n=[`Z`];e.local&&n.push(``),e.offset&&n.push(`([+-](?:[01]\\d|2[0-3]):[0-5]\\d)`);let r=`${t}(?:${n.join(`|`)})`;return RegExp(`^${Ln}T(?:${r})$`)}var Hn=e=>{let t=e?`[\\s\\S]{${e?.minimum??0},${e?.maximum??``}}`:`[\\s\\S]*`;return RegExp(`^${t}$`)},Un=/^-?\d+$/,Wn=/^-?\d+(?:\.\d+)?$/,Gn=/^(?:true|false)$/i,Kn=/^[^A-Z]*$/,qn=/^[^a-z]*$/,G=L(`$ZodCheck`,(e,t)=>{var n;e._zod??={},e._zod.def=t,(n=e._zod).onattach??(n.onattach=[])}),Jn={number:`number`,bigint:`bigint`,object:`date`},Yn=L(`$ZodCheckLessThan`,(e,t)=>{G.init(e,t);let n=Jn[typeof t.value];e._zod.onattach.push(e=>{let n=e._zod.bag,r=(t.inclusive?n.maximum:n.exclusiveMaximum)??1/0;t.value{(t.inclusive?r.value<=t.value:r.value{G.init(e,t);let n=Jn[typeof t.value];e._zod.onattach.push(e=>{let n=e._zod.bag,r=(t.inclusive?n.minimum:n.exclusiveMinimum)??-1/0;t.value>r&&(t.inclusive?n.minimum=t.value:n.exclusiveMinimum=t.value)}),e._zod.check=r=>{(t.inclusive?r.value>=t.value:r.value>t.value)||r.issues.push({origin:n,code:`too_small`,minimum:typeof t.value==`object`?t.value.getTime():t.value,input:r.value,inclusive:t.inclusive,inst:e,continue:!t.abort})}}),Zn=L(`$ZodCheckMultipleOf`,(e,t)=>{G.init(e,t),e._zod.onattach.push(e=>{var n;(n=e._zod.bag).multipleOf??(n.multipleOf=t.value)}),e._zod.check=n=>{if(typeof n.value!=typeof t.value)throw Error(`Cannot mix number and bigint in multiple_of check.`);(typeof n.value==`bigint`?n.value%t.value===BigInt(0):Ct(n.value,t.value)===0)||n.issues.push({origin:typeof n.value,code:`not_multiple_of`,divisor:t.value,input:n.value,inst:e,continue:!t.abort})}}),Qn=L(`$ZodCheckNumberFormat`,(e,t)=>{G.init(e,t),t.format=t.format||`float64`;let n=t.format?.includes(`int`),r=n?`int`:`number`,[i,a]=Ft[t.format];e._zod.onattach.push(e=>{let r=e._zod.bag;r.format=t.format,r.minimum=i,r.maximum=a,n&&(r.pattern=Un)}),e._zod.check=o=>{let s=o.value;if(n){if(!Number.isInteger(s)){o.issues.push({expected:r,format:t.format,code:`invalid_type`,continue:!1,input:s,inst:e});return}if(!Number.isSafeInteger(s)){s>0?o.issues.push({input:s,code:`too_big`,maximum:2**53-1,note:`Integers must be within the safe integer range.`,inst:e,origin:r,inclusive:!0,continue:!t.abort}):o.issues.push({input:s,code:`too_small`,minimum:-(2**53-1),note:`Integers must be within the safe integer range.`,inst:e,origin:r,inclusive:!0,continue:!t.abort});return}}sa&&o.issues.push({origin:`number`,input:s,code:`too_big`,maximum:a,inclusive:!0,inst:e,continue:!t.abort})}}),$n=L(`$ZodCheckMaxLength`,(e,t)=>{var n;G.init(e,t),(n=e._zod.def).when??(n.when=e=>{let t=e.value;return!xt(t)&&t.length!==void 0}),e._zod.onattach.push(e=>{let n=e._zod.bag.maximum??1/0;t.maximum{let r=n.value;if(r.length<=t.maximum)return;let i=qt(r);n.issues.push({origin:i,code:`too_big`,maximum:t.maximum,inclusive:!0,input:r,inst:e,continue:!t.abort})}}),er=L(`$ZodCheckMinLength`,(e,t)=>{var n;G.init(e,t),(n=e._zod.def).when??(n.when=e=>{let t=e.value;return!xt(t)&&t.length!==void 0}),e._zod.onattach.push(e=>{let n=e._zod.bag.minimum??-1/0;t.minimum>n&&(e._zod.bag.minimum=t.minimum)}),e._zod.check=n=>{let r=n.value;if(r.length>=t.minimum)return;let i=qt(r);n.issues.push({origin:i,code:`too_small`,minimum:t.minimum,inclusive:!0,input:r,inst:e,continue:!t.abort})}}),tr=L(`$ZodCheckLengthEquals`,(e,t)=>{var n;G.init(e,t),(n=e._zod.def).when??(n.when=e=>{let t=e.value;return!xt(t)&&t.length!==void 0}),e._zod.onattach.push(e=>{let n=e._zod.bag;n.minimum=t.length,n.maximum=t.length,n.length=t.length}),e._zod.check=n=>{let r=n.value,i=r.length;if(i===t.length)return;let a=qt(r),o=i>t.length;n.issues.push({origin:a,...o?{code:`too_big`,maximum:t.length}:{code:`too_small`,minimum:t.length},inclusive:!0,exact:!0,input:n.value,inst:e,continue:!t.abort})}}),nr=L(`$ZodCheckStringFormat`,(e,t)=>{var n,r;G.init(e,t),e._zod.onattach.push(e=>{let n=e._zod.bag;n.format=t.format,t.pattern&&(n.patterns??=new Set,n.patterns.add(t.pattern))}),t.pattern?(n=e._zod).check??(n.check=n=>{t.pattern.lastIndex=0,!t.pattern.test(n.value)&&n.issues.push({origin:`string`,code:`invalid_format`,format:t.format,input:n.value,...t.pattern?{pattern:t.pattern.toString()}:{},inst:e,continue:!t.abort})}):(r=e._zod).check??(r.check=()=>{})}),rr=L(`$ZodCheckRegex`,(e,t)=>{nr.init(e,t),e._zod.check=n=>{t.pattern.lastIndex=0,!t.pattern.test(n.value)&&n.issues.push({origin:`string`,code:`invalid_format`,format:`regex`,input:n.value,pattern:t.pattern.toString(),inst:e,continue:!t.abort})}}),ir=L(`$ZodCheckLowerCase`,(e,t)=>{t.pattern??=Kn,nr.init(e,t)}),ar=L(`$ZodCheckUpperCase`,(e,t)=>{t.pattern??=qn,nr.init(e,t)}),or=L(`$ZodCheckIncludes`,(e,t)=>{G.init(e,t);let n=Nt(t.includes),r=new RegExp(typeof t.position==`number`?`^.{${t.position}}${n}`:n);t.pattern=r,e._zod.onattach.push(e=>{let t=e._zod.bag;t.patterns??=new Set,t.patterns.add(r)}),e._zod.check=n=>{n.value.includes(t.includes,t.position)||n.issues.push({origin:`string`,code:`invalid_format`,format:`includes`,includes:t.includes,input:n.value,inst:e,continue:!t.abort})}}),sr=L(`$ZodCheckStartsWith`,(e,t)=>{G.init(e,t);let n=RegExp(`^${Nt(t.prefix)}.*`);t.pattern??=n,e._zod.onattach.push(e=>{let t=e._zod.bag;t.patterns??=new Set,t.patterns.add(n)}),e._zod.check=n=>{n.value.startsWith(t.prefix)||n.issues.push({origin:`string`,code:`invalid_format`,format:`starts_with`,prefix:t.prefix,input:n.value,inst:e,continue:!t.abort})}}),cr=L(`$ZodCheckEndsWith`,(e,t)=>{G.init(e,t);let n=RegExp(`.*${Nt(t.suffix)}$`);t.pattern??=n,e._zod.onattach.push(e=>{let t=e._zod.bag;t.patterns??=new Set,t.patterns.add(n)}),e._zod.check=n=>{n.value.endsWith(t.suffix)||n.issues.push({origin:`string`,code:`invalid_format`,format:`ends_with`,suffix:t.suffix,input:n.value,inst:e,continue:!t.abort})}}),lr=L(`$ZodCheckOverwrite`,(e,t)=>{G.init(e,t),e._zod.check=e=>{e.value=t.tx(e.value)}}),ur=class{constructor(e=[]){this.content=[],this.indent=0,this&&(this.args=e)}indented(e){this.indent+=1,e(this),--this.indent}write(e){if(typeof e==`function`){e(this,{execution:`sync`}),e(this,{execution:`async`});return}let t=e.split(`
+`).filter(e=>e),n=Math.min(...t.map(e=>e.length-e.trimStart().length)),r=t.map(e=>e.slice(n)).map(e=>` `.repeat(this.indent*2)+e);for(let e of r)this.content.push(e)}compile(){let e=Function,t=this?.args,n=[...(this?.content??[``]).map(e=>` ${e}`)];return new e(...t,n.join(`
+`))}},dr={major:4,minor:4,patch:3},K=L(`$ZodType`,(e,t)=>{var n;e??={},e._zod.def=t,e._zod.bag=e._zod.bag||{},e._zod.version=dr;let r=[...e._zod.def.checks??[]];e._zod.traits.has(`$ZodCheck`)&&r.unshift(e);for(let t of r)for(let n of t._zod.onattach)n(e);if(r.length===0)(n=e._zod).deferred??(n.deferred=[]),e._zod.deferred?.push(()=>{e._zod.run=e._zod.parse});else{let t=(e,t,n)=>{let r=Ut(e),i;for(let a of t){if(a._zod.def.when){if(Wt(e)||!a._zod.def.when(e))continue}else if(r)continue;let t=e.issues.length,o=a._zod.check(e);if(o instanceof Promise&&n?.async===!1)throw new ht;if(i||o instanceof Promise)i=(i??Promise.resolve()).then(async()=>{await o,e.issues.length!==t&&(r||=Ut(e,t))});else{if(e.issues.length===t)continue;r||=Ut(e,t)}}return i?i.then(()=>e):e},n=(n,i,a)=>{if(Ut(n))return n.aborted=!0,n;let o=t(i,r,a);if(o instanceof Promise){if(a.async===!1)throw new ht;return o.then(t=>e._zod.parse(t,a))}return e._zod.parse(o,a)};e._zod.run=(i,a)=>{if(a.skipChecks)return e._zod.parse(i,a);if(a.direction===`backward`){let t=e._zod.parse({value:i.value,issues:[]},{...a,skipChecks:!0});return t instanceof Promise?t.then(e=>n(e,i,a)):n(t,i,a)}let o=e._zod.parse(i,a);if(o instanceof Promise){if(a.async===!1)throw new ht;return o.then(e=>t(e,r,a))}return t(o,r,a)}}z(e,`~standard`,()=>({validate:t=>{try{let n=on(e,t);return n.success?{value:n.data}:{issues:n.error?.issues}}catch{return cn(e,t).then(e=>e.success?{value:e.data}:{issues:e.error?.issues})}},vendor:`zod`,version:1}))}),fr=L(`$ZodString`,(e,t)=>{K.init(e,t),e._zod.pattern=[...e?._zod.bag?.patterns??[]].pop()??Hn(e._zod.bag),e._zod.parse=(n,r)=>{if(t.coerce)try{n.value=String(n.value)}catch{}return typeof n.value==`string`||n.issues.push({expected:`string`,code:`invalid_type`,input:n.value,inst:e}),n}}),q=L(`$ZodStringFormat`,(e,t)=>{nr.init(e,t),fr.init(e,t)}),pr=L(`$ZodGUID`,(e,t)=>{t.pattern??=wn,q.init(e,t)}),mr=L(`$ZodUUID`,(e,t)=>{if(t.version){let e={v1:1,v2:2,v3:3,v4:4,v5:5,v6:6,v7:7,v8:8}[t.version];if(e===void 0)throw Error(`Invalid UUID version: "${t.version}"`);t.pattern??=Tn(e)}else t.pattern??=Tn();q.init(e,t)}),hr=L(`$ZodEmail`,(e,t)=>{t.pattern??=En,q.init(e,t)}),gr=L(`$ZodURL`,(e,t)=>{q.init(e,t),e._zod.check=n=>{try{let r=n.value.trim();if(!t.normalize&&t.protocol?.source===Fn.source&&!/^https?:\/\//i.test(r)){n.issues.push({code:`invalid_format`,format:`url`,note:`Invalid URL format`,input:n.value,inst:e,continue:!t.abort});return}let i=new URL(r);t.hostname&&(t.hostname.lastIndex=0,t.hostname.test(i.hostname)||n.issues.push({code:`invalid_format`,format:`url`,note:`Invalid hostname`,pattern:t.hostname.source,input:n.value,inst:e,continue:!t.abort})),t.protocol&&(t.protocol.lastIndex=0,t.protocol.test(i.protocol.endsWith(`:`)?i.protocol.slice(0,-1):i.protocol)||n.issues.push({code:`invalid_format`,format:`url`,note:`Invalid protocol`,pattern:t.protocol.source,input:n.value,inst:e,continue:!t.abort})),t.normalize?n.value=i.href:n.value=r;return}catch{n.issues.push({code:`invalid_format`,format:`url`,input:n.value,inst:e,continue:!t.abort})}}}),_r=L(`$ZodEmoji`,(e,t)=>{t.pattern??=On(),q.init(e,t)}),vr=L(`$ZodNanoID`,(e,t)=>{t.pattern??=Sn,q.init(e,t)}),yr=L(`$ZodCUID`,(e,t)=>{t.pattern??=_n,q.init(e,t)}),br=L(`$ZodCUID2`,(e,t)=>{t.pattern??=vn,q.init(e,t)}),xr=L(`$ZodULID`,(e,t)=>{t.pattern??=yn,q.init(e,t)}),Sr=L(`$ZodXID`,(e,t)=>{t.pattern??=bn,q.init(e,t)}),Cr=L(`$ZodKSUID`,(e,t)=>{t.pattern??=xn,q.init(e,t)}),wr=L(`$ZodISODateTime`,(e,t)=>{t.pattern??=Vn(t),q.init(e,t)}),Tr=L(`$ZodISODate`,(e,t)=>{t.pattern??=Rn,q.init(e,t)}),Er=L(`$ZodISOTime`,(e,t)=>{t.pattern??=Bn(t),q.init(e,t)}),Dr=L(`$ZodISODuration`,(e,t)=>{t.pattern??=Cn,q.init(e,t)}),Or=L(`$ZodIPv4`,(e,t)=>{t.pattern??=kn,q.init(e,t),e._zod.bag.format=`ipv4`}),kr=L(`$ZodIPv6`,(e,t)=>{t.pattern??=An,q.init(e,t),e._zod.bag.format=`ipv6`,e._zod.check=n=>{try{new URL(`http://[${n.value}]`)}catch{n.issues.push({code:`invalid_format`,format:`ipv6`,input:n.value,inst:e,continue:!t.abort})}}}),Ar=L(`$ZodCIDRv4`,(e,t)=>{t.pattern??=jn,q.init(e,t)}),jr=L(`$ZodCIDRv6`,(e,t)=>{t.pattern??=Mn,q.init(e,t),e._zod.check=n=>{let r=n.value.split(`/`);try{if(r.length!==2)throw Error();let[e,t]=r;if(!t)throw Error();let n=Number(t);if(`${n}`!==t||n<0||n>128)throw Error();new URL(`http://[${e}]`)}catch{n.issues.push({code:`invalid_format`,format:`cidrv6`,input:n.value,inst:e,continue:!t.abort})}}});function Mr(e){if(e===``)return!0;if(/\s/.test(e)||e.length%4!=0)return!1;try{return atob(e),!0}catch{return!1}}var Nr=L(`$ZodBase64`,(e,t)=>{t.pattern??=Nn,q.init(e,t),e._zod.bag.contentEncoding=`base64`,e._zod.check=n=>{Mr(n.value)||n.issues.push({code:`invalid_format`,format:`base64`,input:n.value,inst:e,continue:!t.abort})}});function Pr(e){if(!Pn.test(e))return!1;let t=e.replace(/[-_]/g,e=>e===`-`?`+`:`/`);return Mr(t.padEnd(Math.ceil(t.length/4)*4,`=`))}var Fr=L(`$ZodBase64URL`,(e,t)=>{t.pattern??=Pn,q.init(e,t),e._zod.bag.contentEncoding=`base64url`,e._zod.check=n=>{Pr(n.value)||n.issues.push({code:`invalid_format`,format:`base64url`,input:n.value,inst:e,continue:!t.abort})}}),Ir=L(`$ZodE164`,(e,t)=>{t.pattern??=In,q.init(e,t)});function Lr(e,t=null){try{let n=e.split(`.`);if(n.length!==3)return!1;let[r]=n;if(!r)return!1;let i=JSON.parse(atob(r));return!(`typ`in i&&i?.typ!==`JWT`||!i.alg||t&&(!(`alg`in i)||i.alg!==t))}catch{return!1}}var Rr=L(`$ZodJWT`,(e,t)=>{q.init(e,t),e._zod.check=n=>{Lr(n.value,t.alg)||n.issues.push({code:`invalid_format`,format:`jwt`,input:n.value,inst:e,continue:!t.abort})}}),zr=L(`$ZodNumber`,(e,t)=>{K.init(e,t),e._zod.pattern=e._zod.bag.pattern??Wn,e._zod.parse=(n,r)=>{if(t.coerce)try{n.value=Number(n.value)}catch{}let i=n.value;if(typeof i==`number`&&!Number.isNaN(i)&&Number.isFinite(i))return n;let a=typeof i==`number`?Number.isNaN(i)?`NaN`:Number.isFinite(i)?void 0:`Infinity`:void 0;return n.issues.push({expected:`number`,code:`invalid_type`,input:i,inst:e,...a?{received:a}:{}}),n}}),Br=L(`$ZodNumberFormat`,(e,t)=>{Qn.init(e,t),zr.init(e,t)}),Vr=L(`$ZodBoolean`,(e,t)=>{K.init(e,t),e._zod.pattern=Gn,e._zod.parse=(n,r)=>{if(t.coerce)try{n.value=!!n.value}catch{}let i=n.value;return typeof i==`boolean`||n.issues.push({expected:`boolean`,code:`invalid_type`,input:i,inst:e}),n}}),Hr=L(`$ZodUnknown`,(e,t)=>{K.init(e,t),e._zod.parse=e=>e}),Ur=L(`$ZodNever`,(e,t)=>{K.init(e,t),e._zod.parse=(t,n)=>(t.issues.push({expected:`never`,code:`invalid_type`,input:t.value,inst:e}),t)});function Wr(e,t,n){e.issues.length&&t.issues.push(...Gt(n,e.issues)),t.value[n]=e.value}var Gr=L(`$ZodArray`,(e,t)=>{K.init(e,t),e._zod.parse=(n,r)=>{let i=n.value;if(!Array.isArray(i))return n.issues.push({expected:`array`,code:`invalid_type`,input:i,inst:e}),n;n.value=Array(i.length);let a=[];for(let e=0;eWr(t,n,e))):Wr(s,n,e)}return a.length?Promise.all(a).then(()=>n):n}});function Kr(e,t,n,r,i,a){let o=n in r;if(e.issues.length){if(i&&a&&!o)return;t.issues.push(...Gt(n,e.issues))}if(!o&&!i){e.issues.length||t.issues.push({code:`invalid_type`,expected:`nonoptional`,input:void 0,path:[n]});return}e.value===void 0?o&&(t.value[n]=void 0):t.value[n]=e.value}function qr(e){let t=Object.keys(e.shape);for(let n of t)if(!e.shape?.[n]?._zod?.traits?.has(`$ZodType`))throw Error(`Invalid element at key "${n}": expected a Zod schema`);let n=Pt(e.shape);return{...e,keys:t,keySet:new Set(t),numKeys:t.length,optionalKeys:new Set(n)}}function Jr(e,t,n,r,i,a){let o=[],s=i.keySet,c=i.catchall._zod,l=c.def.type,u=c.optin===`optional`,d=c.optout===`optional`;for(let i in t){if(i===`__proto__`||s.has(i))continue;if(l===`never`){o.push(i);continue}let a=c.run({value:t[i],issues:[]},r);a instanceof Promise?e.push(a.then(e=>Kr(e,n,i,t,u,d))):Kr(a,n,i,t,u,d)}return o.length&&n.issues.push({code:`unrecognized_keys`,keys:o,input:t,inst:a}),e.length?Promise.all(e).then(()=>n):n}var Yr=L(`$ZodObject`,(e,t)=>{if(K.init(e,t),!Object.getOwnPropertyDescriptor(t,`shape`)?.get){let e=t.shape;Object.defineProperty(t,"shape",{get:()=>{let n={...e};return Object.defineProperty(t,"shape",{value:n}),n}})}let n=bt(()=>qr(t));z(e._zod,`propValues`,()=>{let e=t.shape,n={};for(let t in e){let r=e[t]._zod;if(r.values){n[t]??(n[t]=new Set);for(let e of r.values)n[t].add(e)}}return n});let r=Ot,i=t.catchall,a;e._zod.parse=(t,o)=>{a??=n.value;let s=t.value;if(!r(s))return t.issues.push({expected:`object`,code:`invalid_type`,input:s,inst:e}),t;t.value={};let c=[],l=a.shape;for(let e of a.keys){let n=l[e],r=n._zod.optin===`optional`,i=n._zod.optout===`optional`,a=n._zod.run({value:s[e],issues:[]},o);a instanceof Promise?c.push(a.then(n=>Kr(n,t,e,s,r,i))):Kr(a,t,e,s,r,i)}return i?Jr(c,s,t,o,n.value,e):c.length?Promise.all(c).then(()=>t):t}}),Xr=L(`$ZodObjectJIT`,(e,t)=>{Yr.init(e,t);let n=e._zod.parse,r=bt(()=>qr(t)),i=e=>{let t=new ur([`shape`,`payload`,`ctx`]),n=r.value,i=e=>{let t=Tt(e);return`shape[${t}]._zod.run({ value: input[${t}], issues: [] }, ctx)`};t.write(`const input = payload.value;`);let a=Object.create(null),o=0;for(let e of n.keys)a[e]=`key_${o++}`;t.write(`const newResult = {};`);for(let r of n.keys){let n=a[r],o=Tt(r),s=e[r],c=s?._zod?.optin===`optional`,l=s?._zod?.optout===`optional`;t.write(`const ${n} = ${i(r)};`),c&&l?t.write(`
+ if (${n}.issues.length) {
+ if (${o} in input) {
+ payload.issues = payload.issues.concat(${n}.issues.map(iss => ({
+ ...iss,
+ path: iss.path ? [${o}, ...iss.path] : [${o}]
+ })));
+ }
+ }
+
+ if (${n}.value === undefined) {
+ if (${o} in input) {
+ newResult[${o}] = undefined;
+ }
+ } else {
+ newResult[${o}] = ${n}.value;
+ }
+
+ `):c?t.write(`
+ if (${n}.issues.length) {
+ payload.issues = payload.issues.concat(${n}.issues.map(iss => ({
+ ...iss,
+ path: iss.path ? [${o}, ...iss.path] : [${o}]
+ })));
+ }
+
+ if (${n}.value === undefined) {
+ if (${o} in input) {
+ newResult[${o}] = undefined;
+ }
+ } else {
+ newResult[${o}] = ${n}.value;
+ }
+
+ `):t.write(`
+ const ${n}_present = ${o} in input;
+ if (${n}.issues.length) {
+ payload.issues = payload.issues.concat(${n}.issues.map(iss => ({
+ ...iss,
+ path: iss.path ? [${o}, ...iss.path] : [${o}]
+ })));
+ }
+ if (!${n}_present && !${n}.issues.length) {
+ payload.issues.push({
+ code: "invalid_type",
+ expected: "nonoptional",
+ input: undefined,
+ path: [${o}]
+ });
+ }
+
+ if (${n}_present) {
+ if (${n}.value === undefined) {
+ newResult[${o}] = undefined;
+ } else {
+ newResult[${o}] = ${n}.value;
+ }
+ }
+
+ `)}t.write(`payload.value = newResult;`),t.write(`return payload;`);let s=t.compile();return(t,n)=>s(e,t,n)},a,o=Ot,s=!_t.jitless,c=s&&kt.value,l=t.catchall,u;e._zod.parse=(d,f)=>{u??=r.value;let p=d.value;return o(p)?s&&c&&f?.async===!1&&f.jitless!==!0?(a||=i(t.shape),d=a(d,f),l?Jr([],p,d,f,u,e):d):n(d,f):(d.issues.push({expected:`object`,code:`invalid_type`,input:p,inst:e}),d)}});function Zr(e,t,n,r){for(let n of e)if(n.issues.length===0)return t.value=n.value,t;let i=e.filter(e=>!Ut(e));return i.length===1?(t.value=i[0].value,i[0]):(t.issues.push({code:`invalid_union`,input:t.value,inst:n,errors:e.map(e=>e.issues.map(e=>W(e,r,R())))}),t)}var Qr=L(`$ZodUnion`,(e,t)=>{K.init(e,t),z(e._zod,`optin`,()=>t.options.some(e=>e._zod.optin===`optional`)?`optional`:void 0),z(e._zod,`optout`,()=>t.options.some(e=>e._zod.optout===`optional`)?`optional`:void 0),z(e._zod,`values`,()=>{if(t.options.every(e=>e._zod.values))return new Set(t.options.flatMap(e=>Array.from(e._zod.values)))}),z(e._zod,`pattern`,()=>{if(t.options.every(e=>e._zod.pattern)){let e=t.options.map(e=>e._zod.pattern);return RegExp(`^(${e.map(e=>St(e.source)).join(`|`)})$`)}});let n=t.options.length===1?t.options[0]._zod.run:null;e._zod.parse=(r,i)=>{if(n)return n(r,i);let a=!1,o=[];for(let e of t.options){let t=e._zod.run({value:r.value,issues:[]},i);if(t instanceof Promise)o.push(t),a=!0;else{if(t.issues.length===0)return t;o.push(t)}}return a?Promise.all(o).then(t=>Zr(t,r,e,i)):Zr(o,r,e,i)}}),$r=L(`$ZodIntersection`,(e,t)=>{K.init(e,t),e._zod.parse=(e,n)=>{let r=e.value,i=t.left._zod.run({value:r,issues:[]},n),a=t.right._zod.run({value:r,issues:[]},n);return i instanceof Promise||a instanceof Promise?Promise.all([i,a]).then(([t,n])=>ti(e,t,n)):ti(e,i,a)}});function ei(e,t){if(e===t||e instanceof Date&&t instanceof Date&&+e==+t)return{valid:!0,data:e};if(At(e)&&At(t)){let n=Object.keys(t),r=Object.keys(e).filter(e=>n.indexOf(e)!==-1),i={...e,...t};for(let n of r){let r=ei(e[n],t[n]);if(!r.valid)return{valid:!1,mergeErrorPath:[n,...r.mergeErrorPath]};i[n]=r.data}return{valid:!0,data:i}}if(Array.isArray(e)&&Array.isArray(t)){if(e.length!==t.length)return{valid:!1,mergeErrorPath:[]};let n=[];for(let r=0;re.l&&e.r).map(([e])=>e);if(a.length&&i&&e.issues.push({...i,keys:a}),Ut(e))return e;let o=ei(t.value,n.value);if(!o.valid)throw Error(`Unmergable intersection. Error path: ${JSON.stringify(o.mergeErrorPath)}`);return e.value=o.data,e}var ni=L(`$ZodRecord`,(e,t)=>{K.init(e,t),e._zod.parse=(n,r)=>{let i=n.value;if(!At(i))return n.issues.push({expected:`record`,code:`invalid_type`,input:i,inst:e}),n;let a=[],o=t.keyType._zod.values;if(o){n.value={};let s=new Set;for(let c of o)if(typeof c==`string`||typeof c==`number`||typeof c==`symbol`){s.add(typeof c==`number`?c.toString():c);let o=t.keyType._zod.run({value:c,issues:[]},r);if(o instanceof Promise)throw Error(`Async schemas not supported in object keys currently`);if(o.issues.length){n.issues.push({code:`invalid_key`,origin:`record`,issues:o.issues.map(e=>W(e,r,R())),input:c,path:[c],inst:e});continue}let l=o.value,u=t.valueType._zod.run({value:i[c],issues:[]},r);u instanceof Promise?a.push(u.then(e=>{e.issues.length&&n.issues.push(...Gt(c,e.issues)),n.value[l]=e.value})):(u.issues.length&&n.issues.push(...Gt(c,u.issues)),n.value[l]=u.value)}let c;for(let e in i)s.has(e)||(c??=[],c.push(e));c&&c.length>0&&n.issues.push({code:`unrecognized_keys`,input:i,inst:e,keys:c})}else{n.value={};for(let o of Reflect.ownKeys(i)){if(o===`__proto__`||!Object.prototype.propertyIsEnumerable.call(i,o))continue;let s=t.keyType._zod.run({value:o,issues:[]},r);if(s instanceof Promise)throw Error(`Async schemas not supported in object keys currently`);if(typeof o==`string`&&Wn.test(o)&&s.issues.length){let e=t.keyType._zod.run({value:Number(o),issues:[]},r);if(e instanceof Promise)throw Error(`Async schemas not supported in object keys currently`);e.issues.length===0&&(s=e)}if(s.issues.length){t.mode===`loose`?n.value[o]=i[o]:n.issues.push({code:`invalid_key`,origin:`record`,issues:s.issues.map(e=>W(e,r,R())),input:o,path:[o],inst:e});continue}let c=t.valueType._zod.run({value:i[o],issues:[]},r);c instanceof Promise?a.push(c.then(e=>{e.issues.length&&n.issues.push(...Gt(o,e.issues)),n.value[s.value]=e.value})):(c.issues.length&&n.issues.push(...Gt(o,c.issues)),n.value[s.value]=c.value)}}return a.length?Promise.all(a).then(()=>n):n}}),ri=L(`$ZodEnum`,(e,t)=>{K.init(e,t);let n=vt(t.entries),r=new Set(n);e._zod.values=r,e._zod.pattern=RegExp(`^(${n.filter(e=>Mt.has(typeof e)).map(e=>typeof e==`string`?Nt(e):e.toString()).join(`|`)})$`),e._zod.parse=(t,i)=>{let a=t.value;return r.has(a)||t.issues.push({code:`invalid_value`,values:n,input:a,inst:e}),t}}),ii=L(`$ZodTransform`,(e,t)=>{K.init(e,t),e._zod.optin=`optional`,e._zod.parse=(n,r)=>{if(r.direction===`backward`)throw new gt(e.constructor.name);let i=t.transform(n.value,n);if(r.async)return(i instanceof Promise?i:Promise.resolve(i)).then(e=>(n.value=e,n.fallback=!0,n));if(i instanceof Promise)throw new ht;return n.value=i,n.fallback=!0,n}});function ai(e,t){return t===void 0&&(e.issues.length||e.fallback)?{issues:[],value:void 0}:e}var oi=L(`$ZodOptional`,(e,t)=>{K.init(e,t),e._zod.optin=`optional`,e._zod.optout=`optional`,z(e._zod,`values`,()=>t.innerType._zod.values?new Set([...t.innerType._zod.values,void 0]):void 0),z(e._zod,`pattern`,()=>{let e=t.innerType._zod.pattern;return e?RegExp(`^(${St(e.source)})?$`):void 0}),e._zod.parse=(e,n)=>{if(t.innerType._zod.optin===`optional`){let r=e.value,i=t.innerType._zod.run(e,n);return i instanceof Promise?i.then(e=>ai(e,r)):ai(i,r)}return e.value===void 0?e:t.innerType._zod.run(e,n)}}),si=L(`$ZodExactOptional`,(e,t)=>{oi.init(e,t),z(e._zod,`values`,()=>t.innerType._zod.values),z(e._zod,`pattern`,()=>t.innerType._zod.pattern),e._zod.parse=(e,n)=>t.innerType._zod.run(e,n)}),ci=L(`$ZodNullable`,(e,t)=>{K.init(e,t),z(e._zod,`optin`,()=>t.innerType._zod.optin),z(e._zod,`optout`,()=>t.innerType._zod.optout),z(e._zod,`pattern`,()=>{let e=t.innerType._zod.pattern;return e?RegExp(`^(${St(e.source)}|null)$`):void 0}),z(e._zod,`values`,()=>t.innerType._zod.values?new Set([...t.innerType._zod.values,null]):void 0),e._zod.parse=(e,n)=>e.value===null?e:t.innerType._zod.run(e,n)}),li=L(`$ZodDefault`,(e,t)=>{K.init(e,t),e._zod.optin=`optional`,z(e._zod,`values`,()=>t.innerType._zod.values),e._zod.parse=(e,n)=>{if(n.direction===`backward`)return t.innerType._zod.run(e,n);if(e.value===void 0)return e.value=t.defaultValue,e;let r=t.innerType._zod.run(e,n);return r instanceof Promise?r.then(e=>ui(e,t)):ui(r,t)}});function ui(e,t){return e.value===void 0&&(e.value=t.defaultValue),e}var di=L(`$ZodPrefault`,(e,t)=>{K.init(e,t),e._zod.optin=`optional`,z(e._zod,`values`,()=>t.innerType._zod.values),e._zod.parse=(e,n)=>(n.direction===`backward`||e.value===void 0&&(e.value=t.defaultValue),t.innerType._zod.run(e,n))}),fi=L(`$ZodNonOptional`,(e,t)=>{K.init(e,t),z(e._zod,`values`,()=>{let e=t.innerType._zod.values;return e?new Set([...e].filter(e=>e!==void 0)):void 0}),e._zod.parse=(n,r)=>{let i=t.innerType._zod.run(n,r);return i instanceof Promise?i.then(t=>pi(t,e)):pi(i,e)}});function pi(e,t){return!e.issues.length&&e.value===void 0&&e.issues.push({code:`invalid_type`,expected:`nonoptional`,input:e.value,inst:t}),e}var mi=L(`$ZodCatch`,(e,t)=>{K.init(e,t),e._zod.optin=`optional`,z(e._zod,`optout`,()=>t.innerType._zod.optout),z(e._zod,`values`,()=>t.innerType._zod.values),e._zod.parse=(e,n)=>{if(n.direction===`backward`)return t.innerType._zod.run(e,n);let r=t.innerType._zod.run(e,n);return r instanceof Promise?r.then(r=>(e.value=r.value,r.issues.length&&(e.value=t.catchValue({...e,error:{issues:r.issues.map(e=>W(e,n,R()))},input:e.value}),e.issues=[],e.fallback=!0),e)):(e.value=r.value,r.issues.length&&(e.value=t.catchValue({...e,error:{issues:r.issues.map(e=>W(e,n,R()))},input:e.value}),e.issues=[],e.fallback=!0),e)}}),hi=L(`$ZodPipe`,(e,t)=>{K.init(e,t),z(e._zod,`values`,()=>t.in._zod.values),z(e._zod,`optin`,()=>t.in._zod.optin),z(e._zod,`optout`,()=>t.out._zod.optout),z(e._zod,`propValues`,()=>t.in._zod.propValues),e._zod.parse=(e,n)=>{if(n.direction===`backward`){let r=t.out._zod.run(e,n);return r instanceof Promise?r.then(e=>gi(e,t.in,n)):gi(r,t.in,n)}let r=t.in._zod.run(e,n);return r instanceof Promise?r.then(e=>gi(e,t.out,n)):gi(r,t.out,n)}});function gi(e,t,n){return e.issues.length?(e.aborted=!0,e):t._zod.run({value:e.value,issues:e.issues,fallback:e.fallback},n)}var _i=L(`$ZodReadonly`,(e,t)=>{K.init(e,t),z(e._zod,`propValues`,()=>t.innerType._zod.propValues),z(e._zod,`values`,()=>t.innerType._zod.values),z(e._zod,`optin`,()=>t.innerType?._zod?.optin),z(e._zod,`optout`,()=>t.innerType?._zod?.optout),e._zod.parse=(e,n)=>{if(n.direction===`backward`)return t.innerType._zod.run(e,n);let r=t.innerType._zod.run(e,n);return r instanceof Promise?r.then(vi):vi(r)}});function vi(e){return e.value=Object.freeze(e.value),e}var yi=L(`$ZodCustom`,(e,t)=>{G.init(e,t),K.init(e,t),e._zod.parse=(e,t)=>e,e._zod.check=n=>{let r=n.value,i=t.fn(r);if(i instanceof Promise)return i.then(t=>bi(t,n,r,e));bi(i,n,r,e)}});function bi(e,t,n,r){if(!e){let e={code:`custom`,input:n,inst:r,path:[...r._zod.def.path??[]],continue:!r._zod.def.abort};r._zod.def.params&&(e.params=r._zod.def.params),t.issues.push(Jt(e))}}var xi,Si=class{constructor(){this._map=new WeakMap,this._idmap=new Map}add(e,...t){let n=t[0];return this._map.set(e,n),n&&typeof n==`object`&&`id`in n&&this._idmap.set(n.id,e),this}clear(){return this._map=new WeakMap,this._idmap=new Map,this}remove(e){let t=this._map.get(e);return t&&typeof t==`object`&&`id`in t&&this._idmap.delete(t.id),this._map.delete(e),this}get(e){let t=e._zod.parent;if(t){let n={...this.get(t)??{}};delete n.id;let r={...n,...this._map.get(e)};return Object.keys(r).length?r:void 0}return this._map.get(e)}has(e){return this._map.has(e)}};function Ci(){return new Si}(xi=globalThis).__zod_globalRegistry??(xi.__zod_globalRegistry=Ci());var wi=globalThis.__zod_globalRegistry;function Ti(e,t){return new e({type:`string`,...U(t)})}function Ei(e,t){return new e({type:`string`,format:`email`,check:`string_format`,abort:!1,...U(t)})}function Di(e,t){return new e({type:`string`,format:`guid`,check:`string_format`,abort:!1,...U(t)})}function Oi(e,t){return new e({type:`string`,format:`uuid`,check:`string_format`,abort:!1,...U(t)})}function ki(e,t){return new e({type:`string`,format:`uuid`,check:`string_format`,abort:!1,version:`v4`,...U(t)})}function Ai(e,t){return new e({type:`string`,format:`uuid`,check:`string_format`,abort:!1,version:`v6`,...U(t)})}function ji(e,t){return new e({type:`string`,format:`uuid`,check:`string_format`,abort:!1,version:`v7`,...U(t)})}function Mi(e,t){return new e({type:`string`,format:`url`,check:`string_format`,abort:!1,...U(t)})}function Ni(e,t){return new e({type:`string`,format:`emoji`,check:`string_format`,abort:!1,...U(t)})}function Pi(e,t){return new e({type:`string`,format:`nanoid`,check:`string_format`,abort:!1,...U(t)})}function Fi(e,t){return new e({type:`string`,format:`cuid`,check:`string_format`,abort:!1,...U(t)})}function Ii(e,t){return new e({type:`string`,format:`cuid2`,check:`string_format`,abort:!1,...U(t)})}function Li(e,t){return new e({type:`string`,format:`ulid`,check:`string_format`,abort:!1,...U(t)})}function Ri(e,t){return new e({type:`string`,format:`xid`,check:`string_format`,abort:!1,...U(t)})}function zi(e,t){return new e({type:`string`,format:`ksuid`,check:`string_format`,abort:!1,...U(t)})}function Bi(e,t){return new e({type:`string`,format:`ipv4`,check:`string_format`,abort:!1,...U(t)})}function Vi(e,t){return new e({type:`string`,format:`ipv6`,check:`string_format`,abort:!1,...U(t)})}function Hi(e,t){return new e({type:`string`,format:`cidrv4`,check:`string_format`,abort:!1,...U(t)})}function Ui(e,t){return new e({type:`string`,format:`cidrv6`,check:`string_format`,abort:!1,...U(t)})}function Wi(e,t){return new e({type:`string`,format:`base64`,check:`string_format`,abort:!1,...U(t)})}function Gi(e,t){return new e({type:`string`,format:`base64url`,check:`string_format`,abort:!1,...U(t)})}function Ki(e,t){return new e({type:`string`,format:`e164`,check:`string_format`,abort:!1,...U(t)})}function qi(e,t){return new e({type:`string`,format:`jwt`,check:`string_format`,abort:!1,...U(t)})}function Ji(e,t){return new e({type:`string`,format:`datetime`,check:`string_format`,offset:!1,local:!1,precision:null,...U(t)})}function Yi(e,t){return new e({type:`string`,format:`date`,check:`string_format`,...U(t)})}function Xi(e,t){return new e({type:`string`,format:`time`,check:`string_format`,precision:null,...U(t)})}function Zi(e,t){return new e({type:`string`,format:`duration`,check:`string_format`,...U(t)})}function Qi(e,t){return new e({type:`number`,checks:[],...U(t)})}function $i(e,t){return new e({type:`number`,check:`number_format`,abort:!1,format:`safeint`,...U(t)})}function ea(e,t){return new e({type:`boolean`,...U(t)})}function ta(e){return new e({type:`unknown`})}function na(e,t){return new e({type:`never`,...U(t)})}function ra(e,t){return new Yn({check:`less_than`,...U(t),value:e,inclusive:!1})}function ia(e,t){return new Yn({check:`less_than`,...U(t),value:e,inclusive:!0})}function aa(e,t){return new Xn({check:`greater_than`,...U(t),value:e,inclusive:!1})}function oa(e,t){return new Xn({check:`greater_than`,...U(t),value:e,inclusive:!0})}function sa(e,t){return new Zn({check:`multiple_of`,...U(t),value:e})}function ca(e,t){return new $n({check:`max_length`,...U(t),maximum:e})}function la(e,t){return new er({check:`min_length`,...U(t),minimum:e})}function ua(e,t){return new tr({check:`length_equals`,...U(t),length:e})}function da(e,t){return new rr({check:`string_format`,format:`regex`,...U(t),pattern:e})}function fa(e){return new ir({check:`string_format`,format:`lowercase`,...U(e)})}function pa(e){return new ar({check:`string_format`,format:`uppercase`,...U(e)})}function ma(e,t){return new or({check:`string_format`,format:`includes`,...U(t),includes:e})}function ha(e,t){return new sr({check:`string_format`,format:`starts_with`,...U(t),prefix:e})}function ga(e,t){return new cr({check:`string_format`,format:`ends_with`,...U(t),suffix:e})}function _a(e){return new lr({check:`overwrite`,tx:e})}function va(e){return _a(t=>t.normalize(e))}function ya(){return _a(e=>e.trim())}function ba(){return _a(e=>e.toLowerCase())}function xa(){return _a(e=>e.toUpperCase())}function Sa(){return _a(e=>Et(e))}function Ca(e,t,n){return new e({type:`array`,element:t,...U(n)})}function wa(e,t,n){return new e({type:`custom`,check:`custom`,fn:t,...U(n)})}function Ta(e,t){let n=Ea(t=>(t.addIssue=e=>{if(typeof e==`string`)t.issues.push(Jt(e,t.value,n._zod.def));else{let r=e;r.fatal&&(r.continue=!1),r.code??=`custom`,r.input??=t.value,r.inst??=n,r.continue??=!n._zod.def.abort,t.issues.push(Jt(r))}},e(t.value,t)),t);return n}function Ea(e,t){let n=new G({check:`custom`,...U(t)});return n._zod.check=e,n}function Da(e){let t=e?.target??`draft-2020-12`;return t===`draft-4`&&(t=`draft-04`),t===`draft-7`&&(t=`draft-07`),{processors:e.processors??{},metadataRegistry:e?.metadata??wi,target:t,unrepresentable:e?.unrepresentable??`throw`,override:e?.override??(()=>{}),io:e?.io??`output`,counter:0,seen:new Map,cycles:e?.cycles??`ref`,reused:e?.reused??`inline`,external:e?.external??void 0}}function J(e,t,n={path:[],schemaPath:[]}){var r;let i=e._zod.def,a=t.seen.get(e);if(a)return a.count++,n.schemaPath.includes(e)&&(a.cycle=n.path),a.schema;let o={schema:{},count:1,cycle:void 0,path:n.path};t.seen.set(e,o);let s=e._zod.toJSONSchema?.();if(s)o.schema=s;else{let r={...n,schemaPath:[...n.schemaPath,e],path:n.path};if(e._zod.processJSONSchema)e._zod.processJSONSchema(t,o.schema,r);else{let n=o.schema,a=t.processors[i.type];if(!a)throw Error(`[toJSONSchema]: Non-representable type encountered: ${i.type}`);a(e,t,n,r)}let a=e._zod.parent;a&&(o.ref||=a,J(a,t,r),t.seen.get(a).isParent=!0)}let c=t.metadataRegistry.get(e);return c&&Object.assign(o.schema,c),t.io===`input`&&Y(e)&&(delete o.schema.examples,delete o.schema.default),t.io===`input`&&`_prefault`in o.schema&&((r=o.schema).default??(r.default=o.schema._prefault)),delete o.schema._prefault,t.seen.get(e).schema}function Oa(e,t){let n=e.seen.get(t);if(!n)throw Error(`Unprocessed schema. This is a bug in Zod.`);let r=new Map;for(let t of e.seen.entries()){let n=e.metadataRegistry.get(t[0])?.id;if(n){let e=r.get(n);if(e&&e!==t[0])throw Error(`Duplicate schema id "${n}" detected during JSON Schema conversion. Two different schemas cannot share the same id when converted together.`);r.set(n,t[0])}}let i=t=>{let r=e.target===`draft-2020-12`?`$defs`:`definitions`;if(e.external){let n=e.external.registry.get(t[0])?.id,i=e.external.uri??(e=>e);if(n)return{ref:i(n)};let a=t[1].defId??t[1].schema.id??`schema${e.counter++}`;return t[1].defId=a,{defId:a,ref:`${i(`__shared`)}#/${r}/${a}`}}if(t[1]===n)return{ref:`#`};let i=`#/${r}/`,a=t[1].schema.id??`__schema${e.counter++}`;return{defId:a,ref:i+a}},a=e=>{if(e[1].schema.$ref)return;let t=e[1],{ref:n,defId:r}=i(e);t.def={...t.schema},r&&(t.defId=r);let a=t.schema;for(let e in a)delete a[e];a.$ref=n};if(e.cycles===`throw`)for(let t of e.seen.entries()){let e=t[1];if(e.cycle)throw Error(`Cycle detected: #/${e.cycle?.join(`/`)}/
+
+Set the \`cycles\` parameter to \`"ref"\` to resolve cyclical schemas with defs.`)}for(let n of e.seen.entries()){let r=n[1];if(t===n[0]){a(n);continue}if(e.external){let r=e.external.registry.get(n[0])?.id;if(t!==n[0]&&r){a(n);continue}}if(e.metadataRegistry.get(n[0])?.id){a(n);continue}if(r.cycle){a(n);continue}if(r.count>1&&e.reused===`ref`){a(n);continue}}}function ka(e,t){let n=e.seen.get(t);if(!n)throw Error(`Unprocessed schema. This is a bug in Zod.`);let r=t=>{let n=e.seen.get(t);if(n.ref===null)return;let i=n.def??n.schema,a={...i},o=n.ref;if(n.ref=null,o){r(o);let n=e.seen.get(o),s=n.schema;if(s.$ref&&(e.target===`draft-07`||e.target===`draft-04`||e.target===`openapi-3.0`)?(i.allOf=i.allOf??[],i.allOf.push(s)):Object.assign(i,s),Object.assign(i,a),t._zod.parent===o)for(let e in i)e===`$ref`||e===`allOf`||e in a||delete i[e];if(s.$ref&&n.def)for(let e in i)e===`$ref`||e===`allOf`||e in n.def&&JSON.stringify(i[e])===JSON.stringify(n.def[e])&&delete i[e]}let s=t._zod.parent;if(s&&s!==o){r(s);let t=e.seen.get(s);if(t?.schema.$ref&&(i.$ref=t.schema.$ref,t.def))for(let e in i)e===`$ref`||e===`allOf`||e in t.def&&JSON.stringify(i[e])===JSON.stringify(t.def[e])&&delete i[e]}e.override({zodSchema:t,jsonSchema:i,path:n.path??[]})};for(let t of[...e.seen.entries()].reverse())r(t[0]);let i={};if(e.target===`draft-2020-12`?i.$schema=`https://json-schema.org/draft/2020-12/schema`:e.target===`draft-07`?i.$schema=`http://json-schema.org/draft-07/schema#`:e.target===`draft-04`?i.$schema=`http://json-schema.org/draft-04/schema#`:e.target,e.external?.uri){let n=e.external.registry.get(t)?.id;if(!n)throw Error("Schema is missing an `id` property");i.$id=e.external.uri(n)}Object.assign(i,n.def??n.schema);let a=e.metadataRegistry.get(t)?.id;a!==void 0&&i.id===a&&delete i.id;let o=e.external?.defs??{};for(let t of e.seen.entries()){let e=t[1];e.def&&e.defId&&(e.def.id===e.defId&&delete e.def.id,o[e.defId]=e.def)}e.external||Object.keys(o).length>0&&(e.target===`draft-2020-12`?i.$defs=o:i.definitions=o);try{let n=JSON.parse(JSON.stringify(i));return Object.defineProperty(n,"~standard",{value:{...t[`~standard`],jsonSchema:{input:ja(t,`input`,e.processors),output:ja(t,`output`,e.processors)}},enumerable:!1,writable:!1}),n}catch{throw Error(`Error converting schema to JSON.`)}}function Y(e,t){let n=t??{seen:new Set};if(n.seen.has(e))return!1;n.seen.add(e);let r=e._zod.def;if(r.type===`transform`)return!0;if(r.type===`array`)return Y(r.element,n);if(r.type===`set`)return Y(r.valueType,n);if(r.type===`lazy`)return Y(r.getter(),n);if(r.type===`promise`||r.type===`optional`||r.type===`nonoptional`||r.type===`nullable`||r.type===`readonly`||r.type==="default"||r.type===`prefault`)return Y(r.innerType,n);if(r.type===`intersection`)return Y(r.left,n)||Y(r.right,n);if(r.type===`record`||r.type===`map`)return Y(r.keyType,n)||Y(r.valueType,n);if(r.type===`pipe`)return e._zod.traits.has(`$ZodCodec`)?!0:Y(r.in,n)||Y(r.out,n);if(r.type===`object`){for(let e in r.shape)if(Y(r.shape[e],n))return!0;return!1}if(r.type===`union`){for(let e of r.options)if(Y(e,n))return!0;return!1}if(r.type===`tuple`){for(let e of r.items)if(Y(e,n))return!0;return!!(r.rest&&Y(r.rest,n))}return!1}var Aa=(e,t={})=>n=>{let r=Da({...n,processors:t});return J(e,r),Oa(r,e),ka(r,e)},ja=(e,t,n={})=>r=>{let{libraryOptions:i,target:a}=r??{},o=Da({...i??{},target:a,io:t,processors:n});return J(e,o),Oa(o,e),ka(o,e)},Ma={guid:`uuid`,url:`uri`,datetime:`date-time`,json_string:`json-string`,regex:``},Na=(e,t,n,r)=>{let i=n;i.type=`string`;let{minimum:a,maximum:o,format:s,patterns:c,contentEncoding:l}=e._zod.bag;if(typeof a==`number`&&(i.minLength=a),typeof o==`number`&&(i.maxLength=o),s&&(i.format=Ma[s]??s,i.format===``&&delete i.format,s===`time`&&delete i.format),l&&(i.contentEncoding=l),c&&c.size>0){let e=[...c];e.length===1?i.pattern=e[0].source:e.length>1&&(i.allOf=[...e.map(e=>({...t.target===`draft-07`||t.target===`draft-04`||t.target===`openapi-3.0`?{type:`string`}:{},pattern:e.source}))])}},Pa=(e,t,n,r)=>{let i=n,{minimum:a,maximum:o,format:s,multipleOf:c,exclusiveMaximum:l,exclusiveMinimum:u}=e._zod.bag;typeof s==`string`&&s.includes(`int`)?i.type=`integer`:i.type=`number`;let d=typeof u==`number`&&u>=(a??-1/0),f=typeof l==`number`&&l<=(o??1/0),p=t.target===`draft-04`||t.target===`openapi-3.0`;d?p?(i.minimum=u,i.exclusiveMinimum=!0):i.exclusiveMinimum=u:typeof a==`number`&&(i.minimum=a),f?p?(i.maximum=l,i.exclusiveMaximum=!0):i.exclusiveMaximum=l:typeof o==`number`&&(i.maximum=o),typeof c==`number`&&(i.multipleOf=c)},Fa=(e,t,n,r)=>{n.type=`boolean`},Ia=(e,t,n,r)=>{n.not={}},La=(e,t,n,r)=>{let i=e._zod.def,a=vt(i.entries);a.every(e=>typeof e==`number`)&&(n.type=`number`),a.every(e=>typeof e==`string`)&&(n.type=`string`),n.enum=a},Ra=(e,t,n,r)=>{if(t.unrepresentable===`throw`)throw Error(`Custom types cannot be represented in JSON Schema`)},za=(e,t,n,r)=>{if(t.unrepresentable===`throw`)throw Error(`Transforms cannot be represented in JSON Schema`)},Ba=(e,t,n,r)=>{let i=n,a=e._zod.def,{minimum:o,maximum:s}=e._zod.bag;typeof o==`number`&&(i.minItems=o),typeof s==`number`&&(i.maxItems=s),i.type=`array`,i.items=J(a.element,t,{...r,path:[...r.path,`items`]})},Va=(e,t,n,r)=>{let i=n,a=e._zod.def;i.type=`object`,i.properties={};let o=a.shape;for(let e in o)i.properties[e]=J(o[e],t,{...r,path:[...r.path,`properties`,e]});let s=new Set(Object.keys(o)),c=new Set([...s].filter(e=>{let n=a.shape[e]._zod;return t.io===`input`?n.optin===void 0:n.optout===void 0}));c.size>0&&(i.required=Array.from(c)),a.catchall?._zod.def.type===`never`?i.additionalProperties=!1:a.catchall?a.catchall&&(i.additionalProperties=J(a.catchall,t,{...r,path:[...r.path,`additionalProperties`]})):t.io===`output`&&(i.additionalProperties=!1)},Ha=(e,t,n,r)=>{let i=e._zod.def,a=i.inclusive===!1,o=i.options.map((e,n)=>J(e,t,{...r,path:[...r.path,a?`oneOf`:`anyOf`,n]}));a?n.oneOf=o:n.anyOf=o},Ua=(e,t,n,r)=>{let i=e._zod.def,a=J(i.left,t,{...r,path:[...r.path,`allOf`,0]}),o=J(i.right,t,{...r,path:[...r.path,`allOf`,1]}),s=e=>`allOf`in e&&Object.keys(e).length===1;n.allOf=[...s(a)?a.allOf:[a],...s(o)?o.allOf:[o]]},Wa=(e,t,n,r)=>{let i=n,a=e._zod.def;i.type=`object`;let o=a.keyType,s=o._zod.bag?.patterns;if(a.mode===`loose`&&s&&s.size>0){let e=J(a.valueType,t,{...r,path:[...r.path,`patternProperties`,`*`]});i.patternProperties={};for(let t of s)i.patternProperties[t.source]=e}else (t.target===`draft-07`||t.target===`draft-2020-12`)&&(i.propertyNames=J(a.keyType,t,{...r,path:[...r.path,`propertyNames`]})),i.additionalProperties=J(a.valueType,t,{...r,path:[...r.path,`additionalProperties`]});let c=o._zod.values;if(c){let e=[...c].filter(e=>typeof e==`string`||typeof e==`number`);e.length>0&&(i.required=e)}},Ga=(e,t,n,r)=>{let i=e._zod.def,a=J(i.innerType,t,r),o=t.seen.get(e);t.target===`openapi-3.0`?(o.ref=i.innerType,n.nullable=!0):n.anyOf=[a,{type:`null`}]},Ka=(e,t,n,r)=>{let i=e._zod.def;J(i.innerType,t,r);let a=t.seen.get(e);a.ref=i.innerType},qa=(e,t,n,r)=>{let i=e._zod.def;J(i.innerType,t,r);let a=t.seen.get(e);a.ref=i.innerType,n.default=JSON.parse(JSON.stringify(i.defaultValue))},Ja=(e,t,n,r)=>{let i=e._zod.def;J(i.innerType,t,r);let a=t.seen.get(e);a.ref=i.innerType,t.io===`input`&&(n._prefault=JSON.parse(JSON.stringify(i.defaultValue)))},Ya=(e,t,n,r)=>{let i=e._zod.def;J(i.innerType,t,r);let a=t.seen.get(e);a.ref=i.innerType;let o;try{o=i.catchValue(void 0)}catch{throw Error(`Dynamic catch values are not supported in JSON Schema`)}n.default=o},Xa=(e,t,n,r)=>{let i=e._zod.def,a=i.in._zod.traits.has(`$ZodTransform`),o=t.io===`input`?a?i.out:i.in:i.out;J(o,t,r);let s=t.seen.get(e);s.ref=o},Za=(e,t,n,r)=>{let i=e._zod.def;J(i.innerType,t,r);let a=t.seen.get(e);a.ref=i.innerType,n.readOnly=!0},Qa=(e,t,n,r)=>{let i=e._zod.def;J(i.innerType,t,r);let a=t.seen.get(e);a.ref=i.innerType},$a=L(`ZodISODateTime`,(e,t)=>{wr.init(e,t),Q.init(e,t)});function eo(e){return Ji($a,e)}var to=L(`ZodISODate`,(e,t)=>{Tr.init(e,t),Q.init(e,t)});function no(e){return Yi(to,e)}var ro=L(`ZodISOTime`,(e,t)=>{Er.init(e,t),Q.init(e,t)});function io(e){return Xi(ro,e)}var ao=L(`ZodISODuration`,(e,t)=>{Dr.init(e,t),Q.init(e,t)});function oo(e){return Zi(ao,e)}var X=L(`ZodError`,(e,t)=>{Xt.init(e,t),e.name=`ZodError`,Object.defineProperties(e,{format:{value:t=>$t(e,t)},flatten:{value:t=>Qt(e,t)},addIssue:{value:t=>{e.issues.push(t),e.message=JSON.stringify(e.issues,yt,2)}},addIssues:{value:t=>{e.issues.push(...t),e.message=JSON.stringify(e.issues,yt,2)}},isEmpty:{get(){return e.issues.length===0}}})},{Parent:Error}),so=en(X),co=nn(X),lo=an(X),uo=sn(X),fo=ln(X),po=un(X),mo=dn(X),ho=fn(X),go=pn(X),_o=mn(X),vo=hn(X),yo=gn(X),bo=new WeakMap;function xo(e,t,n){let r=Object.getPrototypeOf(e),i=bo.get(r);if(i||(i=new Set,bo.set(r,i)),!i.has(t)){i.add(t);for(let e in n){let t=n[e];Object.defineProperty(r,e,{configurable:!0,enumerable:!1,get(){let n=t.bind(this);return Object.defineProperty(this,e,{configurable:!0,writable:!0,enumerable:!0,value:n}),n},set(t){Object.defineProperty(this,e,{configurable:!0,writable:!0,enumerable:!0,value:t})}})}}}var Z=L(`ZodType`,(e,t)=>(K.init(e,t),Object.assign(e[`~standard`],{jsonSchema:{input:ja(e,`input`),output:ja(e,`output`)}}),e.toJSONSchema=Aa(e,{}),e.def=t,e.type=t.type,Object.defineProperty(e,"_def",{value:t}),e.parse=(t,n)=>so(e,t,n,{callee:e.parse}),e.safeParse=(t,n)=>lo(e,t,n),e.parseAsync=async(t,n)=>co(e,t,n,{callee:e.parseAsync}),e.safeParseAsync=async(t,n)=>uo(e,t,n),e.spa=e.safeParseAsync,e.encode=(t,n)=>fo(e,t,n),e.decode=(t,n)=>po(e,t,n),e.encodeAsync=async(t,n)=>mo(e,t,n),e.decodeAsync=async(t,n)=>ho(e,t,n),e.safeEncode=(t,n)=>go(e,t,n),e.safeDecode=(t,n)=>_o(e,t,n),e.safeEncodeAsync=async(t,n)=>vo(e,t,n),e.safeDecodeAsync=async(t,n)=>yo(e,t,n),xo(e,`ZodType`,{check(...e){let t=this.def;return this.clone(V(t,{checks:[...t.checks??[],...e.map(e=>typeof e==`function`?{_zod:{check:e,def:{check:`custom`},onattach:[]}}:e)]}),{parent:!0})},with(...e){return this.check(...e)},clone(e,t){return H(this,e,t)},brand(){return this},register(e,t){return e.add(this,t),this},refine(e,t){return this.check(Ns(e,t))},superRefine(e,t){return this.check(Ps(e,t))},overwrite(e){return this.check(_a(e))},optional(){return hs(this)},exactOptional(){return _s(this)},nullable(){return ys(this)},nullish(){return hs(ys(this))},nonoptional(e){return Ts(this,e)},array(){return ts(this)},or(e){return as([this,e])},and(e){return ss(this,e)},transform(e){return ks(this,ps(e))},default(e){return xs(this,e)},prefault(e){return Cs(this,e)},catch(e){return Ds(this,e)},pipe(e){return ks(this,e)},readonly(){return js(this)},describe(e){let t=this.clone();return wi.add(t,{description:e}),t},meta(...e){if(e.length===0)return wi.get(this);let t=this.clone();return wi.add(t,e[0]),t},isOptional(){return this.safeParse(void 0).success},isNullable(){return this.safeParse(null).success},apply(e){return e(this)}}),Object.defineProperty(e,"description",{get(){return wi.get(e)?.description},configurable:!0}),e)),So=L(`_ZodString`,(e,t)=>{fr.init(e,t),Z.init(e,t),e._zod.processJSONSchema=(t,n,r)=>Na(e,t,n,r);let n=e._zod.bag;e.format=n.format??null,e.minLength=n.minimum??null,e.maxLength=n.maximum??null,xo(e,`_ZodString`,{regex(...e){return this.check(da(...e))},includes(...e){return this.check(ma(...e))},startsWith(...e){return this.check(ha(...e))},endsWith(...e){return this.check(ga(...e))},min(...e){return this.check(la(...e))},max(...e){return this.check(ca(...e))},length(...e){return this.check(ua(...e))},nonempty(...e){return this.check(la(1,...e))},lowercase(e){return this.check(fa(e))},uppercase(e){return this.check(pa(e))},trim(){return this.check(ya())},normalize(...e){return this.check(va(...e))},toLowerCase(){return this.check(ba())},toUpperCase(){return this.check(xa())},slugify(){return this.check(Sa())}})}),Co=L(`ZodString`,(e,t)=>{fr.init(e,t),So.init(e,t),e.email=t=>e.check(Ei(To,t)),e.url=t=>e.check(Mi(Oo,t)),e.jwt=t=>e.check(qi(Uo,t)),e.emoji=t=>e.check(Ni(ko,t)),e.guid=t=>e.check(Di(Eo,t)),e.uuid=t=>e.check(Oi(Do,t)),e.uuidv4=t=>e.check(ki(Do,t)),e.uuidv6=t=>e.check(Ai(Do,t)),e.uuidv7=t=>e.check(ji(Do,t)),e.nanoid=t=>e.check(Pi(Ao,t)),e.guid=t=>e.check(Di(Eo,t)),e.cuid=t=>e.check(Fi(jo,t)),e.cuid2=t=>e.check(Ii(Mo,t)),e.ulid=t=>e.check(Li(No,t)),e.base64=t=>e.check(Wi(Bo,t)),e.base64url=t=>e.check(Gi(Vo,t)),e.xid=t=>e.check(Ri(Po,t)),e.ksuid=t=>e.check(zi(Fo,t)),e.ipv4=t=>e.check(Bi(Io,t)),e.ipv6=t=>e.check(Vi(Lo,t)),e.cidrv4=t=>e.check(Hi(Ro,t)),e.cidrv6=t=>e.check(Ui(zo,t)),e.e164=t=>e.check(Ki(Ho,t)),e.datetime=t=>e.check(eo(t)),e.date=t=>e.check(no(t)),e.time=t=>e.check(io(t)),e.duration=t=>e.check(oo(t))});function wo(e){return Ti(Co,e)}var Q=L(`ZodStringFormat`,(e,t)=>{q.init(e,t),So.init(e,t)}),To=L(`ZodEmail`,(e,t)=>{hr.init(e,t),Q.init(e,t)}),Eo=L(`ZodGUID`,(e,t)=>{pr.init(e,t),Q.init(e,t)}),Do=L(`ZodUUID`,(e,t)=>{mr.init(e,t),Q.init(e,t)}),Oo=L(`ZodURL`,(e,t)=>{gr.init(e,t),Q.init(e,t)}),ko=L(`ZodEmoji`,(e,t)=>{_r.init(e,t),Q.init(e,t)}),Ao=L(`ZodNanoID`,(e,t)=>{vr.init(e,t),Q.init(e,t)}),jo=L(`ZodCUID`,(e,t)=>{yr.init(e,t),Q.init(e,t)}),Mo=L(`ZodCUID2`,(e,t)=>{br.init(e,t),Q.init(e,t)}),No=L(`ZodULID`,(e,t)=>{xr.init(e,t),Q.init(e,t)}),Po=L(`ZodXID`,(e,t)=>{Sr.init(e,t),Q.init(e,t)}),Fo=L(`ZodKSUID`,(e,t)=>{Cr.init(e,t),Q.init(e,t)}),Io=L(`ZodIPv4`,(e,t)=>{Or.init(e,t),Q.init(e,t)}),Lo=L(`ZodIPv6`,(e,t)=>{kr.init(e,t),Q.init(e,t)}),Ro=L(`ZodCIDRv4`,(e,t)=>{Ar.init(e,t),Q.init(e,t)}),zo=L(`ZodCIDRv6`,(e,t)=>{jr.init(e,t),Q.init(e,t)}),Bo=L(`ZodBase64`,(e,t)=>{Nr.init(e,t),Q.init(e,t)}),Vo=L(`ZodBase64URL`,(e,t)=>{Fr.init(e,t),Q.init(e,t)}),Ho=L(`ZodE164`,(e,t)=>{Ir.init(e,t),Q.init(e,t)}),Uo=L(`ZodJWT`,(e,t)=>{Rr.init(e,t),Q.init(e,t)}),Wo=L(`ZodNumber`,(e,t)=>{zr.init(e,t),Z.init(e,t),e._zod.processJSONSchema=(t,n,r)=>Pa(e,t,n,r),xo(e,`ZodNumber`,{gt(e,t){return this.check(aa(e,t))},gte(e,t){return this.check(oa(e,t))},min(e,t){return this.check(oa(e,t))},lt(e,t){return this.check(ra(e,t))},lte(e,t){return this.check(ia(e,t))},max(e,t){return this.check(ia(e,t))},int(e){return this.check(qo(e))},safe(e){return this.check(qo(e))},positive(e){return this.check(aa(0,e))},nonnegative(e){return this.check(oa(0,e))},negative(e){return this.check(ra(0,e))},nonpositive(e){return this.check(ia(0,e))},multipleOf(e,t){return this.check(sa(e,t))},step(e,t){return this.check(sa(e,t))},finite(){return this}});let n=e._zod.bag;e.minValue=Math.max(n.minimum??-1/0,n.exclusiveMinimum??-1/0)??null,e.maxValue=Math.min(n.maximum??1/0,n.exclusiveMaximum??1/0)??null,e.isInt=(n.format??``).includes(`int`)||Number.isSafeInteger(n.multipleOf??.5),e.isFinite=!0,e.format=n.format??null});function Go(e){return Qi(Wo,e)}var Ko=L(`ZodNumberFormat`,(e,t)=>{Br.init(e,t),Wo.init(e,t)});function qo(e){return $i(Ko,e)}var Jo=L(`ZodBoolean`,(e,t)=>{Vr.init(e,t),Z.init(e,t),e._zod.processJSONSchema=(t,n,r)=>Fa(e,t,n,r)});function Yo(e){return ea(Jo,e)}var Xo=L(`ZodUnknown`,(e,t)=>{Hr.init(e,t),Z.init(e,t),e._zod.processJSONSchema=(e,t,n)=>void 0});function Zo(){return ta(Xo)}var Qo=L(`ZodNever`,(e,t)=>{Ur.init(e,t),Z.init(e,t),e._zod.processJSONSchema=(t,n,r)=>Ia(e,t,n,r)});function $o(e){return na(Qo,e)}var es=L(`ZodArray`,(e,t)=>{Gr.init(e,t),Z.init(e,t),e._zod.processJSONSchema=(t,n,r)=>Ba(e,t,n,r),e.element=t.element,xo(e,`ZodArray`,{min(e,t){return this.check(la(e,t))},nonempty(e){return this.check(la(1,e))},max(e,t){return this.check(ca(e,t))},length(e,t){return this.check(ua(e,t))},unwrap(){return this.element}})});function ts(e,t){return Ca(es,e,t)}var ns=L(`ZodObject`,(e,t)=>{Xr.init(e,t),Z.init(e,t),e._zod.processJSONSchema=(t,n,r)=>Va(e,t,n,r),z(e,`shape`,()=>t.shape),xo(e,`ZodObject`,{keyof(){return ds(Object.keys(this._zod.def.shape))},catchall(e){return this.clone({...this._zod.def,catchall:e})},passthrough(){return this.clone({...this._zod.def,catchall:Zo()})},loose(){return this.clone({...this._zod.def,catchall:Zo()})},strict(){return this.clone({...this._zod.def,catchall:$o()})},strip(){return this.clone({...this._zod.def,catchall:void 0})},extend(e){return Rt(this,e)},safeExtend(e){return zt(this,e)},merge(e){return Bt(this,e)},pick(e){return It(this,e)},omit(e){return Lt(this,e)},partial(...e){return Vt(ms,this,e[0])},required(...e){return Ht(ws,this,e[0])}})});function rs(e,t){return new ns({type:`object`,shape:e??{},...U(t)})}var is=L(`ZodUnion`,(e,t)=>{Qr.init(e,t),Z.init(e,t),e._zod.processJSONSchema=(t,n,r)=>Ha(e,t,n,r),e.options=t.options});function as(e,t){return new is({type:`union`,options:e,...U(t)})}var os=L(`ZodIntersection`,(e,t)=>{$r.init(e,t),Z.init(e,t),e._zod.processJSONSchema=(t,n,r)=>Ua(e,t,n,r)});function ss(e,t){return new os({type:`intersection`,left:e,right:t})}var cs=L(`ZodRecord`,(e,t)=>{ni.init(e,t),Z.init(e,t),e._zod.processJSONSchema=(t,n,r)=>Wa(e,t,n,r),e.keyType=t.keyType,e.valueType=t.valueType});function ls(e,t,n){return!t||!t._zod?new cs({type:`record`,keyType:wo(),valueType:e,...U(t)}):new cs({type:`record`,keyType:e,valueType:t,...U(n)})}var us=L(`ZodEnum`,(e,t)=>{ri.init(e,t),Z.init(e,t),e._zod.processJSONSchema=(t,n,r)=>La(e,t,n,r),e.enum=t.entries,e.options=Object.values(t.entries);let n=new Set(Object.keys(t.entries));e.extract=(e,r)=>{let i={};for(let r of e)if(n.has(r))i[r]=t.entries[r];else throw Error(`Key ${r} not found in enum`);return new us({...t,checks:[],...U(r),entries:i})},e.exclude=(e,r)=>{let i={...t.entries};for(let t of e)if(n.has(t))delete i[t];else throw Error(`Key ${t} not found in enum`);return new us({...t,checks:[],...U(r),entries:i})}});function ds(e,t){return new us({type:`enum`,entries:Array.isArray(e)?Object.fromEntries(e.map(e=>[e,e])):e,...U(t)})}var fs=L(`ZodTransform`,(e,t)=>{ii.init(e,t),Z.init(e,t),e._zod.processJSONSchema=(t,n,r)=>za(e,t,n,r),e._zod.parse=(n,r)=>{if(r.direction===`backward`)throw new gt(e.constructor.name);n.addIssue=r=>{if(typeof r==`string`)n.issues.push(Jt(r,n.value,t));else{let t=r;t.fatal&&(t.continue=!1),t.code??=`custom`,t.input??=n.value,t.inst??=e,n.issues.push(Jt(t))}};let i=t.transform(n.value,n);return i instanceof Promise?i.then(e=>(n.value=e,n.fallback=!0,n)):(n.value=i,n.fallback=!0,n)}});function ps(e){return new fs({type:`transform`,transform:e})}var ms=L(`ZodOptional`,(e,t)=>{oi.init(e,t),Z.init(e,t),e._zod.processJSONSchema=(t,n,r)=>Qa(e,t,n,r),e.unwrap=()=>e._zod.def.innerType});function hs(e){return new ms({type:`optional`,innerType:e})}var gs=L(`ZodExactOptional`,(e,t)=>{si.init(e,t),Z.init(e,t),e._zod.processJSONSchema=(t,n,r)=>Qa(e,t,n,r),e.unwrap=()=>e._zod.def.innerType});function _s(e){return new gs({type:`optional`,innerType:e})}var vs=L(`ZodNullable`,(e,t)=>{ci.init(e,t),Z.init(e,t),e._zod.processJSONSchema=(t,n,r)=>Ga(e,t,n,r),e.unwrap=()=>e._zod.def.innerType});function ys(e){return new vs({type:`nullable`,innerType:e})}var bs=L(`ZodDefault`,(e,t)=>{li.init(e,t),Z.init(e,t),e._zod.processJSONSchema=(t,n,r)=>qa(e,t,n,r),e.unwrap=()=>e._zod.def.innerType,e.removeDefault=e.unwrap});function xs(e,t){return new bs({type:`default`,innerType:e,get defaultValue(){return typeof t==`function`?t():jt(t)}})}var Ss=L(`ZodPrefault`,(e,t)=>{di.init(e,t),Z.init(e,t),e._zod.processJSONSchema=(t,n,r)=>Ja(e,t,n,r),e.unwrap=()=>e._zod.def.innerType});function Cs(e,t){return new Ss({type:`prefault`,innerType:e,get defaultValue(){return typeof t==`function`?t():jt(t)}})}var ws=L(`ZodNonOptional`,(e,t)=>{fi.init(e,t),Z.init(e,t),e._zod.processJSONSchema=(t,n,r)=>Ka(e,t,n,r),e.unwrap=()=>e._zod.def.innerType});function Ts(e,t){return new ws({type:`nonoptional`,innerType:e,...U(t)})}var Es=L(`ZodCatch`,(e,t)=>{mi.init(e,t),Z.init(e,t),e._zod.processJSONSchema=(t,n,r)=>Ya(e,t,n,r),e.unwrap=()=>e._zod.def.innerType,e.removeCatch=e.unwrap});function Ds(e,t){return new Es({type:`catch`,innerType:e,catchValue:typeof t==`function`?t:()=>t})}var Os=L(`ZodPipe`,(e,t)=>{hi.init(e,t),Z.init(e,t),e._zod.processJSONSchema=(t,n,r)=>Xa(e,t,n,r),e.in=t.in,e.out=t.out});function ks(e,t){return new Os({type:`pipe`,in:e,out:t})}var As=L(`ZodReadonly`,(e,t)=>{_i.init(e,t),Z.init(e,t),e._zod.processJSONSchema=(t,n,r)=>Za(e,t,n,r),e.unwrap=()=>e._zod.def.innerType});function js(e){return new As({type:`readonly`,innerType:e})}var Ms=L(`ZodCustom`,(e,t)=>{yi.init(e,t),Z.init(e,t),e._zod.processJSONSchema=(t,n,r)=>Ra(e,t,n,r)});function Ns(e,t={}){return wa(Ms,e,t)}function Ps(e,t){return Ta(e,t)}var Fs=`spinifex:v1:aws-credentials`,Is=rs({accessKeyId:wo().min(16,`Access Key ID must be at least 16 characters`),secretAccessKey:wo().min(1,`Secret Access Key is required`)}),$;function Ls(){if($!==void 0)return $;try{let e=localStorage.getItem(Fs);if(!e)return $=null,null;let t=JSON.parse(e),n=Is.safeParse(t);return $=n.success?n.data:null,$}catch{return $=null,null}}function Rs(e){try{localStorage.setItem(Fs,JSON.stringify(e)),$=e}catch{$=e}}function zs(){try{localStorage.removeItem(Fs)}catch{}$=null}var Bs={name:`SHA-256`},Vs={name:`HMAC`,hash:Bs},Hs=new Uint8Array([227,176,196,66,152,252,28,20,154,251,244,200,153,111,185,36,39,174,65,228,100,155,147,76,164,149,153,27,120,82,184,85]),Us={};function Ws(){return typeof window<`u`?window:typeof self<`u`?self:Us}var Gs=function(){function e(e){this.toHash=new Uint8Array,this.secret=e,this.reset()}return e.prototype.update=function(e){if(!r(e)){var t=n(e),i=new Uint8Array(this.toHash.byteLength+t.byteLength);i.set(this.toHash,0),i.set(t,this.toHash.byteLength),this.toHash=i}},e.prototype.digest=function(){var e=this;return this.key?this.key.then(function(t){return Ws().crypto.subtle.sign(Vs,t,e.toHash).then(function(e){return new Uint8Array(e)})}):r(this.toHash)?Promise.resolve(Hs):Promise.resolve().then(function(){return Ws().crypto.subtle.digest(Bs,e.toHash)}).then(function(e){return Promise.resolve(new Uint8Array(e))})},e.prototype.reset=function(){var e=this;this.toHash=new Uint8Array,this.secret&&this.secret!==void 0&&(this.key=new Promise(function(t,r){Ws().crypto.subtle.importKey(`raw`,n(e.secret),Vs,!1,[`sign`]).then(t,r)}),this.key.catch(function(){}))},e}(),Ks=new Uint32Array([1116352408,1899447441,3049323471,3921009573,961987163,1508970993,2453635748,2870763221,3624381080,310598401,607225278,1426881987,1925078388,2162078206,2614888103,3248222580,3835390401,4022224774,264347078,604807628,770255983,1249150122,1555081692,1996064986,2554220882,2821834349,2952996808,3210313671,3336571891,3584528711,113926993,338241895,666307205,773529912,1294757372,1396182291,1695183700,1986661051,2177026350,2456956037,2730485921,2820302411,3259730800,3345764771,3516065817,3600352804,4094571909,275423344,430227734,506948616,659060556,883997877,958139571,1322822218,1537002063,1747873779,1955562222,2024104815,2227730452,2361852424,2428436474,2756734187,3204031479,3329325298]),qs=[1779033703,3144134277,1013904242,2773480762,1359893119,2600822924,528734635,1541459225],Js=2**53-1,Ys=function(){function e(){this.state=Int32Array.from(qs),this.temp=new Int32Array(64),this.buffer=new Uint8Array(64),this.bufferLength=0,this.bytesHashed=0,this.finished=!1}return e.prototype.update=function(e){if(this.finished)throw Error(`Attempted to update an already finished hash.`);var t=0,n=e.byteLength;if(this.bytesHashed+=n,this.bytesHashed*8>Js)throw Error(`Cannot hash more than 2^53 - 1 bits`);for(;n>0;)this.buffer[this.bufferLength++]=e[t++],n--,this.bufferLength===64&&(this.hashBuffer(),this.bufferLength=0)},e.prototype.digest=function(){if(!this.finished){var e=this.bytesHashed*8,t=new DataView(this.buffer.buffer,this.buffer.byteOffset,this.buffer.byteLength),n=this.bufferLength;if(t.setUint8(this.bufferLength++,128),n%64>=56){for(var r=this.bufferLength;r<64;r++)t.setUint8(r,0);this.hashBuffer(),this.bufferLength=0}for(var r=this.bufferLength;r<56;r++)t.setUint8(r,0);t.setUint32(56,Math.floor(e/4294967296),!0),t.setUint32(60,e),this.hashBuffer(),this.finished=!0}for(var i=new Uint8Array(32),r=0;r<8;r++)i[r*4]=this.state[r]>>>24&255,i[r*4+1]=this.state[r]>>>16&255,i[r*4+2]=this.state[r]>>>8&255,i[r*4+3]=this.state[r]>>>0&255;return i},e.prototype.hashBuffer=function(){for(var e=this,t=e.buffer,n=e.state,r=n[0],i=n[1],a=n[2],o=n[3],s=n[4],c=n[5],l=n[6],u=n[7],d=0;d<64;d++){if(d<16)this.temp[d]=(t[d*4]&255)<<24|(t[d*4+1]&255)<<16|(t[d*4+2]&255)<<8|t[d*4+3]&255;else{var f=this.temp[d-2],p=(f>>>17|f<<15)^(f>>>19|f<<13)^f>>>10;f=this.temp[d-15];var m=(f>>>7|f<<25)^(f>>>18|f<<14)^f>>>3;this.temp[d]=(p+this.temp[d-7]|0)+(m+this.temp[d-16]|0)}var h=(((s>>>6|s<<26)^(s>>>11|s<<21)^(s>>>25|s<<7))+(s&c^~s&l)|0)+(u+(Ks[d]+this.temp[d]|0)|0)|0,g=((r>>>2|r<<30)^(r>>>13|r<<19)^(r>>>22|r<<10))+(r&i^r&a^i&a)|0;u=l,l=c,c=s,s=o+h|0,o=a,a=i,i=r,r=h+g|0}n[0]+=r,n[1]+=i,n[2]+=a,n[3]+=o,n[4]+=s,n[5]+=c,n[6]+=l,n[7]+=u},e}(),Xs=function(){function t(e){this.secret=e,this.hash=new Ys,this.reset()}return t.prototype.update=function(e){if(!(r(e)||this.error))try{this.hash.update(n(e))}catch(e){this.error=e}},t.prototype.digestSync=function(){if(this.error)throw this.error;return this.outer?(this.outer.finished||this.outer.update(this.hash.digest()),this.outer.digest()):this.hash.digest()},t.prototype.digest=function(){return s(this,void 0,void 0,function(){return e(this,function(e){return[2,this.digestSync()]})})},t.prototype.reset=function(){if(this.hash=new Ys,this.secret){this.outer=new Ys;var e=Zs(this.secret),t=new Uint8Array(64);t.set(e);for(var n=0;n<64;n++)e[n]^=54,t[n]^=92;this.hash.update(e),this.outer.update(t);for(var n=0;n64){var r=new Ys;r.update(t),t=r.digest()}var i=new Uint8Array(64);return i.set(t),i}var Qs=[`decrypt`,`digest`,`encrypt`,`exportKey`,`generateKey`,`importKey`,`sign`,`verify`];function $s(e){if(ec(e)&&typeof e.crypto.subtle==`object`){var t=e.crypto.subtle;return tc(t)}return!1}function ec(e){return typeof e==`object`&&typeof e.crypto==`object`?typeof e.crypto.getRandomValues==`function`:!1}function tc(e){return e&&Qs.every(function(t){return typeof e[t]==`function`})}var nc=function(){function e(e){$s(Ws())?this.hash=new Gs(e):this.hash=new Xs(e)}return e.prototype.update=function(e,t){this.hash.update(n(e))},e.prototype.digest=function(){return this.hash.digest()},e.prototype.reset=function(){this.hash.reset()},e}(),rc=class e{method;protocol;hostname;port;path;query;headers;username;password;fragment;body;constructor(e){this.method=e.method||`GET`,this.hostname=e.hostname||`localhost`,this.port=e.port,this.query=e.query||{},this.headers=e.headers||{},this.body=e.body,this.protocol=e.protocol?e.protocol.slice(-1)===`:`?e.protocol:`${e.protocol}:`:`https:`,this.path=e.path?e.path.charAt(0)===`/`?e.path:`/${e.path}`:`/`,this.username=e.username,this.password=e.password,this.fragment=e.fragment}static clone(t){let n=new e({...t,headers:{...t.headers}});return n.query&&=ic(n.query),n}static isInstance(e){if(!e)return!1;let t=e;return`method`in t&&`protocol`in t&&`hostname`in t&&`path`in t&&typeof t.query==`object`&&typeof t.headers==`object`}clone(){return e.clone(this)}};function ic(e){return Object.keys(e).reduce((t,n)=>{let r=e[n];return{...t,[n]:Array.isArray(r)?[...r]:r}},{})}var ac=e=>{if(typeof e==`function`)return e;let t=Promise.resolve(e);return()=>t},oc=e=>encodeURIComponent(e).replace(/[!'()*]/g,sc),sc=e=>`%${e.charCodeAt(0).toString(16).toUpperCase()}`,cc=class{format(e){let n=[];for(let r of Object.keys(e)){let i=t(r);n.push(Uint8Array.from([i.byteLength]),i,this.formatHeaderValue(e[r]))}let r=new Uint8Array(n.reduce((e,t)=>e+t.byteLength,0)),i=0;for(let e of n)r.set(e,i),i+=e.byteLength;return r}formatHeaderValue(e){switch(e.type){case`boolean`:return Uint8Array.from([+!e.value]);case`byte`:return Uint8Array.from([2,e.value]);case`short`:let n=new DataView(new ArrayBuffer(3));return n.setUint8(0,3),n.setInt16(1,e.value,!1),new Uint8Array(n.buffer);case`integer`:let r=new DataView(new ArrayBuffer(5));return r.setUint8(0,4),r.setInt32(1,e.value,!1),new Uint8Array(r.buffer);case`long`:let i=new Uint8Array(9);return i[0]=5,i.set(e.value.bytes,1),i;case`binary`:let o=new DataView(new ArrayBuffer(3+e.value.byteLength));o.setUint8(0,6),o.setUint16(1,e.value.byteLength,!1);let s=new Uint8Array(o.buffer);return s.set(e.value,3),s;case`string`:let c=t(e.value),l=new DataView(new ArrayBuffer(3+c.byteLength));l.setUint8(0,7),l.setUint16(1,c.byteLength,!1);let u=new Uint8Array(l.buffer);return u.set(c,3),u;case`timestamp`:let d=new Uint8Array(9);return d[0]=8,d.set(dc.fromNumber(e.value.valueOf()).bytes,1),d;case`uuid`:if(!uc.test(e.value))throw Error(`Invalid UUID received: ${e.value}`);let f=new Uint8Array(17);return f[0]=9,f.set(a(e.value.replace(/\-/g,``)),1),f}}},lc;(function(e){e[e.boolTrue=0]=`boolTrue`,e[e.boolFalse=1]=`boolFalse`,e[e.byte=2]=`byte`,e[e.short=3]=`short`,e[e.integer=4]=`integer`,e[e.long=5]=`long`,e[e.byteArray=6]=`byteArray`,e[e.string=7]=`string`,e[e.timestamp=8]=`timestamp`,e[e.uuid=9]=`uuid`})(lc||={});var uc=/^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/,dc=class e{bytes;constructor(e){if(this.bytes=e,e.byteLength!==8)throw Error(`Int64 buffers must be exactly 8 bytes`)}static fromNumber(t){if(t>0x8000000000000000||t<-0x8000000000000000)throw Error(`${t} is too large (or, if negative, too small) to represent as an Int64`);let n=new Uint8Array(8);for(let e=7,r=Math.abs(Math.round(t));e>-1&&r>0;e--,r/=256)n[e]=r;return t<0&&fc(n),new e(n)}valueOf(){let e=this.bytes.slice(0),t=e[0]&128;return t&&fc(e),parseInt(o(e),16)*(t?-1:1)}toString(){return String(this.valueOf())}};function fc(e){for(let t=0;t<8;t++)e[t]^=255;for(let t=7;t>-1&&(e[t]++,e[t]===0);t--);}var pc=`X-Amz-Algorithm`,mc=`X-Amz-Credential`,hc=`X-Amz-Date`,gc=`X-Amz-SignedHeaders`,_c=`X-Amz-Expires`,vc=`X-Amz-Signature`,yc=`X-Amz-Security-Token`,bc=`authorization`,xc=hc.toLowerCase(),Sc=[bc,xc,`date`];vc.toLowerCase();var Cc=`x-amz-content-sha256`,wc=yc.toLowerCase(),Tc={authorization:!0,"cache-control":!0,connection:!0,expect:!0,from:!0,"keep-alive":!0,"max-forwards":!0,pragma:!0,referer:!0,te:!0,trailer:!0,"transfer-encoding":!0,upgrade:!0,"user-agent":!0,"x-amzn-trace-id":!0},Ec=/^proxy-/,Dc=/^sec-/,Oc=`AWS4-HMAC-SHA256`,kc=`AWS4-HMAC-SHA256-PAYLOAD`,Ac=`UNSIGNED-PAYLOAD`,jc=`aws4_request`,Mc=({query:e={}})=>{let t=[],n={};for(let r of Object.keys(e)){if(r.toLowerCase()===`x-amz-signature`)continue;let i=oc(r);t.push(i);let a=e[r];typeof a==`string`?n[i]=`${i}=${oc(a)}`:Array.isArray(a)&&(n[i]=a.slice(0).reduce((e,t)=>e.concat([`${i}=${oc(t)}`]),[]).sort().join(`&`))}return t.sort().map(e=>n[e]).filter(e=>e).join(`&`)},Nc=e=>Pc(e).toISOString().replace(/\.\d{3}Z$/,`Z`),Pc=e=>typeof e==`number`?new Date(e*1e3):typeof e==`string`?Number(e)?new Date(Number(e)*1e3):new Date(e):e,Fc=class{service;regionProvider;credentialProvider;sha256;uriEscapePath;applyChecksum;constructor({applyChecksum:e,credentials:t,region:n,service:r,sha256:i,uriEscapePath:a=!0}){this.service=r,this.sha256=i,this.uriEscapePath=a,this.applyChecksum=typeof e==`boolean`?e:!0,this.regionProvider=ac(n),this.credentialProvider=ac(t)}createCanonicalRequest(e,t,n){let r=Object.keys(t).sort();return`${e.method}
+${this.getCanonicalPath(e)}
+${Mc(e)}
+${r.map(e=>`${e}:${t[e]}`).join(`
+`)}
+
+${r.join(`;`)}
+${n}`}async createStringToSign(e,t,n,r){let i=new this.sha256;return i.update(c(n)),`${r}
+${e}
+${t}
+${o(await i.digest())}`}getCanonicalPath({path:e}){if(this.uriEscapePath){let t=[];for(let n of e.split(`/`))n?.length!==0&&n!==`.`&&(n===`..`?t.pop():t.push(n));return oc(`${e?.startsWith(`/`)?`/`:``}${t.join(`/`)}${t.length>0&&e?.endsWith(`/`)?`/`:``}`).replace(/%2F/g,`/`)}return e}validateResolvedCredentials(e){if(typeof e!=`object`||typeof e.accessKeyId!=`string`||typeof e.secretAccessKey!=`string`)throw Error(`Resolved credential object is not valid`)}formatDate(e){let t=Nc(e).replace(/[\-:]/g,``);return{longDate:t,shortDate:t.slice(0,8)}}getCanonicalHeaderList(e){return Object.keys(e).sort().join(`;`)}},Ic={},Lc=[],Rc=(e,t,n)=>`${e}/${t}/${n}/${jc}`,zc=async(e,t,n,r,i)=>{let a=`${n}:${r}:${i}:${o(await Bc(e,t.secretAccessKey,t.accessKeyId))}:${t.sessionToken}`;if(a in Ic)return Ic[a];for(Lc.push(a);Lc.length>50;)delete Ic[Lc.shift()];let s=`AWS4${t.secretAccessKey}`;for(let t of[n,r,i,jc])s=await Bc(e,s,t);return Ic[a]=s},Bc=(e,t,n)=>{let r=new e(t);return r.update(c(n)),r.digest()},Vc=({headers:e},t,n)=>{let r={};for(let i of Object.keys(e).sort()){if(e[i]==null)continue;let a=i.toLowerCase();(a in Tc||t?.has(a)||Ec.test(a)||Dc.test(a))&&(!n||n&&!n.has(a))||(r[a]=e[i].trim().replace(/\s+/g,` `))}return r},Hc=async({headers:e,body:t},n)=>{for(let t of Object.keys(e))if(t.toLowerCase()===`x-amz-content-sha256`)return e[t];if(t==null)return`e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855`;if(typeof t==`string`||ArrayBuffer.isView(t)||i(t)){let e=new n;return e.update(c(t)),o(await e.digest())}return Ac},Uc=(e,t)=>{e=e.toLowerCase();for(let n of Object.keys(t))if(e===n.toLowerCase())return!0;return!1},Wc=(e,t={})=>{let{headers:n,query:r={}}=rc.clone(e);for(let e of Object.keys(n)){let i=e.toLowerCase();(i.slice(0,6)===`x-amz-`&&!t.unhoistableHeaders?.has(i)||t.hoistableHeaders?.has(i))&&(r[e]=n[e],delete n[e])}return{...e,headers:n,query:r}},Gc=e=>{e=rc.clone(e);for(let t of Object.keys(e.headers))Sc.indexOf(t.toLowerCase())>-1&&delete e.headers[t];return e},Kc=class extends Fc{headerFormatter=new cc;constructor({applyChecksum:e,credentials:t,region:n,service:r,sha256:i,uriEscapePath:a=!0}){super({applyChecksum:e,credentials:t,region:n,service:r,sha256:i,uriEscapePath:a})}async presign(e,t={}){let{signingDate:n=new Date,expiresIn:r=3600,unsignableHeaders:i,unhoistableHeaders:a,signableHeaders:o,hoistableHeaders:s,signingRegion:c,signingService:l}=t,u=await this.credentialProvider();this.validateResolvedCredentials(u);let d=c??await this.regionProvider(),{longDate:f,shortDate:p}=this.formatDate(n);if(r>604800)return Promise.reject(`Signature version 4 presigned URLs must have an expiration date less than one week in the future`);let m=Rc(p,d,l??this.service),h=Wc(Gc(e),{unhoistableHeaders:a,hoistableHeaders:s});u.sessionToken&&(h.query[yc]=u.sessionToken),h.query[pc]=Oc,h.query[mc]=`${u.accessKeyId}/${m}`,h.query[hc]=f,h.query[_c]=r.toString(10);let g=Vc(h,i,o);return h.query[gc]=this.getCanonicalHeaderList(g),h.query[vc]=await this.getSignature(f,m,this.getSigningKey(u,d,p,l),this.createCanonicalRequest(h,g,await Hc(e,this.sha256))),h}async sign(e,t){return typeof e==`string`?this.signString(e,t):e.headers&&e.payload?this.signEvent(e,t):e.message?this.signMessage(e,t):this.signRequest(e,t)}async signEvent({headers:e,payload:t},{signingDate:n=new Date,priorSignature:r,signingRegion:i,signingService:a,eventStreamCredentials:s}){let c=i??await this.regionProvider(),{shortDate:l,longDate:u}=this.formatDate(n),d=Rc(l,c,a??this.service),f=await Hc({headers:{},body:t},this.sha256),p=new this.sha256;p.update(e);let m=[kc,u,d,r,o(await p.digest()),f].join(`
+`);return this.signString(m,{signingDate:n,signingRegion:c,signingService:a,eventStreamCredentials:s})}async signMessage(e,{signingDate:t=new Date,signingRegion:n,signingService:r,eventStreamCredentials:i}){return this.signEvent({headers:this.headerFormatter.format(e.message.headers),payload:e.message.body},{signingDate:t,signingRegion:n,signingService:r,priorSignature:e.priorSignature,eventStreamCredentials:i}).then(t=>({message:e.message,signature:t}))}async signString(e,{signingDate:t=new Date,signingRegion:n,signingService:r,eventStreamCredentials:i}={}){let a=i??await this.credentialProvider();this.validateResolvedCredentials(a);let s=n??await this.regionProvider(),{shortDate:l}=this.formatDate(t),u=new this.sha256(await this.getSigningKey(a,s,l,r));return u.update(c(e)),o(await u.digest())}async signRequest(e,{signingDate:t=new Date,signableHeaders:n,unsignableHeaders:r,signingRegion:i,signingService:a}={}){let o=await this.credentialProvider();this.validateResolvedCredentials(o);let s=i??await this.regionProvider(),c=Gc(e),{longDate:l,shortDate:u}=this.formatDate(t),d=Rc(u,s,a??this.service);c.headers[xc]=l,o.sessionToken&&(c.headers[wc]=o.sessionToken);let f=await Hc(c,this.sha256);!Uc(`x-amz-content-sha256`,c.headers)&&this.applyChecksum&&(c.headers[Cc]=f);let p=Vc(c,r,n),m=await this.getSignature(l,d,this.getSigningKey(o,s,u,a),this.createCanonicalRequest(c,p,f));return c.headers[bc]=`${Oc} Credential=${o.accessKeyId}/${d}, SignedHeaders=${this.getCanonicalHeaderList(p)}, Signature=${m}`,c}async getSignature(e,t,n,r){let i=await this.createStringToSign(e,t,r,Oc),a=new this.sha256(await n);return a.update(c(i)),o(await a.digest())}getSigningKey(e,t,n,r){return zc(this.sha256,e,n,t,r||this.service)}};export{ct as A,ft as C,it as D,pt as E,u as M,st as O,Xt as S,dt as T,rs as _,nc as a,tn as b,Is as c,Rs as d,ds as f,Go as g,qo as h,rc as i,ut as j,at as k,zs as l,Yo as m,oc as n,$s as o,ts as p,ac as r,Ws as s,Kc as t,Ls as u,ls as v,nt as w,rn as x,wo as y};
\ No newline at end of file
diff --git a/spinifex/services/spinifexui/frontend/dist/assets/_.js b/spinifex/services/spinifexui/frontend/dist/assets/_.js
index 7594c96ee..5aced5cb1 100644
--- a/spinifex/services/spinifexui/frontend/dist/assets/_.js
+++ b/spinifex/services/spinifexui/frontend/dist/assets/_.js
@@ -1 +1 @@
-import{t as e}from"./error-banner.js";import{t}from"./useSuspenseQuery.js";import{t as n}from"./back-link.js";import{n as r,r as i,t as a}from"./upload-button.js";import{r as o}from"./s3.js";import{Fo as s,Ho as c,Lo as l,Qa as u,eo as d,oo as f,to as p,y as m,z as h}from"./index.js";var g=s(),_=c();function v(){let s=(0,g.c)(24),{bucket:c,_splat:v}=m.useParams(),y=d(v??``),{data:b}=t(h(c,y)),x=o(),S;s[0]===b.Contents?S=s[1]:(S=b.Contents??[],s[0]=b.Contents,s[1]=S);let C=S,w;s[2]===b.CommonPrefixes?w=s[3]:(w=b.CommonPrefixes??[],s[2]=b.CommonPrefixes,s[3]=w);let T=w,E=y.split(`/`).filter(Boolean),D=[{name:c,path:`/s3/ls/${c}`},...E.map((e,t)=>({name:e,path:`/s3/ls/${c}/${`${E.slice(0,t+1).join(`/`)}/`}`}))],O=E.length>1?`/s3/ls/${c}/${E.slice(0,-1).join(`/`)}/`:`/s3/ls/${c}`,k;s[4]===O?k=s[5]:(k=(0,_.jsx)(n,{to:O,children:`Back`}),s[4]=O,s[5]=k);let A;s[6]!==c||s[7]!==y||s[8]!==x.isPending||s[9]!==x.mutateAsync?(A=(0,_.jsx)(a,{bucket:c,isPending:x.isPending,onUpload:x.mutateAsync,prefix:y}),s[6]=c,s[7]=y,s[8]=x.isPending,s[9]=x.mutateAsync,s[10]=A):A=s[10];let j;s[11]===x.error?j=s[12]:(j=x.error&&(0,_.jsx)(e,{error:x.error,msg:`Failed to upload object`}),s[11]=x.error,s[12]=j);let M;s[13]!==c||s[14]!==T||s[15]!==y?(M=T.length>0&&(0,_.jsxs)(`div`,{className:`mb-6`,children:[(0,_.jsx)(`h2`,{className:`mb-3 text-lg font-semibold`,children:`Folders`}),(0,_.jsx)(`div`,{className:`space-y-2`,children:T.map(e=>e.Prefix?(0,_.jsx)(i,{bucketName:c,folderName:f(p(e.Prefix,y)),fullPrefix:u(e.Prefix,y)},e.Prefix):null)})]}),s[13]=c,s[14]=T,s[15]=y,s[16]=M):M=s[16];let N;s[17]!==c||s[18]!==C||s[19]!==y?(N=C.length>0&&(0,_.jsxs)(`div`,{className:`space-y-2`,children:[(0,_.jsx)(`h2`,{className:`mb-3 text-lg font-semibold`,children:`Objects`}),C.map(e=>e.Key?(0,_.jsx)(r,{bucketName:c,displayName:p(e.Key,y),fullKey:u(e.Key,y),object:e},e.Key):null)]}),s[17]=c,s[18]=C,s[19]=y,s[20]=N):N=s[20];let P;return s[21]!==T.length||s[22]!==C.length?(P=C.length===0&&T.length===0&&(0,_.jsx)(`p`,{className:`text-muted-foreground`,children:`No objects found in this folder.`}),s[21]=T.length,s[22]=C.length,s[23]=P):P=s[23],(0,_.jsxs)(_.Fragment,{children:[k,(0,_.jsx)(`div`,{className:`mb-6`,children:(0,_.jsxs)(`div`,{className:`mb-2 flex items-center justify-between`,children:[(0,_.jsx)(`div`,{className:`flex items-center gap-2 text-sm text-muted-foreground`,children:D.map((e,t)=>(0,_.jsxs)(`div`,{className:`flex items-center gap-2`,children:[t>0&&(0,_.jsx)(`span`,{children:`/`}),t===D.length-1?(0,_.jsx)(`span`,{className:`text-foreground`,children:e.name}):(0,_.jsx)(l,{className:`hover:text-foreground`,to:e.path,children:e.name})]},e.path))}),A]})}),j,M,N,P]})}export{v as component};
\ No newline at end of file
+import{n as e,t}from"./compiler-runtime.js";import{t as n}from"./error-banner.js";import{t as r}from"./useSuspenseQuery.js";import{Or as i}from"./awsClient.js";import{C as a,E as o,T as s,j as c}from"./SignatureV4.js";import{t as l}from"./back-link.js";import{n as u,r as d,t as f}from"./upload-button.js";import{r as p}from"./s3.js";import{S as m,ot as h}from"./index.js";var g=t(),_=e();function v(){let e=(0,g.c)(24),{bucket:t,_splat:v}=m.useParams(),y=s(v??``),{data:b}=r(h(t,y)),x=p(),S;e[0]===b.Contents?S=e[1]:(S=b.Contents??[],e[0]=b.Contents,e[1]=S);let C=S,w;e[2]===b.CommonPrefixes?w=e[3]:(w=b.CommonPrefixes??[],e[2]=b.CommonPrefixes,e[3]=w);let T=w,E=y.split(`/`).filter(Boolean),D=[{name:t,path:`/s3/ls/${t}`},...E.map((e,n)=>({name:e,path:`/s3/ls/${t}/${`${E.slice(0,n+1).join(`/`)}/`}`}))],O=E.length>1?`/s3/ls/${t}/${E.slice(0,-1).join(`/`)}/`:`/s3/ls/${t}`,k;e[4]===O?k=e[5]:(k=(0,_.jsx)(l,{to:O,children:`Back`}),e[4]=O,e[5]=k);let A;e[6]!==t||e[7]!==y||e[8]!==x.isPending||e[9]!==x.mutateAsync?(A=(0,_.jsx)(f,{bucket:t,isPending:x.isPending,onUpload:x.mutateAsync,prefix:y}),e[6]=t,e[7]=y,e[8]=x.isPending,e[9]=x.mutateAsync,e[10]=A):A=e[10];let j;e[11]===x.error?j=e[12]:(j=x.error&&(0,_.jsx)(n,{error:x.error,msg:`Failed to upload object`}),e[11]=x.error,e[12]=j);let M;e[13]!==t||e[14]!==T||e[15]!==y?(M=T.length>0&&(0,_.jsxs)(`div`,{className:`mb-6`,children:[(0,_.jsx)(`h2`,{className:`mb-3 text-lg font-semibold`,children:`Folders`}),(0,_.jsx)(`div`,{className:`space-y-2`,children:T.map(e=>e.Prefix?(0,_.jsx)(d,{bucketName:t,folderName:c(o(e.Prefix,y)),fullPrefix:a(e.Prefix,y)},e.Prefix):null)})]}),e[13]=t,e[14]=T,e[15]=y,e[16]=M):M=e[16];let N;e[17]!==t||e[18]!==C||e[19]!==y?(N=C.length>0&&(0,_.jsxs)(`div`,{className:`space-y-2`,children:[(0,_.jsx)(`h2`,{className:`mb-3 text-lg font-semibold`,children:`Objects`}),C.map(e=>e.Key?(0,_.jsx)(u,{bucketName:t,displayName:o(e.Key,y),fullKey:a(e.Key,y),object:e},e.Key):null)]}),e[17]=t,e[18]=C,e[19]=y,e[20]=N):N=e[20];let P;return e[21]!==T.length||e[22]!==C.length?(P=C.length===0&&T.length===0&&(0,_.jsx)(`p`,{className:`text-muted-foreground`,children:`No objects found in this folder.`}),e[21]=T.length,e[22]=C.length,e[23]=P):P=e[23],(0,_.jsxs)(_.Fragment,{children:[k,(0,_.jsx)(`div`,{className:`mb-6`,children:(0,_.jsxs)(`div`,{className:`mb-2 flex items-center justify-between`,children:[(0,_.jsx)(`div`,{className:`flex items-center gap-2 text-sm text-muted-foreground`,children:D.map((e,t)=>(0,_.jsxs)(`div`,{className:`flex items-center gap-2`,children:[t>0&&(0,_.jsx)(`span`,{children:`/`}),t===D.length-1?(0,_.jsx)(`span`,{className:`text-foreground`,children:e.name}):(0,_.jsx)(i,{className:`hover:text-foreground`,to:e.path,children:e.name})]},e.path))}),A]})}),j,M,N,P]})}export{v as component};
\ No newline at end of file
diff --git a/spinifex/services/spinifexui/frontend/dist/assets/_auth.js b/spinifex/services/spinifexui/frontend/dist/assets/_auth.js
index 8ba71f311..b2eafb3e5 100644
--- a/spinifex/services/spinifexui/frontend/dist/assets/_auth.js
+++ b/spinifex/services/spinifexui/frontend/dist/assets/_auth.js
@@ -1 +1 @@
-import{t as e}from"./useQuery.js";import{t}from"./chevron-down.js";import{t as n}from"./chevron-right.js";import{B as r,Fo as i,H as a,Ho as o,J as s,Lo as c,Q as l,So as u,Uo as d,X as f,_o as p,ao as m,bo as h,cs as g,et as _,go as v,gr as y,ho as b,ht as x,pt as S,st as C,vo as w,wo as T,xo as E,yo as D}from"./index.js";import{t as O}from"./page-heading.js";import{t as k}from"./state-badge.js";import{r as ee,t as te}from"./admin.js";import{t as ne}from"./badge.js";import{a as A,i as j,n as M,o as N,r as P,t as F}from"./card.js";var I=T(`box`,[[`path`,{d:`M21 8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16Z`,key:`hh9hay`}],[`path`,{d:`m3.3 7 8.7 5 8.7-5`,key:`g66t2b`}],[`path`,{d:`M12 22V12`,key:`d0xqtd`}]]),L=T(`folder-open`,[[`path`,{d:`m6 14 1.5-2.9A2 2 0 0 1 9.24 10H20a2 2 0 0 1 1.94 2.5l-1.54 6a2 2 0 0 1-1.95 1.5H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h3.9a2 2 0 0 1 1.69.9l.81 1.2a2 2 0 0 0 1.67.9H18a2 2 0 0 1 2 2v2`,key:`usdka0`}]]),R=T(`lock`,[[`rect`,{width:`18`,height:`11`,x:`3`,y:`11`,rx:`2`,ry:`2`,key:`1w4ew1`}],[`path`,{d:`M7 11V7a5 5 0 0 1 10 0v4`,key:`fwvmzm`}]]),re=T(`monitor`,[[`rect`,{width:`20`,height:`14`,x:`2`,y:`3`,rx:`2`,key:`48i651`}],[`line`,{x1:`8`,x2:`16`,y1:`21`,y2:`21`,key:`1svkeh`}],[`line`,{x1:`12`,x2:`12`,y1:`17`,y2:`21`,key:`vw1qmm`}]]),z=i(),B=g(d(),1),V=o(),H=[{name:`Air-gap Support`,key:`airgap`},{name:`NVIDIA Bluefield DPU`,key:`bluefield`},{name:`NVIDIA MIG Support`,key:`mig`}];function U(e){let t=Math.floor(e/86400),n=Math.floor(e%86400/3600);if(t>0)return`${t}d ${n}h`;let r=Math.floor(e%3600/60);return n>0?`${n}h ${r}m`:`${r}m`}function W(){let r=(0,z.c)(110),{isAdmin:i,version:a,license:o}=y(),[s,c]=(0,B.useState)(!1),l;r[0]===i?l=r[1]:(l={...te,enabled:i},r[0]=i,r[1]=l);let{data:u}=e(l),d;r[2]===i?d=r[3]:(d={...ee,enabled:i},r[2]=i,r[3]=d);let{data:f}=e(d);if(!i||!a)return null;let m,h,g,_,v,b,x,S,C,w,T,E,D,O,k,j,M,I,L;if(r[4]!==o||r[5]!==u?.cluster_mode||r[6]!==u?.nodes||r[7]!==a.arch||r[8]!==a.commit||r[9]!==a.os||r[10]!==a.version||r[11]!==f?.vms){_=u?.nodes??[];let e;r[31]===f?.vms?e=r[32]:(e=f?.vms??[],r[31]=f?.vms,r[32]=e);let t=e,n=u?.cluster_mode??`single-node`;g=o===`open-source`;let i=_[0]?.region??null,s=_.reduce(se,0),c=_.reduce(oe,0),l=_.reduce(ae,0),d=_.reduce(ie,0);T=`mb-6 space-y-4`,S=`grid grid-cols-1 gap-4 md:grid-cols-3`;let y;r[33]===Symbol.for(`react.memo_cache_sentinel`)?(y=(0,V.jsx)(A,{className:`pb-2`,children:(0,V.jsxs)(N,{className:`flex items-center gap-2 text-sm`,children:[(0,V.jsx)(re,{className:`size-3.5`}),`Spinifex Info`]})}),r[33]=y):y=r[33];let ee;r[34]===Symbol.for(`react.memo_cache_sentinel`)?(ee=(0,V.jsx)(`span`,{className:`text-muted-foreground`,children:`Version`}),r[34]=ee):ee=r[34];let te;r[35]===a.version?te=r[36]:(te=(0,V.jsxs)(`div`,{className:`flex justify-between`,children:[ee,(0,V.jsx)(`span`,{className:`font-mono`,children:a.version})]}),r[35]=a.version,r[36]=te);let R;r[37]===Symbol.for(`react.memo_cache_sentinel`)?(R=(0,V.jsx)(`span`,{className:`text-muted-foreground`,children:`Commit`}),r[37]=R):R=r[37];let z;r[38]===a.commit?z=r[39]:(z=(0,V.jsxs)(`div`,{className:`flex justify-between`,children:[R,(0,V.jsx)(`span`,{className:`font-mono`,children:a.commit})]}),r[38]=a.commit,r[39]=z);let B;r[40]===Symbol.for(`react.memo_cache_sentinel`)?(B=(0,V.jsx)(`span`,{className:`text-muted-foreground`,children:`Platform`}),r[40]=B):B=r[40];let H;r[41]!==a.arch||r[42]!==a.os?(H=(0,V.jsxs)(`div`,{className:`flex justify-between`,children:[B,(0,V.jsxs)(`span`,{children:[a.os,`/`,a.arch]})]}),r[41]=a.arch,r[42]=a.os,r[43]=H):H=r[43],C=(0,V.jsxs)(F,{children:[y,(0,V.jsxs)(P,{className:`space-y-1 text-xs`,children:[te,z,H,i&&(0,V.jsxs)(`div`,{className:`flex justify-between`,children:[(0,V.jsx)(`span`,{className:`text-muted-foreground`,children:`Region`}),(0,V.jsx)(`span`,{children:i})]})]})]});let U;r[44]===Symbol.for(`react.memo_cache_sentinel`)?(U=(0,V.jsx)(A,{className:`pb-2`,children:(0,V.jsx)(N,{className:`text-sm`,children:`License & Support`})}),r[44]=U):U=r[44];let W;r[45]===Symbol.for(`react.memo_cache_sentinel`)?(W=(0,V.jsx)(`span`,{className:`text-muted-foreground`,children:`License`}),r[45]=W):W=r[45];let ce=g?`secondary`:`default`,G=g?`Open Source (AGPLv3)`:`Commercial`,K;r[46]!==ce||r[47]!==G?(K=(0,V.jsxs)(`div`,{className:`flex items-center justify-between`,children:[W,(0,V.jsx)(ne,{variant:ce,children:G})]}),r[46]=ce,r[47]=G,r[48]=K):K=r[48];let q;r[49]===Symbol.for(`react.memo_cache_sentinel`)?(q=(0,V.jsx)(`span`,{className:`text-muted-foreground`,children:`Support`}),r[49]=q):q=r[49];let J=g?`Community`:`Standard`,Y;r[50]===J?Y=r[51]:(Y=(0,V.jsxs)(`div`,{className:`flex items-center justify-between`,children:[q,(0,V.jsx)(`span`,{children:J})]}),r[50]=J,r[51]=Y);let X;r[52]===g?X=r[53]:(X=g&&(0,V.jsx)(`a`,{href:`https://mulgadc.com/purchase`,target:`_blank`,rel:`noopener noreferrer`,className:`mt-2 block text-xs font-medium text-primary hover:underline`,children:`Upgrade to Commercial →`}),r[52]=g,r[53]=X),r[54]!==K||r[55]!==Y||r[56]!==X?(w=(0,V.jsxs)(F,{children:[U,(0,V.jsxs)(P,{className:`space-y-2 text-xs`,children:[K,Y,X]})]}),r[54]=K,r[55]=Y,r[56]=X,r[57]=w):w=r[57],h=F,r[58]===Symbol.for(`react.memo_cache_sentinel`)?(x=(0,V.jsx)(A,{className:`pb-2`,children:(0,V.jsxs)(N,{className:`flex items-center gap-2 text-sm`,children:[(0,V.jsx)(p,{className:`size-3.5`}),`Cluster Status`]})}),r[58]=x):x=r[58],m=P,M=`space-y-2 text-xs`,r[59]===n?I=r[60]:(I=(0,V.jsx)(`div`,{className:`flex items-center gap-2`,children:(0,V.jsx)(ne,{variant:`outline`,children:n})}),r[59]=n,r[60]=I);let Z;r[61]===Symbol.for(`react.memo_cache_sentinel`)?(Z=(0,V.jsx)(`span`,{className:`text-muted-foreground`,children:`Nodes`}),r[61]=Z):Z=r[61],L=(0,V.jsxs)(`div`,{className:`flex justify-between`,children:[Z,(0,V.jsx)(`span`,{children:_.length})]});let Q;r[62]===Symbol.for(`react.memo_cache_sentinel`)?(Q=(0,V.jsx)(`span`,{className:`text-muted-foreground`,children:`Instances`}),r[62]=Q):Q=r[62],r[63]===t.length?v=r[64]:(v=(0,V.jsxs)(`div`,{className:`flex justify-between`,children:[Q,(0,V.jsx)(`span`,{children:t.length})]}),r[63]=t.length,r[64]=v);let $;r[65]===Symbol.for(`react.memo_cache_sentinel`)?($=(0,V.jsx)(`span`,{className:`text-muted-foreground`,children:`vCPU`}),r[65]=$):$=r[65],b=(0,V.jsxs)(`div`,{className:`flex justify-between`,children:[$,(0,V.jsxs)(`span`,{children:[c,` / `,s]})]}),k=`flex justify-between`,r[66]===Symbol.for(`react.memo_cache_sentinel`)?(j=(0,V.jsx)(`span`,{className:`text-muted-foreground`,children:`Memory`}),r[66]=j):j=r[66],E=d.toFixed(1),D=` / `,O=l.toFixed(1),r[4]=o,r[5]=u?.cluster_mode,r[6]=u?.nodes,r[7]=a.arch,r[8]=a.commit,r[9]=a.os,r[10]=a.version,r[11]=f?.vms,r[12]=m,r[13]=h,r[14]=g,r[15]=_,r[16]=v,r[17]=b,r[18]=x,r[19]=S,r[20]=C,r[21]=w,r[22]=T,r[23]=E,r[24]=D,r[25]=O,r[26]=k,r[27]=j,r[28]=M,r[29]=I,r[30]=L}else m=r[12],h=r[13],g=r[14],_=r[15],v=r[16],b=r[17],x=r[18],S=r[19],C=r[20],w=r[21],T=r[22],E=r[23],D=r[24],O=r[25],k=r[26],j=r[27],M=r[28],I=r[29],L=r[30];let U;r[67]!==E||r[68]!==D||r[69]!==O?(U=(0,V.jsxs)(`span`,{children:[E,D,O,` GB`]}),r[67]=E,r[68]=D,r[69]=O,r[70]=U):U=r[70];let W;r[71]!==U||r[72]!==k||r[73]!==j?(W=(0,V.jsxs)(`div`,{className:k,children:[j,U]}),r[71]=U,r[72]=k,r[73]=j,r[74]=W):W=r[74];let G;r[75]!==m||r[76]!==v||r[77]!==b||r[78]!==W||r[79]!==M||r[80]!==I||r[81]!==L?(G=(0,V.jsxs)(m,{className:M,children:[I,L,v,b,W]}),r[75]=m,r[76]=v,r[77]=b,r[78]=W,r[79]=M,r[80]=I,r[81]=L,r[82]=G):G=r[82];let K;r[83]!==h||r[84]!==x||r[85]!==G?(K=(0,V.jsxs)(h,{children:[x,G]}),r[83]=h,r[84]=x,r[85]=G,r[86]=K):K=r[86];let q;r[87]!==S||r[88]!==C||r[89]!==w||r[90]!==K?(q=(0,V.jsxs)(`div`,{className:S,children:[C,w,K]}),r[87]=S,r[88]=C,r[89]=w,r[90]=K,r[91]=q):q=r[91];let J;r[92]!==_||r[93]!==s?(J=_.length>0&&(0,V.jsxs)(F,{children:[(0,V.jsx)(A,{className:`pb-0`,children:(0,V.jsxs)(`button`,{type:`button`,className:`flex items-center gap-2 text-sm font-medium`,onClick:()=>c(!s),children:[s?(0,V.jsx)(t,{className:`size-3.5`}):(0,V.jsx)(n,{className:`size-3.5`}),`Nodes (`,_.length,`)`]})}),s&&(0,V.jsx)(P,{className:`pt-2`,children:(0,V.jsx)(ce,{nodes:_})})]}),r[92]=_,r[93]=s,r[94]=J):J=r[94];let Y;r[95]===Symbol.for(`react.memo_cache_sentinel`)?(Y=(0,V.jsx)(A,{className:`pb-2`,children:(0,V.jsx)(N,{className:`text-sm`,children:`Commercial Add-ons`})}),r[95]=Y):Y=r[95];let X;r[96]===g?X=r[97]:(X=H.map(e=>(0,V.jsxs)(`div`,{className:`flex items-center justify-between text-xs`,children:[(0,V.jsx)(`span`,{className:g?`text-muted-foreground`:void 0,children:e.name}),g?(0,V.jsx)(R,{className:`size-3 text-muted-foreground`}):(0,V.jsx)(ne,{variant:`outline`,className:`text-[0.625rem]`,children:`Active`})]},e.key)),r[96]=g,r[97]=X);let Z;r[98]===X?Z=r[99]:(Z=(0,V.jsx)(`div`,{className:`space-y-2`,children:X}),r[98]=X,r[99]=Z);let Q;r[100]===g?Q=r[101]:(Q=g&&(0,V.jsx)(`a`,{href:`https://mulgadc.com/purchase`,target:`_blank`,rel:`noopener noreferrer`,className:`mt-3 block text-xs font-medium text-primary hover:underline`,children:`Learn more about commercial features →`}),r[100]=g,r[101]=Q);let $;r[102]!==Z||r[103]!==Q?($=(0,V.jsxs)(F,{children:[Y,(0,V.jsxs)(P,{children:[Z,Q]})]}),r[102]=Z,r[103]=Q,r[104]=$):$=r[104];let le;return r[105]!==T||r[106]!==q||r[107]!==J||r[108]!==$?(le=(0,V.jsxs)(`div`,{className:T,children:[q,J,$]}),r[105]=T,r[106]=q,r[107]=J,r[108]=$,r[109]=le):le=r[109],le}function ie(e,t){return e+t.alloc_mem_gb}function ae(e,t){return e+t.total_mem_gb}function oe(e,t){return e+t.alloc_vcpu}function se(e,t){return e+t.total_vcpu}function ce(e){let t=(0,z.c)(5),{nodes:n}=e,r;t[0]===Symbol.for(`react.memo_cache_sentinel`)?(r=(0,V.jsx)(`thead`,{children:(0,V.jsxs)(`tr`,{className:`border-b text-left text-muted-foreground`,children:[(0,V.jsx)(`th`,{className:`pr-4 pb-1 font-medium`,children:`Name`}),(0,V.jsx)(`th`,{className:`pr-4 pb-1 font-medium`,children:`Status`}),(0,V.jsx)(`th`,{className:`pr-4 pb-1 font-medium`,children:`Roles`}),(0,V.jsx)(`th`,{className:`pr-4 pb-1 font-medium`,children:`IP`}),(0,V.jsx)(`th`,{className:`pr-4 pb-1 font-medium`,children:`Region`}),(0,V.jsx)(`th`,{className:`pr-4 pb-1 font-medium`,children:`AZ`}),(0,V.jsx)(`th`,{className:`pr-4 pb-1 font-medium`,children:`Uptime`}),(0,V.jsx)(`th`,{className:`pr-4 pb-1 font-medium`,children:`EC2`}),(0,V.jsx)(`th`,{className:`pb-1 font-medium`,children:`Services`})]})}),t[0]=r):r=t[0];let i;t[1]===n?i=t[2]:(i=n.map(G),t[1]=n,t[2]=i);let a;return t[3]===i?a=t[4]:(a=(0,V.jsx)(`div`,{className:`overflow-x-auto`,children:(0,V.jsxs)(`table`,{className:`w-full text-xs`,children:[r,(0,V.jsx)(`tbody`,{children:i})]})}),t[3]=i,t[4]=a),a}function G(e){return(0,V.jsxs)(`tr`,{className:`border-b last:border-0`,children:[(0,V.jsx)(`td`,{className:`py-1.5 pr-4 font-mono font-medium`,children:e.node}),(0,V.jsx)(`td`,{className:`py-1.5 pr-4`,children:(0,V.jsx)(ne,{variant:e.status===`Ready`?`default`:`secondary`,className:`text-[0.625rem]`,children:e.status})}),(0,V.jsx)(`td`,{className:`py-1.5 pr-4`,children:(0,V.jsxs)(`div`,{className:`flex gap-1`,children:[e.nats_role&&(0,V.jsxs)(`span`,{className:`text-muted-foreground`,children:[`nats:`,e.nats_role]}),e.predastore_role&&(0,V.jsxs)(`span`,{className:`text-muted-foreground`,children:[`ps:`,e.predastore_role]})]})}),(0,V.jsx)(`td`,{className:`py-1.5 pr-4 font-mono`,children:e.host}),(0,V.jsx)(`td`,{className:`py-1.5 pr-4`,children:e.region}),(0,V.jsx)(`td`,{className:`py-1.5 pr-4`,children:e.az}),(0,V.jsx)(`td`,{className:`py-1.5 pr-4`,children:U(e.uptime)}),(0,V.jsx)(`td`,{className:`py-1.5 pr-4`,children:e.vm_count}),(0,V.jsx)(`td`,{className:`py-1.5`,children:(0,V.jsx)(`span`,{className:`text-muted-foreground`,children:e.services.length})})]},e.node)}var K=5;function q(){let e=(0,z.c)(11),t,n;e[0]===Symbol.for(`react.memo_cache_sentinel`)?(t=(0,V.jsx)(O,{title:`Dashboard`}),n=(0,V.jsx)(W,{}),e[0]=t,e[1]=n):(t=e[0],n=e[1]);let r;e[2]===Symbol.for(`react.memo_cache_sentinel`)?(r=(0,V.jsx)(B.Suspense,{fallback:(0,V.jsx)(J,{}),children:(0,V.jsx)(Q,{})}),e[2]=r):r=e[2];let i;e[3]===Symbol.for(`react.memo_cache_sentinel`)?(i=(0,V.jsx)(B.Suspense,{fallback:(0,V.jsx)(J,{}),children:(0,V.jsx)(me,{})}),e[3]=i):i=e[3];let a;e[4]===Symbol.for(`react.memo_cache_sentinel`)?(a=(0,V.jsx)(B.Suspense,{fallback:(0,V.jsx)(J,{}),children:(0,V.jsx)(ye,{})}),e[4]=a):a=e[4];let o;e[5]===Symbol.for(`react.memo_cache_sentinel`)?(o=(0,V.jsx)(B.Suspense,{fallback:(0,V.jsx)(J,{}),children:(0,V.jsx)(xe,{})}),e[5]=o):o=e[5];let s;e[6]===Symbol.for(`react.memo_cache_sentinel`)?(s=(0,V.jsx)(B.Suspense,{fallback:(0,V.jsx)(J,{}),children:(0,V.jsx)(Ce,{})}),e[6]=s):s=e[6];let c;e[7]===Symbol.for(`react.memo_cache_sentinel`)?(c=(0,V.jsx)(B.Suspense,{fallback:(0,V.jsx)(J,{}),children:(0,V.jsx)(Te,{})}),e[7]=c):c=e[7];let l;e[8]===Symbol.for(`react.memo_cache_sentinel`)?(l=(0,V.jsx)(B.Suspense,{fallback:(0,V.jsx)(J,{}),children:(0,V.jsx)(De,{})}),e[8]=l):l=e[8];let u;e[9]===Symbol.for(`react.memo_cache_sentinel`)?(u=(0,V.jsx)(B.Suspense,{fallback:(0,V.jsx)(J,{}),children:(0,V.jsx)(je,{})}),e[9]=u):u=e[9];let d;return e[10]===Symbol.for(`react.memo_cache_sentinel`)?(d=(0,V.jsxs)(V.Fragment,{children:[t,n,(0,V.jsxs)(`div`,{className:`grid grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-3`,children:[r,i,a,o,s,c,l,u,(0,V.jsx)(B.Suspense,{fallback:(0,V.jsx)(J,{}),children:(0,V.jsx)(ke,{})})]})]}),e[10]=d):d=e[10],d}function J(){let e=(0,z.c)(2),t;e[0]===Symbol.for(`react.memo_cache_sentinel`)?(t=(0,V.jsx)(A,{children:(0,V.jsx)(`div`,{className:`h-4 w-32 animate-pulse rounded bg-muted`})}),e[0]=t):t=e[0];let n;return e[1]===Symbol.for(`react.memo_cache_sentinel`)?(n=(0,V.jsxs)(F,{children:[t,(0,V.jsx)(P,{children:(0,V.jsxs)(`div`,{className:`space-y-2`,children:[(0,V.jsx)(`div`,{className:`h-3 w-48 animate-pulse rounded bg-muted`}),(0,V.jsx)(`div`,{className:`h-3 w-36 animate-pulse rounded bg-muted`})]})})]}),e[1]=n):n=e[1],n}function Y(e){let t=(0,z.c)(16),{title:n,icon:r,count:i,to:a,children:o}=e,s;t[0]!==r||t[1]!==n?(s=(0,V.jsxs)(N,{className:`flex items-center gap-2`,children:[r,n]}),t[0]=r,t[1]=n,t[2]=s):s=t[2];let l;t[3]===i?l=t[4]:(l=(0,V.jsx)(M,{children:(0,V.jsx)(`span`,{className:`rounded-md bg-muted px-2 py-0.5 text-xs font-semibold tabular-nums`,children:i})}),t[3]=i,t[4]=l);let u;t[5]!==s||t[6]!==l?(u=(0,V.jsxs)(A,{children:[s,l]}),t[5]=s,t[6]=l,t[7]=u):u=t[7];let d;t[8]===o?d=t[9]:(d=(0,V.jsx)(P,{children:o}),t[8]=o,t[9]=d);let f;t[10]===a?f=t[11]:(f=(0,V.jsx)(j,{children:(0,V.jsx)(c,{to:a,className:`text-xs font-medium text-primary hover:underline`,children:`View all →`})}),t[10]=a,t[11]=f);let p;return t[12]!==u||t[13]!==d||t[14]!==f?(p=(0,V.jsxs)(F,{children:[u,d,f]}),t[12]=u,t[13]=d,t[14]=f,t[15]=p):p=t[15],p}function X(e){let t=(0,z.c)(2),{total:n}=e;if(n<=K)return null;let r=n-K,i;return t[0]===r?i=t[1]:(i=(0,V.jsxs)(`p`,{className:`mt-1 text-[0.625rem] text-muted-foreground`,children:[`+`,r,` more`]}),t[0]=r,t[1]=i),i}function Z(e){let t=(0,z.c)(2),{children:n}=e,r;return t[0]===n?r=t[1]:(r=(0,V.jsx)(`div`,{className:`flex items-center justify-between py-0.5 text-xs`,children:n}),t[0]=n,t[1]=r),r}function Q(){let t=(0,z.c)(16),{data:n}=e(s),r,i,a,o,c;if(t[0]!==n?.Reservations){let e=n?.Reservations?.flatMap(pe)??[];r=e.filter(fe),a=e.filter(de),o=e.filter(ue),i=e.filter(le),c=[...a,...i].slice(0,K),t[0]=n?.Reservations,t[1]=r,t[2]=i,t[3]=a,t[4]=o,t[5]=c}else r=t[1],i=t[2],a=t[3],o=t[4],c=t[5];let l=c,u;t[6]===Symbol.for(`react.memo_cache_sentinel`)?(u=(0,V.jsx)(E,{className:`size-3.5`}),t[6]=u):u=t[6];let d;t[7]!==r.length||t[8]!==l||t[9]!==i||t[10]!==a||t[11]!==o?(d=r.length===0?(0,V.jsx)(`p`,{className:`text-xs text-muted-foreground`,children:`No instances`}):(0,V.jsxs)(V.Fragment,{children:[(0,V.jsxs)(`div`,{className:`mb-2 flex gap-3 text-[0.625rem] text-muted-foreground`,children:[(0,V.jsxs)(`span`,{children:[a.length,` running`]}),(0,V.jsxs)(`span`,{children:[o.length,` stopped`]})]}),(0,V.jsx)(`div`,{className:`space-y-0.5`,children:l.map($)}),(0,V.jsx)(X,{total:a.length+i.length})]}),t[7]=r.length,t[8]=l,t[9]=i,t[10]=a,t[11]=o,t[12]=d):d=t[12];let f;return t[13]!==r.length||t[14]!==d?(f=(0,V.jsx)(Y,{title:`Instances`,icon:u,count:r.length,to:`/ec2/describe-instances`,children:d}),t[13]=r.length,t[14]=d,t[15]=f):f=t[15],f}function $(e){return(0,V.jsxs)(Z,{children:[(0,V.jsx)(`span`,{className:`font-mono`,children:e.InstanceId}),(0,V.jsx)(`span`,{className:`text-muted-foreground`,children:e.InstanceType})]},e.InstanceId)}function le(e){return e.State?.Name===`pending`}function ue(e){return e.State?.Name===`stopped`}function de(e){return e.State?.Name===`running`}function fe(e){return e.State?.Name!==`terminated`}function pe(e){return e.Instances??[]}function me(){let t=(0,z.c)(3),{data:n}=e(S),r;if(t[0]!==n?.Volumes){let e=n?.Volumes??[],i=e.reduce(ve,0),a=e.filter(_e),o=e.filter(ge),s=e.slice(0,K),c;t[2]===Symbol.for(`react.memo_cache_sentinel`)?(c=(0,V.jsx)(I,{className:`size-3.5`}),t[2]=c):c=t[2],r=(0,V.jsx)(Y,{title:`Volumes`,icon:c,count:e.length,to:`/ec2/describe-volumes`,children:e.length===0?(0,V.jsx)(`p`,{className:`text-xs text-muted-foreground`,children:`No volumes`}):(0,V.jsxs)(V.Fragment,{children:[(0,V.jsxs)(`div`,{className:`mb-2 flex gap-3 text-[0.625rem] text-muted-foreground`,children:[(0,V.jsxs)(`span`,{children:[i,` GiB total`]}),(0,V.jsxs)(`span`,{children:[a.length,` in-use`]}),(0,V.jsxs)(`span`,{children:[o.length,` available`]})]}),(0,V.jsx)(`div`,{className:`space-y-0.5`,children:s.map(he)}),(0,V.jsx)(X,{total:e.length})]})}),t[0]=n?.Volumes,t[1]=r}else r=t[1];return r}function he(e){return(0,V.jsxs)(Z,{children:[(0,V.jsx)(`span`,{className:`font-mono`,children:e.VolumeId}),(0,V.jsxs)(`span`,{className:`flex items-center gap-2`,children:[(0,V.jsxs)(`span`,{className:`text-muted-foreground`,children:[e.Size,` GiB`]}),(0,V.jsx)(k,{state:e.State})]})]},e.VolumeId)}function ge(e){return e.State===`available`}function _e(e){return e.State===`in-use`}function ve(e,t){return e+(t.Size??0)}function ye(){let t=(0,z.c)(3),{data:n}=e(x),r;if(t[0]!==n?.Vpcs){let e=n?.Vpcs??[],i=e.slice(0,K),a;t[2]===Symbol.for(`react.memo_cache_sentinel`)?(a=(0,V.jsx)(D,{className:`size-3.5`}),t[2]=a):a=t[2],r=(0,V.jsx)(Y,{title:`VPCs`,icon:a,count:e.length,to:`/ec2/describe-vpcs`,children:e.length===0?(0,V.jsx)(`p`,{className:`text-xs text-muted-foreground`,children:`No VPCs`}):(0,V.jsxs)(`div`,{className:`space-y-0.5`,children:[i.map(be),(0,V.jsx)(X,{total:e.length})]})}),t[0]=n?.Vpcs,t[1]=r}else r=t[1];return r}function be(e){let t=m(e.Tags);return(0,V.jsxs)(Z,{children:[(0,V.jsxs)(`span`,{className:`font-mono`,children:[e.VpcId,t&&(0,V.jsxs)(`span`,{className:`ml-1 font-sans text-muted-foreground`,children:[`(`,t,`)`]})]}),(0,V.jsx)(`span`,{className:`text-muted-foreground`,children:e.CidrBlock})]},e.VpcId)}function xe(){let t=(0,z.c)(3),{data:n}=e(l),r;if(t[0]!==n?.KeyPairs){let e=n?.KeyPairs??[],i=e.slice(0,K),a;t[2]===Symbol.for(`react.memo_cache_sentinel`)?(a=(0,V.jsx)(h,{className:`size-3.5`}),t[2]=a):a=t[2],r=(0,V.jsx)(Y,{title:`Key Pairs`,icon:a,count:e.length,to:`/ec2/describe-key-pairs`,children:e.length===0?(0,V.jsx)(`p`,{className:`text-xs text-muted-foreground`,children:`No key pairs`}):(0,V.jsxs)(`div`,{className:`space-y-0.5`,children:[i.map(Se),(0,V.jsx)(X,{total:e.length})]})}),t[0]=n?.KeyPairs,t[1]=r}else r=t[1];return r}function Se(e){return(0,V.jsx)(Z,{children:(0,V.jsx)(`span`,{className:`font-mono`,children:e.KeyName})},e.KeyPairId)}function Ce(){let t=(0,z.c)(3),{data:n}=e(r),i;if(t[0]!==n?.Buckets){let e=n?.Buckets??[],r=e.slice(0,K),a;t[2]===Symbol.for(`react.memo_cache_sentinel`)?(a=(0,V.jsx)(L,{className:`size-3.5`}),t[2]=a):a=t[2],i=(0,V.jsx)(Y,{title:`S3 Buckets`,icon:a,count:e.length,to:`/s3/ls`,children:e.length===0?(0,V.jsx)(`p`,{className:`text-xs text-muted-foreground`,children:`No buckets`}):(0,V.jsxs)(`div`,{className:`space-y-0.5`,children:[r.map(we),(0,V.jsx)(X,{total:e.length})]})}),t[0]=n?.Buckets,t[1]=i}else i=t[1];return i}function we(e){return(0,V.jsx)(Z,{children:(0,V.jsx)(`span`,{className:`font-mono`,children:e.Name})},e.Name)}function Te(){let t=(0,z.c)(3),{data:n}=e(C),r;if(t[0]!==n?.SecurityGroups){let e=n?.SecurityGroups??[],i=e.slice(0,K),a;t[2]===Symbol.for(`react.memo_cache_sentinel`)?(a=(0,V.jsx)(v,{className:`size-3.5`}),t[2]=a):a=t[2],r=(0,V.jsx)(Y,{title:`Security Groups`,icon:a,count:e.length,to:`/ec2/describe-security-groups`,children:e.length===0?(0,V.jsx)(`p`,{className:`text-xs text-muted-foreground`,children:`No security groups`}):(0,V.jsxs)(`div`,{className:`space-y-0.5`,children:[i.map(Ee),(0,V.jsx)(X,{total:e.length})]})}),t[0]=n?.SecurityGroups,t[1]=r}else r=t[1];return r}function Ee(e){return(0,V.jsxs)(Z,{children:[(0,V.jsx)(`span`,{className:`font-mono`,children:e.GroupName}),(0,V.jsx)(`span`,{className:`text-muted-foreground`,children:e.VpcId})]},e.GroupId)}function De(){let t=(0,z.c)(3),{data:n}=e(_),r;if(t[0]!==n?.NatGateways){let e=n?.NatGateways??[],i=e.slice(0,K),a;t[2]===Symbol.for(`react.memo_cache_sentinel`)?(a=(0,V.jsx)(b,{className:`size-3.5`}),t[2]=a):a=t[2],r=(0,V.jsx)(Y,{title:`NAT Gateways`,icon:a,count:e.length,to:`/ec2/describe-nat-gateways`,children:e.length===0?(0,V.jsx)(`p`,{className:`text-xs text-muted-foreground`,children:`No NAT gateways`}):(0,V.jsxs)(`div`,{className:`space-y-0.5`,children:[i.map(Oe),(0,V.jsx)(X,{total:e.length})]})}),t[0]=n?.NatGateways,t[1]=r}else r=t[1];return r}function Oe(e){return(0,V.jsxs)(Z,{children:[(0,V.jsx)(`span`,{className:`font-mono`,children:e.NatGatewayId}),(0,V.jsx)(`span`,{className:`text-muted-foreground`,children:e.State})]},e.NatGatewayId)}function ke(){let t=(0,z.c)(3),{data:n}=e(f),r;if(t[0]!==n?.InternetGateways){let e=n?.InternetGateways??[],i=e.slice(0,K),a;t[2]===Symbol.for(`react.memo_cache_sentinel`)?(a=(0,V.jsx)(w,{className:`size-3.5`}),t[2]=a):a=t[2],r=(0,V.jsx)(Y,{title:`Internet Gateways`,icon:a,count:e.length,to:`/ec2/describe-internet-gateways`,children:e.length===0?(0,V.jsx)(`p`,{className:`text-xs text-muted-foreground`,children:`No internet gateways`}):(0,V.jsxs)(`div`,{className:`space-y-0.5`,children:[i.map(Ae),(0,V.jsx)(X,{total:e.length})]})}),t[0]=n?.InternetGateways,t[1]=r}else r=t[1];return r}function Ae(e){return(0,V.jsxs)(Z,{children:[(0,V.jsx)(`span`,{className:`font-mono`,children:e.InternetGatewayId}),(0,V.jsx)(`span`,{className:`text-muted-foreground`,children:e.Attachments?.[0]?.VpcId?`attached`:`detached`})]},e.InternetGatewayId)}function je(){let t=(0,z.c)(3),{data:n}=e(a),r;if(t[0]!==n?.Addresses){let e=n?.Addresses??[],i=e.slice(0,K),a;t[2]===Symbol.for(`react.memo_cache_sentinel`)?(a=(0,V.jsx)(u,{className:`size-3.5`}),t[2]=a):a=t[2],r=(0,V.jsx)(Y,{title:`Elastic IPs`,icon:a,count:e.length,to:`/ec2/describe-addresses`,children:e.length===0?(0,V.jsx)(`p`,{className:`text-xs text-muted-foreground`,children:`No Elastic IPs`}):(0,V.jsxs)(`div`,{className:`space-y-0.5`,children:[i.map(Me),(0,V.jsx)(X,{total:e.length})]})}),t[0]=n?.Addresses,t[1]=r}else r=t[1];return r}function Me(e){return(0,V.jsxs)(Z,{children:[(0,V.jsx)(`span`,{className:`font-mono`,children:e.PublicIp}),(0,V.jsx)(`span`,{className:`text-muted-foreground`,children:e.AssociationId?`associated`:`available`})]},e.AllocationId)}export{q as component};
\ No newline at end of file
+import{n as e,o as t,r as n,t as r}from"./compiler-runtime.js";import{t as i}from"./useQuery.js";import{Or as a}from"./awsClient.js";import{A as o}from"./SignatureV4.js";import{t as s}from"./chevron-down.js";import{t as c}from"./chevron-right.js";import{Ar as l,Bt as u,Dr as d,Er as f,Ft as p,Lt as m,Nr as h,Or as g,St as _,Tr as v,bt as y,gt as b,jr as x,kr as S,kt as C,lt as w,st as T,vt as E,wr as D}from"./index.js";import{t as O}from"./page-heading.js";import{t as k}from"./state-badge.js";import{r as A,t as ee}from"./admin.js";import{t as te}from"./badge.js";import{a as j,i as M,n as N,o as ne,r as P,t as F}from"./card.js";var I=h(`box`,[[`path`,{d:`M21 8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16Z`,key:`hh9hay`}],[`path`,{d:`m3.3 7 8.7 5 8.7-5`,key:`g66t2b`}],[`path`,{d:`M12 22V12`,key:`d0xqtd`}]]),L=h(`folder-open`,[[`path`,{d:`m6 14 1.5-2.9A2 2 0 0 1 9.24 10H20a2 2 0 0 1 1.94 2.5l-1.54 6a2 2 0 0 1-1.95 1.5H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h3.9a2 2 0 0 1 1.69.9l.81 1.2a2 2 0 0 0 1.67.9H18a2 2 0 0 1 2 2v2`,key:`usdka0`}]]),R=h(`lock`,[[`rect`,{width:`18`,height:`11`,x:`3`,y:`11`,rx:`2`,ry:`2`,key:`1w4ew1`}],[`path`,{d:`M7 11V7a5 5 0 0 1 10 0v4`,key:`fwvmzm`}]]),re=h(`monitor`,[[`rect`,{width:`20`,height:`14`,x:`2`,y:`3`,rx:`2`,key:`48i651`}],[`line`,{x1:`8`,x2:`16`,y1:`21`,y2:`21`,key:`1svkeh`}],[`line`,{x1:`12`,x2:`12`,y1:`17`,y2:`21`,key:`vw1qmm`}]]),z=r(),B=t(n(),1),V=e(),H=[{name:`Air-gap Support`,key:`airgap`},{name:`NVIDIA Bluefield DPU`,key:`bluefield`},{name:`NVIDIA MIG Support`,key:`mig`}];function U(e){let t=Math.floor(e/86400),n=Math.floor(e%86400/3600);if(t>0)return`${t}d ${n}h`;let r=Math.floor(e%3600/60);return n>0?`${n}h ${r}m`:`${r}m`}function W(){let e=(0,z.c)(110),{isAdmin:t,version:n,license:r}=u(),[a,o]=(0,B.useState)(!1),l;e[0]===t?l=e[1]:(l={...ee,enabled:t},e[0]=t,e[1]=l);let{data:d}=i(l),p;e[2]===t?p=e[3]:(p={...A,enabled:t},e[2]=t,e[3]=p);let{data:m}=i(p);if(!t||!n)return null;let h,g,_,v,y,b,x,S,C,w,T,E,D,O,k,M,N,I,L;if(e[4]!==r||e[5]!==d?.cluster_mode||e[6]!==d?.nodes||e[7]!==n.arch||e[8]!==n.commit||e[9]!==n.os||e[10]!==n.version||e[11]!==m?.vms){v=d?.nodes??[];let t;e[31]===m?.vms?t=e[32]:(t=m?.vms??[],e[31]=m?.vms,e[32]=t);let i=t,a=d?.cluster_mode??`single-node`;_=r===`open-source`;let o=v[0]?.region??null,s=v.reduce(se,0),c=v.reduce(oe,0),l=v.reduce(ae,0),u=v.reduce(ie,0);T=`mb-6 space-y-4`,S=`grid grid-cols-1 gap-4 md:grid-cols-3`;let p;e[33]===Symbol.for(`react.memo_cache_sentinel`)?(p=(0,V.jsx)(j,{className:`pb-2`,children:(0,V.jsxs)(ne,{className:`flex items-center gap-2 text-sm`,children:[(0,V.jsx)(re,{className:`size-3.5`}),`Spinifex Info`]})}),e[33]=p):p=e[33];let A;e[34]===Symbol.for(`react.memo_cache_sentinel`)?(A=(0,V.jsx)(`span`,{className:`text-muted-foreground`,children:`Version`}),e[34]=A):A=e[34];let ee;e[35]===n.version?ee=e[36]:(ee=(0,V.jsxs)(`div`,{className:`flex justify-between`,children:[A,(0,V.jsx)(`span`,{className:`font-mono`,children:n.version})]}),e[35]=n.version,e[36]=ee);let R;e[37]===Symbol.for(`react.memo_cache_sentinel`)?(R=(0,V.jsx)(`span`,{className:`text-muted-foreground`,children:`Commit`}),e[37]=R):R=e[37];let z;e[38]===n.commit?z=e[39]:(z=(0,V.jsxs)(`div`,{className:`flex justify-between`,children:[R,(0,V.jsx)(`span`,{className:`font-mono`,children:n.commit})]}),e[38]=n.commit,e[39]=z);let B;e[40]===Symbol.for(`react.memo_cache_sentinel`)?(B=(0,V.jsx)(`span`,{className:`text-muted-foreground`,children:`Platform`}),e[40]=B):B=e[40];let H;e[41]!==n.arch||e[42]!==n.os?(H=(0,V.jsxs)(`div`,{className:`flex justify-between`,children:[B,(0,V.jsxs)(`span`,{children:[n.os,`/`,n.arch]})]}),e[41]=n.arch,e[42]=n.os,e[43]=H):H=e[43],C=(0,V.jsxs)(F,{children:[p,(0,V.jsxs)(P,{className:`space-y-1 text-xs`,children:[ee,z,H,o&&(0,V.jsxs)(`div`,{className:`flex justify-between`,children:[(0,V.jsx)(`span`,{className:`text-muted-foreground`,children:`Region`}),(0,V.jsx)(`span`,{children:o})]})]})]});let U;e[44]===Symbol.for(`react.memo_cache_sentinel`)?(U=(0,V.jsx)(j,{className:`pb-2`,children:(0,V.jsx)(ne,{className:`text-sm`,children:`License & Support`})}),e[44]=U):U=e[44];let W;e[45]===Symbol.for(`react.memo_cache_sentinel`)?(W=(0,V.jsx)(`span`,{className:`text-muted-foreground`,children:`License`}),e[45]=W):W=e[45];let ce=_?`secondary`:`default`,G=_?`Open Source (AGPLv3)`:`Commercial`,K;e[46]!==ce||e[47]!==G?(K=(0,V.jsxs)(`div`,{className:`flex items-center justify-between`,children:[W,(0,V.jsx)(te,{variant:ce,children:G})]}),e[46]=ce,e[47]=G,e[48]=K):K=e[48];let q;e[49]===Symbol.for(`react.memo_cache_sentinel`)?(q=(0,V.jsx)(`span`,{className:`text-muted-foreground`,children:`Support`}),e[49]=q):q=e[49];let J=_?`Community`:`Standard`,Y;e[50]===J?Y=e[51]:(Y=(0,V.jsxs)(`div`,{className:`flex items-center justify-between`,children:[q,(0,V.jsx)(`span`,{children:J})]}),e[50]=J,e[51]=Y);let X;e[52]===_?X=e[53]:(X=_&&(0,V.jsx)(`a`,{href:`https://mulgadc.com/purchase`,target:`_blank`,rel:`noopener noreferrer`,className:`mt-2 block text-xs font-medium text-primary hover:underline`,children:`Upgrade to Commercial →`}),e[52]=_,e[53]=X),e[54]!==K||e[55]!==Y||e[56]!==X?(w=(0,V.jsxs)(F,{children:[U,(0,V.jsxs)(P,{className:`space-y-2 text-xs`,children:[K,Y,X]})]}),e[54]=K,e[55]=Y,e[56]=X,e[57]=w):w=e[57],g=F,e[58]===Symbol.for(`react.memo_cache_sentinel`)?(x=(0,V.jsx)(j,{className:`pb-2`,children:(0,V.jsxs)(ne,{className:`flex items-center gap-2 text-sm`,children:[(0,V.jsx)(f,{className:`size-3.5`}),`Cluster Status`]})}),e[58]=x):x=e[58],h=P,N=`space-y-2 text-xs`,e[59]===a?I=e[60]:(I=(0,V.jsx)(`div`,{className:`flex items-center gap-2`,children:(0,V.jsx)(te,{variant:`outline`,children:a})}),e[59]=a,e[60]=I);let Z;e[61]===Symbol.for(`react.memo_cache_sentinel`)?(Z=(0,V.jsx)(`span`,{className:`text-muted-foreground`,children:`Nodes`}),e[61]=Z):Z=e[61],L=(0,V.jsxs)(`div`,{className:`flex justify-between`,children:[Z,(0,V.jsx)(`span`,{children:v.length})]});let Q;e[62]===Symbol.for(`react.memo_cache_sentinel`)?(Q=(0,V.jsx)(`span`,{className:`text-muted-foreground`,children:`Instances`}),e[62]=Q):Q=e[62],e[63]===i.length?y=e[64]:(y=(0,V.jsxs)(`div`,{className:`flex justify-between`,children:[Q,(0,V.jsx)(`span`,{children:i.length})]}),e[63]=i.length,e[64]=y);let $;e[65]===Symbol.for(`react.memo_cache_sentinel`)?($=(0,V.jsx)(`span`,{className:`text-muted-foreground`,children:`vCPU`}),e[65]=$):$=e[65],b=(0,V.jsxs)(`div`,{className:`flex justify-between`,children:[$,(0,V.jsxs)(`span`,{children:[c,` / `,s]})]}),k=`flex justify-between`,e[66]===Symbol.for(`react.memo_cache_sentinel`)?(M=(0,V.jsx)(`span`,{className:`text-muted-foreground`,children:`Memory`}),e[66]=M):M=e[66],E=u.toFixed(1),D=` / `,O=l.toFixed(1),e[4]=r,e[5]=d?.cluster_mode,e[6]=d?.nodes,e[7]=n.arch,e[8]=n.commit,e[9]=n.os,e[10]=n.version,e[11]=m?.vms,e[12]=h,e[13]=g,e[14]=_,e[15]=v,e[16]=y,e[17]=b,e[18]=x,e[19]=S,e[20]=C,e[21]=w,e[22]=T,e[23]=E,e[24]=D,e[25]=O,e[26]=k,e[27]=M,e[28]=N,e[29]=I,e[30]=L}else h=e[12],g=e[13],_=e[14],v=e[15],y=e[16],b=e[17],x=e[18],S=e[19],C=e[20],w=e[21],T=e[22],E=e[23],D=e[24],O=e[25],k=e[26],M=e[27],N=e[28],I=e[29],L=e[30];let U;e[67]!==E||e[68]!==D||e[69]!==O?(U=(0,V.jsxs)(`span`,{children:[E,D,O,` GB`]}),e[67]=E,e[68]=D,e[69]=O,e[70]=U):U=e[70];let W;e[71]!==U||e[72]!==k||e[73]!==M?(W=(0,V.jsxs)(`div`,{className:k,children:[M,U]}),e[71]=U,e[72]=k,e[73]=M,e[74]=W):W=e[74];let G;e[75]!==h||e[76]!==y||e[77]!==b||e[78]!==W||e[79]!==N||e[80]!==I||e[81]!==L?(G=(0,V.jsxs)(h,{className:N,children:[I,L,y,b,W]}),e[75]=h,e[76]=y,e[77]=b,e[78]=W,e[79]=N,e[80]=I,e[81]=L,e[82]=G):G=e[82];let K;e[83]!==g||e[84]!==x||e[85]!==G?(K=(0,V.jsxs)(g,{children:[x,G]}),e[83]=g,e[84]=x,e[85]=G,e[86]=K):K=e[86];let q;e[87]!==S||e[88]!==C||e[89]!==w||e[90]!==K?(q=(0,V.jsxs)(`div`,{className:S,children:[C,w,K]}),e[87]=S,e[88]=C,e[89]=w,e[90]=K,e[91]=q):q=e[91];let J;e[92]!==v||e[93]!==a?(J=v.length>0&&(0,V.jsxs)(F,{children:[(0,V.jsx)(j,{className:`pb-0`,children:(0,V.jsxs)(`button`,{type:`button`,className:`flex items-center gap-2 text-sm font-medium`,onClick:()=>o(!a),children:[a?(0,V.jsx)(s,{className:`size-3.5`}):(0,V.jsx)(c,{className:`size-3.5`}),`Nodes (`,v.length,`)`]})}),a&&(0,V.jsx)(P,{className:`pt-2`,children:(0,V.jsx)(ce,{nodes:v})})]}),e[92]=v,e[93]=a,e[94]=J):J=e[94];let Y;e[95]===Symbol.for(`react.memo_cache_sentinel`)?(Y=(0,V.jsx)(j,{className:`pb-2`,children:(0,V.jsx)(ne,{className:`text-sm`,children:`Commercial Add-ons`})}),e[95]=Y):Y=e[95];let X;e[96]===_?X=e[97]:(X=H.map(e=>(0,V.jsxs)(`div`,{className:`flex items-center justify-between text-xs`,children:[(0,V.jsx)(`span`,{className:_?`text-muted-foreground`:void 0,children:e.name}),_?(0,V.jsx)(R,{className:`size-3 text-muted-foreground`}):(0,V.jsx)(te,{variant:`outline`,className:`text-[0.625rem]`,children:`Active`})]},e.key)),e[96]=_,e[97]=X);let Z;e[98]===X?Z=e[99]:(Z=(0,V.jsx)(`div`,{className:`space-y-2`,children:X}),e[98]=X,e[99]=Z);let Q;e[100]===_?Q=e[101]:(Q=_&&(0,V.jsx)(`a`,{href:`https://mulgadc.com/purchase`,target:`_blank`,rel:`noopener noreferrer`,className:`mt-3 block text-xs font-medium text-primary hover:underline`,children:`Learn more about commercial features →`}),e[100]=_,e[101]=Q);let $;e[102]!==Z||e[103]!==Q?($=(0,V.jsxs)(F,{children:[Y,(0,V.jsxs)(P,{children:[Z,Q]})]}),e[102]=Z,e[103]=Q,e[104]=$):$=e[104];let le;return e[105]!==T||e[106]!==q||e[107]!==J||e[108]!==$?(le=(0,V.jsxs)(`div`,{className:T,children:[q,J,$]}),e[105]=T,e[106]=q,e[107]=J,e[108]=$,e[109]=le):le=e[109],le}function ie(e,t){return e+t.alloc_mem_gb}function ae(e,t){return e+t.total_mem_gb}function oe(e,t){return e+t.alloc_vcpu}function se(e,t){return e+t.total_vcpu}function ce(e){let t=(0,z.c)(5),{nodes:n}=e,r;t[0]===Symbol.for(`react.memo_cache_sentinel`)?(r=(0,V.jsx)(`thead`,{children:(0,V.jsxs)(`tr`,{className:`border-b text-left text-muted-foreground`,children:[(0,V.jsx)(`th`,{className:`pr-4 pb-1 font-medium`,children:`Name`}),(0,V.jsx)(`th`,{className:`pr-4 pb-1 font-medium`,children:`Status`}),(0,V.jsx)(`th`,{className:`pr-4 pb-1 font-medium`,children:`Roles`}),(0,V.jsx)(`th`,{className:`pr-4 pb-1 font-medium`,children:`IP`}),(0,V.jsx)(`th`,{className:`pr-4 pb-1 font-medium`,children:`Region`}),(0,V.jsx)(`th`,{className:`pr-4 pb-1 font-medium`,children:`AZ`}),(0,V.jsx)(`th`,{className:`pr-4 pb-1 font-medium`,children:`Uptime`}),(0,V.jsx)(`th`,{className:`pr-4 pb-1 font-medium`,children:`EC2`}),(0,V.jsx)(`th`,{className:`pb-1 font-medium`,children:`Services`})]})}),t[0]=r):r=t[0];let i;t[1]===n?i=t[2]:(i=n.map(G),t[1]=n,t[2]=i);let a;return t[3]===i?a=t[4]:(a=(0,V.jsx)(`div`,{className:`overflow-x-auto`,children:(0,V.jsxs)(`table`,{className:`w-full text-xs`,children:[r,(0,V.jsx)(`tbody`,{children:i})]})}),t[3]=i,t[4]=a),a}function G(e){return(0,V.jsxs)(`tr`,{className:`border-b last:border-0`,children:[(0,V.jsx)(`td`,{className:`py-1.5 pr-4 font-mono font-medium`,children:e.node}),(0,V.jsx)(`td`,{className:`py-1.5 pr-4`,children:(0,V.jsx)(te,{variant:e.status===`Ready`?`default`:`secondary`,className:`text-[0.625rem]`,children:e.status})}),(0,V.jsx)(`td`,{className:`py-1.5 pr-4`,children:(0,V.jsxs)(`div`,{className:`flex gap-1`,children:[e.nats_role&&(0,V.jsxs)(`span`,{className:`text-muted-foreground`,children:[`nats:`,e.nats_role]}),e.predastore_role&&(0,V.jsxs)(`span`,{className:`text-muted-foreground`,children:[`ps:`,e.predastore_role]})]})}),(0,V.jsx)(`td`,{className:`py-1.5 pr-4 font-mono`,children:e.host}),(0,V.jsx)(`td`,{className:`py-1.5 pr-4`,children:e.region}),(0,V.jsx)(`td`,{className:`py-1.5 pr-4`,children:e.az}),(0,V.jsx)(`td`,{className:`py-1.5 pr-4`,children:U(e.uptime)}),(0,V.jsx)(`td`,{className:`py-1.5 pr-4`,children:e.vm_count}),(0,V.jsx)(`td`,{className:`py-1.5`,children:(0,V.jsx)(`span`,{className:`text-muted-foreground`,children:e.services.length})})]},e.node)}var K=5;function q(){let e=(0,z.c)(11),t,n;e[0]===Symbol.for(`react.memo_cache_sentinel`)?(t=(0,V.jsx)(O,{title:`Dashboard`}),n=(0,V.jsx)(W,{}),e[0]=t,e[1]=n):(t=e[0],n=e[1]);let r;e[2]===Symbol.for(`react.memo_cache_sentinel`)?(r=(0,V.jsx)(B.Suspense,{fallback:(0,V.jsx)(J,{}),children:(0,V.jsx)(Q,{})}),e[2]=r):r=e[2];let i;e[3]===Symbol.for(`react.memo_cache_sentinel`)?(i=(0,V.jsx)(B.Suspense,{fallback:(0,V.jsx)(J,{}),children:(0,V.jsx)(me,{})}),e[3]=i):i=e[3];let a;e[4]===Symbol.for(`react.memo_cache_sentinel`)?(a=(0,V.jsx)(B.Suspense,{fallback:(0,V.jsx)(J,{}),children:(0,V.jsx)(ye,{})}),e[4]=a):a=e[4];let o;e[5]===Symbol.for(`react.memo_cache_sentinel`)?(o=(0,V.jsx)(B.Suspense,{fallback:(0,V.jsx)(J,{}),children:(0,V.jsx)(xe,{})}),e[5]=o):o=e[5];let s;e[6]===Symbol.for(`react.memo_cache_sentinel`)?(s=(0,V.jsx)(B.Suspense,{fallback:(0,V.jsx)(J,{}),children:(0,V.jsx)(Ce,{})}),e[6]=s):s=e[6];let c;e[7]===Symbol.for(`react.memo_cache_sentinel`)?(c=(0,V.jsx)(B.Suspense,{fallback:(0,V.jsx)(J,{}),children:(0,V.jsx)(Te,{})}),e[7]=c):c=e[7];let l;e[8]===Symbol.for(`react.memo_cache_sentinel`)?(l=(0,V.jsx)(B.Suspense,{fallback:(0,V.jsx)(J,{}),children:(0,V.jsx)(De,{})}),e[8]=l):l=e[8];let u;e[9]===Symbol.for(`react.memo_cache_sentinel`)?(u=(0,V.jsx)(B.Suspense,{fallback:(0,V.jsx)(J,{}),children:(0,V.jsx)(je,{})}),e[9]=u):u=e[9];let d;return e[10]===Symbol.for(`react.memo_cache_sentinel`)?(d=(0,V.jsxs)(V.Fragment,{children:[t,n,(0,V.jsxs)(`div`,{className:`grid grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-3`,children:[r,i,a,o,s,c,l,u,(0,V.jsx)(B.Suspense,{fallback:(0,V.jsx)(J,{}),children:(0,V.jsx)(ke,{})})]})]}),e[10]=d):d=e[10],d}function J(){let e=(0,z.c)(2),t;e[0]===Symbol.for(`react.memo_cache_sentinel`)?(t=(0,V.jsx)(j,{children:(0,V.jsx)(`div`,{className:`h-4 w-32 animate-pulse rounded bg-muted`})}),e[0]=t):t=e[0];let n;return e[1]===Symbol.for(`react.memo_cache_sentinel`)?(n=(0,V.jsxs)(F,{children:[t,(0,V.jsx)(P,{children:(0,V.jsxs)(`div`,{className:`space-y-2`,children:[(0,V.jsx)(`div`,{className:`h-3 w-48 animate-pulse rounded bg-muted`}),(0,V.jsx)(`div`,{className:`h-3 w-36 animate-pulse rounded bg-muted`})]})})]}),e[1]=n):n=e[1],n}function Y(e){let t=(0,z.c)(16),{title:n,icon:r,count:i,to:o,children:s}=e,c;t[0]!==r||t[1]!==n?(c=(0,V.jsxs)(ne,{className:`flex items-center gap-2`,children:[r,n]}),t[0]=r,t[1]=n,t[2]=c):c=t[2];let l;t[3]===i?l=t[4]:(l=(0,V.jsx)(N,{children:(0,V.jsx)(`span`,{className:`rounded-md bg-muted px-2 py-0.5 text-xs font-semibold tabular-nums`,children:i})}),t[3]=i,t[4]=l);let u;t[5]!==c||t[6]!==l?(u=(0,V.jsxs)(j,{children:[c,l]}),t[5]=c,t[6]=l,t[7]=u):u=t[7];let d;t[8]===s?d=t[9]:(d=(0,V.jsx)(P,{children:s}),t[8]=s,t[9]=d);let f;t[10]===o?f=t[11]:(f=(0,V.jsx)(M,{children:(0,V.jsx)(a,{to:o,className:`text-xs font-medium text-primary hover:underline`,children:`View all →`})}),t[10]=o,t[11]=f);let p;return t[12]!==u||t[13]!==d||t[14]!==f?(p=(0,V.jsxs)(F,{children:[u,d,f]}),t[12]=u,t[13]=d,t[14]=f,t[15]=p):p=t[15],p}function X(e){let t=(0,z.c)(2),{total:n}=e;if(n<=K)return null;let r=n-K,i;return t[0]===r?i=t[1]:(i=(0,V.jsxs)(`p`,{className:`mt-1 text-[0.625rem] text-muted-foreground`,children:[`+`,r,` more`]}),t[0]=r,t[1]=i),i}function Z(e){let t=(0,z.c)(2),{children:n}=e,r;return t[0]===n?r=t[1]:(r=(0,V.jsx)(`div`,{className:`flex items-center justify-between py-0.5 text-xs`,children:n}),t[0]=n,t[1]=r),r}function Q(){let e=(0,z.c)(16),{data:t}=i(b),n,r,a,o,s;if(e[0]!==t?.Reservations){let i=t?.Reservations?.flatMap(pe)??[];n=i.filter(fe),a=i.filter(de),o=i.filter(ue),r=i.filter(le),s=[...a,...r].slice(0,K),e[0]=t?.Reservations,e[1]=n,e[2]=r,e[3]=a,e[4]=o,e[5]=s}else n=e[1],r=e[2],a=e[3],o=e[4],s=e[5];let c=s,u;e[6]===Symbol.for(`react.memo_cache_sentinel`)?(u=(0,V.jsx)(l,{className:`size-3.5`}),e[6]=u):u=e[6];let d;e[7]!==n.length||e[8]!==c||e[9]!==r||e[10]!==a||e[11]!==o?(d=n.length===0?(0,V.jsx)(`p`,{className:`text-xs text-muted-foreground`,children:`No instances`}):(0,V.jsxs)(V.Fragment,{children:[(0,V.jsxs)(`div`,{className:`mb-2 flex gap-3 text-[0.625rem] text-muted-foreground`,children:[(0,V.jsxs)(`span`,{children:[a.length,` running`]}),(0,V.jsxs)(`span`,{children:[o.length,` stopped`]})]}),(0,V.jsx)(`div`,{className:`space-y-0.5`,children:c.map($)}),(0,V.jsx)(X,{total:a.length+r.length})]}),e[7]=n.length,e[8]=c,e[9]=r,e[10]=a,e[11]=o,e[12]=d):d=e[12];let f;return e[13]!==n.length||e[14]!==d?(f=(0,V.jsx)(Y,{title:`Instances`,icon:u,count:n.length,to:`/ec2/describe-instances`,children:d}),e[13]=n.length,e[14]=d,e[15]=f):f=e[15],f}function $(e){return(0,V.jsxs)(Z,{children:[(0,V.jsx)(`span`,{className:`font-mono`,children:e.InstanceId}),(0,V.jsx)(`span`,{className:`text-muted-foreground`,children:e.InstanceType})]},e.InstanceId)}function le(e){return e.State?.Name===`pending`}function ue(e){return e.State?.Name===`stopped`}function de(e){return e.State?.Name===`running`}function fe(e){return e.State?.Name!==`terminated`}function pe(e){return e.Instances??[]}function me(){let e=(0,z.c)(3),{data:t}=i(p),n;if(e[0]!==t?.Volumes){let r=t?.Volumes??[],i=r.reduce(ve,0),a=r.filter(_e),o=r.filter(ge),s=r.slice(0,K),c;e[2]===Symbol.for(`react.memo_cache_sentinel`)?(c=(0,V.jsx)(I,{className:`size-3.5`}),e[2]=c):c=e[2],n=(0,V.jsx)(Y,{title:`Volumes`,icon:c,count:r.length,to:`/ec2/describe-volumes`,children:r.length===0?(0,V.jsx)(`p`,{className:`text-xs text-muted-foreground`,children:`No volumes`}):(0,V.jsxs)(V.Fragment,{children:[(0,V.jsxs)(`div`,{className:`mb-2 flex gap-3 text-[0.625rem] text-muted-foreground`,children:[(0,V.jsxs)(`span`,{children:[i,` GiB total`]}),(0,V.jsxs)(`span`,{children:[a.length,` in-use`]}),(0,V.jsxs)(`span`,{children:[o.length,` available`]})]}),(0,V.jsx)(`div`,{className:`space-y-0.5`,children:s.map(he)}),(0,V.jsx)(X,{total:r.length})]})}),e[0]=t?.Volumes,e[1]=n}else n=e[1];return n}function he(e){return(0,V.jsxs)(Z,{children:[(0,V.jsx)(`span`,{className:`font-mono`,children:e.VolumeId}),(0,V.jsxs)(`span`,{className:`flex items-center gap-2`,children:[(0,V.jsxs)(`span`,{className:`text-muted-foreground`,children:[e.Size,` GiB`]}),(0,V.jsx)(k,{state:e.State})]})]},e.VolumeId)}function ge(e){return e.State===`available`}function _e(e){return e.State===`in-use`}function ve(e,t){return e+(t.Size??0)}function ye(){let e=(0,z.c)(3),{data:t}=i(m),n;if(e[0]!==t?.Vpcs){let r=t?.Vpcs??[],i=r.slice(0,K),a;e[2]===Symbol.for(`react.memo_cache_sentinel`)?(a=(0,V.jsx)(g,{className:`size-3.5`}),e[2]=a):a=e[2],n=(0,V.jsx)(Y,{title:`VPCs`,icon:a,count:r.length,to:`/ec2/describe-vpcs`,children:r.length===0?(0,V.jsx)(`p`,{className:`text-xs text-muted-foreground`,children:`No VPCs`}):(0,V.jsxs)(`div`,{className:`space-y-0.5`,children:[i.map(be),(0,V.jsx)(X,{total:r.length})]})}),e[0]=t?.Vpcs,e[1]=n}else n=e[1];return n}function be(e){let t=o(e.Tags);return(0,V.jsxs)(Z,{children:[(0,V.jsxs)(`span`,{className:`font-mono`,children:[e.VpcId,t&&(0,V.jsxs)(`span`,{className:`ml-1 font-sans text-muted-foreground`,children:[`(`,t,`)`]})]}),(0,V.jsx)(`span`,{className:`text-muted-foreground`,children:e.CidrBlock})]},e.VpcId)}function xe(){let e=(0,z.c)(3),{data:t}=i(y),n;if(e[0]!==t?.KeyPairs){let r=t?.KeyPairs??[],i=r.slice(0,K),a;e[2]===Symbol.for(`react.memo_cache_sentinel`)?(a=(0,V.jsx)(S,{className:`size-3.5`}),e[2]=a):a=e[2],n=(0,V.jsx)(Y,{title:`Key Pairs`,icon:a,count:r.length,to:`/ec2/describe-key-pairs`,children:r.length===0?(0,V.jsx)(`p`,{className:`text-xs text-muted-foreground`,children:`No key pairs`}):(0,V.jsxs)(`div`,{className:`space-y-0.5`,children:[i.map(Se),(0,V.jsx)(X,{total:r.length})]})}),e[0]=t?.KeyPairs,e[1]=n}else n=e[1];return n}function Se(e){return(0,V.jsx)(Z,{children:(0,V.jsx)(`span`,{className:`font-mono`,children:e.KeyName})},e.KeyPairId)}function Ce(){let e=(0,z.c)(3),{data:t}=i(T),n;if(e[0]!==t?.Buckets){let r=t?.Buckets??[],i=r.slice(0,K),a;e[2]===Symbol.for(`react.memo_cache_sentinel`)?(a=(0,V.jsx)(L,{className:`size-3.5`}),e[2]=a):a=e[2],n=(0,V.jsx)(Y,{title:`S3 Buckets`,icon:a,count:r.length,to:`/s3/ls`,children:r.length===0?(0,V.jsx)(`p`,{className:`text-xs text-muted-foreground`,children:`No buckets`}):(0,V.jsxs)(`div`,{className:`space-y-0.5`,children:[i.map(we),(0,V.jsx)(X,{total:r.length})]})}),e[0]=t?.Buckets,e[1]=n}else n=e[1];return n}function we(e){return(0,V.jsx)(Z,{children:(0,V.jsx)(`span`,{className:`font-mono`,children:e.Name})},e.Name)}function Te(){let e=(0,z.c)(3),{data:t}=i(C),n;if(e[0]!==t?.SecurityGroups){let r=t?.SecurityGroups??[],i=r.slice(0,K),a;e[2]===Symbol.for(`react.memo_cache_sentinel`)?(a=(0,V.jsx)(v,{className:`size-3.5`}),e[2]=a):a=e[2],n=(0,V.jsx)(Y,{title:`Security Groups`,icon:a,count:r.length,to:`/ec2/describe-security-groups`,children:r.length===0?(0,V.jsx)(`p`,{className:`text-xs text-muted-foreground`,children:`No security groups`}):(0,V.jsxs)(`div`,{className:`space-y-0.5`,children:[i.map(Ee),(0,V.jsx)(X,{total:r.length})]})}),e[0]=t?.SecurityGroups,e[1]=n}else n=e[1];return n}function Ee(e){return(0,V.jsxs)(Z,{children:[(0,V.jsx)(`span`,{className:`font-mono`,children:e.GroupName}),(0,V.jsx)(`span`,{className:`text-muted-foreground`,children:e.VpcId})]},e.GroupId)}function De(){let e=(0,z.c)(3),{data:t}=i(_),n;if(e[0]!==t?.NatGateways){let r=t?.NatGateways??[],i=r.slice(0,K),a;e[2]===Symbol.for(`react.memo_cache_sentinel`)?(a=(0,V.jsx)(D,{className:`size-3.5`}),e[2]=a):a=e[2],n=(0,V.jsx)(Y,{title:`NAT Gateways`,icon:a,count:r.length,to:`/ec2/describe-nat-gateways`,children:r.length===0?(0,V.jsx)(`p`,{className:`text-xs text-muted-foreground`,children:`No NAT gateways`}):(0,V.jsxs)(`div`,{className:`space-y-0.5`,children:[i.map(Oe),(0,V.jsx)(X,{total:r.length})]})}),e[0]=t?.NatGateways,e[1]=n}else n=e[1];return n}function Oe(e){return(0,V.jsxs)(Z,{children:[(0,V.jsx)(`span`,{className:`font-mono`,children:e.NatGatewayId}),(0,V.jsx)(`span`,{className:`text-muted-foreground`,children:e.State})]},e.NatGatewayId)}function ke(){let e=(0,z.c)(3),{data:t}=i(E),n;if(e[0]!==t?.InternetGateways){let r=t?.InternetGateways??[],i=r.slice(0,K),a;e[2]===Symbol.for(`react.memo_cache_sentinel`)?(a=(0,V.jsx)(d,{className:`size-3.5`}),e[2]=a):a=e[2],n=(0,V.jsx)(Y,{title:`Internet Gateways`,icon:a,count:r.length,to:`/ec2/describe-internet-gateways`,children:r.length===0?(0,V.jsx)(`p`,{className:`text-xs text-muted-foreground`,children:`No internet gateways`}):(0,V.jsxs)(`div`,{className:`space-y-0.5`,children:[i.map(Ae),(0,V.jsx)(X,{total:r.length})]})}),e[0]=t?.InternetGateways,e[1]=n}else n=e[1];return n}function Ae(e){return(0,V.jsxs)(Z,{children:[(0,V.jsx)(`span`,{className:`font-mono`,children:e.InternetGatewayId}),(0,V.jsx)(`span`,{className:`text-muted-foreground`,children:e.Attachments?.[0]?.VpcId?`attached`:`detached`})]},e.InternetGatewayId)}function je(){let e=(0,z.c)(3),{data:t}=i(w),n;if(e[0]!==t?.Addresses){let r=t?.Addresses??[],i=r.slice(0,K),a;e[2]===Symbol.for(`react.memo_cache_sentinel`)?(a=(0,V.jsx)(x,{className:`size-3.5`}),e[2]=a):a=e[2],n=(0,V.jsx)(Y,{title:`Elastic IPs`,icon:a,count:r.length,to:`/ec2/describe-addresses`,children:r.length===0?(0,V.jsx)(`p`,{className:`text-xs text-muted-foreground`,children:`No Elastic IPs`}):(0,V.jsxs)(`div`,{className:`space-y-0.5`,children:[i.map(Me),(0,V.jsx)(X,{total:r.length})]})}),e[0]=t?.Addresses,e[1]=n}else n=e[1];return n}function Me(e){return(0,V.jsxs)(Z,{children:[(0,V.jsx)(`span`,{className:`font-mono`,children:e.PublicIp}),(0,V.jsx)(`span`,{className:`text-muted-foreground`,children:e.AssociationId?`associated`:`available`})]},e.AllocationId)}export{q as component};
\ No newline at end of file
diff --git a/spinifex/services/spinifexui/frontend/dist/assets/_bucket.js b/spinifex/services/spinifexui/frontend/dist/assets/_bucket.js
index 31e1e407c..74c62c4a3 100644
--- a/spinifex/services/spinifexui/frontend/dist/assets/_bucket.js
+++ b/spinifex/services/spinifexui/frontend/dist/assets/_bucket.js
@@ -1 +1 @@
-import{t as e}from"./error-banner.js";import{t}from"./useSuspenseQuery.js";import{t as n}from"./back-link.js";import{n as r,r as i,t as a}from"./upload-button.js";import{r as o}from"./s3.js";import{D as s,Fo as c,Ho as l,oo as u,z as d}from"./index.js";import{t as f}from"./page-heading.js";var p=c(),m=l();function h(){let c=(0,p.c)(31),{bucket:l}=s.useParams(),h;c[0]===l?h=c[1]:(h=d(l),c[0]=l,c[1]=h);let{data:g}=t(h),_=o(),v;c[2]===g.Contents?v=c[3]:(v=g.Contents??[],c[2]=g.Contents,c[3]=v);let y=v,b;c[4]===g.CommonPrefixes?b=c[5]:(b=g.CommonPrefixes??[],c[4]=g.CommonPrefixes,c[5]=b);let x=b,S;c[6]===Symbol.for(`react.memo_cache_sentinel`)?(S=(0,m.jsx)(n,{to:`/s3/ls`,children:`Back to buckets`}),c[6]=S):S=c[6];let C;c[7]!==l||c[8]!==_.isPending||c[9]!==_.mutateAsync?(C=(0,m.jsx)(a,{bucket:l,isPending:_.isPending,onUpload:_.mutateAsync}),c[7]=l,c[8]=_.isPending,c[9]=_.mutateAsync,c[10]=C):C=c[10];let w;c[11]!==l||c[12]!==C?(w=(0,m.jsx)(f,{actions:C,title:l}),c[11]=l,c[12]=C,c[13]=w):w=c[13];let T;c[14]===_.error?T=c[15]:(T=_.error&&(0,m.jsx)(e,{error:_.error,msg:`Failed to upload object`}),c[14]=_.error,c[15]=T);let E;c[16]!==l||c[17]!==x?(E=x.length>0&&(0,m.jsxs)(`div`,{className:`mb-6`,children:[(0,m.jsx)(`h2`,{className:`mb-3 text-lg font-semibold`,children:`Folders`}),(0,m.jsx)(`div`,{className:`space-y-2`,children:x.map(e=>e.Prefix?(0,m.jsx)(i,{bucketName:l,folderName:u(e.Prefix),fullPrefix:e.Prefix},e.Prefix):null)})]}),c[16]=l,c[17]=x,c[18]=E):E=c[18];let D;c[19]!==l||c[20]!==y?(D=y.length>0&&(0,m.jsxs)(`div`,{className:`space-y-2`,children:[(0,m.jsx)(`h2`,{className:`mb-3 text-lg font-semibold`,children:`Objects`}),y.map(e=>e.Key?(0,m.jsx)(r,{bucketName:l,displayName:e.Key,fullKey:e.Key,object:e},e.Key):null)]}),c[19]=l,c[20]=y,c[21]=D):D=c[21];let O;c[22]!==x.length||c[23]!==y.length?(O=y.length===0&&x.length===0&&(0,m.jsx)(`p`,{className:`text-muted-foreground`,children:`No objects found in this bucket.`}),c[22]=x.length,c[23]=y.length,c[24]=O):O=c[24];let k;return c[25]!==w||c[26]!==T||c[27]!==E||c[28]!==D||c[29]!==O?(k=(0,m.jsxs)(m.Fragment,{children:[S,w,T,E,D,O]}),c[25]=w,c[26]=T,c[27]=E,c[28]=D,c[29]=O,c[30]=k):k=c[30],k}export{h as component};
\ No newline at end of file
+import{n as e,t}from"./compiler-runtime.js";import{t as n}from"./error-banner.js";import{t as r}from"./useSuspenseQuery.js";import{j as i}from"./SignatureV4.js";import{t as a}from"./back-link.js";import{n as o,r as s,t as c}from"./upload-button.js";import{r as l}from"./s3.js";import{N as u,ot as d}from"./index.js";import{t as f}from"./page-heading.js";var p=t(),m=e();function h(){let e=(0,p.c)(31),{bucket:t}=u.useParams(),h;e[0]===t?h=e[1]:(h=d(t),e[0]=t,e[1]=h);let{data:g}=r(h),_=l(),v;e[2]===g.Contents?v=e[3]:(v=g.Contents??[],e[2]=g.Contents,e[3]=v);let y=v,b;e[4]===g.CommonPrefixes?b=e[5]:(b=g.CommonPrefixes??[],e[4]=g.CommonPrefixes,e[5]=b);let x=b,S;e[6]===Symbol.for(`react.memo_cache_sentinel`)?(S=(0,m.jsx)(a,{to:`/s3/ls`,children:`Back to buckets`}),e[6]=S):S=e[6];let C;e[7]!==t||e[8]!==_.isPending||e[9]!==_.mutateAsync?(C=(0,m.jsx)(c,{bucket:t,isPending:_.isPending,onUpload:_.mutateAsync}),e[7]=t,e[8]=_.isPending,e[9]=_.mutateAsync,e[10]=C):C=e[10];let w;e[11]!==t||e[12]!==C?(w=(0,m.jsx)(f,{actions:C,title:t}),e[11]=t,e[12]=C,e[13]=w):w=e[13];let T;e[14]===_.error?T=e[15]:(T=_.error&&(0,m.jsx)(n,{error:_.error,msg:`Failed to upload object`}),e[14]=_.error,e[15]=T);let E;e[16]!==t||e[17]!==x?(E=x.length>0&&(0,m.jsxs)(`div`,{className:`mb-6`,children:[(0,m.jsx)(`h2`,{className:`mb-3 text-lg font-semibold`,children:`Folders`}),(0,m.jsx)(`div`,{className:`space-y-2`,children:x.map(e=>e.Prefix?(0,m.jsx)(s,{bucketName:t,folderName:i(e.Prefix),fullPrefix:e.Prefix},e.Prefix):null)})]}),e[16]=t,e[17]=x,e[18]=E):E=e[18];let D;e[19]!==t||e[20]!==y?(D=y.length>0&&(0,m.jsxs)(`div`,{className:`space-y-2`,children:[(0,m.jsx)(`h2`,{className:`mb-3 text-lg font-semibold`,children:`Objects`}),y.map(e=>e.Key?(0,m.jsx)(o,{bucketName:t,displayName:e.Key,fullKey:e.Key,object:e},e.Key):null)]}),e[19]=t,e[20]=y,e[21]=D):D=e[21];let O;e[22]!==x.length||e[23]!==y.length?(O=y.length===0&&x.length===0&&(0,m.jsx)(`p`,{className:`text-muted-foreground`,children:`No objects found in this bucket.`}),e[22]=x.length,e[23]=y.length,e[24]=O):O=e[24];let k;return e[25]!==w||e[26]!==T||e[27]!==E||e[28]!==D||e[29]!==O?(k=(0,m.jsxs)(m.Fragment,{children:[S,w,T,E,D,O]}),e[25]=w,e[26]=T,e[27]=E,e[28]=D,e[29]=O,e[30]=k):k=e[30],k}export{h as component};
\ No newline at end of file
diff --git a/spinifex/services/spinifexui/frontend/dist/assets/_clusterName.js b/spinifex/services/spinifexui/frontend/dist/assets/_clusterName.js
new file mode 100644
index 000000000..553ce05e7
--- /dev/null
+++ b/spinifex/services/spinifexui/frontend/dist/assets/_clusterName.js
@@ -0,0 +1 @@
+import{n as e,o as t,r as n,t as r}from"./compiler-runtime.js";import{t as i}from"./error-banner.js";import{t as a}from"./useQuery.js";import{t as o}from"./useSuspenseQuery.js";import{Or as s}from"./awsClient.js";import{D as c}from"./SignatureV4.js";import{t as l}from"./back-link.js";import{t as u}from"./cli-command-panel.js";import{t as d}from"./trash-2.js";import{t as f}from"./button.js";import{S as p,_ as m,a as h,b as g,d as _,f as v,g as y,h as b,l as x,m as S,n as C,o as w,r as T,s as E,t as D,u as O,v as k,x as A,y as j}from"./eks.js";import{C as M,D as N,E as P,Ir as F,M as I,Nr as L,Nt as R,O as z,Rn as B,T as V,_ as H,j as ee,k as te,rt as ne,w as re}from"./index.js";import{i as U,t as ie}from"./zod.js";import{t as ae}from"./page-heading.js";import{a as W,r as G,t as K}from"./field.js";import{i as oe,l as q,n as se,o as ce,r as le,s as ue,t as J}from"./alert-dialog.js";import{t as de}from"./delete-confirmation-dialog.js";import{n as Y,t as X}from"./detail-row.js";import{t as fe}from"./textarea.js";import{i as pe,n as me,r as he,t as ge}from"./tabs.js";import{t as _e}from"./badge.js";var ve=L(`circle-arrow-up`,[[`circle`,{cx:`12`,cy:`12`,r:`10`,key:`1mglay`}],[`path`,{d:`m16 12-4-4-4 4`,key:`177agl`}],[`path`,{d:`M12 16V8`,key:`1sbj14`}]]),ye=L(`sliders-horizontal`,[[`path`,{d:`M10 5H3`,key:`1qgfaw`}],[`path`,{d:`M12 19H3`,key:`yhmn1j`}],[`path`,{d:`M14 3v4`,key:`1sua03`}],[`path`,{d:`M16 17v4`,key:`1q0r14`}],[`path`,{d:`M21 12h-9`,key:`1o4lsq`}],[`path`,{d:`M21 19h-5`,key:`1rlt1p`}],[`path`,{d:`M21 5h-7`,key:`1oszz2`}],[`path`,{d:`M8 10v4`,key:`tgpxqk`}],[`path`,{d:`M8 12H3`,key:`a7s4jb`}]]),Z=r(),Q=t(n(),1),$=e();function be(e){return e?.split(`/`).pop()??e??``}function xe(e){let t=(0,Z.c)(55),{clusterName:n,principalArn:r,onDelete:i}=e,o;t[0]!==n||t[1]!==r?(o=re(n,r),t[0]=n,t[1]=r,t[2]=o):o=t[2];let{data:s}=a(o),c;t[3]!==n||t[4]!==r?(c=z(n,r),t[3]=n,t[4]=r,t[5]=c):c=t[5];let{data:l}=a(c),u=O(),p=j(),[m,h]=(0,Q.useState)(D[0].arn),g=s?.accessEntry,_;t[6]===l?.associatedAccessPolicies?_=t[7]:(_=l?.associatedAccessPolicies??[],t[6]=l?.associatedAccessPolicies,t[7]=_);let v=_,y;t[8]===r?y=t[9]:(y=(0,$.jsx)(`span`,{className:`truncate font-mono text-xs`,children:r}),t[8]=r,t[9]=y);let b;t[10]!==i||t[11]!==r?(b=()=>i(r),t[10]=i,t[11]=r,t[12]=b):b=t[12];let x;t[13]===Symbol.for(`react.memo_cache_sentinel`)?(x=(0,$.jsx)(d,{className:`size-4`}),t[13]=x):x=t[13];let S;t[14]===b?S=t[15]:(S=(0,$.jsx)(f,{onClick:b,size:`icon`,variant:`ghost`,children:x}),t[14]=b,t[15]=S);let C;t[16]!==y||t[17]!==S?(C=(0,$.jsx)(Y.Header,{children:(0,$.jsxs)(`div`,{className:`flex items-center justify-between gap-2`,children:[y,S]})}),t[16]=y,t[17]=S,t[18]=C):C=t[18];let w=g?.type,T;t[19]===w?T=t[20]:(T=(0,$.jsx)(X,{label:`Type`,value:w}),t[19]=w,t[20]=T);let E;t[21]===g?E=t[22]:(E=g?.kubernetesGroups?.length?g.kubernetesGroups.join(`, `):`—`,t[21]=g,t[22]=E);let k;t[23]===E?k=t[24]:(k=(0,$.jsx)(X,{label:`Kubernetes groups`,value:E}),t[23]=E,t[24]=k);let A;t[25]!==v||t[26]!==n||t[27]!==p||t[28]!==r?(A=v.length>0?(0,$.jsx)(`div`,{className:`space-y-1`,children:v.map(e=>(0,$.jsxs)(`div`,{className:`flex items-center justify-between gap-2`,children:[(0,$.jsx)(`span`,{children:be(e.policyArn)}),(0,$.jsx)(f,{onClick:()=>p.mutate({clusterName:n,principalArn:r,policyArn:e.policyArn??``}),size:`sm`,variant:`ghost`,children:`Remove`})]},e.policyArn))}):`—`,t[25]=v,t[26]=n,t[27]=p,t[28]=r,t[29]=A):A=t[29];let M;t[30]===A?M=t[31]:(M=(0,$.jsx)(X,{label:`Access policies`,value:A}),t[30]=A,t[31]=M);let N;t[32]===Symbol.for(`react.memo_cache_sentinel`)?(N=e=>h(e.target.value),t[32]=N):N=t[32];let P;t[33]===Symbol.for(`react.memo_cache_sentinel`)?(P=D.map(Se),t[33]=P):P=t[33];let F;t[34]===m?F=t[35]:(F=(0,$.jsx)(`select`,{"aria-label":`Access policy`,className:`h-7 flex-1 rounded-md border border-input bg-input/20 px-2 text-sm`,onChange:N,value:m,children:P}),t[34]=m,t[35]=F);let I;t[36]!==u||t[37]!==n||t[38]!==m||t[39]!==r?(I=()=>u.mutate({clusterName:n,principalArn:r,policyArn:m,accessScopeType:`cluster`}),t[36]=u,t[37]=n,t[38]=m,t[39]=r,t[40]=I):I=t[40];let L;t[41]!==u.isPending||t[42]!==I?(L=(0,$.jsx)(f,{disabled:u.isPending,onClick:I,size:`sm`,children:`Associate`}),t[41]=u.isPending,t[42]=I,t[43]=L):L=t[43];let R;t[44]!==F||t[45]!==L?(R=(0,$.jsxs)(`div`,{className:`flex items-center gap-2 pt-2`,children:[F,L]}),t[44]=F,t[45]=L,t[46]=R):R=t[46];let B;t[47]!==T||t[48]!==k||t[49]!==M||t[50]!==R?(B=(0,$.jsxs)(Y.Content,{children:[T,k,M,R]}),t[47]=T,t[48]=k,t[49]=M,t[50]=R,t[51]=B):B=t[51];let V;return t[52]!==B||t[53]!==C?(V=(0,$.jsxs)(Y,{children:[C,B]}),t[52]=B,t[53]=C,t[54]=V):V=t[54],V}function Se(e){return(0,$.jsx)(`option`,{value:e.arn,children:e.name},e.arn)}function Ce(e){let t=(0,Z.c)(41),{clusterName:n,open:r,onOpenChange:i}=e,a=_(),o;t[0]===Symbol.for(`react.memo_cache_sentinel`)?(o={resolver:ie(w),defaultValues:{principalArn:``,kubernetesGroups:``}},t[0]=o):o=t[0];let{formState:s,handleSubmit:c,register:l,reset:u}=U(o),{errors:d}=s,f;t[1]!==n||t[2]!==a||t[3]!==i||t[4]!==u?(f=async e=>{await a.mutateAsync({clusterName:n,principalArn:e.principalArn,kubernetesGroups:e.kubernetesGroups.split(`,`).map(we).filter(Boolean)}),u(),i(!1)},t[1]=n,t[2]=a,t[3]=i,t[4]=u,t[5]=f):f=t[5];let p=f,m;t[6]===Symbol.for(`react.memo_cache_sentinel`)?(m=(0,$.jsx)(ue,{children:(0,$.jsx)(q,{children:`Add access entry`})}),t[6]=m):m=t[6];let h;t[7]===Symbol.for(`react.memo_cache_sentinel`)?(h=(0,$.jsx)(W,{children:(0,$.jsx)(`label`,{htmlFor:`ae-principal`,children:`IAM principal ARN`})}),t[7]=h):h=t[7];let g=!!d.principalArn,v;t[8]===l?v=t[9]:(v=l(`principalArn`),t[8]=l,t[9]=v);let y;t[10]!==g||t[11]!==v?(y=(0,$.jsx)(B,{"aria-invalid":g,id:`ae-principal`,placeholder:`arn:aws:iam::000000000000:role/my-role`,...v}),t[10]=g,t[11]=v,t[12]=y):y=t[12];let b;t[13]===d.principalArn?b=t[14]:(b=(0,$.jsx)(G,{errors:[d.principalArn]}),t[13]=d.principalArn,t[14]=b);let x;t[15]!==y||t[16]!==b?(x=(0,$.jsxs)(K,{children:[h,y,b]}),t[15]=y,t[16]=b,t[17]=x):x=t[17];let S;t[18]===Symbol.for(`react.memo_cache_sentinel`)?(S=(0,$.jsx)(W,{children:(0,$.jsx)(`label`,{htmlFor:`ae-groups`,children:`Kubernetes groups`})}),t[18]=S):S=t[18];let C;t[19]===l?C=t[20]:(C=l(`kubernetesGroups`),t[19]=l,t[20]=C);let T;t[21]===C?T=t[22]:(T=(0,$.jsxs)(K,{children:[S,(0,$.jsx)(B,{id:`ae-groups`,placeholder:`system:masters`,...C})]}),t[21]=C,t[22]=T);let E;t[23]!==x||t[24]!==T?(E=(0,$.jsxs)(`form`,{className:`space-y-4`,id:`add-access-entry-form`,children:[x,T]}),t[23]=x,t[24]=T,t[25]=E):E=t[25];let D;t[26]===Symbol.for(`react.memo_cache_sentinel`)?(D=(0,$.jsx)(le,{children:`Cancel`}),t[26]=D):D=t[26];let O;t[27]!==c||t[28]!==p?(O=e=>{e.preventDefault(),c(p)()},t[27]=c,t[28]=p,t[29]=O):O=t[29];let k=a.isPending?`Creating…`:`Create`,A;t[30]!==a.isPending||t[31]!==O||t[32]!==k?(A=(0,$.jsxs)(ce,{children:[D,(0,$.jsx)(se,{disabled:a.isPending,onClick:O,children:k})]}),t[30]=a.isPending,t[31]=O,t[32]=k,t[33]=A):A=t[33];let j;t[34]!==E||t[35]!==A?(j=(0,$.jsxs)(oe,{children:[m,E,A]}),t[34]=E,t[35]=A,t[36]=j):j=t[36];let M;return t[37]!==i||t[38]!==r||t[39]!==j?(M=(0,$.jsx)(J,{onOpenChange:i,open:r,children:j}),t[37]=i,t[38]=r,t[39]=j,t[40]=M):M=t[40],M}function we(e){return e.trim()}function Te(e){let t=(0,Z.c)(25),{clusterName:n}=e,r;t[0]===n?r=t[1]:(r=M(n),t[0]=n,t[1]=r);let{data:i}=o(r),a=b(),[s,c]=(0,Q.useState)(!1),[l,u]=(0,Q.useState)(),d;t[2]===i.accessEntries?d=t[3]:(d=i.accessEntries??[],t[2]=i.accessEntries,t[3]=d);let p=d,m;t[4]===Symbol.for(`react.memo_cache_sentinel`)?(m=(0,$.jsx)(`div`,{className:`mb-4 flex justify-end`,children:(0,$.jsx)(f,{onClick:()=>c(!0),size:`sm`,children:`Add access entry`})}),t[4]=m):m=t[4];let h;t[5]!==n||t[6]!==p?(h=p.length>0?(0,$.jsx)(`div`,{className:`space-y-4`,children:p.map(e=>(0,$.jsx)(xe,{clusterName:n,onDelete:u,principalArn:e},e))}):(0,$.jsx)(`p`,{className:`text-muted-foreground`,children:`No access entries.`}),t[5]=n,t[6]=p,t[7]=h):h=t[7];let g;t[8]!==n||t[9]!==s?(g=(0,$.jsx)(Ce,{clusterName:n,onOpenChange:c,open:s}),t[8]=n,t[9]=s,t[10]=g):g=t[10];let _=`Delete access entry for "${l??``}"?`,v;t[11]!==n||t[12]!==a||t[13]!==l?(v=()=>{l&&a.mutate({clusterName:n,principalArn:l},{onSuccess:()=>u(void 0)})},t[11]=n,t[12]=a,t[13]=l,t[14]=v):v=t[14];let y;t[15]===Symbol.for(`react.memo_cache_sentinel`)?(y=e=>!e&&u(void 0),t[15]=y):y=t[15];let x=l!==void 0,S;t[16]!==a.isPending||t[17]!==_||t[18]!==v||t[19]!==x?(S=(0,$.jsx)(de,{description:_,isPending:a.isPending,onConfirm:v,onOpenChange:y,open:x,title:`Delete access entry`}),t[16]=a.isPending,t[17]=_,t[18]=v,t[19]=x,t[20]=S):S=t[20];let C;return t[21]!==S||t[22]!==h||t[23]!==g?(C=(0,$.jsxs)($.Fragment,{children:[m,h,g,S]}),t[21]=S,t[22]=h,t[23]=g,t[24]=C):C=t[24],C}var Ee=new Set([`CREATING`,`UPDATING`,`DELETING`]),De=5e3,Oe=`h-7 w-full rounded-md border border-input bg-input/20 px-2 text-sm`;function ke(e){if(e.trim()===``)return!0;try{return JSON.parse(e),!0}catch{return!1}}function Ae({clusterName:e,catalog:t,installed:n,open:r,onOpenChange:i}){let a=v(),o=t.filter(e=>!n.includes(e.addonName??``)),{formState:{errors:s},handleSubmit:c,register:l,reset:u,setValue:d,watch:f}=U({resolver:ie(E),defaultValues:{addonName:``,addonVersion:``,serviceAccountRoleArn:``,configurationValues:``}}),p=f(`addonName`),m=f(`addonVersion`),h=t.find(e=>e.addonName===p)?.addonVersions??[],g=h.find(e=>e.addonVersion===m)?.requiresIamPermissions??!1,_=e=>{let n=t.find(t=>t.addonName===e)?.addonVersions?.[0]?.addonVersion??``;d(`addonName`,e,{shouldValidate:!0}),d(`addonVersion`,n,{shouldValidate:!0})},y=async t=>{await a.mutateAsync({clusterName:e,addonName:t.addonName,addonVersion:t.addonVersion||void 0,serviceAccountRoleArn:t.serviceAccountRoleArn.trim()||void 0,configurationValues:t.configurationValues.trim()||void 0}),u(),i(!1)};return(0,$.jsx)(J,{onOpenChange:i,open:r,children:(0,$.jsxs)(oe,{className:`max-h-[85vh] overflow-y-auto`,children:[(0,$.jsx)(ue,{children:(0,$.jsx)(q,{children:`Add add-on`})}),(0,$.jsxs)(`form`,{className:`space-y-4`,id:`add-addon-form`,children:[(0,$.jsxs)(K,{children:[(0,$.jsx)(W,{children:(0,$.jsx)(`label`,{htmlFor:`addon-name`,children:`Add-on`})}),(0,$.jsxs)(`select`,{"aria-invalid":!!s.addonName,className:Oe,id:`addon-name`,onChange:e=>_(e.target.value),value:p,children:[(0,$.jsx)(`option`,{value:``,children:`Select add-on`}),o.map(e=>(0,$.jsx)(`option`,{value:e.addonName??``,children:e.addonName},e.addonName))]}),(0,$.jsx)(G,{errors:[s.addonName]})]}),(0,$.jsxs)(K,{children:[(0,$.jsx)(W,{children:(0,$.jsx)(`label`,{htmlFor:`addon-version`,children:`Version`})}),(0,$.jsx)(`select`,{"aria-invalid":!!s.addonVersion,className:Oe,disabled:h.length===0,id:`addon-version`,onChange:e=>d(`addonVersion`,e.target.value,{shouldValidate:!0}),value:m,children:h.map((e,t)=>(0,$.jsxs)(`option`,{value:e.addonVersion??``,children:[e.addonVersion,t===0?` (latest)`:``]},e.addonVersion))}),(0,$.jsx)(G,{errors:[s.addonVersion]})]}),(0,$.jsxs)(K,{children:[(0,$.jsx)(W,{children:(0,$.jsx)(`label`,{htmlFor:`addon-role`,children:`Service account IAM role ARN`})}),(0,$.jsx)(B,{id:`addon-role`,placeholder:`arn:aws:iam::…:role/…`,...l(`serviceAccountRoleArn`)}),g&&(0,$.jsx)(`p`,{className:`text-xs text-muted-foreground`,children:`This add-on needs IAM permissions — provide an IRSA role ARN.`})]}),(0,$.jsxs)(K,{children:[(0,$.jsx)(W,{children:(0,$.jsx)(`label`,{htmlFor:`addon-config`,children:`Configuration values (JSON)`})}),(0,$.jsx)(fe,{"aria-invalid":!!s.configurationValues,id:`addon-config`,placeholder:`{"replicaCount": 2}`,rows:4,...l(`configurationValues`)}),(0,$.jsx)(G,{errors:[s.configurationValues]})]})]}),(0,$.jsxs)(ce,{children:[(0,$.jsx)(le,{children:`Cancel`}),(0,$.jsx)(se,{disabled:a.isPending,onClick:e=>{e.preventDefault(),c(y)()},children:a.isPending?`Adding…`:`Add`})]})]})})}function je(e){let t=(0,Z.c)(54),{clusterName:n,addonName:r,current:i,versions:a,open:o,onOpenChange:s}=e,c=A(),[l,u]=(0,Q.useState)(i.addonVersion??``),[d,f]=(0,Q.useState)(i.serviceAccountRoleArn??``),[p,m]=(0,Q.useState)(i.configurationValues??``),[h,g]=(0,Q.useState)(),_,v;t[0]!==i.addonVersion||t[1]!==i.configurationValues||t[2]!==i.serviceAccountRoleArn||t[3]!==o?(_=()=>{o&&(u(i.addonVersion??``),f(i.serviceAccountRoleArn??``),m(i.configurationValues??``),g(void 0))},v=[o,i.addonVersion,i.serviceAccountRoleArn,i.configurationValues],t[0]=i.addonVersion,t[1]=i.configurationValues,t[2]=i.serviceAccountRoleArn,t[3]=o,t[4]=_,t[5]=v):(_=t[4],v=t[5]),(0,Q.useEffect)(_,v);let y;t[6]!==r||t[7]!==n||t[8]!==p||t[9]!==s||t[10]!==d||t[11]!==c||t[12]!==l?(y=()=>{if(!ke(p)){g(`Configuration must be valid JSON`);return}c.mutate({clusterName:n,addonName:r,addonVersion:l||void 0,serviceAccountRoleArn:d.trim()||void 0,configurationValues:p.trim()||void 0},{onSuccess:()=>s(!1)})},t[6]=r,t[7]=n,t[8]=p,t[9]=s,t[10]=d,t[11]=c,t[12]=l,t[13]=y):y=t[13];let b=y,x;t[14]===r?x=t[15]:(x=(0,$.jsx)(ue,{children:(0,$.jsxs)(q,{children:[`Update `,r]})}),t[14]=r,t[15]=x);let S;t[16]===Symbol.for(`react.memo_cache_sentinel`)?(S=(0,$.jsx)(W,{children:(0,$.jsx)(`label`,{htmlFor:`update-addon-version`,children:`Version`})}),t[16]=S):S=t[16];let C=a.length===0,w;t[17]===Symbol.for(`react.memo_cache_sentinel`)?(w=e=>u(e.target.value),t[17]=w):w=t[17];let T;t[18]===a?T=t[19]:(T=a.map(Me),t[18]=a,t[19]=T);let E;t[20]!==C||t[21]!==T||t[22]!==l?(E=(0,$.jsxs)(K,{children:[S,(0,$.jsx)(`select`,{className:Oe,disabled:C,id:`update-addon-version`,onChange:w,value:l,children:T})]}),t[20]=C,t[21]=T,t[22]=l,t[23]=E):E=t[23];let D;t[24]===Symbol.for(`react.memo_cache_sentinel`)?(D=(0,$.jsx)(W,{children:(0,$.jsx)(`label`,{htmlFor:`update-addon-role`,children:`Service account IAM role ARN`})}),t[24]=D):D=t[24];let O;t[25]===Symbol.for(`react.memo_cache_sentinel`)?(O=e=>f(e.target.value),t[25]=O):O=t[25];let k;t[26]===d?k=t[27]:(k=(0,$.jsxs)(K,{children:[D,(0,$.jsx)(B,{id:`update-addon-role`,onChange:O,placeholder:`arn:aws:iam::…:role/…`,value:d})]}),t[26]=d,t[27]=k);let j;t[28]===Symbol.for(`react.memo_cache_sentinel`)?(j=(0,$.jsx)(W,{children:(0,$.jsx)(`label`,{htmlFor:`update-addon-config`,children:`Configuration values (JSON)`})}),t[28]=j):j=t[28];let M;t[29]===Symbol.for(`react.memo_cache_sentinel`)?(M=e=>m(e.target.value),t[29]=M):M=t[29];let N;t[30]===p?N=t[31]:(N=(0,$.jsxs)(K,{children:[j,(0,$.jsx)(fe,{id:`update-addon-config`,onChange:M,rows:4,value:p})]}),t[30]=p,t[31]=N);let P;t[32]!==k||t[33]!==N||t[34]!==E?(P=(0,$.jsxs)(`div`,{className:`space-y-4`,children:[E,k,N]}),t[32]=k,t[33]=N,t[34]=E,t[35]=P):P=t[35];let F;t[36]===h?F=t[37]:(F=h&&(0,$.jsx)(`p`,{className:`mt-2 text-xs text-destructive`,children:h}),t[36]=h,t[37]=F);let I;t[38]===Symbol.for(`react.memo_cache_sentinel`)?(I=(0,$.jsx)(le,{children:`Cancel`}),t[38]=I):I=t[38];let L;t[39]===b?L=t[40]:(L=e=>{e.preventDefault(),b()},t[39]=b,t[40]=L);let R=c.isPending?`Updating…`:`Update`,z;t[41]!==L||t[42]!==R||t[43]!==c.isPending?(z=(0,$.jsxs)(ce,{children:[I,(0,$.jsx)(se,{disabled:c.isPending,onClick:L,children:R})]}),t[41]=L,t[42]=R,t[43]=c.isPending,t[44]=z):z=t[44];let V;t[45]!==P||t[46]!==F||t[47]!==z||t[48]!==x?(V=(0,$.jsxs)(oe,{className:`max-h-[85vh] overflow-y-auto`,children:[x,P,F,z]}),t[45]=P,t[46]=F,t[47]=z,t[48]=x,t[49]=V):V=t[49];let H;return t[50]!==s||t[51]!==o||t[52]!==V?(H=(0,$.jsx)(J,{onOpenChange:s,open:o,children:V}),t[50]=s,t[51]=o,t[52]=V,t[53]=H):H=t[53],H}function Me(e){return(0,$.jsx)(`option`,{value:e,children:e},e)}function Ne(e){let t=(0,Z.c)(53),{clusterName:n,addonName:r,catalog:i,onDelete:o}=e,s;t[0]!==r||t[1]!==n?(s=V(n,r),t[0]=r,t[1]=n,t[2]=s):s=t[2];let c;t[3]===s?c=t[4]:(c={...s,refetchInterval:Fe},t[3]=s,t[4]=c);let{data:l}=a(c),[u,p]=(0,Q.useState)(!1),m=l?.addon,g=m?.health?.issues?.[0]?.message,_;t[5]!==r||t[6]!==i?(_=i.find(e=>e.addonName===r)?.addonVersions?.map(Pe).filter(Boolean)??[],t[5]=r,t[6]=i,t[7]=_):_=t[7];let v=_,y;t[8]===r?y=t[9]:(y=(0,$.jsx)(`span`,{children:r}),t[8]=r,t[9]=y);let b=m?.status,x;t[10]===b?x=t[11]:(x=h(b),t[10]=b,t[11]=x);let S=m?.status??`UNKNOWN`,C;t[12]!==x||t[13]!==S?(C=(0,$.jsx)(_e,{variant:x,children:S}),t[12]=x,t[13]=S,t[14]=C):C=t[14];let w;t[15]===Symbol.for(`react.memo_cache_sentinel`)?(w=()=>p(!0),t[15]=w):w=t[15];let T;t[16]===Symbol.for(`react.memo_cache_sentinel`)?(T=(0,$.jsx)(f,{"aria-label":`Update add-on`,onClick:w,size:`icon`,variant:`ghost`,children:(0,$.jsx)(ve,{className:`size-4`})}),t[16]=T):T=t[16];let E;t[17]!==r||t[18]!==o?(E=()=>o(r),t[17]=r,t[18]=o,t[19]=E):E=t[19];let D;t[20]===Symbol.for(`react.memo_cache_sentinel`)?(D=(0,$.jsx)(d,{className:`size-4`}),t[20]=D):D=t[20];let O;t[21]===E?O=t[22]:(O=(0,$.jsx)(f,{"aria-label":`Remove add-on`,onClick:E,size:`icon`,variant:`ghost`,children:D}),t[21]=E,t[22]=O);let k;t[23]!==O||t[24]!==C?(k=(0,$.jsxs)(`div`,{className:`flex items-center gap-1`,children:[C,T,O]}),t[23]=O,t[24]=C,t[25]=k):k=t[25];let A;t[26]!==k||t[27]!==y?(A=(0,$.jsx)(Y.Header,{children:(0,$.jsxs)(`div`,{className:`flex items-center justify-between`,children:[y,k]})}),t[26]=k,t[27]=y,t[28]=A):A=t[28];let j=m?.addonVersion,M;t[29]===j?M=t[30]:(M=(0,$.jsx)(X,{label:`Version`,value:j}),t[29]=j,t[30]=M);let N=m?.serviceAccountRoleArn,P;t[31]===N?P=t[32]:(P=(0,$.jsx)(X,{label:`IRSA role`,value:N}),t[31]=N,t[32]=P);let F;t[33]===g?F=t[34]:(F=g&&(0,$.jsx)(X,{label:`Issue`,value:(0,$.jsx)(`span`,{className:`text-destructive`,children:g})}),t[33]=g,t[34]=F);let I;t[35]!==M||t[36]!==P||t[37]!==F?(I=(0,$.jsxs)(Y.Content,{children:[M,P,F]}),t[35]=M,t[36]=P,t[37]=F,t[38]=I):I=t[38];let L=m?.addonVersion,R=m?.serviceAccountRoleArn,z=m?.configurationValues,B;t[39]!==L||t[40]!==R||t[41]!==z?(B={addonVersion:L,serviceAccountRoleArn:R,configurationValues:z},t[39]=L,t[40]=R,t[41]=z,t[42]=B):B=t[42];let H;t[43]!==r||t[44]!==n||t[45]!==u||t[46]!==B||t[47]!==v?(H=(0,$.jsx)(je,{addonName:r,clusterName:n,current:B,onOpenChange:p,open:u,versions:v}),t[43]=r,t[44]=n,t[45]=u,t[46]=B,t[47]=v,t[48]=H):H=t[48];let ee;return t[49]!==A||t[50]!==I||t[51]!==H?(ee=(0,$.jsxs)(Y,{children:[A,I,H]}),t[49]=A,t[50]=I,t[51]=H,t[52]=ee):ee=t[52],ee}function Pe(e){return e.addonVersion??``}function Fe(e){return Ee.has(e.state.data?.addon?.status??``)?De:!1}function Ie(e){let t=(0,Z.c)(31),{clusterName:n}=e,r;t[0]===n?r=t[1]:(r=N(n),t[0]=n,t[1]=r);let{data:i}=o(r),{data:s}=a(P),c=y(),[l,u]=(0,Q.useState)(!1),[d,p]=(0,Q.useState)(),m;t[2]===i.addons?m=t[3]:(m=i.addons??[],t[2]=i.addons,t[3]=m);let h=m,g;t[4]===s?.addons?g=t[5]:(g=s?.addons??[],t[4]=s?.addons,t[5]=g);let _=g,v;t[6]===Symbol.for(`react.memo_cache_sentinel`)?(v=(0,$.jsx)(`p`,{className:`text-xs text-muted-foreground`,children:`Add-on manifests are staged; activation completes when the cluster reports the workload ready.`}),t[6]=v):v=t[6];let b;t[7]===Symbol.for(`react.memo_cache_sentinel`)?(b=(0,$.jsxs)(`div`,{className:`mb-4 flex items-center justify-between gap-4`,children:[v,(0,$.jsx)(f,{onClick:()=>u(!0),size:`sm`,children:`Add add-on`})]}),t[7]=b):b=t[7];let x;t[8]!==h||t[9]!==_||t[10]!==n?(x=h.length>0?(0,$.jsx)(`div`,{className:`space-y-4`,children:h.map(e=>(0,$.jsx)(Ne,{addonName:e,catalog:_,clusterName:n,onDelete:p},e))}):(0,$.jsx)(`p`,{className:`text-muted-foreground`,children:`No add-ons installed.`}),t[8]=h,t[9]=_,t[10]=n,t[11]=x):x=t[11];let S;t[12]!==h||t[13]!==_||t[14]!==n||t[15]!==l?(S=(0,$.jsx)(Ae,{catalog:_,clusterName:n,installed:h,onOpenChange:u,open:l}),t[12]=h,t[13]=_,t[14]=n,t[15]=l,t[16]=S):S=t[16];let C=`Remove add-on "${d??``}" from the cluster?`,w;t[17]!==n||t[18]!==c||t[19]!==d?(w=()=>{d&&c.mutate({clusterName:n,addonName:d},{onSuccess:()=>p(void 0)})},t[17]=n,t[18]=c,t[19]=d,t[20]=w):w=t[20];let T;t[21]===Symbol.for(`react.memo_cache_sentinel`)?(T=e=>!e&&p(void 0),t[21]=T):T=t[21];let E=d!==void 0,D;t[22]!==c.isPending||t[23]!==E||t[24]!==C||t[25]!==w?(D=(0,$.jsx)(de,{confirmLabel:`Remove`,description:C,isPending:c.isPending,onConfirm:w,onOpenChange:T,open:E,pendingLabel:`Removing…`,title:`Remove add-on`}),t[22]=c.isPending,t[23]=E,t[24]=C,t[25]=w,t[26]=D):D=t[26];let O;return t[27]!==D||t[28]!==x||t[29]!==S?(O=(0,$.jsxs)($.Fragment,{children:[b,x,S,D]}),t[27]=D,t[28]=x,t[29]=S,t[30]=O):O=t[30],O}function Le(e){let t=(0,Z.c)(6),{to:n,id:r}=e,i;t[0]===r?i=t[1]:(i={id:r},t[0]=r,t[1]=i);let a;return t[2]!==r||t[3]!==i||t[4]!==n?(a=(0,$.jsx)(s,{className:`font-mono text-primary underline-offset-2 hover:underline`,params:i,to:n,children:r}),t[2]=r,t[3]=i,t[4]=n,t[5]=a):a=t[5],a}function Re(e){let t=(0,Z.c)(45),{cluster:n}=e,r=n?.resourcesVpcConfig,i;t[0]===r?.subnetIds?i=t[1]:(i=r?.subnetIds??[],t[0]=r?.subnetIds,t[1]=i);let a=i,o;t[2]===r?.securityGroupIds?o=t[3]:(o=r?.securityGroupIds??[],t[2]=r?.securityGroupIds,t[3]=o);let s=o,c;t[4]===r?.publicAccessCidrs?c=t[5]:(c=r?.publicAccessCidrs??[],t[4]=r?.publicAccessCidrs,t[5]=c);let l=c,u;t[6]===Symbol.for(`react.memo_cache_sentinel`)?(u=(0,$.jsx)(Y.Header,{children:`VPC`}),t[6]=u):u=t[6];let d;t[7]===r?d=t[8]:(d=r?.vpcId?(0,$.jsx)(Le,{id:r.vpcId,to:`/ec2/describe-vpcs/$id`}):void 0,t[7]=r,t[8]=d);let f;t[9]===d?f=t[10]:(f=(0,$.jsx)(X,{label:`VPC`,value:d}),t[9]=d,t[10]=f);let p=n?.endpoint,m;t[11]===p?m=t[12]:(m=(0,$.jsx)(X,{label:`API server endpoint`,value:p}),t[11]=p,t[12]=m);let h=r?.endpointPublicAccess?`Enabled`:`Disabled`,g;t[13]===h?g=t[14]:(g=(0,$.jsx)(X,{label:`Public access`,value:h}),t[13]=h,t[14]=g);let _=r?.endpointPrivateAccess?`Enabled`:`Disabled`,v;t[15]===_?v=t[16]:(v=(0,$.jsx)(X,{label:`Private access`,value:_}),t[15]=_,t[16]=v);let y;t[17]!==l||t[18]!==r?.endpointPublicAccess?(y=r?.endpointPublicAccess&&(0,$.jsx)(X,{label:`Public access CIDRs`,value:l.length>0?(0,$.jsx)(`div`,{className:`space-y-1 font-mono text-xs`,children:l.map(Ve)}):`Any (0.0.0.0/0)`}),t[17]=l,t[18]=r?.endpointPublicAccess,t[19]=y):y=t[19];let b;t[20]!==g||t[21]!==v||t[22]!==y||t[23]!==f||t[24]!==m?(b=(0,$.jsxs)(Y,{children:[u,(0,$.jsxs)(Y.Content,{children:[f,m,g,v,y]})]}),t[20]=g,t[21]=v,t[22]=y,t[23]=f,t[24]=m,t[25]=b):b=t[25];let x;t[26]===Symbol.for(`react.memo_cache_sentinel`)?(x=(0,$.jsx)(Y.Header,{children:`Subnets`}),t[26]=x):x=t[26];let S;t[27]===a?S=t[28]:(S=(0,$.jsxs)(Y,{children:[x,(0,$.jsx)(Y.Content,{children:a.length>0?(0,$.jsx)(`div`,{className:`space-y-1`,children:a.map(Be)}):(0,$.jsx)(`p`,{className:`text-muted-foreground`,children:`No subnets.`})})]}),t[27]=a,t[28]=S);let C;t[29]===Symbol.for(`react.memo_cache_sentinel`)?(C=(0,$.jsx)(Y.Header,{children:`Security groups`}),t[29]=C):C=t[29];let w;t[30]===r?w=t[31]:(w=r?.clusterSecurityGroupId?(0,$.jsx)(Le,{id:r.clusterSecurityGroupId,to:`/ec2/describe-security-groups/$id`}):void 0,t[30]=r,t[31]=w);let T;t[32]===w?T=t[33]:(T=(0,$.jsx)(X,{label:`Cluster security group`,value:w}),t[32]=w,t[33]=T);let E;t[34]===s?E=t[35]:(E=s.length>0?(0,$.jsx)(`div`,{className:`space-y-1`,children:s.map(ze)}):`—`,t[34]=s,t[35]=E);let D;t[36]===E?D=t[37]:(D=(0,$.jsx)(X,{label:`Additional security groups`,value:E}),t[36]=E,t[37]=D);let O;t[38]!==T||t[39]!==D?(O=(0,$.jsxs)(Y,{children:[C,(0,$.jsxs)(Y.Content,{children:[T,D]})]}),t[38]=T,t[39]=D,t[40]=O):O=t[40];let k;return t[41]!==b||t[42]!==S||t[43]!==O?(k=(0,$.jsxs)($.Fragment,{children:[b,S,O]}),t[41]=b,t[42]=S,t[43]=O,t[44]=k):k=t[44],k}function ze(e){return(0,$.jsx)(Le,{id:e,to:`/ec2/describe-security-groups/$id`},e)}function Be(e){return(0,$.jsx)(Le,{id:e,to:`/ec2/describe-subnets/$id`},e)}function Ve(e){return(0,$.jsx)(`div`,{children:e},e)}function He(e){let t=(0,Z.c)(50),{clusterName:n,nodegroupName:r,current:i,open:a,onOpenChange:o}=e,s=g(),[c,l]=(0,Q.useState)(i.minSize??0),[u,d]=(0,Q.useState)(i.desiredSize??0),[f,p]=(0,Q.useState)(i.maxSize??0),[m,h]=(0,Q.useState)(),_,v;t[0]!==i.desiredSize||t[1]!==i.maxSize||t[2]!==i.minSize||t[3]!==a?(_=()=>{a&&(l(i.minSize??0),d(i.desiredSize??0),p(i.maxSize??0),h(void 0))},v=[a,i.minSize,i.desiredSize,i.maxSize],t[0]=i.desiredSize,t[1]=i.maxSize,t[2]=i.minSize,t[3]=a,t[4]=_,t[5]=v):(_=t[4],v=t[5]),(0,Q.useEffect)(_,v);let y;t[6]!==n||t[7]!==u||t[8]!==f||t[9]!==c||t[10]!==r||t[11]!==o||t[12]!==s?(y=()=>{if(!(c<=u&&u<=f)){h(`Sizes must satisfy min ≤ desired ≤ max`);return}s.mutate({clusterName:n,nodegroupName:r,minSize:c,desiredSize:u,maxSize:f},{onSuccess:()=>o(!1)})},t[6]=n,t[7]=u,t[8]=f,t[9]=c,t[10]=r,t[11]=o,t[12]=s,t[13]=y):y=t[13];let b=y,x;t[14]===r?x=t[15]:(x=(0,$.jsx)(ue,{children:(0,$.jsxs)(q,{children:[`Scale `,r]})}),t[14]=r,t[15]=x);let S;t[16]===Symbol.for(`react.memo_cache_sentinel`)?(S=(0,$.jsx)(W,{children:(0,$.jsx)(`label`,{htmlFor:`scale-min`,children:`Min`})}),t[16]=S):S=t[16];let C;t[17]===Symbol.for(`react.memo_cache_sentinel`)?(C=e=>l(Number(e.target.value)),t[17]=C):C=t[17];let w;t[18]===c?w=t[19]:(w=(0,$.jsxs)(K,{children:[S,(0,$.jsx)(B,{id:`scale-min`,onChange:C,type:`number`,value:c})]}),t[18]=c,t[19]=w);let T;t[20]===Symbol.for(`react.memo_cache_sentinel`)?(T=(0,$.jsx)(W,{children:(0,$.jsx)(`label`,{htmlFor:`scale-desired`,children:`Desired`})}),t[20]=T):T=t[20];let E;t[21]===Symbol.for(`react.memo_cache_sentinel`)?(E=e=>d(Number(e.target.value)),t[21]=E):E=t[21];let D;t[22]===u?D=t[23]:(D=(0,$.jsxs)(K,{children:[T,(0,$.jsx)(B,{id:`scale-desired`,onChange:E,type:`number`,value:u})]}),t[22]=u,t[23]=D);let O;t[24]===Symbol.for(`react.memo_cache_sentinel`)?(O=(0,$.jsx)(W,{children:(0,$.jsx)(`label`,{htmlFor:`scale-max`,children:`Max`})}),t[24]=O):O=t[24];let k;t[25]===Symbol.for(`react.memo_cache_sentinel`)?(k=e=>p(Number(e.target.value)),t[25]=k):k=t[25];let A;t[26]===f?A=t[27]:(A=(0,$.jsxs)(K,{children:[O,(0,$.jsx)(B,{id:`scale-max`,onChange:k,type:`number`,value:f})]}),t[26]=f,t[27]=A);let j;t[28]!==D||t[29]!==A||t[30]!==w?(j=(0,$.jsxs)(`div`,{className:`grid grid-cols-3 gap-3`,children:[w,D,A]}),t[28]=D,t[29]=A,t[30]=w,t[31]=j):j=t[31];let M;t[32]===m?M=t[33]:(M=m&&(0,$.jsx)(`p`,{className:`mt-2 text-xs text-destructive`,children:m}),t[32]=m,t[33]=M);let N;t[34]===Symbol.for(`react.memo_cache_sentinel`)?(N=(0,$.jsx)(le,{children:`Cancel`}),t[34]=N):N=t[34];let P;t[35]===b?P=t[36]:(P=e=>{e.preventDefault(),b()},t[35]=b,t[36]=P);let F=s.isPending?`Scaling…`:`Scale`,I;t[37]!==s.isPending||t[38]!==P||t[39]!==F?(I=(0,$.jsxs)(ce,{children:[N,(0,$.jsx)(se,{disabled:s.isPending,onClick:P,children:F})]}),t[37]=s.isPending,t[38]=P,t[39]=F,t[40]=I):I=t[40];let L;t[41]!==j||t[42]!==M||t[43]!==I||t[44]!==x?(L=(0,$.jsxs)(oe,{children:[x,j,M,I]}),t[41]=j,t[42]=M,t[43]=I,t[44]=x,t[45]=L):L=t[45];let R;return t[46]!==o||t[47]!==a||t[48]!==L?(R=(0,$.jsx)(J,{onOpenChange:o,open:a,children:L}),t[46]=o,t[47]=a,t[48]=L,t[49]=R):R=t[49],R}function Ue(e){let t=(0,Z.c)(64),{clusterName:n,clusterVersion:r,nodegroupName:i,onDelete:o}=e,s;t[0]!==n||t[1]!==i?(s=ee(n,i),t[0]=n,t[1]=i,t[2]=s):s=t[2];let{data:c}=a(s),l=p(),u=c?.nodegroup,m=u?.scalingConfig,[h,g]=(0,Q.useState)(!1),[_,v]=(0,Q.useState)(!1),y=!!r&&!!u?.version&&u.version!==r,b;t[3]===i?b=t[4]:(b=(0,$.jsx)(`span`,{children:i}),t[3]=i,t[4]=b);let x;t[5]===y?x=t[6]:(x=y&&(0,$.jsx)(f,{"aria-label":`Update node group version`,onClick:()=>v(!0),size:`icon`,variant:`ghost`,children:(0,$.jsx)(ve,{className:`size-4`})}),t[5]=y,t[6]=x);let S;t[7]===Symbol.for(`react.memo_cache_sentinel`)?(S=()=>g(!0),t[7]=S):S=t[7];let C;t[8]===Symbol.for(`react.memo_cache_sentinel`)?(C=(0,$.jsx)(f,{"aria-label":`Scale node group`,onClick:S,size:`icon`,variant:`ghost`,children:(0,$.jsx)(ye,{className:`size-4`})}),t[8]=C):C=t[8];let w;t[9]!==i||t[10]!==o?(w=()=>o(i),t[9]=i,t[10]=o,t[11]=w):w=t[11];let T;t[12]===Symbol.for(`react.memo_cache_sentinel`)?(T=(0,$.jsx)(d,{className:`size-4`}),t[12]=T):T=t[12];let E;t[13]===w?E=t[14]:(E=(0,$.jsx)(f,{"aria-label":`Delete node group`,onClick:w,size:`icon`,variant:`ghost`,children:T}),t[13]=w,t[14]=E);let D;t[15]!==x||t[16]!==E?(D=(0,$.jsxs)(`div`,{className:`flex items-center gap-1`,children:[x,C,E]}),t[15]=x,t[16]=E,t[17]=D):D=t[17];let O;t[18]!==b||t[19]!==D?(O=(0,$.jsx)(Y.Header,{children:(0,$.jsxs)(`div`,{className:`flex items-center justify-between`,children:[b,D]})}),t[18]=b,t[19]=D,t[20]=O):O=t[20];let k=u?.status,A;t[21]===k?A=t[22]:(A=(0,$.jsx)(X,{label:`Status`,value:k}),t[21]=k,t[22]=A);let j=y?`${u?.version} (cluster on ${r})`:u?.version,M;t[23]===j?M=t[24]:(M=(0,$.jsx)(X,{label:`Kubernetes version`,value:j}),t[23]=j,t[24]=M);let N;t[25]===u?.instanceTypes?N=t[26]:(N=u?.instanceTypes?.join(`, `),t[25]=u?.instanceTypes,t[26]=N);let P;t[27]===N?P=t[28]:(P=(0,$.jsx)(X,{label:`Instance types`,value:N}),t[27]=N,t[28]=P);let F=u?.amiType,I;t[29]===F?I=t[30]:(I=(0,$.jsx)(X,{label:`AMI type`,value:F}),t[29]=F,t[30]=I);let L=u?.capacityType,R;t[31]===L?R=t[32]:(R=(0,$.jsx)(X,{label:`Capacity type`,value:L}),t[31]=L,t[32]=R);let z=m?`${m.minSize} / ${m.desiredSize} / ${m.maxSize}`:void 0,B;t[33]===z?B=t[34]:(B=(0,$.jsx)(X,{label:`Scaling (min / desired / max)`,value:z}),t[33]=z,t[34]=B);let V;t[35]!==A||t[36]!==M||t[37]!==P||t[38]!==I||t[39]!==R||t[40]!==B?(V=(0,$.jsxs)(Y.Content,{children:[A,M,P,I,R,B]}),t[35]=A,t[36]=M,t[37]=P,t[38]=I,t[39]=R,t[40]=B,t[41]=V):V=t[41];let H;t[42]===m?H=t[43]:(H=m??{},t[42]=m,t[43]=H);let te;t[44]!==n||t[45]!==i||t[46]!==h||t[47]!==H?(te=(0,$.jsx)(He,{clusterName:n,current:H,nodegroupName:i,onOpenChange:g,open:h}),t[44]=n,t[45]=i,t[46]=h,t[47]=H,t[48]=te):te=t[48];let ne=`Update node group "${i}" from ${u?.version} to ${r}? Nodes are replaced in a rolling update.`,re;t[49]!==n||t[50]!==r||t[51]!==i||t[52]!==l?(re=()=>l.mutate({clusterName:n,nodegroupName:i,version:r},{onSuccess:()=>v(!1)}),t[49]=n,t[50]=r,t[51]=i,t[52]=l,t[53]=re):re=t[53];let U;t[54]!==_||t[55]!==ne||t[56]!==re||t[57]!==l.isPending?(U=(0,$.jsx)(de,{confirmLabel:`Update`,description:ne,isPending:l.isPending,onConfirm:re,onOpenChange:v,open:_,title:`Update node group version`}),t[54]=_,t[55]=ne,t[56]=re,t[57]=l.isPending,t[58]=U):U=t[58];let ie;return t[59]!==O||t[60]!==V||t[61]!==te||t[62]!==U?(ie=(0,$.jsxs)(Y,{children:[O,V,te,U]}),t[59]=O,t[60]=V,t[61]=te,t[62]=U,t[63]=ie):ie=t[63],ie}function We({clusterName:e,vpcId:t,open:n,onOpenChange:r}){let{data:i}=a(ne),{data:o}=a(R),s=S(),c=i?.Roles??[],l=(o?.Subnets??[]).filter(e=>e.VpcId===t),{formState:{errors:u},handleSubmit:d,register:f,reset:p,setValue:m,watch:h}=U({resolver:ie(x),defaultValues:{nodegroupName:``,nodeRole:``,subnetIds:[],instanceTypes:`t3.medium`,amiType:`AL2_x86_64`,capacityType:`ON_DEMAND`,diskSize:20,minSize:1,desiredSize:2,maxSize:3}}),g=h(`subnetIds`),_=e=>{m(`subnetIds`,g.includes(e)?g.filter(t=>t!==e):[...g,e],{shouldValidate:!0})},v=async t=>{await s.mutateAsync({clusterName:e,nodegroupName:t.nodegroupName,nodeRole:t.nodeRole,subnetIds:t.subnetIds,instanceTypes:t.instanceTypes.split(`,`).map(e=>e.trim()).filter(Boolean),amiType:t.amiType,capacityType:t.capacityType,diskSize:t.diskSize,minSize:t.minSize,desiredSize:t.desiredSize,maxSize:t.maxSize}),p(),r(!1)};return(0,$.jsx)(J,{onOpenChange:r,open:n,children:(0,$.jsxs)(oe,{className:`max-h-[85vh] overflow-y-auto`,children:[(0,$.jsx)(ue,{children:(0,$.jsx)(q,{children:`Add node group`})}),(0,$.jsxs)(`form`,{className:`space-y-4`,id:`add-nodegroup-form`,children:[(0,$.jsxs)(K,{children:[(0,$.jsx)(W,{children:(0,$.jsx)(`label`,{htmlFor:`ng-name`,children:`Name`})}),(0,$.jsx)(B,{"aria-invalid":!!u.nodegroupName,id:`ng-name`,placeholder:`my-nodegroup`,...f(`nodegroupName`)}),(0,$.jsx)(G,{errors:[u.nodegroupName]})]}),(0,$.jsxs)(K,{children:[(0,$.jsx)(W,{children:(0,$.jsx)(`label`,{htmlFor:`ng-role`,children:`Node IAM role`})}),(0,$.jsxs)(`select`,{"aria-invalid":!!u.nodeRole,className:`h-7 w-full rounded-md border border-input bg-input/20 px-2 text-sm`,id:`ng-role`,...f(`nodeRole`),children:[(0,$.jsx)(`option`,{value:``,children:`Select role`}),c.map(e=>(0,$.jsx)(`option`,{value:e.Arn??``,children:e.RoleName},e.Arn))]}),(0,$.jsx)(G,{errors:[u.nodeRole]})]}),(0,$.jsxs)(K,{children:[(0,$.jsx)(W,{children:`Subnets`}),l.length===0?(0,$.jsx)(`p`,{className:`text-xs text-muted-foreground`,children:`No subnets in the cluster VPC.`}):(0,$.jsx)(`div`,{className:`space-y-1`,children:l.map(e=>(0,$.jsxs)(`label`,{className:`flex items-center gap-2 text-xs`,children:[(0,$.jsx)(`input`,{"aria-label":`Subnet ${e.SubnetId}`,checked:g.includes(e.SubnetId??``),onChange:()=>_(e.SubnetId??``),type:`checkbox`}),(0,$.jsxs)(`span`,{className:`font-mono`,children:[e.SubnetId,` · `,e.CidrBlock]})]},e.SubnetId))}),(0,$.jsx)(G,{errors:[u.subnetIds]})]}),(0,$.jsxs)(K,{children:[(0,$.jsx)(W,{children:(0,$.jsx)(`label`,{htmlFor:`ng-instance-types`,children:`Instance types`})}),(0,$.jsx)(B,{id:`ng-instance-types`,placeholder:`t3.medium, t3.large`,...f(`instanceTypes`)}),(0,$.jsx)(G,{errors:[u.instanceTypes]})]}),(0,$.jsxs)(`div`,{className:`grid grid-cols-2 gap-3`,children:[(0,$.jsxs)(K,{children:[(0,$.jsx)(W,{children:(0,$.jsx)(`label`,{htmlFor:`ng-ami`,children:`AMI type`})}),(0,$.jsx)(`select`,{className:`h-7 w-full rounded-md border border-input bg-input/20 px-2 text-sm`,id:`ng-ami`,...f(`amiType`),children:C.map(e=>(0,$.jsx)(`option`,{value:e,children:e},e))})]}),(0,$.jsxs)(K,{children:[(0,$.jsx)(W,{children:(0,$.jsx)(`label`,{htmlFor:`ng-capacity`,children:`Capacity type`})}),(0,$.jsx)(`select`,{className:`h-7 w-full rounded-md border border-input bg-input/20 px-2 text-sm`,id:`ng-capacity`,...f(`capacityType`),children:T.map(e=>(0,$.jsx)(`option`,{value:e,children:e},e))})]})]}),(0,$.jsxs)(`div`,{className:`grid grid-cols-4 gap-3`,children:[(0,$.jsxs)(K,{children:[(0,$.jsx)(W,{children:(0,$.jsx)(`label`,{htmlFor:`ng-disk`,children:`Disk (GB)`})}),(0,$.jsx)(B,{id:`ng-disk`,type:`number`,...f(`diskSize`,{valueAsNumber:!0})})]}),(0,$.jsxs)(K,{children:[(0,$.jsx)(W,{children:(0,$.jsx)(`label`,{htmlFor:`ng-min`,children:`Min`})}),(0,$.jsx)(B,{id:`ng-min`,type:`number`,...f(`minSize`,{valueAsNumber:!0})})]}),(0,$.jsxs)(K,{children:[(0,$.jsx)(W,{children:(0,$.jsx)(`label`,{htmlFor:`ng-desired`,children:`Desired`})}),(0,$.jsx)(B,{id:`ng-desired`,type:`number`,...f(`desiredSize`,{valueAsNumber:!0})})]}),(0,$.jsxs)(K,{children:[(0,$.jsx)(W,{children:(0,$.jsx)(`label`,{htmlFor:`ng-max`,children:`Max`})}),(0,$.jsx)(B,{id:`ng-max`,type:`number`,...f(`maxSize`,{valueAsNumber:!0})})]})]}),(0,$.jsx)(G,{errors:[u.desiredSize]})]}),(0,$.jsxs)(ce,{children:[(0,$.jsx)(le,{children:`Cancel`}),(0,$.jsx)(se,{disabled:s.isPending,onClick:e=>{e.preventDefault(),d(v)()},children:s.isPending?`Creating…`:`Create`})]})]})})}function Ge(e){let t=(0,Z.c)(27),{clusterName:n,clusterVersion:r,vpcId:i}=e,a;t[0]===n?a=t[1]:(a=I(n),t[0]=n,t[1]=a);let{data:s}=o(a),c=k(),[l,u]=(0,Q.useState)(!1),[d,p]=(0,Q.useState)(),m;t[2]===s.nodegroups?m=t[3]:(m=s.nodegroups??[],t[2]=s.nodegroups,t[3]=m);let h=m,g;t[4]===Symbol.for(`react.memo_cache_sentinel`)?(g=(0,$.jsx)(`div`,{className:`mb-4 flex justify-end`,children:(0,$.jsx)(f,{onClick:()=>u(!0),size:`sm`,children:`Add node group`})}),t[4]=g):g=t[4];let _;t[5]!==n||t[6]!==r||t[7]!==h?(_=h.length>0?(0,$.jsx)(`div`,{className:`space-y-4`,children:h.map(e=>(0,$.jsx)(Ue,{clusterName:n,clusterVersion:r,nodegroupName:e,onDelete:p},e))}):(0,$.jsx)(`p`,{className:`text-muted-foreground`,children:`No node groups.`}),t[5]=n,t[6]=r,t[7]=h,t[8]=_):_=t[8];let v;t[9]!==n||t[10]!==l||t[11]!==i?(v=(0,$.jsx)(We,{clusterName:n,onOpenChange:u,open:l,vpcId:i}),t[9]=n,t[10]=l,t[11]=i,t[12]=v):v=t[12];let y=`Delete node group "${d??``}"? This terminates its nodes.`,b;t[13]!==n||t[14]!==c||t[15]!==d?(b=()=>{d&&c.mutate({clusterName:n,nodegroupName:d},{onSuccess:()=>p(void 0)})},t[13]=n,t[14]=c,t[15]=d,t[16]=b):b=t[16];let x;t[17]===Symbol.for(`react.memo_cache_sentinel`)?(x=e=>!e&&p(void 0),t[17]=x):x=t[17];let S=d!==void 0,C;t[18]!==c.isPending||t[19]!==y||t[20]!==b||t[21]!==S?(C=(0,$.jsx)(de,{description:y,isPending:c.isPending,onConfirm:b,onOpenChange:x,open:S,title:`Delete node group`}),t[18]=c.isPending,t[19]=y,t[20]=b,t[21]=S,t[22]=C):C=t[22];let w;return t[23]!==C||t[24]!==_||t[25]!==v?(w=(0,$.jsxs)($.Fragment,{children:[g,_,v,C]}),t[23]=C,t[24]=_,t[25]=v,t[26]=w):w=t[26],w}var Ke=`ap-southeast-2`;function qe(e){switch(e??``){case`ACTIVE`:return`default`;case`FAILED`:return`destructive`;default:return`secondary`}}function Je(e){let t=(0,Z.c)(89),{clusterName:n}=e,r=F(),[a,s]=(0,Q.useState)(!1),p;t[0]===n?p=t[1]:(p=te(n),t[0]=n,t[1]=p);let{data:h}=o(p),g=m(),_=h.cluster,v;t[2]===Symbol.for(`react.memo_cache_sentinel`)?(v={type:`bin`,value:`aws eks update-kubeconfig`},t[2]=v):v=t[2];let y;t[3]===Symbol.for(`react.memo_cache_sentinel`)?(y={type:`flag`,value:` --name `},t[3]=y):y=t[3];let b;t[4]===n?b=t[5]:(b={type:`value`,value:n},t[4]=n,t[5]=b);let x;t[6]===Symbol.for(`react.memo_cache_sentinel`)?(x={type:`flag`,value:` --region `},t[6]=x):x=t[6];let S;t[7]===Symbol.for(`react.memo_cache_sentinel`)?(S={type:`value`,value:Ke},t[7]=S):S=t[7];let C;t[8]===b?C=t[9]:(C={label:`Configure kubectl`,parts:[v,y,b,x,S]},t[8]=b,t[9]=C);let w;t[10]===Symbol.for(`react.memo_cache_sentinel`)?(w={label:`Verify access`,parts:[{type:`bin`,value:`kubectl get nodes`}]},t[10]=w):w=t[10];let T;t[11]===C?T=t[12]:(T=[C,w],t[11]=C,t[12]=T);let E=T,D;t[13]!==n||t[14]!==g||t[15]!==r?(D=function(){g.mutate(n,{onSuccess:()=>{r({to:`/eks/list-clusters`})}})},t[13]=n,t[14]=g,t[15]=r,t[16]=D):D=t[16];let O=D,k;t[17]===Symbol.for(`react.memo_cache_sentinel`)?(k=(0,$.jsx)(l,{to:`/eks/list-clusters`,children:`Clusters`}),t[17]=k):k=t[17];let A;t[18]===Symbol.for(`react.memo_cache_sentinel`)?(A=()=>s(!0),t[18]=A):A=t[18];let j;t[19]===Symbol.for(`react.memo_cache_sentinel`)?(j=(0,$.jsxs)(f,{onClick:A,size:`sm`,variant:`destructive`,children:[(0,$.jsx)(d,{className:`size-4`}),`Delete`]}),t[19]=j):j=t[19];let M=_?.status,N;t[20]===M?N=t[21]:(N=qe(M),t[20]=M,t[21]=N);let P=_?.status??`UNKNOWN`,I;t[22]!==N||t[23]!==P?(I=(0,$.jsxs)(`div`,{className:`flex items-center gap-2`,children:[j,(0,$.jsx)(_e,{variant:N,children:P})]}),t[22]=N,t[23]=P,t[24]=I):I=t[24];let L;t[25]!==n||t[26]!==I?(L=(0,$.jsx)(ae,{actions:I,subtitle:`Cluster Details`,title:n}),t[25]=n,t[26]=I,t[27]=L):L=t[27];let R;t[28]!==g.error||t[29]!==g.isError?(R=g.isError&&(0,$.jsx)(i,{error:g.error instanceof Error?g.error:void 0,msg:`Failed to delete cluster.`}),t[28]=g.error,t[29]=g.isError,t[30]=R):R=t[30];let z;t[31]===Symbol.for(`react.memo_cache_sentinel`)?(z=(0,$.jsxs)(me,{children:[(0,$.jsx)(pe,{value:`overview`,children:`Overview`}),(0,$.jsx)(pe,{value:`compute`,children:`Compute`}),(0,$.jsx)(pe,{value:`addons`,children:`Add-ons`}),(0,$.jsx)(pe,{value:`access`,children:`Access`}),(0,$.jsx)(pe,{value:`networking`,children:`Networking`}),(0,$.jsx)(pe,{value:`connect`,children:`Connect`})]}),t[31]=z):z=t[31];let B;t[32]===Symbol.for(`react.memo_cache_sentinel`)?(B=(0,$.jsx)(Y.Header,{children:`Cluster`}),t[32]=B):B=t[32];let V=_?.arn,H;t[33]===V?H=t[34]:(H=(0,$.jsx)(X,{label:`ARN`,value:V}),t[33]=V,t[34]=H);let ee=_?.status,ne;t[35]===ee?ne=t[36]:(ne=(0,$.jsx)(X,{label:`Status`,value:ee}),t[35]=ee,t[36]=ne);let re=_?.version,U;t[37]===re?U=t[38]:(U=(0,$.jsx)(X,{label:`Kubernetes version`,value:re}),t[37]=re,t[38]=U);let ie=_?.platformVersion,W;t[39]===ie?W=t[40]:(W=(0,$.jsx)(X,{label:`Platform version`,value:ie}),t[39]=ie,t[40]=W);let G=_?.endpoint,K;t[41]===G?K=t[42]:(K=(0,$.jsx)(X,{label:`Endpoint`,value:G}),t[41]=G,t[42]=K);let oe=_?.identity?.oidc?.issuer,q;t[43]===oe?q=t[44]:(q=(0,$.jsx)(X,{label:`OIDC issuer`,value:oe}),t[43]=oe,t[44]=q);let se=_?.resourcesVpcConfig?.vpcId,ce;t[45]===se?ce=t[46]:(ce=(0,$.jsx)(X,{label:`VPC`,value:se}),t[45]=se,t[46]=ce);let le=_?.createdAt,ue;t[47]===le?ue=t[48]:(ue=c(le),t[47]=le,t[48]=ue);let J;t[49]===ue?J=t[50]:(J=(0,$.jsx)(X,{label:`Created at`,value:ue}),t[49]=ue,t[50]=J);let fe;t[51]!==H||t[52]!==ne||t[53]!==U||t[54]!==W||t[55]!==K||t[56]!==q||t[57]!==ce||t[58]!==J?(fe=(0,$.jsx)(he,{value:`overview`,children:(0,$.jsxs)(Y,{children:[B,(0,$.jsxs)(Y.Content,{children:[H,ne,U,W,K,q,ce,J]})]})}),t[51]=H,t[52]=ne,t[53]=U,t[54]=W,t[55]=K,t[56]=q,t[57]=ce,t[58]=J,t[59]=fe):fe=t[59];let ve=_?.version,ye=_?.resourcesVpcConfig?.vpcId,be;t[60]!==n||t[61]!==ve||t[62]!==ye?(be=(0,$.jsx)(he,{value:`compute`,children:(0,$.jsx)(Ge,{clusterName:n,clusterVersion:ve,vpcId:ye})}),t[60]=n,t[61]=ve,t[62]=ye,t[63]=be):be=t[63];let xe;t[64]===n?xe=t[65]:(xe=(0,$.jsx)(he,{value:`addons`,children:(0,$.jsx)(Ie,{clusterName:n})}),t[64]=n,t[65]=xe);let Se;t[66]===n?Se=t[67]:(Se=(0,$.jsx)(he,{value:`access`,children:(0,$.jsx)(Te,{clusterName:n})}),t[66]=n,t[67]=Se);let Ce;t[68]===_?Ce=t[69]:(Ce=(0,$.jsx)(he,{value:`networking`,children:(0,$.jsx)(Re,{cluster:_})}),t[68]=_,t[69]=Ce);let we;t[70]===E?we=t[71]:(we=(0,$.jsx)(he,{value:`connect`,children:(0,$.jsx)(u,{commands:E})}),t[70]=E,t[71]=we);let Ee;t[72]!==fe||t[73]!==be||t[74]!==xe||t[75]!==Se||t[76]!==Ce||t[77]!==we?(Ee=(0,$.jsxs)(ge,{defaultValue:`overview`,children:[z,fe,be,xe,Se,Ce,we]}),t[72]=fe,t[73]=be,t[74]=xe,t[75]=Se,t[76]=Ce,t[77]=we,t[78]=Ee):Ee=t[78];let De=`This permanently deletes cluster "${n}" and its control plane. This cannot be undone.`,Oe;t[79]!==g.isPending||t[80]!==O||t[81]!==a||t[82]!==De?(Oe=(0,$.jsx)(de,{description:De,isPending:g.isPending,onConfirm:O,onOpenChange:s,open:a,title:`Delete cluster`}),t[79]=g.isPending,t[80]=O,t[81]=a,t[82]=De,t[83]=Oe):Oe=t[83];let ke;return t[84]!==L||t[85]!==R||t[86]!==Ee||t[87]!==Oe?(ke=(0,$.jsxs)($.Fragment,{children:[k,L,R,Ee,Oe]}),t[84]=L,t[85]=R,t[86]=Ee,t[87]=Oe,t[88]=ke):ke=t[88],ke}function Ye(){let e=(0,Z.c)(2),{clusterName:t}=H.useParams(),n;return e[0]===t?n=e[1]:(n=(0,$.jsx)(Je,{clusterName:t}),e[0]=t,e[1]=n),n}export{Ye as component};
\ No newline at end of file
diff --git a/spinifex/services/spinifexui/frontend/dist/assets/_id.js b/spinifex/services/spinifexui/frontend/dist/assets/_id.js
index aa4930931..cc9c1f8c0 100644
--- a/spinifex/services/spinifexui/frontend/dist/assets/_id.js
+++ b/spinifex/services/spinifexui/frontend/dist/assets/_id.js
@@ -1 +1 @@
-import{t as e}from"./error-banner.js";import{t}from"./useSuspenseQuery.js";import{t as n}from"./back-link.js";import{c as r,i,n as a,s as o,t as s}from"./select.js";import{t as c}from"./trash-2.js";import{I as l,U as u,n as d}from"./ec2.js";import{Ho as f,J as p,Ro as m,Uo as h,V as g,Xa as _,ao as v,cs as y,t as b}from"./index.js";import{t as x}from"./page-heading.js";import{t as S}from"./delete-confirmation-dialog.js";import{n as C,t as w}from"./detail-row.js";var T=y(h()),E=f();function D(){let{id:f}=b.useParams(),h=m(),{data:y}=t(g(f)),{data:D}=t(p),O=d(),k=l(),A=u(),[j,M]=(0,T.useState)(!1),[N,P]=(0,T.useState)(``),F=y.Addresses?.[0],I=D.Reservations?.flatMap(e=>e.Instances??[]).filter(e=>e.State?.Name===`running`||e.State?.Name===`stopped`)??[],L=async()=>{try{await A.mutateAsync(f),h({to:`/ec2/describe-addresses`})}finally{M(!1)}},R=async()=>{N&&await O.mutateAsync({allocationId:f,instanceId:N})},z=async()=>{F?.AssociationId&&await k.mutateAsync(F.AssociationId)};if(!F?.AllocationId)return(0,E.jsxs)(E.Fragment,{children:[(0,E.jsx)(n,{to:`/ec2/describe-addresses`,children:`Back to Elastic IPs`}),(0,E.jsx)(`p`,{className:`text-muted-foreground`,children:`Elastic IP not found.`})]});let B=!!F.AssociationId,V=v(F.Tags);return(0,E.jsxs)(E.Fragment,{children:[(0,E.jsx)(n,{to:`/ec2/describe-addresses`,children:`Back to Elastic IPs`}),(A.error??O.error??k.error)&&(0,E.jsx)(e,{error:A.error??O.error??k.error??void 0,msg:`Elastic IP action failed`}),(0,E.jsxs)(`div`,{className:`space-y-6`,children:[(0,E.jsx)(x,{actions:(0,E.jsxs)(_,{disabled:B||A.isPending,onClick:()=>M(!0),size:`sm`,variant:`destructive`,children:[(0,E.jsx)(c,{className:`size-4`}),`Release`]}),subtitle:`Elastic IP Details`,title:V?`${F.AllocationId} (${V})`:F.AllocationId}),(0,E.jsxs)(C,{children:[(0,E.jsx)(C.Header,{children:`Elastic IP Information`}),(0,E.jsxs)(C.Content,{children:[(0,E.jsx)(w,{label:`Allocation ID`,value:F.AllocationId}),(0,E.jsx)(w,{label:`Public IP`,value:F.PublicIp}),(0,E.jsx)(w,{label:`Domain`,value:F.Domain}),(0,E.jsx)(w,{label:`Association ID`,value:F.AssociationId??`—`}),(0,E.jsx)(w,{label:`Instance ID`,value:F.InstanceId??`—`}),(0,E.jsx)(w,{label:`Private IP`,value:F.PrivateIpAddress??`—`})]})]}),(0,E.jsxs)(C,{children:[(0,E.jsx)(C.Header,{children:`Association`}),(0,E.jsx)(C.Content,{children:B?(0,E.jsx)(_,{disabled:k.isPending,onClick:z,size:`sm`,variant:`outline`,children:`Disassociate`}):(0,E.jsxs)(`div`,{className:`flex items-end gap-2`,children:[(0,E.jsx)(`div`,{className:`flex-1`,children:(0,E.jsxs)(s,{onValueChange:e=>P(e??``),value:N,children:[(0,E.jsx)(o,{className:`w-full`,children:(0,E.jsx)(r,{placeholder:`Select an instance`})}),(0,E.jsx)(a,{children:I.map(e=>{let t=v(e.Tags);return(0,E.jsx)(i,{value:e.InstanceId??``,children:t?`${e.InstanceId} (${t})`:e.InstanceId},e.InstanceId)})})]})}),(0,E.jsx)(_,{disabled:!N||O.isPending,onClick:R,size:`sm`,children:`Associate`})]})})]})]}),(0,E.jsx)(S,{description:`Are you sure you want to release the Elastic IP "${F.AllocationId}" (${F.PublicIp})? This action cannot be undone.`,isPending:A.isPending,onConfirm:L,onOpenChange:M,open:j,title:`Release Elastic IP`})]})}export{D as component};
\ No newline at end of file
+import{n as e,o as t,r as n}from"./compiler-runtime.js";import{t as r}from"./error-banner.js";import{t as i}from"./useSuspenseQuery.js";import{A as a}from"./SignatureV4.js";import{t as o}from"./back-link.js";import{c as s,i as c,n as l,s as u,t as d}from"./select.js";import{t as f}from"./trash-2.js";import{t as p}from"./button.js";import{G as m,L as h,n as g}from"./ec2.js";import{Ir as _,ct as v,gt as y,t as b}from"./index.js";import{t as x}from"./page-heading.js";import{t as S}from"./delete-confirmation-dialog.js";import{n as C,t as w}from"./detail-row.js";var T=t(n()),E=e();function D(){let{id:e}=b.useParams(),t=_(),{data:n}=i(v(e)),{data:D}=i(y),O=g(),k=h(),A=m(),[j,M]=(0,T.useState)(!1),[N,P]=(0,T.useState)(``),F=n.Addresses?.[0],I=D.Reservations?.flatMap(e=>e.Instances??[]).filter(e=>e.State?.Name===`running`||e.State?.Name===`stopped`)??[],L=async()=>{try{await A.mutateAsync(e),t({to:`/ec2/describe-addresses`})}finally{M(!1)}},R=async()=>{N&&await O.mutateAsync({allocationId:e,instanceId:N})},z=async()=>{F?.AssociationId&&await k.mutateAsync(F.AssociationId)};if(!F?.AllocationId)return(0,E.jsxs)(E.Fragment,{children:[(0,E.jsx)(o,{to:`/ec2/describe-addresses`,children:`Back to Elastic IPs`}),(0,E.jsx)(`p`,{className:`text-muted-foreground`,children:`Elastic IP not found.`})]});let B=!!F.AssociationId,V=a(F.Tags);return(0,E.jsxs)(E.Fragment,{children:[(0,E.jsx)(o,{to:`/ec2/describe-addresses`,children:`Back to Elastic IPs`}),(A.error??O.error??k.error)&&(0,E.jsx)(r,{error:A.error??O.error??k.error??void 0,msg:`Elastic IP action failed`}),(0,E.jsxs)(`div`,{className:`space-y-6`,children:[(0,E.jsx)(x,{actions:(0,E.jsxs)(p,{disabled:B||A.isPending,onClick:()=>M(!0),size:`sm`,variant:`destructive`,children:[(0,E.jsx)(f,{className:`size-4`}),`Release`]}),subtitle:`Elastic IP Details`,title:V?`${F.AllocationId} (${V})`:F.AllocationId}),(0,E.jsxs)(C,{children:[(0,E.jsx)(C.Header,{children:`Elastic IP Information`}),(0,E.jsxs)(C.Content,{children:[(0,E.jsx)(w,{label:`Allocation ID`,value:F.AllocationId}),(0,E.jsx)(w,{label:`Public IP`,value:F.PublicIp}),(0,E.jsx)(w,{label:`Domain`,value:F.Domain}),(0,E.jsx)(w,{label:`Association ID`,value:F.AssociationId??`—`}),(0,E.jsx)(w,{label:`Instance ID`,value:F.InstanceId??`—`}),(0,E.jsx)(w,{label:`Private IP`,value:F.PrivateIpAddress??`—`})]})]}),(0,E.jsxs)(C,{children:[(0,E.jsx)(C.Header,{children:`Association`}),(0,E.jsx)(C.Content,{children:B?(0,E.jsx)(p,{disabled:k.isPending,onClick:z,size:`sm`,variant:`outline`,children:`Disassociate`}):(0,E.jsxs)(`div`,{className:`flex items-end gap-2`,children:[(0,E.jsx)(`div`,{className:`flex-1`,children:(0,E.jsxs)(d,{onValueChange:e=>P(e??``),value:N,children:[(0,E.jsx)(u,{className:`w-full`,children:(0,E.jsx)(s,{placeholder:`Select an instance`})}),(0,E.jsx)(l,{children:I.map(e=>{let t=a(e.Tags);return(0,E.jsx)(c,{value:e.InstanceId??``,children:t?`${e.InstanceId} (${t})`:e.InstanceId},e.InstanceId)})})]})}),(0,E.jsx)(p,{disabled:!N||O.isPending,onClick:R,size:`sm`,children:`Associate`})]})})]})]}),(0,E.jsx)(S,{description:`Are you sure you want to release the Elastic IP "${F.AllocationId}" (${F.PublicIp})? This action cannot be undone.`,isPending:A.isPending,onConfirm:L,onOpenChange:M,open:j,title:`Release Elastic IP`})]})}export{D as component};
\ No newline at end of file
diff --git a/spinifex/services/spinifexui/frontend/dist/assets/_id10.js b/spinifex/services/spinifexui/frontend/dist/assets/_id10.js
index 9e5244beb..34cc7a79d 100644
--- a/spinifex/services/spinifexui/frontend/dist/assets/_id10.js
+++ b/spinifex/services/spinifexui/frontend/dist/assets/_id10.js
@@ -1 +1 @@
-import{t as e}from"./error-banner.js";import{t}from"./useSuspenseQuery.js";import{t as n}from"./back-link.js";import{c as r,i,n as a,s as o,t as s}from"./select.js";import{t as c}from"./plus.js";import{t as l}from"./trash-2.js";import{G as u,W as d,k as f,o as p,s as m}from"./ec2.js";import{Ho as h,Lo as g,Ro as _,Uo as v,Xa as y,Xi as b,cs as x,mo as S,ot as C,u as w}from"./index.js";import{i as T,n as E,t as D}from"./zod.js";import{t as O}from"./page-heading.js";import{a as k,r as A,t as j}from"./field.js";import{y as M}from"./ec22.js";import{i as N,l as P,n as F,o as I,r as L,s as R,t as z}from"./alert-dialog.js";import{t as B}from"./delete-confirmation-dialog.js";import{n as V,t as H}from"./detail-row.js";var U=x(v()),W=h();function G(e){if(!e||e===`-1`)return`All traffic`;let t=e.toUpperCase();return t===`TCP`||t===`UDP`||t===`ICMP`?t:e}function K(e,t,n){return!n||n===`-1`||e===void 0||t===void 0||e===-1&&t===-1?`All`:e===t?e.toString():`${e}-${t}`}function q({open:t,onOpenChange:n,direction:c,groupId:l}){let u=m(),d=p(),f=c===`ingress`?u:d,{control:h,handleSubmit:g,register:_,reset:v,watch:y,formState:{errors:x}}=T({resolver:D(M),defaultValues:{ipProtocol:`tcp`,fromPort:0,toPort:0,cidrIp:`0.0.0.0/0`}}),S=y(`ipProtocol`),C=S===`-1`||S===`icmp`;return(0,W.jsx)(z,{onOpenChange:n,open:t,children:(0,W.jsxs)(N,{children:[(0,W.jsx)(R,{children:(0,W.jsxs)(P,{children:[`Add `,c===`ingress`?`Inbound`:`Outbound`,` Rule`]})}),f.error&&(0,W.jsx)(e,{error:f.error,msg:`Failed to add rule`}),(0,W.jsxs)(`form`,{className:`space-y-4`,id:`add-rule-form`,onSubmit:g(async e=>{let t={groupId:l,ipProtocol:e.ipProtocol,fromPort:C?-1:e.fromPort,toPort:C?-1:e.toPort,cidrIp:e.cidrIp};await f.mutateAsync(t),v(),n(!1)}),children:[(0,W.jsxs)(j,{children:[(0,W.jsx)(k,{children:(0,W.jsx)(`label`,{htmlFor:`ipProtocol`,children:`Protocol`})}),(0,W.jsx)(E,{control:h,name:`ipProtocol`,render:({field:e})=>(0,W.jsxs)(s,{onValueChange:t=>e.onChange(t),value:e.value,children:[(0,W.jsx)(o,{className:`w-full`,id:`ipProtocol`,children:(0,W.jsx)(r,{})}),(0,W.jsxs)(a,{children:[(0,W.jsx)(i,{value:`tcp`,children:`TCP`}),(0,W.jsx)(i,{value:`udp`,children:`UDP`}),(0,W.jsx)(i,{value:`icmp`,children:`ICMP`}),(0,W.jsx)(i,{value:`-1`,children:`All traffic (-1)`})]})]})}),(0,W.jsx)(A,{errors:[x.ipProtocol]})]}),(0,W.jsxs)(`div`,{className:`grid grid-cols-2 gap-4`,children:[(0,W.jsxs)(j,{children:[(0,W.jsx)(k,{children:(0,W.jsx)(`label`,{htmlFor:`fromPort`,children:`From Port`})}),(0,W.jsx)(b,{"aria-invalid":!!x.fromPort,disabled:C,id:`fromPort`,type:`number`,..._(`fromPort`,{valueAsNumber:!0})}),(0,W.jsx)(A,{errors:[x.fromPort]})]}),(0,W.jsxs)(j,{children:[(0,W.jsx)(k,{children:(0,W.jsx)(`label`,{htmlFor:`toPort`,children:`To Port`})}),(0,W.jsx)(b,{"aria-invalid":!!x.toPort,disabled:C,id:`toPort`,type:`number`,..._(`toPort`,{valueAsNumber:!0})}),(0,W.jsx)(A,{errors:[x.toPort]})]})]}),(0,W.jsxs)(j,{children:[(0,W.jsx)(k,{children:(0,W.jsxs)(`label`,{htmlFor:`cidrIp`,children:[c===`ingress`?`Source`:`Destination`,` CIDR`]})}),(0,W.jsx)(b,{"aria-invalid":!!x.cidrIp,id:`cidrIp`,placeholder:`0.0.0.0/0`,..._(`cidrIp`)}),(0,W.jsx)(A,{errors:[x.cidrIp]})]})]}),(0,W.jsxs)(I,{children:[(0,W.jsx)(L,{onClick:()=>{v(),f.reset()},children:`Cancel`}),(0,W.jsx)(F,{disabled:f.isPending,form:`add-rule-form`,type:`submit`,children:f.isPending?`Adding…`:`Add Rule`})]})]})})}function J(e){return[...(e.IpRanges??[]).map(e=>({kind:`cidr`,value:e.CidrIp??``})),...(e.UserIdGroupPairs??[]).map(e=>({kind:`group`,value:e.GroupId??``}))]}function Y({rule:t,source:n,groupId:r,direction:i}){let a=u(),o=d(),s=i===`ingress`?a:o,c=async()=>{try{await s.mutateAsync({groupId:r,ipProtocol:t.IpProtocol??`-1`,fromPort:t.FromPort??-1,toPort:t.ToPort??-1,...n.kind===`group`?{sourceGroupId:n.value}:{cidrIp:n.value}})}catch{}},l=n.kind===`group`&&n.value===r?`${n.value} (this security group)`:n.value;return(0,W.jsxs)(`div`,{className:`space-y-0`,children:[(0,W.jsxs)(`div`,{className:`flex items-center justify-between border-b px-4 py-3 last:border-b-0`,children:[(0,W.jsxs)(`div`,{className:`grid flex-1 grid-cols-3 gap-4 text-sm`,children:[(0,W.jsxs)(`div`,{children:[(0,W.jsx)(`span`,{className:`text-muted-foreground`,children:`Protocol: `}),G(t.IpProtocol)]}),(0,W.jsxs)(`div`,{children:[(0,W.jsx)(`span`,{className:`text-muted-foreground`,children:`Port Range: `}),K(t.FromPort,t.ToPort,t.IpProtocol)]}),(0,W.jsxs)(`div`,{children:[(0,W.jsxs)(`span`,{className:`text-muted-foreground`,children:[i===`ingress`?`Source`:`Destination`,`:`,` `]}),l]})]}),(0,W.jsx)(y,{disabled:s.isPending,onClick:c,size:`icon-xs`,variant:`ghost`,children:(0,W.jsx)(S,{className:`size-3`})})]}),s.error&&(0,W.jsx)(`div`,{className:`px-4 pb-2`,children:(0,W.jsx)(e,{error:s.error,msg:`Failed to remove rule`})})]})}function X(){let{id:r}=w.useParams(),i=_(),{data:a}=t(C(r)),o=a.SecurityGroups?.[0],s=f(),[u,d]=(0,U.useState)(!1),[p,m]=(0,U.useState)(!1),[h,v]=(0,U.useState)(!1),b=async()=>{try{await s.mutateAsync(r),i({to:`/ec2/describe-security-groups`})}finally{d(!1)}};if(!o?.GroupId)return(0,W.jsxs)(W.Fragment,{children:[(0,W.jsx)(n,{to:`/ec2/describe-security-groups`,children:`Back to Security Groups`}),(0,W.jsx)(`p`,{className:`text-muted-foreground`,children:`Security group not found.`})]});let x=o.IpPermissions??[],S=o.IpPermissionsEgress??[];return(0,W.jsxs)(W.Fragment,{children:[(0,W.jsx)(n,{to:`/ec2/describe-security-groups`,children:`Back to Security Groups`}),s.error&&(0,W.jsx)(e,{error:s.error,msg:`Failed to delete security group`}),(0,W.jsxs)(`div`,{className:`space-y-6`,children:[(0,W.jsx)(O,{actions:(0,W.jsx)(`div`,{className:`flex items-center gap-2`,children:(0,W.jsxs)(y,{onClick:()=>d(!0),size:`sm`,variant:`destructive`,children:[(0,W.jsx)(l,{className:`size-4`}),`Delete`]})}),subtitle:`Security Group Details`,title:o.GroupName?`${o.GroupId} (${o.GroupName})`:o.GroupId}),(0,W.jsxs)(V,{children:[(0,W.jsx)(V.Header,{children:`Security Group Information`}),(0,W.jsxs)(V.Content,{children:[(0,W.jsx)(H,{label:`Group ID`,value:o.GroupId}),(0,W.jsx)(H,{label:`Group Name`,value:o.GroupName}),(0,W.jsx)(H,{label:`Description`,value:o.Description}),(0,W.jsx)(H,{label:`VPC ID`,value:o.VpcId?(0,W.jsx)(g,{className:`text-primary hover:underline`,params:{id:o.VpcId},to:`/ec2/describe-vpcs/$id`,children:o.VpcId}):`—`}),(0,W.jsx)(H,{label:`Owner ID`,value:o.OwnerId})]})]}),(0,W.jsxs)(V,{children:[(0,W.jsxs)(`div`,{className:`flex items-center justify-between border-b p-4`,children:[(0,W.jsxs)(`h2`,{className:`font-semibold`,children:[`Inbound Rules (`,x.length,`)`]}),(0,W.jsxs)(y,{onClick:()=>m(!0),size:`sm`,variant:`outline`,children:[(0,W.jsx)(c,{className:`size-4`}),`Add Rule`]})]}),x.length>0?(0,W.jsx)(`div`,{children:x.flatMap(e=>J(e).map(t=>(0,W.jsx)(Y,{direction:`ingress`,groupId:o.GroupId??``,rule:e,source:t},`${e.IpProtocol}-${e.FromPort}-${e.ToPort}-${t.kind}-${t.value}`)))}):(0,W.jsx)(`p`,{className:`p-4 text-sm text-muted-foreground`,children:`No inbound rules.`})]}),(0,W.jsxs)(V,{children:[(0,W.jsxs)(`div`,{className:`flex items-center justify-between border-b p-4`,children:[(0,W.jsxs)(`h2`,{className:`font-semibold`,children:[`Outbound Rules (`,S.length,`)`]}),(0,W.jsxs)(y,{onClick:()=>v(!0),size:`sm`,variant:`outline`,children:[(0,W.jsx)(c,{className:`size-4`}),`Add Rule`]})]}),S.length>0?(0,W.jsx)(`div`,{children:S.flatMap(e=>J(e).map(t=>(0,W.jsx)(Y,{direction:`egress`,groupId:o.GroupId??``,rule:e,source:t},`${e.IpProtocol}-${e.FromPort}-${e.ToPort}-${t.kind}-${t.value}`)))}):(0,W.jsx)(`p`,{className:`p-4 text-sm text-muted-foreground`,children:`No outbound rules.`})]})]}),(0,W.jsx)(B,{description:`Are you sure you want to delete the security group "${o.GroupId}"? This action cannot be undone.`,isPending:s.isPending,onConfirm:b,onOpenChange:d,open:u,title:`Delete Security Group`}),(0,W.jsx)(q,{direction:`ingress`,groupId:o.GroupId,onOpenChange:m,open:p}),(0,W.jsx)(q,{direction:`egress`,groupId:o.GroupId,onOpenChange:v,open:h})]})}export{X as component};
\ No newline at end of file
+import{n as e,o as t,r as n}from"./compiler-runtime.js";import{t as r}from"./error-banner.js";import{t as i}from"./useSuspenseQuery.js";import{Or as a}from"./awsClient.js";import{t as o}from"./back-link.js";import{c as s,i as c,n as l,s as u,t as d}from"./select.js";import{t as f}from"./plus.js";import{t as p}from"./trash-2.js";import{t as m}from"./button.js";import{A as h,K as g,c as _,q as v,s as y}from"./ec2.js";import{Cr as b,Ir as x,Ot as S,Rn as C,u as w}from"./index.js";import{i as T,n as E,t as D}from"./zod.js";import{t as O}from"./page-heading.js";import{a as k,r as A,t as j}from"./field.js";import{y as M}from"./ec22.js";import{i as N,l as P,n as F,o as I,r as L,s as R,t as z}from"./alert-dialog.js";import{t as B}from"./delete-confirmation-dialog.js";import{n as V,t as H}from"./detail-row.js";var U=t(n()),W=e();function G(e){if(!e||e===`-1`)return`All traffic`;let t=e.toUpperCase();return t===`TCP`||t===`UDP`||t===`ICMP`?t:e}function K(e,t,n){return!n||n===`-1`||e===void 0||t===void 0||e===-1&&t===-1?`All`:e===t?e.toString():`${e}-${t}`}function q({open:e,onOpenChange:t,direction:n,groupId:i}){let a=_(),o=y(),f=n===`ingress`?a:o,{control:p,handleSubmit:m,register:h,reset:g,watch:v,formState:{errors:b}}=T({resolver:D(M),defaultValues:{ipProtocol:`tcp`,fromPort:0,toPort:0,cidrIp:`0.0.0.0/0`}}),x=v(`ipProtocol`),S=x===`-1`||x===`icmp`;return(0,W.jsx)(z,{onOpenChange:t,open:e,children:(0,W.jsxs)(N,{children:[(0,W.jsx)(R,{children:(0,W.jsxs)(P,{children:[`Add `,n===`ingress`?`Inbound`:`Outbound`,` Rule`]})}),f.error&&(0,W.jsx)(r,{error:f.error,msg:`Failed to add rule`}),(0,W.jsxs)(`form`,{className:`space-y-4`,id:`add-rule-form`,onSubmit:m(async e=>{let n={groupId:i,ipProtocol:e.ipProtocol,fromPort:S?-1:e.fromPort,toPort:S?-1:e.toPort,cidrIp:e.cidrIp};await f.mutateAsync(n),g(),t(!1)}),children:[(0,W.jsxs)(j,{children:[(0,W.jsx)(k,{children:(0,W.jsx)(`label`,{htmlFor:`ipProtocol`,children:`Protocol`})}),(0,W.jsx)(E,{control:p,name:`ipProtocol`,render:({field:e})=>(0,W.jsxs)(d,{onValueChange:t=>e.onChange(t),value:e.value,children:[(0,W.jsx)(u,{className:`w-full`,id:`ipProtocol`,children:(0,W.jsx)(s,{})}),(0,W.jsxs)(l,{children:[(0,W.jsx)(c,{value:`tcp`,children:`TCP`}),(0,W.jsx)(c,{value:`udp`,children:`UDP`}),(0,W.jsx)(c,{value:`icmp`,children:`ICMP`}),(0,W.jsx)(c,{value:`-1`,children:`All traffic (-1)`})]})]})}),(0,W.jsx)(A,{errors:[b.ipProtocol]})]}),(0,W.jsxs)(`div`,{className:`grid grid-cols-2 gap-4`,children:[(0,W.jsxs)(j,{children:[(0,W.jsx)(k,{children:(0,W.jsx)(`label`,{htmlFor:`fromPort`,children:`From Port`})}),(0,W.jsx)(C,{"aria-invalid":!!b.fromPort,disabled:S,id:`fromPort`,type:`number`,...h(`fromPort`,{valueAsNumber:!0})}),(0,W.jsx)(A,{errors:[b.fromPort]})]}),(0,W.jsxs)(j,{children:[(0,W.jsx)(k,{children:(0,W.jsx)(`label`,{htmlFor:`toPort`,children:`To Port`})}),(0,W.jsx)(C,{"aria-invalid":!!b.toPort,disabled:S,id:`toPort`,type:`number`,...h(`toPort`,{valueAsNumber:!0})}),(0,W.jsx)(A,{errors:[b.toPort]})]})]}),(0,W.jsxs)(j,{children:[(0,W.jsx)(k,{children:(0,W.jsxs)(`label`,{htmlFor:`cidrIp`,children:[n===`ingress`?`Source`:`Destination`,` CIDR`]})}),(0,W.jsx)(C,{"aria-invalid":!!b.cidrIp,id:`cidrIp`,placeholder:`0.0.0.0/0`,...h(`cidrIp`)}),(0,W.jsx)(A,{errors:[b.cidrIp]})]})]}),(0,W.jsxs)(I,{children:[(0,W.jsx)(L,{onClick:()=>{g(),f.reset()},children:`Cancel`}),(0,W.jsx)(F,{disabled:f.isPending,form:`add-rule-form`,type:`submit`,children:f.isPending?`Adding…`:`Add Rule`})]})]})})}function J(e){return[...(e.IpRanges??[]).map(e=>({kind:`cidr`,value:e.CidrIp??``})),...(e.UserIdGroupPairs??[]).map(e=>({kind:`group`,value:e.GroupId??``}))]}function Y({rule:e,source:t,groupId:n,direction:i}){let a=v(),o=g(),s=i===`ingress`?a:o,c=async()=>{try{await s.mutateAsync({groupId:n,ipProtocol:e.IpProtocol??`-1`,fromPort:e.FromPort??-1,toPort:e.ToPort??-1,...t.kind===`group`?{sourceGroupId:t.value}:{cidrIp:t.value}})}catch{}},l=t.kind===`group`&&t.value===n?`${t.value} (this security group)`:t.value;return(0,W.jsxs)(`div`,{className:`space-y-0`,children:[(0,W.jsxs)(`div`,{className:`flex items-center justify-between border-b px-4 py-3 last:border-b-0`,children:[(0,W.jsxs)(`div`,{className:`grid flex-1 grid-cols-3 gap-4 text-sm`,children:[(0,W.jsxs)(`div`,{children:[(0,W.jsx)(`span`,{className:`text-muted-foreground`,children:`Protocol: `}),G(e.IpProtocol)]}),(0,W.jsxs)(`div`,{children:[(0,W.jsx)(`span`,{className:`text-muted-foreground`,children:`Port Range: `}),K(e.FromPort,e.ToPort,e.IpProtocol)]}),(0,W.jsxs)(`div`,{children:[(0,W.jsxs)(`span`,{className:`text-muted-foreground`,children:[i===`ingress`?`Source`:`Destination`,`:`,` `]}),l]})]}),(0,W.jsx)(m,{disabled:s.isPending,onClick:c,size:`icon-xs`,variant:`ghost`,children:(0,W.jsx)(b,{className:`size-3`})})]}),s.error&&(0,W.jsx)(`div`,{className:`px-4 pb-2`,children:(0,W.jsx)(r,{error:s.error,msg:`Failed to remove rule`})})]})}function X(){let{id:e}=w.useParams(),t=x(),{data:n}=i(S(e)),s=n.SecurityGroups?.[0],c=h(),[l,u]=(0,U.useState)(!1),[d,g]=(0,U.useState)(!1),[_,v]=(0,U.useState)(!1),y=async()=>{try{await c.mutateAsync(e),t({to:`/ec2/describe-security-groups`})}finally{u(!1)}};if(!s?.GroupId)return(0,W.jsxs)(W.Fragment,{children:[(0,W.jsx)(o,{to:`/ec2/describe-security-groups`,children:`Back to Security Groups`}),(0,W.jsx)(`p`,{className:`text-muted-foreground`,children:`Security group not found.`})]});let b=s.IpPermissions??[],C=s.IpPermissionsEgress??[];return(0,W.jsxs)(W.Fragment,{children:[(0,W.jsx)(o,{to:`/ec2/describe-security-groups`,children:`Back to Security Groups`}),c.error&&(0,W.jsx)(r,{error:c.error,msg:`Failed to delete security group`}),(0,W.jsxs)(`div`,{className:`space-y-6`,children:[(0,W.jsx)(O,{actions:(0,W.jsx)(`div`,{className:`flex items-center gap-2`,children:(0,W.jsxs)(m,{onClick:()=>u(!0),size:`sm`,variant:`destructive`,children:[(0,W.jsx)(p,{className:`size-4`}),`Delete`]})}),subtitle:`Security Group Details`,title:s.GroupName?`${s.GroupId} (${s.GroupName})`:s.GroupId}),(0,W.jsxs)(V,{children:[(0,W.jsx)(V.Header,{children:`Security Group Information`}),(0,W.jsxs)(V.Content,{children:[(0,W.jsx)(H,{label:`Group ID`,value:s.GroupId}),(0,W.jsx)(H,{label:`Group Name`,value:s.GroupName}),(0,W.jsx)(H,{label:`Description`,value:s.Description}),(0,W.jsx)(H,{label:`VPC ID`,value:s.VpcId?(0,W.jsx)(a,{className:`text-primary hover:underline`,params:{id:s.VpcId},to:`/ec2/describe-vpcs/$id`,children:s.VpcId}):`—`}),(0,W.jsx)(H,{label:`Owner ID`,value:s.OwnerId})]})]}),(0,W.jsxs)(V,{children:[(0,W.jsxs)(`div`,{className:`flex items-center justify-between border-b p-4`,children:[(0,W.jsxs)(`h2`,{className:`font-semibold`,children:[`Inbound Rules (`,b.length,`)`]}),(0,W.jsxs)(m,{onClick:()=>g(!0),size:`sm`,variant:`outline`,children:[(0,W.jsx)(f,{className:`size-4`}),`Add Rule`]})]}),b.length>0?(0,W.jsx)(`div`,{children:b.flatMap(e=>J(e).map(t=>(0,W.jsx)(Y,{direction:`ingress`,groupId:s.GroupId??``,rule:e,source:t},`${e.IpProtocol}-${e.FromPort}-${e.ToPort}-${t.kind}-${t.value}`)))}):(0,W.jsx)(`p`,{className:`p-4 text-sm text-muted-foreground`,children:`No inbound rules.`})]}),(0,W.jsxs)(V,{children:[(0,W.jsxs)(`div`,{className:`flex items-center justify-between border-b p-4`,children:[(0,W.jsxs)(`h2`,{className:`font-semibold`,children:[`Outbound Rules (`,C.length,`)`]}),(0,W.jsxs)(m,{onClick:()=>v(!0),size:`sm`,variant:`outline`,children:[(0,W.jsx)(f,{className:`size-4`}),`Add Rule`]})]}),C.length>0?(0,W.jsx)(`div`,{children:C.flatMap(e=>J(e).map(t=>(0,W.jsx)(Y,{direction:`egress`,groupId:s.GroupId??``,rule:e,source:t},`${e.IpProtocol}-${e.FromPort}-${e.ToPort}-${t.kind}-${t.value}`)))}):(0,W.jsx)(`p`,{className:`p-4 text-sm text-muted-foreground`,children:`No outbound rules.`})]})]}),(0,W.jsx)(B,{description:`Are you sure you want to delete the security group "${s.GroupId}"? This action cannot be undone.`,isPending:c.isPending,onConfirm:y,onOpenChange:u,open:l,title:`Delete Security Group`}),(0,W.jsx)(q,{direction:`ingress`,groupId:s.GroupId,onOpenChange:g,open:d}),(0,W.jsx)(q,{direction:`egress`,groupId:s.GroupId,onOpenChange:v,open:_})]})}export{X as component};
\ No newline at end of file
diff --git a/spinifex/services/spinifexui/frontend/dist/assets/_id11.js b/spinifex/services/spinifexui/frontend/dist/assets/_id11.js
index c853885f2..a6b30dd1e 100644
--- a/spinifex/services/spinifexui/frontend/dist/assets/_id11.js
+++ b/spinifex/services/spinifexui/frontend/dist/assets/_id11.js
@@ -1 +1 @@
-import{t as e}from"./error-banner.js";import{t}from"./useSuspenseQuery.js";import{t as n}from"./back-link.js";import{t as r}from"./copy.js";import{t as i}from"./trash-2.js";import{A as a,c as o}from"./ec2.js";import{Ho as s,Lo as c,Ro as l,Uo as u,Xa as d,Xi as f,cs as p,ct as m,d as h,no as g,rt as _}from"./index.js";import{t as v}from"./page-heading.js";import{a as y,t as b}from"./field.js";import{a as x,i as S,l as C,n as w,o as T,r as E,s as D,t as O}from"./alert-dialog.js";import{t as k}from"./delete-confirmation-dialog.js";import{n as A,t as j}from"./detail-row.js";import{t as M}from"./state-badge.js";var N=p(u()),P=s();function F(){let{id:s}=h.useParams(),u=l(),{data:p}=t(m(s)),{data:F}=t(_),I=p.Snapshots?.[0],L=a(),R=o(),[z,B]=(0,N.useState)(!1),[V,H]=(0,N.useState)(!1),[U,W]=(0,N.useState)(``),G=F.Regions?.[0]?.RegionName??`ap-southeast-2`,K=I?.State===`completed`,q=async()=>{try{await L.mutateAsync(s),u({to:`/ec2/describe-snapshots`})}finally{B(!1)}},J=async()=>{try{await R.mutateAsync({sourceSnapshotId:s,sourceRegion:G,description:U||void 0}),H(!1),W(``)}catch{}};if(!I?.SnapshotId)return(0,P.jsxs)(P.Fragment,{children:[(0,P.jsx)(n,{to:`/ec2/describe-snapshots`,children:`Back to snapshots`}),(0,P.jsx)(`p`,{className:`text-muted-foreground`,children:`Snapshot not found.`})]});let Y=g(I.StartTime);return(0,P.jsxs)(P.Fragment,{children:[(0,P.jsx)(n,{to:`/ec2/describe-snapshots`,children:`Back to snapshots`}),L.error&&(0,P.jsx)(e,{error:L.error,msg:`Failed to delete snapshot`}),R.error&&(0,P.jsx)(e,{error:R.error,msg:`Failed to copy snapshot`}),(0,P.jsxs)(`div`,{className:`space-y-6`,children:[(0,P.jsx)(v,{actions:(0,P.jsxs)(`div`,{className:`flex items-center gap-2`,children:[(0,P.jsxs)(d,{disabled:!K,onClick:()=>B(!0),size:`sm`,variant:`destructive`,children:[(0,P.jsx)(i,{className:`size-4`}),`Delete`]}),(0,P.jsxs)(d,{disabled:!K,onClick:()=>H(!0),size:`sm`,variant:`outline`,children:[(0,P.jsx)(r,{className:`size-4`}),`Copy Snapshot`]}),(0,P.jsx)(M,{state:I.State})]}),subtitle:`Snapshot Details`,title:I.SnapshotId}),(0,P.jsxs)(A,{children:[(0,P.jsx)(A.Header,{children:`Snapshot Information`}),(0,P.jsxs)(A.Content,{children:[(0,P.jsx)(j,{label:`Snapshot ID`,value:I.SnapshotId}),(0,P.jsx)(j,{label:`Volume ID`,value:I.VolumeId?(0,P.jsx)(c,{className:`text-primary hover:underline`,params:{id:I.VolumeId},to:`/ec2/describe-volumes/$id`,children:I.VolumeId}):void 0}),(0,P.jsx)(j,{label:`Volume Size`,value:`${I.VolumeSize} GiB`}),(0,P.jsx)(j,{label:`State`,value:I.State}),(0,P.jsx)(j,{label:`Description`,value:I.Description}),(0,P.jsx)(j,{label:`Progress`,value:I.Progress}),(0,P.jsx)(j,{label:`Start Time`,value:Y}),(0,P.jsx)(j,{label:`Owner ID`,value:I.OwnerId}),(0,P.jsx)(j,{label:`Encrypted`,value:I.Encrypted?`Yes`:`No`})]})]})]}),(0,P.jsx)(k,{description:`Are you sure you want to delete the snapshot "${I.SnapshotId}"? This action cannot be undone.`,isPending:L.isPending,onConfirm:q,onOpenChange:B,open:z,title:`Delete Snapshot`}),(0,P.jsx)(O,{onOpenChange:H,open:V,children:(0,P.jsxs)(S,{children:[(0,P.jsxs)(D,{children:[(0,P.jsx)(C,{children:`Copy Snapshot`}),(0,P.jsx)(x,{children:`Create a copy of this snapshot in the current region.`})]}),(0,P.jsxs)(b,{children:[(0,P.jsx)(y,{children:(0,P.jsx)(`label`,{htmlFor:`copyDescription`,children:`Description (optional)`})}),(0,P.jsx)(f,{id:`copyDescription`,onChange:e=>W(e.target.value),placeholder:`Description for the snapshot copy`,value:U})]}),(0,P.jsxs)(T,{children:[(0,P.jsx)(E,{children:`Cancel`}),(0,P.jsx)(w,{disabled:R.isPending,onClick:J,children:R.isPending?`Copying…`:`Copy Snapshot`})]})]})})]})}export{F as component};
\ No newline at end of file
+import{n as e,o as t,r as n}from"./compiler-runtime.js";import{t as r}from"./error-banner.js";import{t as i}from"./useSuspenseQuery.js";import{Or as a}from"./awsClient.js";import{D as o}from"./SignatureV4.js";import{t as s}from"./back-link.js";import{t as c}from"./copy.js";import{t as l}from"./trash-2.js";import{t as u}from"./button.js";import{j as d,l as f}from"./ec2.js";import{At as p,Ir as m,Rn as h,Tt as g,d as _}from"./index.js";import{t as v}from"./page-heading.js";import{a as y,t as b}from"./field.js";import{a as x,i as S,l as C,n as w,o as T,r as E,s as D,t as O}from"./alert-dialog.js";import{t as k}from"./delete-confirmation-dialog.js";import{n as A,t as j}from"./detail-row.js";import{t as M}from"./state-badge.js";var N=t(n()),P=e();function F(){let{id:e}=_.useParams(),t=m(),{data:n}=i(p(e)),{data:F}=i(g),I=n.Snapshots?.[0],L=d(),R=f(),[z,B]=(0,N.useState)(!1),[V,H]=(0,N.useState)(!1),[U,W]=(0,N.useState)(``),G=F.Regions?.[0]?.RegionName??`ap-southeast-2`,K=I?.State===`completed`,q=async()=>{try{await L.mutateAsync(e),t({to:`/ec2/describe-snapshots`})}finally{B(!1)}},J=async()=>{try{await R.mutateAsync({sourceSnapshotId:e,sourceRegion:G,description:U||void 0}),H(!1),W(``)}catch{}};if(!I?.SnapshotId)return(0,P.jsxs)(P.Fragment,{children:[(0,P.jsx)(s,{to:`/ec2/describe-snapshots`,children:`Back to snapshots`}),(0,P.jsx)(`p`,{className:`text-muted-foreground`,children:`Snapshot not found.`})]});let Y=o(I.StartTime);return(0,P.jsxs)(P.Fragment,{children:[(0,P.jsx)(s,{to:`/ec2/describe-snapshots`,children:`Back to snapshots`}),L.error&&(0,P.jsx)(r,{error:L.error,msg:`Failed to delete snapshot`}),R.error&&(0,P.jsx)(r,{error:R.error,msg:`Failed to copy snapshot`}),(0,P.jsxs)(`div`,{className:`space-y-6`,children:[(0,P.jsx)(v,{actions:(0,P.jsxs)(`div`,{className:`flex items-center gap-2`,children:[(0,P.jsxs)(u,{disabled:!K,onClick:()=>B(!0),size:`sm`,variant:`destructive`,children:[(0,P.jsx)(l,{className:`size-4`}),`Delete`]}),(0,P.jsxs)(u,{disabled:!K,onClick:()=>H(!0),size:`sm`,variant:`outline`,children:[(0,P.jsx)(c,{className:`size-4`}),`Copy Snapshot`]}),(0,P.jsx)(M,{state:I.State})]}),subtitle:`Snapshot Details`,title:I.SnapshotId}),(0,P.jsxs)(A,{children:[(0,P.jsx)(A.Header,{children:`Snapshot Information`}),(0,P.jsxs)(A.Content,{children:[(0,P.jsx)(j,{label:`Snapshot ID`,value:I.SnapshotId}),(0,P.jsx)(j,{label:`Volume ID`,value:I.VolumeId?(0,P.jsx)(a,{className:`text-primary hover:underline`,params:{id:I.VolumeId},to:`/ec2/describe-volumes/$id`,children:I.VolumeId}):void 0}),(0,P.jsx)(j,{label:`Volume Size`,value:`${I.VolumeSize} GiB`}),(0,P.jsx)(j,{label:`State`,value:I.State}),(0,P.jsx)(j,{label:`Description`,value:I.Description}),(0,P.jsx)(j,{label:`Progress`,value:I.Progress}),(0,P.jsx)(j,{label:`Start Time`,value:Y}),(0,P.jsx)(j,{label:`Owner ID`,value:I.OwnerId}),(0,P.jsx)(j,{label:`Encrypted`,value:I.Encrypted?`Yes`:`No`})]})]})]}),(0,P.jsx)(k,{description:`Are you sure you want to delete the snapshot "${I.SnapshotId}"? This action cannot be undone.`,isPending:L.isPending,onConfirm:q,onOpenChange:B,open:z,title:`Delete Snapshot`}),(0,P.jsx)(O,{onOpenChange:H,open:V,children:(0,P.jsxs)(S,{children:[(0,P.jsxs)(D,{children:[(0,P.jsx)(C,{children:`Copy Snapshot`}),(0,P.jsx)(x,{children:`Create a copy of this snapshot in the current region.`})]}),(0,P.jsxs)(b,{children:[(0,P.jsx)(y,{children:(0,P.jsx)(`label`,{htmlFor:`copyDescription`,children:`Description (optional)`})}),(0,P.jsx)(h,{id:`copyDescription`,onChange:e=>W(e.target.value),placeholder:`Description for the snapshot copy`,value:U})]}),(0,P.jsxs)(T,{children:[(0,P.jsx)(E,{children:`Cancel`}),(0,P.jsx)(w,{disabled:R.isPending,onClick:J,children:R.isPending?`Copying…`:`Copy Snapshot`})]})]})})]})}export{F as component};
\ No newline at end of file
diff --git a/spinifex/services/spinifexui/frontend/dist/assets/_id12.js b/spinifex/services/spinifexui/frontend/dist/assets/_id12.js
index c5617764d..ad3a277f1 100644
--- a/spinifex/services/spinifexui/frontend/dist/assets/_id12.js
+++ b/spinifex/services/spinifexui/frontend/dist/assets/_id12.js
@@ -1 +1 @@
-import{t as e}from"./error-banner.js";import{t}from"./useSuspenseQuery.js";import{t as n}from"./back-link.js";import{t as r}from"./trash-2.js";import{j as i}from"./ec2.js";import{Ho as a,Lo as o,Ro as s,Uo as c,Xa as l,ao as u,cs as d,f,ut as p}from"./index.js";import{t as m}from"./page-heading.js";import{t as h}from"./delete-confirmation-dialog.js";import{n as g,t as _}from"./detail-row.js";import{t as v}from"./state-badge.js";var y=d(c()),b=a();function x(){let{id:a}=f.useParams(),c=s(),{data:d}=t(p(a)),x=d.Subnets?.[0],S=i(),[C,w]=(0,y.useState)(!1),T=async()=>{try{await S.mutateAsync(a),c({to:`/ec2/describe-subnets`})}finally{w(!1)}};if(!x?.SubnetId)return(0,b.jsxs)(b.Fragment,{children:[(0,b.jsx)(n,{to:`/ec2/describe-subnets`,children:`Back to Subnets`}),(0,b.jsx)(`p`,{className:`text-muted-foreground`,children:`Subnet not found.`})]});let E=u(x.Tags);return(0,b.jsxs)(b.Fragment,{children:[(0,b.jsx)(n,{to:`/ec2/describe-subnets`,children:`Back to Subnets`}),S.error&&(0,b.jsx)(e,{error:S.error,msg:`Failed to delete subnet`}),(0,b.jsxs)(`div`,{className:`space-y-6`,children:[(0,b.jsx)(m,{actions:(0,b.jsxs)(`div`,{className:`flex items-center gap-2`,children:[(0,b.jsxs)(l,{onClick:()=>w(!0),size:`sm`,variant:`destructive`,children:[(0,b.jsx)(r,{className:`size-4`}),`Delete`]}),(0,b.jsx)(v,{state:x.State})]}),subtitle:`Subnet Details`,title:E?`${x.SubnetId} (${E})`:x.SubnetId}),(0,b.jsxs)(g,{children:[(0,b.jsx)(g.Header,{children:`Subnet Information`}),(0,b.jsxs)(g.Content,{children:[(0,b.jsx)(_,{label:`Subnet ID`,value:x.SubnetId}),(0,b.jsx)(_,{label:`VPC ID`,value:x.VpcId?(0,b.jsx)(o,{className:`text-primary hover:underline`,params:{id:x.VpcId},to:`/ec2/describe-vpcs/$id`,children:x.VpcId}):`—`}),(0,b.jsx)(_,{label:`State`,value:x.State}),(0,b.jsx)(_,{label:`CIDR Block`,value:x.CidrBlock}),(0,b.jsx)(_,{label:`Availability Zone`,value:x.AvailabilityZone}),(0,b.jsx)(_,{label:`Available IP Count`,value:x.AvailableIpAddressCount?.toString()}),(0,b.jsx)(_,{label:`Map Public IP on Launch`,value:x.MapPublicIpOnLaunch?`Yes`:`No`}),(0,b.jsx)(_,{label:`Default for AZ`,value:x.DefaultForAz?`Yes`:`No`})]})]})]}),(0,b.jsx)(h,{description:`Are you sure you want to delete the subnet "${x.SubnetId}"? This action cannot be undone.`,isPending:S.isPending,onConfirm:T,onOpenChange:w,open:C,title:`Delete Subnet`})]})}export{x as component};
\ No newline at end of file
+import{n as e,o as t,r as n}from"./compiler-runtime.js";import{t as r}from"./error-banner.js";import{t as i}from"./useSuspenseQuery.js";import{Or as a}from"./awsClient.js";import{A as o}from"./SignatureV4.js";import{t as s}from"./back-link.js";import{t as c}from"./trash-2.js";import{t as l}from"./button.js";import{M as u}from"./ec2.js";import{Ir as d,Mt as f,f as p}from"./index.js";import{t as m}from"./page-heading.js";import{t as h}from"./delete-confirmation-dialog.js";import{n as g,t as _}from"./detail-row.js";import{t as v}from"./state-badge.js";var y=t(n()),b=e();function x(){let{id:e}=p.useParams(),t=d(),{data:n}=i(f(e)),x=n.Subnets?.[0],S=u(),[C,w]=(0,y.useState)(!1),T=async()=>{try{await S.mutateAsync(e),t({to:`/ec2/describe-subnets`})}finally{w(!1)}};if(!x?.SubnetId)return(0,b.jsxs)(b.Fragment,{children:[(0,b.jsx)(s,{to:`/ec2/describe-subnets`,children:`Back to Subnets`}),(0,b.jsx)(`p`,{className:`text-muted-foreground`,children:`Subnet not found.`})]});let E=o(x.Tags);return(0,b.jsxs)(b.Fragment,{children:[(0,b.jsx)(s,{to:`/ec2/describe-subnets`,children:`Back to Subnets`}),S.error&&(0,b.jsx)(r,{error:S.error,msg:`Failed to delete subnet`}),(0,b.jsxs)(`div`,{className:`space-y-6`,children:[(0,b.jsx)(m,{actions:(0,b.jsxs)(`div`,{className:`flex items-center gap-2`,children:[(0,b.jsxs)(l,{onClick:()=>w(!0),size:`sm`,variant:`destructive`,children:[(0,b.jsx)(c,{className:`size-4`}),`Delete`]}),(0,b.jsx)(v,{state:x.State})]}),subtitle:`Subnet Details`,title:E?`${x.SubnetId} (${E})`:x.SubnetId}),(0,b.jsxs)(g,{children:[(0,b.jsx)(g.Header,{children:`Subnet Information`}),(0,b.jsxs)(g.Content,{children:[(0,b.jsx)(_,{label:`Subnet ID`,value:x.SubnetId}),(0,b.jsx)(_,{label:`VPC ID`,value:x.VpcId?(0,b.jsx)(a,{className:`text-primary hover:underline`,params:{id:x.VpcId},to:`/ec2/describe-vpcs/$id`,children:x.VpcId}):`—`}),(0,b.jsx)(_,{label:`State`,value:x.State}),(0,b.jsx)(_,{label:`CIDR Block`,value:x.CidrBlock}),(0,b.jsx)(_,{label:`Availability Zone`,value:x.AvailabilityZone}),(0,b.jsx)(_,{label:`Available IP Count`,value:x.AvailableIpAddressCount?.toString()}),(0,b.jsx)(_,{label:`Map Public IP on Launch`,value:x.MapPublicIpOnLaunch?`Yes`:`No`}),(0,b.jsx)(_,{label:`Default for AZ`,value:x.DefaultForAz?`Yes`:`No`})]})]})]}),(0,b.jsx)(h,{description:`Are you sure you want to delete the subnet "${x.SubnetId}"? This action cannot be undone.`,isPending:S.isPending,onConfirm:T,onOpenChange:w,open:C,title:`Delete Subnet`})]})}export{x as component};
\ No newline at end of file
diff --git a/spinifex/services/spinifexui/frontend/dist/assets/_id13.js b/spinifex/services/spinifexui/frontend/dist/assets/_id13.js
index 6aa89ad12..a426008ea 100644
--- a/spinifex/services/spinifexui/frontend/dist/assets/_id13.js
+++ b/spinifex/services/spinifexui/frontend/dist/assets/_id13.js
@@ -1 +1 @@
-import{t as e}from"./error-banner.js";import{t}from"./useQuery.js";import{t as n}from"./useSuspenseQuery.js";import{t as r}from"./back-link.js";import{t as i}from"./trash-2.js";import{c as a,l as o,m as s,p as c}from"./elbv2.js";import{$a as l,F as u,Fo as d,Ho as f,J as p,L as m,N as h,P as g,Ro as _,Uo as v,Xa as y,Xi as b,ao as x,cs as S,p as C}from"./index.js";import{t as w}from"./page-heading.js";import{a as T,i as E,l as D,n as O,o as k,r as A,s as j,t as M}from"./alert-dialog.js";import{t as N}from"./delete-confirmation-dialog.js";import{n as P,t as F}from"./detail-row.js";import{a as I,i as L,n as R,r as z,s as B,t as V}from"./tabs.js";var H=d(),U=S(v(),1),W=f();function G(e){let t=e.trim();if(t===``)return;let n=Number(t);if(!(!Number.isInteger(n)||n<1||n>65535))return n}function K(e){let t=(0,H.c)(36),{open:n,onOpenChange:r,instances:i,defaultPort:a,isPending:o,onConfirm:s}=e,c;t[0]===Symbol.for(`react.memo_cache_sentinel`)?(c={},t[0]=c):c=t[0];let[l,u]=(0,U.useState)(c),[d,f]=(0,U.useState)(),p,m;t[1]===n?(p=t[2],m=t[3]):(p=()=>{n||(u({}),f(void 0))},m=[n],t[1]=n,t[2]=p,t[3]=m),(0,U.useEffect)(p,m);let h;t[4]===Symbol.for(`react.memo_cache_sentinel`)?(h=e=>{u(t=>{let n=t[e];return n?.checked?{...t,[e]:{...n,checked:!1}}:{...t,[e]:{checked:!0,port:n?.port??``}}})},t[4]=h):h=t[4];let g=h,_;t[5]===Symbol.for(`react.memo_cache_sentinel`)?(_=(e,t)=>{u(n=>({...n,[e]:{checked:n[e]?.checked??!0,port:t}}))},t[5]=_):_=t[5];let v=_,y;t[6]!==s||t[7]!==l?(y=()=>{let e=Object.entries(l).filter(q);if(e.length===0){f(`Select at least one instance.`);return}let t=[];for(let[n,r]of e){if(r.port.trim()===``){t.push({id:n});continue}let e=G(r.port);if(e===void 0){f(`Port for ${n} must be an integer between 1 and 65535.`);return}t.push({id:n,port:e})}f(void 0),s(t)},t[6]=s,t[7]=l,t[8]=y):y=t[8];let S=y,C;t[9]===Symbol.for(`react.memo_cache_sentinel`)?(C=(0,W.jsx)(D,{children:`Register targets`}),t[9]=C):C=t[9];let w;t[10]===a?w=t[11]:(w=(0,W.jsxs)(j,{children:[C,(0,W.jsxs)(T,{children:[`Select instances to register. Leave port blank to use the target group port (`,a,`).`]})]}),t[10]=a,t[11]=w);let N;t[12]!==a||t[13]!==i||t[14]!==l?(N=i.length===0?(0,W.jsx)(`p`,{className:`text-xs text-muted-foreground`,children:`No instances available in this VPC.`}):(0,W.jsx)(`div`,{className:`max-h-72 space-y-1 overflow-y-auto`,children:i.map(e=>{let t=e.InstanceId??``,n=l[t],r=n?.checked??!1,i=x(e.Tags);return(0,W.jsxs)(`div`,{className:`flex items-center gap-2 rounded px-1 py-1 text-xs hover:bg-accent`,children:[(0,W.jsx)(`input`,{"aria-label":`Register target ${t}`,checked:r,id:`register-target-${t}`,onChange:()=>g(t),type:`checkbox`}),(0,W.jsxs)(`label`,{className:`flex flex-1 items-center gap-2`,htmlFor:`register-target-${t}`,children:[(0,W.jsx)(`span`,{className:`font-mono`,children:t}),i&&(0,W.jsxs)(`span`,{className:`text-muted-foreground`,children:[`(`,i,`)`]}),(0,W.jsx)(`span`,{className:`text-muted-foreground`,children:e.State?.Name??``})]}),r&&(0,W.jsx)(b,{"aria-label":`Port override for ${t}`,className:`w-24`,onChange:e=>v(t,e.target.value),placeholder:String(a),value:n?.port??``})]},t)})}),t[12]=a,t[13]=i,t[14]=l,t[15]=N):N=t[15];let P;t[16]===d?P=t[17]:(P=d&&(0,W.jsx)(`p`,{className:`text-xs text-destructive`,role:`alert`,children:d}),t[16]=d,t[17]=P);let F;t[18]===o?F=t[19]:(F=(0,W.jsx)(A,{disabled:o,children:`Cancel`}),t[18]=o,t[19]=F);let I=o?`Registering…`:`Register`,L;t[20]!==S||t[21]!==o||t[22]!==I?(L=(0,W.jsx)(O,{disabled:o,onClick:S,children:I}),t[20]=S,t[21]=o,t[22]=I,t[23]=L):L=t[23];let R;t[24]!==F||t[25]!==L?(R=(0,W.jsxs)(k,{children:[F,L]}),t[24]=F,t[25]=L,t[26]=R):R=t[26];let z;t[27]!==P||t[28]!==R||t[29]!==w||t[30]!==N?(z=(0,W.jsxs)(E,{className:`sm:max-w-lg`,children:[w,N,P,R]}),t[27]=P,t[28]=R,t[29]=w,t[30]=N,t[31]=z):z=t[31];let B;return t[32]!==r||t[33]!==n||t[34]!==z?(B=(0,W.jsx)(M,{onOpenChange:r,open:n,children:z}),t[32]=r,t[33]=n,t[34]=z,t[35]=B):B=t[35],B}function q(e){let[,t]=e;return t.checked&&t!==void 0}var J=5e3,Y=new Set([`healthy`]),X=new Set([`unhealthy`,`unavailable`]),Z=new Set([`initial`,`draining`]);function Q(e){return e&&Y.has(e)?`bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-100`:e&&X.has(e)?`bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-100`:e&&Z.has(e)?`bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-100`:`bg-gray-100 text-gray-800 dark:bg-gray-800 dark:text-gray-100`}function $(e){return`${e.Id??``}:${e.Port??``}`}function ee({targetGroupArn:r,vpcId:a,defaultPort:c,isActive:u}){let{data:d}=n(p),{data:f,isLoading:h,error:g}=t({...m(r),refetchInterval:u?J:!1,refetchIntervalInBackground:!1}),_=s(),v=o(),[b,x]=(0,U.useState)(!1),[S,C]=(0,U.useState)(),w=(d.Reservations?.flatMap(e=>e.Instances??[])??[]).filter(e=>e.VpcId===a&&e.State?.Name!==`terminated`),T=f?.TargetHealthDescriptions??[];return(0,W.jsxs)(`div`,{className:`space-y-3`,children:[_.error&&(0,W.jsx)(e,{error:_.error,msg:`Failed to register targets`}),v.error&&(0,W.jsx)(e,{error:v.error,msg:`Failed to deregister target`}),g&&(0,W.jsx)(e,{error:g,msg:`Failed to load target health`}),(0,W.jsxs)(`div`,{className:`flex items-center justify-between`,children:[(0,W.jsx)(`p`,{className:`text-xs text-muted-foreground`,children:h?`Loading target health…`:`${T.length} target${T.length===1?``:`s`}`}),(0,W.jsx)(y,{onClick:()=>x(!0),size:`sm`,children:`Register targets`})]}),T.length===0?(0,W.jsx)(`p`,{className:`text-muted-foreground`,children:`No targets registered.`}):(0,W.jsx)(`div`,{className:`overflow-x-auto rounded-lg border bg-card`,children:(0,W.jsxs)(`table`,{className:`w-full text-sm`,children:[(0,W.jsx)(`thead`,{children:(0,W.jsxs)(`tr`,{className:`border-b text-left text-muted-foreground`,children:[(0,W.jsx)(`th`,{className:`px-4 py-2 font-medium`,children:`Target`}),(0,W.jsx)(`th`,{className:`px-4 py-2 font-medium`,children:`Port`}),(0,W.jsx)(`th`,{className:`px-4 py-2 font-medium`,children:`AZ`}),(0,W.jsx)(`th`,{className:`px-4 py-2 font-medium`,children:`Health`}),(0,W.jsx)(`th`,{className:`px-4 py-2 font-medium`,children:`Reason`}),(0,W.jsx)(`th`,{className:`px-4 py-2 font-medium`,children:`Description`}),(0,W.jsx)(`th`,{className:`px-4 py-2 font-medium`,children:(0,W.jsx)(`span`,{className:`sr-only`,children:`Actions`})})]})}),(0,W.jsx)(`tbody`,{children:T.map(e=>{let t=e.Target??{Id:``},n=e.TargetHealth?.State;return(0,W.jsxs)(`tr`,{className:`border-b last:border-0`,children:[(0,W.jsx)(`td`,{className:`px-4 py-2 font-mono text-xs`,children:t.Id??``}),(0,W.jsx)(`td`,{className:`px-4 py-2`,children:t.Port??``}),(0,W.jsx)(`td`,{className:`px-4 py-2 text-xs`,children:t.AvailabilityZone??``}),(0,W.jsx)(`td`,{className:`px-4 py-2`,children:(0,W.jsx)(`span`,{className:l(`rounded-full px-2 py-0.5 text-xs`,Q(n)),children:n??`unknown`})}),(0,W.jsx)(`td`,{className:`px-4 py-2 text-xs`,children:e.TargetHealth?.Reason??``}),(0,W.jsx)(`td`,{className:`px-4 py-2 text-xs`,children:e.TargetHealth?.Description??``}),(0,W.jsx)(`td`,{className:`px-4 py-2 text-right`,children:(0,W.jsx)(y,{"aria-label":`Deregister ${t.Id}`,onClick:()=>C(t),size:`sm`,variant:`ghost`,children:(0,W.jsx)(i,{className:`size-4`})})})]},$(t))})})]})}),(0,W.jsx)(K,{defaultPort:c,instances:w,isPending:_.isPending,onConfirm:async e=>{try{await _.mutateAsync({targetGroupArn:r,targets:e}),x(!1)}catch{}},onOpenChange:x,open:b}),(0,W.jsx)(N,{description:S?`Deregister ${S.Id}${S.Port?`:${S.Port}`:``} from this target group?`:``,isPending:v.isPending,onConfirm:async()=>{if(S?.Id)try{await v.mutateAsync({targetGroupArn:r,targets:[{id:S.Id,port:S.Port}]})}finally{C(void 0)}},onOpenChange:e=>!e&&C(void 0),open:S!==void 0,title:`Deregister target`})]})}function te({arn:t}){let o=_(),{data:s}=n(u(t)),{data:l}=n(g(t)),{data:d}=n(h([t])),f=a(),p=c(),[m,v]=(0,U.useState)(!1),[b,x]=(0,U.useState)(`overview`),S=s.TargetGroups?.[0];if(!S?.TargetGroupArn)return(0,W.jsxs)(W.Fragment,{children:[(0,W.jsx)(r,{to:`/ec2/describe-target-groups`,children:`Back to target groups`}),(0,W.jsx)(`p`,{className:`text-muted-foreground`,children:`Target group not found.`})]});let C=async()=>{try{await f.mutateAsync(t),o({to:`/ec2/describe-target-groups`})}finally{v(!1)}},T=l.Attributes??[],E=d?.TagDescriptions?.find(e=>e.ResourceArn===t)?.Tags??[];return(0,W.jsxs)(W.Fragment,{children:[(0,W.jsx)(r,{to:`/ec2/describe-target-groups`,children:`Back to target groups`}),f.error&&(0,W.jsx)(e,{error:f.error,msg:`Failed to delete target group`}),(0,W.jsxs)(`div`,{className:`space-y-6`,children:[(0,W.jsx)(w,{actions:(0,W.jsxs)(y,{onClick:()=>v(!0),size:`sm`,variant:`destructive`,children:[(0,W.jsx)(i,{className:`size-4`}),`Delete`]}),subtitle:`Target Group Details`,title:S.TargetGroupName??S.TargetGroupArn}),(0,W.jsxs)(V,{onValueChange:x,value:b,children:[(0,W.jsxs)(R,{children:[(0,W.jsx)(L,{value:`overview`,children:`Overview`}),(0,W.jsx)(L,{value:`targets`,children:`Targets`}),(0,W.jsx)(L,{value:`health-checks`,children:`Health checks`}),(0,W.jsx)(L,{value:`attributes`,children:`Attributes`}),(0,W.jsx)(L,{value:`tags`,children:`Tags`})]}),(0,W.jsx)(z,{value:`overview`,children:(0,W.jsxs)(P,{children:[(0,W.jsx)(P.Header,{children:`Target group`}),(0,W.jsxs)(P.Content,{children:[(0,W.jsx)(F,{label:`ARN`,value:S.TargetGroupArn}),(0,W.jsx)(F,{label:`Protocol`,value:S.Protocol}),(0,W.jsx)(F,{label:`Port`,value:S.Port?.toString()}),(0,W.jsx)(F,{label:`VPC`,value:S.VpcId}),(0,W.jsx)(F,{label:`Target type`,value:S.TargetType}),(0,W.jsx)(F,{label:`Protocol version`,value:S.ProtocolVersion}),(0,W.jsx)(F,{label:`IP address type`,value:S.IpAddressType})]})]})}),(0,W.jsx)(z,{value:`targets`,children:(0,W.jsx)(ee,{defaultPort:S.Port??80,isActive:b===`targets`,targetGroupArn:S.TargetGroupArn,vpcId:S.VpcId})}),(0,W.jsxs)(z,{value:`health-checks`,children:[(0,W.jsx)(`p`,{className:`mb-3 text-sm text-muted-foreground`,children:`Health-check settings are set at creation time. Editing lands in a future slice (mulga-948).`}),(0,W.jsxs)(P,{children:[(0,W.jsx)(P.Header,{children:`Health check`}),(0,W.jsxs)(P.Content,{children:[(0,W.jsx)(F,{label:`Enabled`,value:S.HealthCheckEnabled===void 0?void 0:String(S.HealthCheckEnabled)}),(0,W.jsx)(F,{label:`Protocol`,value:S.HealthCheckProtocol}),(0,W.jsx)(F,{label:`Path`,value:S.HealthCheckPath}),(0,W.jsx)(F,{label:`Port`,value:S.HealthCheckPort}),(0,W.jsx)(F,{label:`Interval (s)`,value:S.HealthCheckIntervalSeconds?.toString()}),(0,W.jsx)(F,{label:`Timeout (s)`,value:S.HealthCheckTimeoutSeconds?.toString()}),(0,W.jsx)(F,{label:`Healthy threshold`,value:S.HealthyThresholdCount?.toString()}),(0,W.jsx)(F,{label:`Unhealthy threshold`,value:S.UnhealthyThresholdCount?.toString()}),(0,W.jsx)(F,{label:`Matcher (HTTP codes)`,value:S.Matcher?.HttpCode??S.Matcher?.GrpcCode})]})]})]}),(0,W.jsx)(z,{value:`attributes`,children:(0,W.jsx)(I,{attributes:T,error:p.error,isPending:p.isPending,isSuccess:p.isSuccess,onSubmit:e=>{p.mutate({targetGroupArn:t,attributes:e})},specs:B})}),(0,W.jsx)(z,{value:`tags`,children:E.length>0?(0,W.jsxs)(P,{children:[(0,W.jsx)(P.Header,{children:`Tags`}),(0,W.jsx)(P.Content,{children:E.map(e=>(0,W.jsx)(F,{label:e.Key??``,value:e.Value},e.Key??``))})]}):(0,W.jsx)(`p`,{className:`text-muted-foreground`,children:`No tags.`})})]})]}),(0,W.jsx)(N,{description:`Are you sure you want to delete target group "${S.TargetGroupName??S.TargetGroupArn}"? This action cannot be undone.`,isPending:f.isPending,onConfirm:C,onOpenChange:v,open:m,title:`Delete Target Group`})]})}function ne(){let e=(0,H.c)(2),{id:t}=C.useParams(),n=decodeURIComponent(t),r;return e[0]===n?r=e[1]:(r=(0,W.jsx)(te,{arn:n}),e[0]=n,e[1]=r),r}export{ne as component};
\ No newline at end of file
+import{n as e,o as t,r as n,t as r}from"./compiler-runtime.js";import{t as i}from"./error-banner.js";import{t as a}from"./useQuery.js";import{t as o}from"./useSuspenseQuery.js";import{A as s,w as c}from"./SignatureV4.js";import{t as l}from"./back-link.js";import{t as u}from"./trash-2.js";import{t as d}from"./button.js";import{_ as f,c as p,l as m,m as h,p as g}from"./elbv2.js";import{B as _,H as v,Ir as y,Rn as b,V as x,W as S,gt as C,p as w}from"./index.js";import{t as T}from"./page-heading.js";import{a as E,i as D,l as O,n as k,o as A,r as j,s as M,t as N}from"./alert-dialog.js";import{t as P}from"./delete-confirmation-dialog.js";import{n as F,t as I}from"./detail-row.js";import{i as L,n as R,t as z}from"./tags-editor.js";import{i as B,n as V,r as H,t as U}from"./tabs.js";var W=r(),G=t(n(),1),K=e();function q(e){let t=e.trim();if(t===``)return;let n=Number(t);if(!(!Number.isInteger(n)||n<1||n>65535))return n}function J(e){let t=(0,W.c)(36),{open:n,onOpenChange:r,instances:i,defaultPort:a,isPending:o,onConfirm:c}=e,l;t[0]===Symbol.for(`react.memo_cache_sentinel`)?(l={},t[0]=l):l=t[0];let[u,d]=(0,G.useState)(l),[f,p]=(0,G.useState)(),m,h;t[1]===n?(m=t[2],h=t[3]):(m=()=>{n||(d({}),p(void 0))},h=[n],t[1]=n,t[2]=m,t[3]=h),(0,G.useEffect)(m,h);let g;t[4]===Symbol.for(`react.memo_cache_sentinel`)?(g=e=>{d(t=>{let n=t[e];return n?.checked?{...t,[e]:{...n,checked:!1}}:{...t,[e]:{checked:!0,port:n?.port??``}}})},t[4]=g):g=t[4];let _=g,v;t[5]===Symbol.for(`react.memo_cache_sentinel`)?(v=(e,t)=>{d(n=>({...n,[e]:{checked:n[e]?.checked??!0,port:t}}))},t[5]=v):v=t[5];let y=v,x;t[6]!==c||t[7]!==u?(x=()=>{let e=Object.entries(u).filter(Y);if(e.length===0){p(`Select at least one instance.`);return}let t=[];for(let[n,r]of e){if(r.port.trim()===``){t.push({id:n});continue}let e=q(r.port);if(e===void 0){p(`Port for ${n} must be an integer between 1 and 65535.`);return}t.push({id:n,port:e})}p(void 0),c(t)},t[6]=c,t[7]=u,t[8]=x):x=t[8];let S=x,C;t[9]===Symbol.for(`react.memo_cache_sentinel`)?(C=(0,K.jsx)(O,{children:`Register targets`}),t[9]=C):C=t[9];let w;t[10]===a?w=t[11]:(w=(0,K.jsxs)(M,{children:[C,(0,K.jsxs)(E,{children:[`Select instances to register. Leave port blank to use the target group port (`,a,`).`]})]}),t[10]=a,t[11]=w);let T;t[12]!==a||t[13]!==i||t[14]!==u?(T=i.length===0?(0,K.jsx)(`p`,{className:`text-xs text-muted-foreground`,children:`No instances available in this VPC.`}):(0,K.jsx)(`div`,{className:`max-h-72 space-y-1 overflow-y-auto`,children:i.map(e=>{let t=e.InstanceId??``,n=u[t],r=n?.checked??!1,i=s(e.Tags);return(0,K.jsxs)(`div`,{className:`flex items-center gap-2 rounded px-1 py-1 text-xs hover:bg-accent`,children:[(0,K.jsx)(`input`,{"aria-label":`Register target ${t}`,checked:r,id:`register-target-${t}`,onChange:()=>_(t),type:`checkbox`}),(0,K.jsxs)(`label`,{className:`flex flex-1 items-center gap-2`,htmlFor:`register-target-${t}`,children:[(0,K.jsx)(`span`,{className:`font-mono`,children:t}),i&&(0,K.jsxs)(`span`,{className:`text-muted-foreground`,children:[`(`,i,`)`]}),(0,K.jsx)(`span`,{className:`text-muted-foreground`,children:e.State?.Name??``})]}),r&&(0,K.jsx)(b,{"aria-label":`Port override for ${t}`,className:`w-24`,onChange:e=>y(t,e.target.value),placeholder:String(a),value:n?.port??``})]},t)})}),t[12]=a,t[13]=i,t[14]=u,t[15]=T):T=t[15];let P;t[16]===f?P=t[17]:(P=f&&(0,K.jsx)(`p`,{className:`text-xs text-destructive`,role:`alert`,children:f}),t[16]=f,t[17]=P);let F;t[18]===o?F=t[19]:(F=(0,K.jsx)(j,{disabled:o,children:`Cancel`}),t[18]=o,t[19]=F);let I=o?`Registering…`:`Register`,L;t[20]!==S||t[21]!==o||t[22]!==I?(L=(0,K.jsx)(k,{disabled:o,onClick:S,children:I}),t[20]=S,t[21]=o,t[22]=I,t[23]=L):L=t[23];let R;t[24]!==F||t[25]!==L?(R=(0,K.jsxs)(A,{children:[F,L]}),t[24]=F,t[25]=L,t[26]=R):R=t[26];let z;t[27]!==P||t[28]!==R||t[29]!==w||t[30]!==T?(z=(0,K.jsxs)(D,{className:`sm:max-w-lg`,children:[w,T,P,R]}),t[27]=P,t[28]=R,t[29]=w,t[30]=T,t[31]=z):z=t[31];let B;return t[32]!==r||t[33]!==n||t[34]!==z?(B=(0,K.jsx)(N,{onOpenChange:r,open:n,children:z}),t[32]=r,t[33]=n,t[34]=z,t[35]=B):B=t[35],B}function Y(e){let[,t]=e;return t.checked&&t!==void 0}var X=5e3,Z=new Set([`healthy`]),Q=new Set([`unhealthy`,`unavailable`]),$=new Set([`initial`,`draining`]);function ee(e){return e&&Z.has(e)?`bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-100`:e&&Q.has(e)?`bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-100`:e&&$.has(e)?`bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-100`:`bg-gray-100 text-gray-800 dark:bg-gray-800 dark:text-gray-100`}function te(e){return`${e.Id??``}:${e.Port??``}`}function ne({targetGroupArn:e,vpcId:t,defaultPort:n,isActive:r}){let{data:s}=o(C),{data:l,isLoading:f,error:p}=a({...S(e),refetchInterval:r?X:!1,refetchIntervalInBackground:!1}),g=h(),_=m(),[v,y]=(0,G.useState)(!1),[b,x]=(0,G.useState)(),w=(s.Reservations?.flatMap(e=>e.Instances??[])??[]).filter(e=>e.VpcId===t&&e.State?.Name!==`terminated`),T=l?.TargetHealthDescriptions??[];return(0,K.jsxs)(`div`,{className:`space-y-3`,children:[g.error&&(0,K.jsx)(i,{error:g.error,msg:`Failed to register targets`}),_.error&&(0,K.jsx)(i,{error:_.error,msg:`Failed to deregister target`}),p&&(0,K.jsx)(i,{error:p,msg:`Failed to load target health`}),(0,K.jsxs)(`div`,{className:`flex items-center justify-between`,children:[(0,K.jsx)(`p`,{className:`text-xs text-muted-foreground`,children:f?`Loading target health…`:`${T.length} target${T.length===1?``:`s`}`}),(0,K.jsx)(d,{onClick:()=>y(!0),size:`sm`,children:`Register targets`})]}),T.length===0?(0,K.jsx)(`p`,{className:`text-muted-foreground`,children:`No targets registered.`}):(0,K.jsx)(`div`,{className:`overflow-x-auto rounded-lg border bg-card`,children:(0,K.jsxs)(`table`,{className:`w-full text-sm`,children:[(0,K.jsx)(`thead`,{children:(0,K.jsxs)(`tr`,{className:`border-b text-left text-muted-foreground`,children:[(0,K.jsx)(`th`,{className:`px-4 py-2 font-medium`,children:`Target`}),(0,K.jsx)(`th`,{className:`px-4 py-2 font-medium`,children:`Port`}),(0,K.jsx)(`th`,{className:`px-4 py-2 font-medium`,children:`AZ`}),(0,K.jsx)(`th`,{className:`px-4 py-2 font-medium`,children:`Health`}),(0,K.jsx)(`th`,{className:`px-4 py-2 font-medium`,children:`Reason`}),(0,K.jsx)(`th`,{className:`px-4 py-2 font-medium`,children:`Description`}),(0,K.jsx)(`th`,{className:`px-4 py-2 font-medium`,children:(0,K.jsx)(`span`,{className:`sr-only`,children:`Actions`})})]})}),(0,K.jsx)(`tbody`,{children:T.map(e=>{let t=e.Target??{Id:``},n=e.TargetHealth?.State;return(0,K.jsxs)(`tr`,{className:`border-b last:border-0`,children:[(0,K.jsx)(`td`,{className:`px-4 py-2 font-mono text-xs`,children:t.Id??``}),(0,K.jsx)(`td`,{className:`px-4 py-2`,children:t.Port??``}),(0,K.jsx)(`td`,{className:`px-4 py-2 text-xs`,children:t.AvailabilityZone??``}),(0,K.jsx)(`td`,{className:`px-4 py-2`,children:(0,K.jsx)(`span`,{className:c(`rounded-full px-2 py-0.5 text-xs`,ee(n)),children:n??`unknown`})}),(0,K.jsx)(`td`,{className:`px-4 py-2 text-xs`,children:e.TargetHealth?.Reason??``}),(0,K.jsx)(`td`,{className:`px-4 py-2 text-xs`,children:e.TargetHealth?.Description??``}),(0,K.jsx)(`td`,{className:`px-4 py-2 text-right`,children:(0,K.jsx)(d,{"aria-label":`Deregister ${t.Id}`,onClick:()=>x(t),size:`sm`,variant:`ghost`,children:(0,K.jsx)(u,{className:`size-4`})})})]},te(t))})})]})}),(0,K.jsx)(J,{defaultPort:n,instances:w,isPending:g.isPending,onConfirm:async t=>{try{await g.mutateAsync({targetGroupArn:e,targets:t}),y(!1)}catch{}},onOpenChange:y,open:v}),(0,K.jsx)(P,{description:b?`Deregister ${b.Id}${b.Port?`:${b.Port}`:``} from this target group?`:``,isPending:_.isPending,onConfirm:async()=>{if(b?.Id)try{await _.mutateAsync({targetGroupArn:e,targets:[{id:b.Id,port:b.Port}]})}finally{x(void 0)}},onOpenChange:e=>!e&&x(void 0),open:b!==void 0,title:`Deregister target`})]})}function re({arn:e}){let t=y(),{data:n}=o(v(e)),{data:r}=o(x(e)),{data:a}=o(_([e])),s=p(),c=g(),m=f(),[h,b]=(0,G.useState)(!1),[S,C]=(0,G.useState)(`overview`),w=n.TargetGroups?.[0];if(!w?.TargetGroupArn)return(0,K.jsxs)(K.Fragment,{children:[(0,K.jsx)(l,{to:`/ec2/describe-target-groups`,children:`Back to target groups`}),(0,K.jsx)(`p`,{className:`text-muted-foreground`,children:`Target group not found.`})]});let E=async()=>{try{await s.mutateAsync(e),t({to:`/ec2/describe-target-groups`})}finally{b(!1)}},D=r.Attributes??[],O=a?.TagDescriptions?.find(t=>t.ResourceArn===e)?.Tags??[];return(0,K.jsxs)(K.Fragment,{children:[(0,K.jsx)(l,{to:`/ec2/describe-target-groups`,children:`Back to target groups`}),s.error&&(0,K.jsx)(i,{error:s.error,msg:`Failed to delete target group`}),(0,K.jsxs)(`div`,{className:`space-y-6`,children:[(0,K.jsx)(T,{actions:(0,K.jsxs)(d,{onClick:()=>b(!0),size:`sm`,variant:`destructive`,children:[(0,K.jsx)(u,{className:`size-4`}),`Delete`]}),subtitle:`Target Group Details`,title:w.TargetGroupName??w.TargetGroupArn}),(0,K.jsxs)(U,{onValueChange:C,value:S,children:[(0,K.jsxs)(V,{children:[(0,K.jsx)(B,{value:`overview`,children:`Overview`}),(0,K.jsx)(B,{value:`targets`,children:`Targets`}),(0,K.jsx)(B,{value:`health-checks`,children:`Health checks`}),(0,K.jsx)(B,{value:`attributes`,children:`Attributes`}),(0,K.jsx)(B,{value:`tags`,children:`Tags`})]}),(0,K.jsx)(H,{value:`overview`,children:(0,K.jsxs)(F,{children:[(0,K.jsx)(F.Header,{children:`Target group`}),(0,K.jsxs)(F.Content,{children:[(0,K.jsx)(I,{label:`ARN`,value:w.TargetGroupArn}),(0,K.jsx)(I,{label:`Protocol`,value:w.Protocol}),(0,K.jsx)(I,{label:`Port`,value:w.Port?.toString()}),(0,K.jsx)(I,{label:`VPC`,value:w.VpcId}),(0,K.jsx)(I,{label:`Target type`,value:w.TargetType}),(0,K.jsx)(I,{label:`Protocol version`,value:w.ProtocolVersion}),(0,K.jsx)(I,{label:`IP address type`,value:w.IpAddressType})]})]})}),(0,K.jsx)(H,{value:`targets`,children:(0,K.jsx)(ne,{defaultPort:w.Port??80,isActive:S===`targets`,targetGroupArn:w.TargetGroupArn,vpcId:w.VpcId})}),(0,K.jsxs)(H,{value:`health-checks`,children:[(0,K.jsx)(`p`,{className:`mb-3 text-sm text-muted-foreground`,children:`Health-check settings are set at creation time. Editing lands in a future slice (mulga-948).`}),(0,K.jsxs)(F,{children:[(0,K.jsx)(F.Header,{children:`Health check`}),(0,K.jsxs)(F.Content,{children:[(0,K.jsx)(I,{label:`Enabled`,value:w.HealthCheckEnabled===void 0?void 0:String(w.HealthCheckEnabled)}),(0,K.jsx)(I,{label:`Protocol`,value:w.HealthCheckProtocol}),(0,K.jsx)(I,{label:`Path`,value:w.HealthCheckPath}),(0,K.jsx)(I,{label:`Port`,value:w.HealthCheckPort}),(0,K.jsx)(I,{label:`Interval (s)`,value:w.HealthCheckIntervalSeconds?.toString()}),(0,K.jsx)(I,{label:`Timeout (s)`,value:w.HealthCheckTimeoutSeconds?.toString()}),(0,K.jsx)(I,{label:`Healthy threshold`,value:w.HealthyThresholdCount?.toString()}),(0,K.jsx)(I,{label:`Unhealthy threshold`,value:w.UnhealthyThresholdCount?.toString()}),(0,K.jsx)(I,{label:`Matcher (HTTP codes)`,value:w.Matcher?.HttpCode??w.Matcher?.GrpcCode})]})]})]}),(0,K.jsx)(H,{value:`attributes`,children:(0,K.jsx)(R,{attributes:D,error:c.error,isPending:c.isPending,isSuccess:c.isSuccess,onSubmit:t=>{c.mutate({targetGroupArn:e,attributes:t})},specs:L})}),(0,K.jsx)(H,{value:`tags`,children:(0,K.jsx)(z,{error:m.error,isPending:m.isPending,isSuccess:m.isSuccess,onSubmit:t=>m.mutate({resourceArn:e,tags:t,initialKeys:O.map(e=>e.Key??``).filter(e=>e.length>0)}),tags:O})})]})]}),(0,K.jsx)(P,{description:`Are you sure you want to delete target group "${w.TargetGroupName??w.TargetGroupArn}"? This action cannot be undone.`,isPending:s.isPending,onConfirm:E,onOpenChange:b,open:h,title:`Delete Target Group`})]})}function ie(){let e=(0,W.c)(2),{id:t}=w.useParams(),n=decodeURIComponent(t),r;return e[0]===n?r=e[1]:(r=(0,K.jsx)(re,{arn:n}),e[0]=n,e[1]=r),r}export{ie as component};
\ No newline at end of file
diff --git a/spinifex/services/spinifexui/frontend/dist/assets/_id14.js b/spinifex/services/spinifexui/frontend/dist/assets/_id14.js
index 4892f76e3..2cabd19c2 100644
--- a/spinifex/services/spinifexui/frontend/dist/assets/_id14.js
+++ b/spinifex/services/spinifexui/frontend/dist/assets/_id14.js
@@ -1 +1 @@
-import{t as e}from"./error-banner.js";import{t}from"./useSuspenseQuery.js";import{t as n}from"./back-link.js";import{c as ee,i as r,n as i,s as a,t as te}from"./select.js";import{t as o}from"./trash-2.js";import{F as s,M as c,a as l}from"./ec2.js";import{Co as u,Ho as d,J as f,Lo as p,Ro as m,Uo as h,Xa as g,Xi as _,Za as v,ao as ne,cs as y,ft as re,m as b,no as x,wo as S}from"./index.js";import{t as ie}from"./page-heading.js";import{a as C,t as w}from"./field.js";import{a as T,i as E,l as D,n as O,o as k,r as A,s as j,t as M}from"./alert-dialog.js";import{t as ae}from"./delete-confirmation-dialog.js";import{n as N,t as P}from"./detail-row.js";import{t as F}from"./state-badge.js";var I=S(`link-2-off`,[[`path`,{d:`M9 17H7A5 5 0 0 1 7 7`,key:`10o201`}],[`path`,{d:`M15 7h2a5 5 0 0 1 4 8`,key:`1d3206`}],[`line`,{x1:`8`,x2:`12`,y1:`12`,y2:`12`,key:`rvw6j4`}],[`line`,{x1:`2`,x2:`22`,y1:`2`,y2:`22`,key:`a6p6uj`}]]),L=S(`link-2`,[[`path`,{d:`M9 17H7A5 5 0 0 1 7 7h2`,key:`8i5ue5`}],[`path`,{d:`M15 7h2a5 5 0 1 1 0 10h-2`,key:`1b9ql8`}],[`line`,{x1:`8`,x2:`16`,y1:`12`,y2:`12`,key:`1jonct`}]]),R=y(h()),z=d();function B(){let{id:d}=b.useParams(),h=m(),{data:y}=t(re(d)),{data:S}=t(f),B=y.Volumes?.[0],V=c(),H=l(),U=s(),[W,G]=(0,R.useState)(null),[K,q]=(0,R.useState)(``),[J,Y]=(0,R.useState)(``),[X,Z]=(0,R.useState)(!1),oe=S.Reservations?.flatMap(e=>e.Instances?.filter(e=>e.State?.Name===`running`)??[])??[],Q=B?.State===`available`&&(!B.Attachments||B.Attachments.length===0),$=()=>G(null);return B?.VolumeId?(0,z.jsxs)(z.Fragment,{children:[(0,z.jsx)(n,{to:`/ec2/describe-volumes`,children:`Back to volumes`}),V.error&&(0,z.jsx)(e,{error:V.error,msg:`Failed to delete volume`}),H.error&&(0,z.jsx)(e,{error:H.error,msg:`Failed to attach volume`}),U.error&&(0,z.jsx)(e,{error:U.error,msg:`Failed to detach volume`}),(0,z.jsxs)(`div`,{className:`space-y-6`,children:[(0,z.jsx)(ie,{actions:(0,z.jsxs)(`div`,{className:`flex items-center gap-2`,children:[(0,z.jsxs)(g,{disabled:!Q,onClick:()=>G(`delete`),size:`sm`,variant:`destructive`,children:[(0,z.jsx)(o,{className:`size-4`}),`Delete`]}),Q?(0,z.jsx)(p,{className:v({variant:`outline`}),params:{id:B.VolumeId},to:`/ec2/modify-volume/$id`,children:`Resize Volume`}):(0,z.jsx)(g,{disabled:!0,variant:`outline`,children:`Resize Volume`}),Q?(0,z.jsxs)(g,{onClick:()=>G(`attach`),size:`sm`,variant:`outline`,children:[(0,z.jsx)(L,{className:`size-4`}),`Attach`]}):null,B.Attachments&&B.Attachments.length>0?(0,z.jsxs)(g,{onClick:()=>G(`detach`),size:`sm`,variant:`outline`,children:[(0,z.jsx)(I,{className:`size-4`}),`Detach`]}):null,(0,z.jsxs)(p,{className:v({variant:`outline`,size:`sm`}),search:{volumeId:B.VolumeId},to:`/ec2/create-snapshot`,children:[(0,z.jsx)(u,{className:`size-4`}),`Create Snapshot`]}),(0,z.jsx)(F,{state:B.State})]}),subtitle:`Volume Details`,title:B.VolumeId}),(0,z.jsxs)(N,{children:[(0,z.jsx)(N.Header,{children:`Volume Information`}),(0,z.jsxs)(N.Content,{children:[(0,z.jsx)(P,{label:`Volume ID`,value:B.VolumeId}),(0,z.jsx)(P,{label:`Size`,value:`${B.Size} GiB`}),(0,z.jsx)(P,{label:`Volume Type`,value:B.VolumeType}),(0,z.jsx)(P,{label:`State`,value:B.State}),(0,z.jsx)(P,{label:`Availability Zone`,value:B.AvailabilityZone}),(0,z.jsx)(P,{label:`Create Time`,value:x(B.CreateTime)}),(0,z.jsx)(P,{label:`Encrypted`,value:B.Encrypted?`Yes`:`No`}),(0,z.jsx)(P,{label:`KMS Key ID`,value:B.KmsKeyId})]})]}),B.Attachments&&B.Attachments.length>0&&(0,z.jsxs)(N,{children:[(0,z.jsx)(N.Header,{children:`Attachments`}),B.Attachments.map(e=>(0,z.jsxs)(N.Content,{children:[(0,z.jsx)(P,{label:`Instance ID`,value:e.InstanceId?(0,z.jsx)(p,{className:`text-primary hover:underline`,params:{id:e.InstanceId},to:`/ec2/describe-instances/$id`,children:e.InstanceId}):void 0}),(0,z.jsx)(P,{label:`Device`,value:e.Device}),(0,z.jsx)(P,{label:`Status`,value:e.State}),(0,z.jsx)(P,{label:`Attach Time`,value:x(e.AttachTime)}),(0,z.jsx)(P,{label:`Delete on Termination`,value:e.DeleteOnTermination?`Yes`:`No`})]},e.InstanceId))]})]}),(0,z.jsx)(ae,{description:`Are you sure you want to delete the volume "${B.VolumeId}"? This action cannot be undone.`,isPending:V.isPending,onConfirm:async()=>{try{await V.mutateAsync(d),h({to:`/ec2/describe-volumes`})}finally{$()}},onOpenChange:e=>{e||$()},open:W===`delete`,title:`Delete Volume`}),(0,z.jsx)(M,{onOpenChange:e=>{e||($(),q(``),Y(``))},open:W===`attach`,children:(0,z.jsxs)(E,{children:[(0,z.jsxs)(j,{children:[(0,z.jsx)(D,{children:`Attach Volume`}),(0,z.jsx)(T,{children:`Attach this volume to a running instance.`})]}),(0,z.jsxs)(w,{children:[(0,z.jsx)(C,{children:(0,z.jsx)(`label`,{htmlFor:`attachInstance`,children:`Instance`})}),(0,z.jsxs)(te,{onValueChange:e=>q(e??``),value:K,children:[(0,z.jsx)(a,{className:`w-full`,id:`attachInstance`,children:(0,z.jsx)(ee,{placeholder:`Select an instance`})}),(0,z.jsx)(i,{children:oe.map(e=>{let t=ne(e.Tags);return(0,z.jsxs)(r,{value:e.InstanceId??``,children:[e.InstanceId,t?` (${t})`:``]},e.InstanceId)})})]})]}),(0,z.jsxs)(w,{children:[(0,z.jsx)(C,{children:(0,z.jsx)(`label`,{htmlFor:`attachDevice`,children:`Device (optional)`})}),(0,z.jsx)(_,{id:`attachDevice`,onChange:e=>Y(e.target.value),placeholder:`/dev/sdf`,value:J})]}),(0,z.jsxs)(k,{children:[(0,z.jsx)(A,{children:`Cancel`}),(0,z.jsx)(O,{disabled:H.isPending||!K,onClick:async()=>{try{await H.mutateAsync({volumeId:d,instanceId:K,device:J||void 0}),$(),q(``),Y(``)}catch{}},children:H.isPending?`Attaching…`:`Attach`})]})]})}),(0,z.jsx)(M,{onOpenChange:e=>{e||($(),Z(!1))},open:W===`detach`,children:(0,z.jsxs)(E,{children:[(0,z.jsxs)(j,{children:[(0,z.jsx)(D,{children:`Detach Volume`}),(0,z.jsxs)(T,{children:[`Are you sure you want to detach the volume "`,B.VolumeId,`" from instance "`,B.Attachments?.[0]?.InstanceId,`"?`]})]}),(0,z.jsxs)(`label`,{className:`flex items-center gap-2 text-sm`,children:[(0,z.jsx)(`input`,{"aria-label":`Force detach`,checked:X,onChange:e=>Z(e.target.checked),type:`checkbox`}),`Force detach`]}),(0,z.jsxs)(k,{children:[(0,z.jsx)(A,{children:`Cancel`}),(0,z.jsx)(O,{disabled:U.isPending,onClick:async()=>{let e=B?.Attachments?.[0]?.InstanceId;try{await U.mutateAsync({volumeId:d,instanceId:e??void 0,force:X||void 0}),$(),Z(!1)}catch{}},children:U.isPending?`Detaching…`:`Detach`})]})]})})]}):(0,z.jsxs)(z.Fragment,{children:[(0,z.jsx)(n,{to:`/ec2/describe-volumes`,children:`Back to volumes`}),(0,z.jsx)(`p`,{className:`text-muted-foreground`,children:`Volume not found.`})]})}export{B as component};
\ No newline at end of file
+import{n as e,o as t,r as n}from"./compiler-runtime.js";import{t as r}from"./error-banner.js";import{t as i}from"./useSuspenseQuery.js";import{Or as a}from"./awsClient.js";import{A as o,D as s}from"./SignatureV4.js";import{t as c}from"./back-link.js";import{c as l,i as ee,n as u,s as d,t as f}from"./select.js";import{t as p}from"./trash-2.js";import{n as m,t as h}from"./button.js";import{I as g,N as _,o as v}from"./ec2.js";import{Ir as y,Mr as te,Nr as b,Pt as ne,Rn as x,gt as S,m as re}from"./index.js";import{t as ie}from"./page-heading.js";import{a as C,t as w}from"./field.js";import{a as T,i as E,l as D,n as O,o as k,r as A,s as j,t as M}from"./alert-dialog.js";import{t as ae}from"./delete-confirmation-dialog.js";import{n as N,t as P}from"./detail-row.js";import{t as F}from"./state-badge.js";var I=b(`link-2-off`,[[`path`,{d:`M9 17H7A5 5 0 0 1 7 7`,key:`10o201`}],[`path`,{d:`M15 7h2a5 5 0 0 1 4 8`,key:`1d3206`}],[`line`,{x1:`8`,x2:`12`,y1:`12`,y2:`12`,key:`rvw6j4`}],[`line`,{x1:`2`,x2:`22`,y1:`2`,y2:`22`,key:`a6p6uj`}]]),L=b(`link-2`,[[`path`,{d:`M9 17H7A5 5 0 0 1 7 7h2`,key:`8i5ue5`}],[`path`,{d:`M15 7h2a5 5 0 1 1 0 10h-2`,key:`1b9ql8`}],[`line`,{x1:`8`,x2:`16`,y1:`12`,y2:`12`,key:`1jonct`}]]),R=t(n()),z=e();function B(){let{id:e}=re.useParams(),t=y(),{data:n}=i(ne(e)),{data:b}=i(S),B=n.Volumes?.[0],V=_(),H=v(),U=g(),[W,G]=(0,R.useState)(null),[K,q]=(0,R.useState)(``),[J,Y]=(0,R.useState)(``),[X,Z]=(0,R.useState)(!1),oe=b.Reservations?.flatMap(e=>e.Instances?.filter(e=>e.State?.Name===`running`)??[])??[],Q=B?.State===`available`&&(!B.Attachments||B.Attachments.length===0),$=()=>G(null);return B?.VolumeId?(0,z.jsxs)(z.Fragment,{children:[(0,z.jsx)(c,{to:`/ec2/describe-volumes`,children:`Back to volumes`}),V.error&&(0,z.jsx)(r,{error:V.error,msg:`Failed to delete volume`}),H.error&&(0,z.jsx)(r,{error:H.error,msg:`Failed to attach volume`}),U.error&&(0,z.jsx)(r,{error:U.error,msg:`Failed to detach volume`}),(0,z.jsxs)(`div`,{className:`space-y-6`,children:[(0,z.jsx)(ie,{actions:(0,z.jsxs)(`div`,{className:`flex items-center gap-2`,children:[(0,z.jsxs)(h,{disabled:!Q,onClick:()=>G(`delete`),size:`sm`,variant:`destructive`,children:[(0,z.jsx)(p,{className:`size-4`}),`Delete`]}),Q?(0,z.jsx)(a,{className:m({variant:`outline`}),params:{id:B.VolumeId},to:`/ec2/modify-volume/$id`,children:`Resize Volume`}):(0,z.jsx)(h,{disabled:!0,variant:`outline`,children:`Resize Volume`}),Q?(0,z.jsxs)(h,{onClick:()=>G(`attach`),size:`sm`,variant:`outline`,children:[(0,z.jsx)(L,{className:`size-4`}),`Attach`]}):null,B.Attachments&&B.Attachments.length>0?(0,z.jsxs)(h,{onClick:()=>G(`detach`),size:`sm`,variant:`outline`,children:[(0,z.jsx)(I,{className:`size-4`}),`Detach`]}):null,(0,z.jsxs)(a,{className:m({variant:`outline`,size:`sm`}),search:{volumeId:B.VolumeId},to:`/ec2/create-snapshot`,children:[(0,z.jsx)(te,{className:`size-4`}),`Create Snapshot`]}),(0,z.jsx)(F,{state:B.State})]}),subtitle:`Volume Details`,title:B.VolumeId}),(0,z.jsxs)(N,{children:[(0,z.jsx)(N.Header,{children:`Volume Information`}),(0,z.jsxs)(N.Content,{children:[(0,z.jsx)(P,{label:`Volume ID`,value:B.VolumeId}),(0,z.jsx)(P,{label:`Size`,value:`${B.Size} GiB`}),(0,z.jsx)(P,{label:`Volume Type`,value:B.VolumeType}),(0,z.jsx)(P,{label:`State`,value:B.State}),(0,z.jsx)(P,{label:`Availability Zone`,value:B.AvailabilityZone}),(0,z.jsx)(P,{label:`Create Time`,value:s(B.CreateTime)}),(0,z.jsx)(P,{label:`Encrypted`,value:B.Encrypted?`Yes`:`No`}),(0,z.jsx)(P,{label:`KMS Key ID`,value:B.KmsKeyId})]})]}),B.Attachments&&B.Attachments.length>0&&(0,z.jsxs)(N,{children:[(0,z.jsx)(N.Header,{children:`Attachments`}),B.Attachments.map(e=>(0,z.jsxs)(N.Content,{children:[(0,z.jsx)(P,{label:`Instance ID`,value:e.InstanceId?(0,z.jsx)(a,{className:`text-primary hover:underline`,params:{id:e.InstanceId},to:`/ec2/describe-instances/$id`,children:e.InstanceId}):void 0}),(0,z.jsx)(P,{label:`Device`,value:e.Device}),(0,z.jsx)(P,{label:`Status`,value:e.State}),(0,z.jsx)(P,{label:`Attach Time`,value:s(e.AttachTime)}),(0,z.jsx)(P,{label:`Delete on Termination`,value:e.DeleteOnTermination?`Yes`:`No`})]},e.InstanceId))]})]}),(0,z.jsx)(ae,{description:`Are you sure you want to delete the volume "${B.VolumeId}"? This action cannot be undone.`,isPending:V.isPending,onConfirm:async()=>{try{await V.mutateAsync(e),t({to:`/ec2/describe-volumes`})}finally{$()}},onOpenChange:e=>{e||$()},open:W===`delete`,title:`Delete Volume`}),(0,z.jsx)(M,{onOpenChange:e=>{e||($(),q(``),Y(``))},open:W===`attach`,children:(0,z.jsxs)(E,{children:[(0,z.jsxs)(j,{children:[(0,z.jsx)(D,{children:`Attach Volume`}),(0,z.jsx)(T,{children:`Attach this volume to a running instance.`})]}),(0,z.jsxs)(w,{children:[(0,z.jsx)(C,{children:(0,z.jsx)(`label`,{htmlFor:`attachInstance`,children:`Instance`})}),(0,z.jsxs)(f,{onValueChange:e=>q(e??``),value:K,children:[(0,z.jsx)(d,{className:`w-full`,id:`attachInstance`,children:(0,z.jsx)(l,{placeholder:`Select an instance`})}),(0,z.jsx)(u,{children:oe.map(e=>{let t=o(e.Tags);return(0,z.jsxs)(ee,{value:e.InstanceId??``,children:[e.InstanceId,t?` (${t})`:``]},e.InstanceId)})})]})]}),(0,z.jsxs)(w,{children:[(0,z.jsx)(C,{children:(0,z.jsx)(`label`,{htmlFor:`attachDevice`,children:`Device (optional)`})}),(0,z.jsx)(x,{id:`attachDevice`,onChange:e=>Y(e.target.value),placeholder:`/dev/sdf`,value:J})]}),(0,z.jsxs)(k,{children:[(0,z.jsx)(A,{children:`Cancel`}),(0,z.jsx)(O,{disabled:H.isPending||!K,onClick:async()=>{try{await H.mutateAsync({volumeId:e,instanceId:K,device:J||void 0}),$(),q(``),Y(``)}catch{}},children:H.isPending?`Attaching…`:`Attach`})]})]})}),(0,z.jsx)(M,{onOpenChange:e=>{e||($(),Z(!1))},open:W===`detach`,children:(0,z.jsxs)(E,{children:[(0,z.jsxs)(j,{children:[(0,z.jsx)(D,{children:`Detach Volume`}),(0,z.jsxs)(T,{children:[`Are you sure you want to detach the volume "`,B.VolumeId,`" from instance "`,B.Attachments?.[0]?.InstanceId,`"?`]})]}),(0,z.jsxs)(`label`,{className:`flex items-center gap-2 text-sm`,children:[(0,z.jsx)(`input`,{"aria-label":`Force detach`,checked:X,onChange:e=>Z(e.target.checked),type:`checkbox`}),`Force detach`]}),(0,z.jsxs)(k,{children:[(0,z.jsx)(A,{children:`Cancel`}),(0,z.jsx)(O,{disabled:U.isPending,onClick:async()=>{let t=B?.Attachments?.[0]?.InstanceId;try{await U.mutateAsync({volumeId:e,instanceId:t??void 0,force:X||void 0}),$(),Z(!1)}catch{}},children:U.isPending?`Detaching…`:`Detach`})]})]})})]}):(0,z.jsxs)(z.Fragment,{children:[(0,z.jsx)(c,{to:`/ec2/describe-volumes`,children:`Back to volumes`}),(0,z.jsx)(`p`,{className:`text-muted-foreground`,children:`Volume not found.`})]})}export{B as component};
\ No newline at end of file
diff --git a/spinifex/services/spinifexui/frontend/dist/assets/_id15.js b/spinifex/services/spinifexui/frontend/dist/assets/_id15.js
index ddd546de0..43863d23b 100644
--- a/spinifex/services/spinifexui/frontend/dist/assets/_id15.js
+++ b/spinifex/services/spinifexui/frontend/dist/assets/_id15.js
@@ -1 +1 @@
-import{t as e}from"./error-banner.js";import{t}from"./useSuspenseQuery.js";import{t as n}from"./back-link.js";import{V as r}from"./ec2.js";import{Fo as i,Ho as a,Ro as o,Xi as s,ft as c,h as l}from"./index.js";import{i as u,t as d}from"./zod.js";import{t as f}from"./form-actions.js";import{t as p}from"./page-heading.js";import{a as m,r as h,t as g}from"./field.js";import{_}from"./ec22.js";var v=i(),y=a();function b(){let i=(0,v.c)(53),{id:a}=l.useParams(),b=o(),x;i[0]===a?x=i[1]:(x=c(a),i[0]=a,i[1]=x);let{data:S}=t(x),C=S.Volumes?.[0],w=C?.VolumeId,T=r(),E=C?.Size??1,D;i[2]===E?D=i[3]:(D=d(_.refine(e=>e.size>E,{message:`New size must be greater than current size (${E} GiB)`,path:[`size`]})),i[2]=E,i[3]=D);let O;i[4]===E?O=i[5]:(O={size:E},i[4]=E,i[5]=O);let k;i[6]!==D||i[7]!==O?(k={resolver:D,defaultValues:O},i[6]=D,i[7]=O,i[8]=k):k=i[8];let{handleSubmit:A,register:j,formState:M}=u(k),{errors:N,isSubmitting:P}=M;if(!w){let e;return i[9]===Symbol.for(`react.memo_cache_sentinel`)?(e=(0,y.jsxs)(y.Fragment,{children:[(0,y.jsx)(n,{to:`/ec2/describe-volumes`,children:`Back to volumes`}),(0,y.jsx)(`p`,{className:`text-muted-foreground`,children:`Volume not found.`})]}),i[9]=e):e=i[9],e}let F;i[10]!==T||i[11]!==b||i[12]!==w?(F=async e=>{await T.mutateAsync({...e,volumeId:w}),b({to:`/ec2/describe-volumes/$id`,params:{id:w}})},i[10]=T,i[11]=b,i[12]=w,i[13]=F):F=i[13];let I=F,L,R;i[14]===w?(L=i[15],R=i[16]):(L=(0,y.jsx)(n,{params:{id:w},to:`/ec2/describe-volumes/$id`,children:`Back to volume`}),R=(0,y.jsx)(p,{subtitle:w,title:`Resize Volume`}),i[14]=w,i[15]=L,i[16]=R);let z;i[17]===T.error?z=i[18]:(z=T.error&&(0,y.jsx)(e,{error:T.error,msg:`Failed to resize volume`}),i[17]=T.error,i[18]=z);let B;i[19]!==A||i[20]!==I?(B=A(I),i[19]=A,i[20]=I,i[21]=B):B=i[21];let V;i[22]===E?V=i[23]:(V=(0,y.jsx)(`div`,{className:`space-y-2`,children:(0,y.jsxs)(`p`,{className:`text-sm text-muted-foreground`,children:[`Current size: `,E,` GiB`]})}),i[22]=E,i[23]=V);let H;i[24]===Symbol.for(`react.memo_cache_sentinel`)?(H=(0,y.jsx)(m,{children:(0,y.jsx)(`label`,{htmlFor:`size`,children:`New size (GiB)`})}),i[24]=H):H=i[24];let U=!!N.size,W=E+1,G;i[25]===j?G=i[26]:(G=j(`size`,{valueAsNumber:!0}),i[25]=j,i[26]=G);let K;i[27]!==U||i[28]!==W||i[29]!==G?(K=(0,y.jsx)(s,{"aria-invalid":U,id:`size`,min:W,type:`number`,...G}),i[27]=U,i[28]=W,i[29]=G,i[30]=K):K=i[30];let q;i[31]===N.size?q=i[32]:(q=(0,y.jsx)(h,{errors:[N.size]}),i[31]=N.size,i[32]=q);let J;i[33]!==K||i[34]!==q?(J=(0,y.jsxs)(g,{children:[H,K,q]}),i[33]=K,i[34]=q,i[35]=J):J=i[35];let Y;i[36]!==b||i[37]!==w?(Y=async()=>await b({to:`/ec2/describe-volumes/$id`,params:{id:w}}),i[36]=b,i[37]=w,i[38]=Y):Y=i[38];let X;i[39]!==P||i[40]!==T.isPending||i[41]!==Y?(X=(0,y.jsx)(f,{isPending:T.isPending,isSubmitting:P,onCancel:Y,pendingLabel:`Resizing\\u2026`,submitLabel:`Resize Volume`}),i[39]=P,i[40]=T.isPending,i[41]=Y,i[42]=X):X=i[42];let Z;i[43]!==V||i[44]!==J||i[45]!==X||i[46]!==B?(Z=(0,y.jsxs)(`form`,{className:`max-w-4xl space-y-6`,onSubmit:B,children:[V,J,X]}),i[43]=V,i[44]=J,i[45]=X,i[46]=B,i[47]=Z):Z=i[47];let Q;return i[48]!==Z||i[49]!==L||i[50]!==R||i[51]!==z?(Q=(0,y.jsxs)(y.Fragment,{children:[L,R,z,Z]}),i[48]=Z,i[49]=L,i[50]=R,i[51]=z,i[52]=Q):Q=i[52],Q}export{b as component};
\ No newline at end of file
+import{n as e,t}from"./compiler-runtime.js";import{t as n}from"./error-banner.js";import{t as r}from"./useSuspenseQuery.js";import{t as i}from"./back-link.js";import{U as a}from"./ec2.js";import{Ir as o,Pt as s,Rn as c,h as l}from"./index.js";import{i as u,t as d}from"./zod.js";import{t as f}from"./form-actions.js";import{t as p}from"./page-heading.js";import{a as m,r as h,t as g}from"./field.js";import{_}from"./ec22.js";var v=t(),y=e();function b(){let e=(0,v.c)(53),{id:t}=l.useParams(),b=o(),x;e[0]===t?x=e[1]:(x=s(t),e[0]=t,e[1]=x);let{data:S}=r(x),C=S.Volumes?.[0],w=C?.VolumeId,T=a(),E=C?.Size??1,D;e[2]===E?D=e[3]:(D=d(_.refine(e=>e.size>E,{message:`New size must be greater than current size (${E} GiB)`,path:[`size`]})),e[2]=E,e[3]=D);let O;e[4]===E?O=e[5]:(O={size:E},e[4]=E,e[5]=O);let k;e[6]!==D||e[7]!==O?(k={resolver:D,defaultValues:O},e[6]=D,e[7]=O,e[8]=k):k=e[8];let{handleSubmit:A,register:j,formState:M}=u(k),{errors:N,isSubmitting:P}=M;if(!w){let t;return e[9]===Symbol.for(`react.memo_cache_sentinel`)?(t=(0,y.jsxs)(y.Fragment,{children:[(0,y.jsx)(i,{to:`/ec2/describe-volumes`,children:`Back to volumes`}),(0,y.jsx)(`p`,{className:`text-muted-foreground`,children:`Volume not found.`})]}),e[9]=t):t=e[9],t}let F;e[10]!==T||e[11]!==b||e[12]!==w?(F=async e=>{await T.mutateAsync({...e,volumeId:w}),b({to:`/ec2/describe-volumes/$id`,params:{id:w}})},e[10]=T,e[11]=b,e[12]=w,e[13]=F):F=e[13];let I=F,L,R;e[14]===w?(L=e[15],R=e[16]):(L=(0,y.jsx)(i,{params:{id:w},to:`/ec2/describe-volumes/$id`,children:`Back to volume`}),R=(0,y.jsx)(p,{subtitle:w,title:`Resize Volume`}),e[14]=w,e[15]=L,e[16]=R);let z;e[17]===T.error?z=e[18]:(z=T.error&&(0,y.jsx)(n,{error:T.error,msg:`Failed to resize volume`}),e[17]=T.error,e[18]=z);let B;e[19]!==A||e[20]!==I?(B=A(I),e[19]=A,e[20]=I,e[21]=B):B=e[21];let V;e[22]===E?V=e[23]:(V=(0,y.jsx)(`div`,{className:`space-y-2`,children:(0,y.jsxs)(`p`,{className:`text-sm text-muted-foreground`,children:[`Current size: `,E,` GiB`]})}),e[22]=E,e[23]=V);let H;e[24]===Symbol.for(`react.memo_cache_sentinel`)?(H=(0,y.jsx)(m,{children:(0,y.jsx)(`label`,{htmlFor:`size`,children:`New size (GiB)`})}),e[24]=H):H=e[24];let U=!!N.size,W=E+1,G;e[25]===j?G=e[26]:(G=j(`size`,{valueAsNumber:!0}),e[25]=j,e[26]=G);let K;e[27]!==U||e[28]!==W||e[29]!==G?(K=(0,y.jsx)(c,{"aria-invalid":U,id:`size`,min:W,type:`number`,...G}),e[27]=U,e[28]=W,e[29]=G,e[30]=K):K=e[30];let q;e[31]===N.size?q=e[32]:(q=(0,y.jsx)(h,{errors:[N.size]}),e[31]=N.size,e[32]=q);let J;e[33]!==K||e[34]!==q?(J=(0,y.jsxs)(g,{children:[H,K,q]}),e[33]=K,e[34]=q,e[35]=J):J=e[35];let Y;e[36]!==b||e[37]!==w?(Y=async()=>await b({to:`/ec2/describe-volumes/$id`,params:{id:w}}),e[36]=b,e[37]=w,e[38]=Y):Y=e[38];let X;e[39]!==P||e[40]!==T.isPending||e[41]!==Y?(X=(0,y.jsx)(f,{isPending:T.isPending,isSubmitting:P,onCancel:Y,pendingLabel:`Resizing\\u2026`,submitLabel:`Resize Volume`}),e[39]=P,e[40]=T.isPending,e[41]=Y,e[42]=X):X=e[42];let Z;e[43]!==V||e[44]!==J||e[45]!==X||e[46]!==B?(Z=(0,y.jsxs)(`form`,{className:`max-w-4xl space-y-6`,onSubmit:B,children:[V,J,X]}),e[43]=V,e[44]=J,e[45]=X,e[46]=B,e[47]=Z):Z=e[47];let Q;return e[48]!==Z||e[49]!==L||e[50]!==R||e[51]!==z?(Q=(0,y.jsxs)(y.Fragment,{children:[L,R,z,Z]}),e[48]=Z,e[49]=L,e[50]=R,e[51]=z,e[52]=Q):Q=e[52],Q}export{b as component};
\ No newline at end of file
diff --git a/spinifex/services/spinifexui/frontend/dist/assets/_id16.js b/spinifex/services/spinifexui/frontend/dist/assets/_id16.js
index bb3486afb..d51b86509 100644
--- a/spinifex/services/spinifexui/frontend/dist/assets/_id16.js
+++ b/spinifex/services/spinifexui/frontend/dist/assets/_id16.js
@@ -1 +1 @@
-import{t as e}from"./error-banner.js";import{t}from"./useSuspenseQuery.js";import{t as n}from"./back-link.js";import{t as r}from"./trash-2.js";import{N as i}from"./ec2.js";import{Ho as a,Lo as o,Ro as s,Uo as c,Xa as l,ao as u,cs as d,dt as f,g as p,mt as m}from"./index.js";import{t as h}from"./page-heading.js";import{t as g}from"./delete-confirmation-dialog.js";import{n as _,t as v}from"./detail-row.js";import{t as y}from"./state-badge.js";var b=d(c()),x=a();function S(){let{id:a}=p.useParams(),c=s(),{data:d}=t(m(a)),{data:S}=t(f),C=d.Vpcs?.[0],w=i(),[T,E]=(0,b.useState)(!1),D=S.Subnets?.filter(e=>e.VpcId===a)??[],O=async()=>{try{await w.mutateAsync(a),c({to:`/ec2/describe-vpcs`})}finally{E(!1)}};if(!C?.VpcId)return(0,x.jsxs)(x.Fragment,{children:[(0,x.jsx)(n,{to:`/ec2/describe-vpcs`,children:`Back to VPCs`}),(0,x.jsx)(`p`,{className:`text-muted-foreground`,children:`VPC not found.`})]});let k=u(C.Tags);return(0,x.jsxs)(x.Fragment,{children:[(0,x.jsx)(n,{to:`/ec2/describe-vpcs`,children:`Back to VPCs`}),w.error&&(0,x.jsx)(e,{error:w.error,msg:`Failed to delete VPC`}),(0,x.jsxs)(`div`,{className:`space-y-6`,children:[(0,x.jsx)(h,{actions:(0,x.jsxs)(`div`,{className:`flex items-center gap-2`,children:[(0,x.jsxs)(l,{onClick:()=>E(!0),size:`sm`,variant:`destructive`,children:[(0,x.jsx)(r,{className:`size-4`}),`Delete`]}),(0,x.jsx)(y,{state:C.State})]}),subtitle:`VPC Details`,title:k?`${C.VpcId} (${k})`:C.VpcId}),(0,x.jsxs)(_,{children:[(0,x.jsx)(_.Header,{children:`VPC Information`}),(0,x.jsxs)(_.Content,{children:[(0,x.jsx)(v,{label:`VPC ID`,value:C.VpcId}),(0,x.jsx)(v,{label:`State`,value:C.State}),(0,x.jsx)(v,{label:`CIDR Block`,value:C.CidrBlock}),(0,x.jsx)(v,{label:`Is Default`,value:C.IsDefault?`Yes`:`No`}),(0,x.jsx)(v,{label:`DHCP Options ID`,value:C.DhcpOptionsId}),(0,x.jsx)(v,{label:`Instance Tenancy`,value:C.InstanceTenancy}),(0,x.jsx)(v,{label:`Owner ID`,value:C.OwnerId})]})]}),D.length>0&&(0,x.jsxs)(_,{children:[(0,x.jsxs)(_.Header,{children:[`Subnets (`,D.length,`)`]}),D.map(e=>e.SubnetId?(0,x.jsxs)(_.Content,{children:[(0,x.jsx)(v,{label:`Subnet ID`,value:(0,x.jsx)(o,{className:`text-primary hover:underline`,params:{id:e.SubnetId},to:`/ec2/describe-subnets/$id`,children:e.SubnetId})}),(0,x.jsx)(v,{label:`CIDR Block`,value:e.CidrBlock}),(0,x.jsx)(v,{label:`Availability Zone`,value:e.AvailabilityZone}),(0,x.jsx)(v,{label:`State`,value:e.State})]},e.SubnetId):null)]})]}),(0,x.jsx)(g,{description:`Are you sure you want to delete the VPC "${C.VpcId}"? This action cannot be undone. The VPC must have no associated subnets or internet gateways.`,isPending:w.isPending,onConfirm:O,onOpenChange:E,open:T,title:`Delete VPC`})]})}export{S as component};
\ No newline at end of file
+import{n as e,o as t,r as n}from"./compiler-runtime.js";import{t as r}from"./error-banner.js";import{t as i}from"./useSuspenseQuery.js";import{Or as a}from"./awsClient.js";import{A as o}from"./SignatureV4.js";import{t as s}from"./back-link.js";import{t as c}from"./trash-2.js";import{t as l}from"./button.js";import{P as u}from"./ec2.js";import{Ir as d,It as f,Nt as p,g as m}from"./index.js";import{t as h}from"./page-heading.js";import{t as g}from"./delete-confirmation-dialog.js";import{n as _,t as v}from"./detail-row.js";import{t as y}from"./state-badge.js";var b=t(n()),x=e();function S(){let{id:e}=m.useParams(),t=d(),{data:n}=i(f(e)),{data:S}=i(p),C=n.Vpcs?.[0],w=u(),[T,E]=(0,b.useState)(!1),D=S.Subnets?.filter(t=>t.VpcId===e)??[],O=async()=>{try{await w.mutateAsync(e),t({to:`/ec2/describe-vpcs`})}finally{E(!1)}};if(!C?.VpcId)return(0,x.jsxs)(x.Fragment,{children:[(0,x.jsx)(s,{to:`/ec2/describe-vpcs`,children:`Back to VPCs`}),(0,x.jsx)(`p`,{className:`text-muted-foreground`,children:`VPC not found.`})]});let k=o(C.Tags);return(0,x.jsxs)(x.Fragment,{children:[(0,x.jsx)(s,{to:`/ec2/describe-vpcs`,children:`Back to VPCs`}),w.error&&(0,x.jsx)(r,{error:w.error,msg:`Failed to delete VPC`}),(0,x.jsxs)(`div`,{className:`space-y-6`,children:[(0,x.jsx)(h,{actions:(0,x.jsxs)(`div`,{className:`flex items-center gap-2`,children:[(0,x.jsxs)(l,{onClick:()=>E(!0),size:`sm`,variant:`destructive`,children:[(0,x.jsx)(c,{className:`size-4`}),`Delete`]}),(0,x.jsx)(y,{state:C.State})]}),subtitle:`VPC Details`,title:k?`${C.VpcId} (${k})`:C.VpcId}),(0,x.jsxs)(_,{children:[(0,x.jsx)(_.Header,{children:`VPC Information`}),(0,x.jsxs)(_.Content,{children:[(0,x.jsx)(v,{label:`VPC ID`,value:C.VpcId}),(0,x.jsx)(v,{label:`State`,value:C.State}),(0,x.jsx)(v,{label:`CIDR Block`,value:C.CidrBlock}),(0,x.jsx)(v,{label:`Is Default`,value:C.IsDefault?`Yes`:`No`}),(0,x.jsx)(v,{label:`DHCP Options ID`,value:C.DhcpOptionsId}),(0,x.jsx)(v,{label:`Instance Tenancy`,value:C.InstanceTenancy}),(0,x.jsx)(v,{label:`Owner ID`,value:C.OwnerId})]})]}),D.length>0&&(0,x.jsxs)(_,{children:[(0,x.jsxs)(_.Header,{children:[`Subnets (`,D.length,`)`]}),D.map(e=>e.SubnetId?(0,x.jsxs)(_.Content,{children:[(0,x.jsx)(v,{label:`Subnet ID`,value:(0,x.jsx)(a,{className:`text-primary hover:underline`,params:{id:e.SubnetId},to:`/ec2/describe-subnets/$id`,children:e.SubnetId})}),(0,x.jsx)(v,{label:`CIDR Block`,value:e.CidrBlock}),(0,x.jsx)(v,{label:`Availability Zone`,value:e.AvailabilityZone}),(0,x.jsx)(v,{label:`State`,value:e.State})]},e.SubnetId):null)]})]}),(0,x.jsx)(g,{description:`Are you sure you want to delete the VPC "${C.VpcId}"? This action cannot be undone. The VPC must have no associated subnets or internet gateways.`,isPending:w.isPending,onConfirm:O,onOpenChange:E,open:T,title:`Delete VPC`})]})}export{S as component};
\ No newline at end of file
diff --git a/spinifex/services/spinifexui/frontend/dist/assets/_id2.js b/spinifex/services/spinifexui/frontend/dist/assets/_id2.js
index 3d1dabace..dcd8de4a6 100644
--- a/spinifex/services/spinifexui/frontend/dist/assets/_id2.js
+++ b/spinifex/services/spinifexui/frontend/dist/assets/_id2.js
@@ -1 +1 @@
-import{t as e}from"./useSuspenseQuery.js";import{t}from"./back-link.js";import{Fo as n,Ho as r,W as i,n as a}from"./index.js";import{t as o}from"./page-heading.js";import{t as s}from"./state-badge.js";import{t as c}from"./system-managed.js";import{t as l}from"./ami-details.js";var u=n(),d=r();function f(){let n=(0,u.c)(14),{id:r}=a.useParams(),f;n[0]===r?f=n[1]:(f=i(r),n[0]=r,n[1]=f);let{data:p}=e(f),m=p?.Images?.[0];if(!m?.ImageId||c(m)){let e;return n[2]===Symbol.for(`react.memo_cache_sentinel`)?(e=(0,d.jsxs)(d.Fragment,{children:[(0,d.jsx)(t,{to:`/ec2/describe-images`,children:`Back to images`}),(0,d.jsx)(`p`,{className:`text-muted-foreground`,children:`Image not found.`})]}),n[2]=e):e=n[2],e}let h;n[3]===Symbol.for(`react.memo_cache_sentinel`)?(h=(0,d.jsx)(t,{to:`/ec2/describe-images`,children:`Back to images`}),n[3]=h):h=n[3];let g;n[4]===m.State?g=n[5]:(g=(0,d.jsx)(s,{state:m.State}),n[4]=m.State,n[5]=g);let _;n[6]!==m.ImageId||n[7]!==g?(_=(0,d.jsx)(o,{actions:g,subtitle:`Image Details`,title:m.ImageId}),n[6]=m.ImageId,n[7]=g,n[8]=_):_=n[8];let v;n[9]===m?v=n[10]:(v=(0,d.jsx)(l,{image:m,showExtendedDetails:!0}),n[9]=m,n[10]=v);let y;return n[11]!==_||n[12]!==v?(y=(0,d.jsxs)(d.Fragment,{children:[h,(0,d.jsxs)(`div`,{className:`space-y-6`,children:[_,v]})]}),n[11]=_,n[12]=v,n[13]=y):y=n[13],y}export{f as component};
\ No newline at end of file
+import{n as e,t}from"./compiler-runtime.js";import{t as n}from"./useSuspenseQuery.js";import{t as r}from"./back-link.js";import{ft as i,n as a}from"./index.js";import{t as o}from"./page-heading.js";import{t as s}from"./state-badge.js";import{n as c}from"./system-managed.js";import{t as l}from"./ami-details.js";var u=t(),d=e();function f(){let e=(0,u.c)(14),{id:t}=a.useParams(),f;e[0]===t?f=e[1]:(f=i(t),e[0]=t,e[1]=f);let{data:p}=n(f),m=p?.Images?.[0];if(!m?.ImageId||c(m)){let t;return e[2]===Symbol.for(`react.memo_cache_sentinel`)?(t=(0,d.jsxs)(d.Fragment,{children:[(0,d.jsx)(r,{to:`/ec2/describe-images`,children:`Back to images`}),(0,d.jsx)(`p`,{className:`text-muted-foreground`,children:`Image not found.`})]}),e[2]=t):t=e[2],t}let h;e[3]===Symbol.for(`react.memo_cache_sentinel`)?(h=(0,d.jsx)(r,{to:`/ec2/describe-images`,children:`Back to images`}),e[3]=h):h=e[3];let g;e[4]===m.State?g=e[5]:(g=(0,d.jsx)(s,{state:m.State}),e[4]=m.State,e[5]=g);let _;e[6]!==m.ImageId||e[7]!==g?(_=(0,d.jsx)(o,{actions:g,subtitle:`Image Details`,title:m.ImageId}),e[6]=m.ImageId,e[7]=g,e[8]=_):_=e[8];let v;e[9]===m?v=e[10]:(v=(0,d.jsx)(l,{image:m,showExtendedDetails:!0}),e[9]=m,e[10]=v);let y;return e[11]!==_||e[12]!==v?(y=(0,d.jsxs)(d.Fragment,{children:[h,(0,d.jsxs)(`div`,{className:`space-y-6`,children:[_,v]})]}),e[11]=_,e[12]=v,e[13]=y):y=e[13],y}export{f as component};
\ No newline at end of file
diff --git a/spinifex/services/spinifexui/frontend/dist/assets/_id3.js b/spinifex/services/spinifexui/frontend/dist/assets/_id3.js
index 0e77dfdfd..8b0b6267d 100644
--- a/spinifex/services/spinifexui/frontend/dist/assets/_id3.js
+++ b/spinifex/services/spinifexui/frontend/dist/assets/_id3.js
@@ -1 +1 @@
-import{t as e}from"./error-banner.js";import{t}from"./useQuery.js";import{t as n}from"./useSuspenseQuery.js";import{t as r}from"./back-link.js";import{c as i,i as a,n as o,s,t as c}from"./select.js";import{t as l}from"./trash-2.js";import{B as u,H as d,J as f,K as p,R as m,l as h,q as g}from"./ec2.js";import{Fo as _,Ho as v,K as y,Lo as b,Uo as x,W as S,Xa as C,Xi as w,cs as T,gr as E,io as D,no as O,q as k,r as A,wo as j}from"./index.js";import{t as ee}from"./page-heading.js";import{a as M,t as N}from"./field.js";import{a as P,i as F,l as I,n as L,o as R,r as z,s as B,t as V}from"./alert-dialog.js";import{t as H}from"./delete-confirmation-dialog.js";import{n as U,t as W}from"./detail-row.js";import{t as te}from"./state-badge.js";import{t as ne}from"./ami-details.js";import{r as re}from"./admin.js";import{t as G}from"./textarea.js";var ie=j(`image-plus`,[[`path`,{d:`M16 5h6`,key:`1vod17`}],[`path`,{d:`M19 2v6`,key:`4bpg5p`}],[`path`,{d:`M21 11.5V19a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h7.5`,key:`1ue2ih`}],[`path`,{d:`m21 15-3.086-3.086a2 2 0 0 0-2.828 0L6 21`,key:`1xmnt7`}],[`circle`,{cx:`9`,cy:`9`,r:`2`,key:`af1f0g`}]]),K=j(`pause`,[[`rect`,{x:`14`,y:`3`,width:`5`,height:`18`,rx:`1`,key:`kaeet6`}],[`rect`,{x:`5`,y:`3`,width:`5`,height:`18`,rx:`1`,key:`1wsw3u`}]]),q=j(`play`,[[`path`,{d:`M5 5a2 2 0 0 1 3.008-1.728l11.997 6.998a2 2 0 0 1 .003 3.458l-12 7A2 2 0 0 1 5 19z`,key:`10ikf1`}]]),J=j(`rotate-cw`,[[`path`,{d:`M21 12a9 9 0 1 1-9-9c2.52 0 4.93 1 6.74 2.74L21 8`,key:`1p45f6`}],[`path`,{d:`M21 3v5h-5`,key:`1q7to0`}]]),ae=j(`settings-2`,[[`path`,{d:`M14 17H5`,key:`gfn3mx`}],[`path`,{d:`M19 7h-9`,key:`6i9tg`}],[`circle`,{cx:`17`,cy:`17`,r:`3`,key:`18b49y`}],[`circle`,{cx:`7`,cy:`7`,r:`3`,key:`dfmy0x`}]]),oe=j(`terminal`,[[`path`,{d:`M12 19h8`,key:`baeox8`}],[`path`,{d:`m4 17 6-6-6-6`,key:`1yngyt`}]]),Y=T(x(),1),X=v();function se({instanceId:e,open:t,onOpenChange:n}){let r=h(),[i,a]=(0,Y.useState)(``),[o,s]=(0,Y.useState)(``),[c,l]=(0,Y.useState)(null);function u(e){e||(a(``),s(``),l(null)),n(e)}async function d(){try{l((await r.mutateAsync({instanceId:e,name:i,description:o||void 0})).ImageId??null),a(``),s(``)}catch{}}return(0,X.jsx)(V,{onOpenChange:u,open:t,children:(0,X.jsxs)(F,{children:[(0,X.jsxs)(B,{children:[(0,X.jsx)(I,{children:c?`Image Created`:`Create Image (AMI)`}),(0,X.jsx)(P,{children:c?`Image "${c}" was created successfully.`:`Create an AMI from instance "${e}".`})]}),!c&&(0,X.jsxs)(X.Fragment,{children:[(0,X.jsxs)(N,{children:[(0,X.jsx)(M,{children:(0,X.jsx)(`label`,{htmlFor:`imageName`,children:`Name`})}),(0,X.jsx)(w,{id:`imageName`,onChange:e=>a(e.target.value),placeholder:`my-image`,value:i})]}),(0,X.jsxs)(N,{children:[(0,X.jsx)(M,{children:(0,X.jsx)(`label`,{htmlFor:`imageDescription`,children:`Description`})}),(0,X.jsx)(G,{id:`imageDescription`,onChange:e=>s(e.target.value),placeholder:`Optional description`,rows:2,value:o})]})]}),(0,X.jsxs)(R,{children:[(0,X.jsx)(z,{children:c?`Close`:`Cancel`}),!c&&(0,X.jsx)(L,{disabled:r.isPending||!i.trim(),onClick:d,children:r.isPending?`Creating…`:`Create Image`})]})]})})}var Z=_(),Q=new Set([`pending`,`stopping`,`shutting-down`,`terminated`]);function ce(t){let n=(0,Z.c)(43),{instanceId:r,state:i}=t,a=p(),o=g(),s=d(),c=f(),[u,m]=(0,Y.useState)(!1),h=Q.has(i??``),_;n[0]!==r||n[1]!==c?(_=()=>{c.mutate(r,{onSettled:()=>m(!1)})},n[0]=r,n[1]=c,n[2]=_):_=n[2];let v=_;if(h&&i!==`terminated`){let e;return n[3]===i?e=n[4]:(e=(0,X.jsx)(`div`,{className:`rounded-lg border bg-muted/50 p-4`,children:(0,X.jsxs)(`p`,{className:`text-center text-sm text-muted-foreground`,children:[`Instance is `,i,`. Actions will be available once the operation completes.`]})}),n[3]=i,n[4]=e),e}if(i===`terminated`){let e;return n[5]===Symbol.for(`react.memo_cache_sentinel`)?(e=(0,X.jsx)(`div`,{className:`rounded-lg border bg-muted/50 p-4`,children:(0,X.jsx)(`p`,{className:`text-center text-sm text-muted-foreground`,children:`This instance has been terminated and cannot be managed.`})}),n[5]=e):e=n[5],e}let y;n[6]===Symbol.for(`react.memo_cache_sentinel`)?(y=(0,X.jsx)(`h2`,{className:`mb-4 font-semibold`,children:`Instance Actions`}),n[6]=y):y=n[6];let b;n[7]===a.error?b=n[8]:(b=a.error&&(0,X.jsx)(e,{error:a.error,msg:`Failed to start instance`}),n[7]=a.error,n[8]=b);let x;n[9]===o.error?x=n[10]:(x=o.error&&(0,X.jsx)(e,{error:o.error,msg:`Failed to stop instance`}),n[9]=o.error,n[10]=x);let S;n[11]===s.error?S=n[12]:(S=s.error&&(0,X.jsx)(e,{error:s.error,msg:`Failed to reboot instance`}),n[11]=s.error,n[12]=S);let w;n[13]===c.error?w=n[14]:(w=c.error&&(0,X.jsx)(e,{error:c.error,msg:`Failed to terminate instance`}),n[13]=c.error,n[14]=w);let T;n[15]!==r||n[16]!==a||n[17]!==i?(T=i===`stopped`&&(0,X.jsxs)(C,{disabled:a.isPending,onClick:()=>a.mutate(r),size:`sm`,variant:`default`,children:[(0,X.jsx)(q,{className:`size-4`}),a.isPending?`Starting`:`Start`]}),n[15]=r,n[16]=a,n[17]=i,n[18]=T):T=n[18];let E;n[19]!==r||n[20]!==s||n[21]!==i||n[22]!==o?(E=i===`running`&&(0,X.jsxs)(X.Fragment,{children:[(0,X.jsxs)(C,{disabled:o.isPending,onClick:()=>o.mutate(r),size:`sm`,variant:`outline`,children:[(0,X.jsx)(K,{className:`size-4`}),o.isPending?`Stopping`:`Stop`]}),(0,X.jsxs)(C,{disabled:s.isPending,onClick:()=>s.mutate(r),size:`sm`,variant:`outline`,children:[(0,X.jsx)(J,{className:`size-4`}),s.isPending?`Rebooting`:`Reboot`]})]}),n[19]=r,n[20]=s,n[21]=i,n[22]=o,n[23]=E):E=n[23];let D;n[24]!==i||n[25]!==c.isPending?(D=(i===`stopped`||i===`running`)&&(0,X.jsxs)(C,{disabled:c.isPending,onClick:()=>m(!0),size:`sm`,variant:`destructive`,children:[(0,X.jsx)(l,{className:`size-4`}),c.isPending?`Terminating`:`Terminate`]}),n[24]=i,n[25]=c.isPending,n[26]=D):D=n[26];let O;n[27]!==T||n[28]!==E||n[29]!==D?(O=(0,X.jsxs)(`div`,{className:`flex flex-wrap gap-2`,children:[T,E,D]}),n[27]=T,n[28]=E,n[29]=D,n[30]=O):O=n[30];let k=`Are you sure you want to terminate the instance "${r}"? This action cannot be undone.`,A;n[31]!==v||n[32]!==u||n[33]!==k||n[34]!==c.isPending?(A=(0,X.jsx)(H,{confirmLabel:`Terminate`,description:k,isPending:c.isPending,onConfirm:v,onOpenChange:m,open:u,pendingLabel:`Terminating…`,title:`Terminate Instance`}),n[31]=v,n[32]=u,n[33]=k,n[34]=c.isPending,n[35]=A):A=n[35];let j;return n[36]!==O||n[37]!==A||n[38]!==b||n[39]!==x||n[40]!==S||n[41]!==w?(j=(0,X.jsxs)(`div`,{className:`rounded-lg border bg-card p-4`,children:[y,b,x,S,w,O,A]}),n[36]=O,n[37]=A,n[38]=b,n[39]=x,n[40]=S,n[41]=w,n[42]=j):j=n[42],j}function $(){let{id:l}=A.useParams(),{isAdmin:d}=E(),{data:f}=n(y(l)),p=f.Reservations?.[0]?.Instances?.[0],{data:h}=n(S(p?.ImageId)),g=h?.Images?.[0],{data:_}=n(k),{data:v}=t({...re,enabled:d,refetchInterval:5e3}),x=v?.vms?.find(e=>e.instance_id===l),w=u(),T=m(),[j,H]=(0,Y.useState)(!1),[G,K]=(0,Y.useState)(``),[q,J]=(0,Y.useState)(!1),[Z,Q]=(0,Y.useState)(null),[$,le]=(0,Y.useState)(!1),ue=[...new Set(_.InstanceTypes?.flatMap(e=>e.InstanceType?[e.InstanceType]:[]))].toSorted();if(!p?.InstanceId)return(0,X.jsxs)(X.Fragment,{children:[(0,X.jsx)(r,{to:`/ec2/describe-instances`,children:`Back to instances`}),(0,X.jsx)(`p`,{className:`text-muted-foreground`,children:`Instance not found.`})]});let de=O(p.LaunchTime),fe=p.State?.Name===`running`,pe=p.State?.Name===`stopped`;async function me(){try{await w.mutateAsync({instanceId:l,instanceType:G}),H(!1),K(``)}catch{}}async function he(){try{let e=await T.mutateAsync(l);Q(e.Output?atob(e.Output):`(no output)`),J(!0)}catch{}}return(0,X.jsxs)(X.Fragment,{children:[(0,X.jsx)(r,{to:`/ec2/describe-instances`,children:`Back to instances`}),w.error&&(0,X.jsx)(e,{error:w.error,msg:`Failed to modify instance`}),T.error&&(0,X.jsx)(e,{error:T.error,msg:`Failed to get console output`}),(0,X.jsxs)(`div`,{className:`space-y-6`,children:[(0,X.jsx)(ee,{actions:(0,X.jsxs)(`div`,{className:`flex items-center gap-2`,children:[fe&&(0,X.jsxs)(C,{disabled:T.isPending,onClick:he,size:`sm`,variant:`outline`,children:[(0,X.jsx)(oe,{className:`size-4`}),T.isPending?`Fetching…`:`View Console`]}),(fe||pe)&&(0,X.jsxs)(C,{onClick:()=>le(!0),size:`sm`,variant:`outline`,children:[(0,X.jsx)(ie,{className:`size-4`}),`Create Image`]}),pe&&(0,X.jsxs)(C,{onClick:()=>{K(p.InstanceType??``),H(!0)},size:`sm`,variant:`outline`,children:[(0,X.jsx)(ae,{className:`size-4`}),`Modify Instance`]}),(0,X.jsx)(te,{state:p.State?.Name})]}),subtitle:`Instance Details`,title:p.InstanceId}),(0,X.jsx)(ce,{instanceId:p.InstanceId,state:p.State?.Name}),q&&Z!==null&&(0,X.jsxs)(U,{children:[(0,X.jsx)(U.Header,{children:(0,X.jsxs)(`div`,{className:`flex items-center justify-between`,children:[`Console Output`,(0,X.jsx)(C,{onClick:()=>{J(!1),Q(null)},size:`sm`,variant:`ghost`,children:`Hide`})]})}),(0,X.jsx)(`div`,{className:`p-4`,children:(0,X.jsx)(`pre`,{className:`max-h-96 overflow-y-auto rounded bg-muted p-3 font-mono text-xs whitespace-pre-wrap`,children:Z})})]}),(0,X.jsxs)(U,{children:[(0,X.jsx)(U.Header,{children:`Instance Information`}),(0,X.jsxs)(U.Content,{children:[(0,X.jsx)(W,{label:`Instance ID`,value:p.InstanceId}),(0,X.jsx)(W,{label:`Instance Type`,value:p.InstanceType}),(0,X.jsx)(W,{label:`Instance State`,value:p.State?.Name}),(0,X.jsx)(W,{label:`State Code`,value:p.State?.Code?.toString()}),(0,X.jsx)(W,{label:`Launch Time`,value:de}),(0,X.jsx)(W,{label:`Availability Zone`,value:p.Placement?.AvailabilityZone}),(0,X.jsx)(W,{label:`Placement Group`,value:p.Placement?.GroupName?(0,X.jsx)(b,{className:`text-primary hover:underline`,to:`/ec2/describe-placement-groups`,children:p.Placement.GroupName}):void 0})]})]}),(p.PrivateIpAddress||p.PublicIpAddress||p.VpcId||p.SubnetId||p.SecurityGroups&&p.SecurityGroups.length>0)&&(0,X.jsxs)(U,{children:[(0,X.jsx)(U.Header,{children:`Network & Security`}),(0,X.jsxs)(U.Content,{children:[(0,X.jsx)(W,{label:`Private IP Address`,value:p.PrivateIpAddress}),(0,X.jsx)(W,{label:`Public IP Address`,value:p.PublicIpAddress}),(0,X.jsx)(W,{label:`Private DNS`,value:p.PrivateDnsName}),(0,X.jsx)(W,{label:`Public DNS`,value:p.PublicDnsName}),(0,X.jsx)(W,{label:`VPC ID`,value:p.VpcId?(0,X.jsx)(b,{className:`text-primary hover:underline`,params:{id:p.VpcId},to:`/ec2/describe-vpcs/$id`,children:p.VpcId}):void 0}),(0,X.jsx)(W,{label:`Subnet ID`,value:p.SubnetId?(0,X.jsx)(b,{className:`text-primary hover:underline`,params:{id:p.SubnetId},to:`/ec2/describe-subnets/$id`,children:p.SubnetId}):void 0}),(0,X.jsx)(W,{label:`Key Name`,value:p.KeyName}),(0,X.jsx)(W,{label:`Security Groups`,value:p.SecurityGroups?.map(e=>e.GroupName).join(`, `)})]})]}),d&&x?.gpu&&(0,X.jsxs)(U,{children:[(0,X.jsx)(U.Header,{children:`GPU`}),(0,X.jsxs)(U.Content,{children:[(0,X.jsx)(W,{label:`Model`,value:x.gpu.model}),(0,X.jsx)(W,{label:`VRAM`,value:D(x.gpu.vram_mib)}),(0,X.jsx)(W,{label:`Attachment`,value:x.gpu.profile?`MIG slice`:`PCIe passthrough`}),x.gpu.profile&&(0,X.jsx)(W,{label:`Profile`,value:x.gpu.profile}),x.gpu.mdev_path&&(0,X.jsx)(W,{label:`Mdev path`,value:x.gpu.mdev_path}),x.gpu.pci_address&&(0,X.jsx)(W,{label:`PCI address`,value:x.gpu.pci_address})]})]}),p.BlockDeviceMappings&&p.BlockDeviceMappings.length>0&&(0,X.jsxs)(U,{children:[(0,X.jsx)(U.Header,{children:`Block Device Mappings`}),p.BlockDeviceMappings.map(e=>(0,X.jsxs)(U.Content,{children:[(0,X.jsx)(W,{label:`Device Name`,value:e.DeviceName}),(0,X.jsx)(W,{label:`Volume ID`,value:e.Ebs?.VolumeId?(0,X.jsx)(b,{className:`text-primary hover:underline`,params:{id:e.Ebs.VolumeId},to:`/ec2/describe-volumes/$id`,children:e.Ebs.VolumeId}):void 0}),(0,X.jsx)(W,{label:`Status`,value:e.Ebs?.Status}),(0,X.jsx)(W,{label:`Attach Time`,value:O(e.Ebs?.AttachTime)}),(0,X.jsx)(W,{label:`Delete on Termination`,value:e.Ebs?.DeleteOnTermination?`Yes`:`No`})]},e.DeviceName))]}),g&&(0,X.jsx)(ne,{image:g})]}),(0,X.jsx)(V,{onOpenChange:e=>{e||(H(!1),K(``))},open:j,children:(0,X.jsxs)(F,{children:[(0,X.jsxs)(B,{children:[(0,X.jsx)(I,{children:`Modify Instance`}),(0,X.jsxs)(P,{children:[`Select a new instance type for "`,p.InstanceId,`". The instance must be stopped to modify its attributes.`]})]}),(0,X.jsxs)(N,{children:[(0,X.jsx)(M,{children:(0,X.jsx)(`label`,{htmlFor:`instanceType`,children:`Instance Type`})}),(0,X.jsxs)(c,{onValueChange:e=>K(e??``),value:G,children:[(0,X.jsx)(s,{className:`w-full`,id:`instanceType`,children:(0,X.jsx)(i,{})}),(0,X.jsx)(o,{children:ue.map(e=>(0,X.jsx)(a,{value:e,children:e},e))})]})]}),(0,X.jsxs)(R,{children:[(0,X.jsx)(z,{children:`Cancel`}),(0,X.jsx)(L,{disabled:w.isPending||!G||G===p.InstanceType,onClick:me,children:w.isPending?`Saving…`:`Save Changes`})]})]})}),(0,X.jsx)(se,{instanceId:p.InstanceId,onOpenChange:le,open:$})]})}export{$ as component};
\ No newline at end of file
+import{n as e,o as t,r as n,t as r}from"./compiler-runtime.js";import{t as i}from"./error-banner.js";import{t as a}from"./useQuery.js";import{t as o}from"./useSuspenseQuery.js";import{Or as s}from"./awsClient.js";import{D as c,k as l}from"./SignatureV4.js";import{t as u}from"./back-link.js";import{c as d,i as f,n as p,s as m,t as h}from"./select.js";import{t as g}from"./rotate-cw.js";import{t as _}from"./trash-2.js";import{t as v}from"./button.js";import{B as y,H as b,J as x,R as S,W as C,X as w,Y as T,r as E,u as D}from"./ec2.js";import{Bt as O,Nr as k,Q as A,Rn as j,dt as M,ft as N,ht as P,mt as ee,r as te}from"./index.js";import{t as ne}from"./page-heading.js";import{a as F,t as I}from"./field.js";import{a as L,i as R,l as z,n as B,o as V,r as H,s as U,t as re}from"./alert-dialog.js";import{t as W}from"./delete-confirmation-dialog.js";import{n as G,t as K}from"./detail-row.js";import{t as ie}from"./state-badge.js";import{t as ae}from"./ami-details.js";import{r as oe}from"./admin.js";import{t as q}from"./textarea.js";var se=k(`image-plus`,[[`path`,{d:`M16 5h6`,key:`1vod17`}],[`path`,{d:`M19 2v6`,key:`4bpg5p`}],[`path`,{d:`M21 11.5V19a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h7.5`,key:`1ue2ih`}],[`path`,{d:`m21 15-3.086-3.086a2 2 0 0 0-2.828 0L6 21`,key:`1xmnt7`}],[`circle`,{cx:`9`,cy:`9`,r:`2`,key:`af1f0g`}]]),ce=k(`pause`,[[`rect`,{x:`14`,y:`3`,width:`5`,height:`18`,rx:`1`,key:`kaeet6`}],[`rect`,{x:`5`,y:`3`,width:`5`,height:`18`,rx:`1`,key:`1wsw3u`}]]),J=k(`play`,[[`path`,{d:`M5 5a2 2 0 0 1 3.008-1.728l11.997 6.998a2 2 0 0 1 .003 3.458l-12 7A2 2 0 0 1 5 19z`,key:`10ikf1`}]]),le=k(`settings-2`,[[`path`,{d:`M14 17H5`,key:`gfn3mx`}],[`path`,{d:`M19 7h-9`,key:`6i9tg`}],[`circle`,{cx:`17`,cy:`17`,r:`3`,key:`18b49y`}],[`circle`,{cx:`7`,cy:`7`,r:`3`,key:`dfmy0x`}]]),ue=k(`terminal`,[[`path`,{d:`M12 19h8`,key:`baeox8`}],[`path`,{d:`m4 17 6-6-6-6`,key:`1yngyt`}]]),Y=t(n(),1),X=e();function de({instanceId:e,open:t,onOpenChange:n}){let r=D(),[i,a]=(0,Y.useState)(``),[o,s]=(0,Y.useState)(``),[c,l]=(0,Y.useState)(null);function u(e){e||(a(``),s(``),l(null)),n(e)}async function d(){try{l((await r.mutateAsync({instanceId:e,name:i,description:o||void 0})).ImageId??null),a(``),s(``)}catch{}}return(0,X.jsx)(re,{onOpenChange:u,open:t,children:(0,X.jsxs)(R,{children:[(0,X.jsxs)(U,{children:[(0,X.jsx)(z,{children:c?`Image Created`:`Create Image (AMI)`}),(0,X.jsx)(L,{children:c?`Image "${c}" was created successfully.`:`Create an AMI from instance "${e}".`})]}),!c&&(0,X.jsxs)(X.Fragment,{children:[(0,X.jsxs)(I,{children:[(0,X.jsx)(F,{children:(0,X.jsx)(`label`,{htmlFor:`imageName`,children:`Name`})}),(0,X.jsx)(j,{id:`imageName`,onChange:e=>a(e.target.value),placeholder:`my-image`,value:i})]}),(0,X.jsxs)(I,{children:[(0,X.jsx)(F,{children:(0,X.jsx)(`label`,{htmlFor:`imageDescription`,children:`Description`})}),(0,X.jsx)(q,{id:`imageDescription`,onChange:e=>s(e.target.value),placeholder:`Optional description`,rows:2,value:o})]})]}),(0,X.jsxs)(V,{children:[(0,X.jsx)(H,{children:c?`Close`:`Cancel`}),!c&&(0,X.jsx)(B,{disabled:r.isPending||!i.trim(),onClick:d,children:r.isPending?`Creating…`:`Create Image`})]})]})})}var Z=r();function fe(e){let t=(0,Z.c)(31),{instanceId:n}=e,r;t[0]===n?r=t[1]:(r=M(n),t[0]=n,t[1]=r);let{data:o}=a(r),{data:c}=a(A),l=E(),u=S(),[f,g]=(0,Y.useState)(``),_;t[2]===o?.IamInstanceProfileAssociations?_=t[3]:(_=o?.IamInstanceProfileAssociations?.find(Q),t[2]=o?.IamInstanceProfileAssociations,t[3]=_);let y=_,b;t[4]===c?.InstanceProfiles?b=t[5]:(b=c?.InstanceProfiles??[],t[4]=c?.InstanceProfiles,t[5]=b);let x=b,C;t[6]!==l||t[7]!==n||t[8]!==f?(C=async()=>{f&&(await l.mutateAsync({instanceId:n,instanceProfileName:f}),g(``))},t[6]=l,t[7]=n,t[8]=f,t[9]=C):C=t[9];let w=C,T;t[10]!==y||t[11]!==u||t[12]!==n?(T=async()=>{y?.AssociationId&&await u.mutateAsync({associationId:y.AssociationId,instanceId:n})},t[10]=y,t[11]=u,t[12]=n,t[13]=T):T=t[13];let D=T,O;t[14]===Symbol.for(`react.memo_cache_sentinel`)?(O=(0,X.jsx)(G.Header,{children:`IAM Role`}),t[14]=O):O=t[14];let k;t[15]===l.error?k=t[16]:(k=l.error&&(0,X.jsx)(`div`,{className:`col-span-2`,children:(0,X.jsx)(i,{error:l.error,msg:`Failed to associate instance profile`})}),t[15]=l.error,t[16]=k);let j;t[17]===u.error?j=t[18]:(j=u.error&&(0,X.jsx)(`div`,{className:`col-span-2`,children:(0,X.jsx)(i,{error:u.error,msg:`Failed to disassociate instance profile`})}),t[17]=u.error,t[18]=j);let N;t[19]!==l.isPending||t[20]!==y||t[21]!==u.isPending||t[22]!==w||t[23]!==D||t[24]!==x||t[25]!==f?(N=y?(0,X.jsxs)(X.Fragment,{children:[(0,X.jsx)(K,{label:`Instance Profile ARN`,value:y.IamInstanceProfile?.Arn}),(0,X.jsx)(K,{label:`State`,value:(0,X.jsx)(ie,{state:y.State})}),(0,X.jsx)(`div`,{className:`col-span-2 flex justify-end`,children:(0,X.jsx)(v,{disabled:u.isPending,onClick:D,size:`sm`,variant:`destructive`,children:u.isPending?`Detaching…`:`Detach`})})]}):(0,X.jsxs)(`div`,{className:`col-span-2 space-y-3`,children:[(0,X.jsx)(`p`,{className:`text-sm text-muted-foreground`,children:`No instance profile is associated. Associate one to flow role credentials to this instance via IMDS.`}),x.length>0?(0,X.jsxs)(`div`,{className:`flex items-center gap-2`,children:[(0,X.jsxs)(h,{onValueChange:e=>g(e??``),value:f,children:[(0,X.jsx)(m,{className:`w-full`,children:(0,X.jsx)(d,{placeholder:`Select an instance profile`})}),(0,X.jsx)(p,{children:x.map(pe)})]}),(0,X.jsx)(v,{disabled:!f||l.isPending,onClick:w,size:`sm`,children:l.isPending?`Associating…`:`Associate`})]}):(0,X.jsxs)(`p`,{className:`text-sm text-muted-foreground`,children:[`No instance profiles exist.`,` `,(0,X.jsx)(s,{className:`text-primary hover:underline`,to:`/iam/create-instance-profile`,children:`Create one`}),` `,`first.`]})]}),t[19]=l.isPending,t[20]=y,t[21]=u.isPending,t[22]=w,t[23]=D,t[24]=x,t[25]=f,t[26]=N):N=t[26];let P;return t[27]!==k||t[28]!==j||t[29]!==N?(P=(0,X.jsxs)(G,{children:[O,(0,X.jsxs)(G.Content,{children:[k,j,N]})]}),t[27]=k,t[28]=j,t[29]=N,t[30]=P):P=t[30],P}function pe(e){return(0,X.jsx)(f,{value:e.InstanceProfileName??``,children:e.InstanceProfileName},e.InstanceProfileName)}function Q(e){return e.State===`associating`||e.State===`associated`}var $=new Set([`pending`,`stopping`,`shutting-down`,`terminated`]);function me(e){let t=(0,Z.c)(43),{instanceId:n,state:r}=e,a=x(),o=T(),s=C(),c=w(),[l,u]=(0,Y.useState)(!1),d=$.has(r??``),f;t[0]!==n||t[1]!==c?(f=()=>{c.mutate(n,{onSettled:()=>u(!1)})},t[0]=n,t[1]=c,t[2]=f):f=t[2];let p=f;if(d&&r!==`terminated`){let e;return t[3]===r?e=t[4]:(e=(0,X.jsx)(`div`,{className:`rounded-lg border bg-muted/50 p-4`,children:(0,X.jsxs)(`p`,{className:`text-center text-sm text-muted-foreground`,children:[`Instance is `,r,`. Actions will be available once the operation completes.`]})}),t[3]=r,t[4]=e),e}if(r===`terminated`){let e;return t[5]===Symbol.for(`react.memo_cache_sentinel`)?(e=(0,X.jsx)(`div`,{className:`rounded-lg border bg-muted/50 p-4`,children:(0,X.jsx)(`p`,{className:`text-center text-sm text-muted-foreground`,children:`This instance has been terminated and cannot be managed.`})}),t[5]=e):e=t[5],e}let m;t[6]===Symbol.for(`react.memo_cache_sentinel`)?(m=(0,X.jsx)(`h2`,{className:`mb-4 font-semibold`,children:`Instance Actions`}),t[6]=m):m=t[6];let h;t[7]===a.error?h=t[8]:(h=a.error&&(0,X.jsx)(i,{error:a.error,msg:`Failed to start instance`}),t[7]=a.error,t[8]=h);let y;t[9]===o.error?y=t[10]:(y=o.error&&(0,X.jsx)(i,{error:o.error,msg:`Failed to stop instance`}),t[9]=o.error,t[10]=y);let b;t[11]===s.error?b=t[12]:(b=s.error&&(0,X.jsx)(i,{error:s.error,msg:`Failed to reboot instance`}),t[11]=s.error,t[12]=b);let S;t[13]===c.error?S=t[14]:(S=c.error&&(0,X.jsx)(i,{error:c.error,msg:`Failed to terminate instance`}),t[13]=c.error,t[14]=S);let E;t[15]!==n||t[16]!==a||t[17]!==r?(E=r===`stopped`&&(0,X.jsxs)(v,{disabled:a.isPending,onClick:()=>a.mutate(n),size:`sm`,variant:`default`,children:[(0,X.jsx)(J,{className:`size-4`}),a.isPending?`Starting`:`Start`]}),t[15]=n,t[16]=a,t[17]=r,t[18]=E):E=t[18];let D;t[19]!==n||t[20]!==s||t[21]!==r||t[22]!==o?(D=r===`running`&&(0,X.jsxs)(X.Fragment,{children:[(0,X.jsxs)(v,{disabled:o.isPending,onClick:()=>o.mutate(n),size:`sm`,variant:`outline`,children:[(0,X.jsx)(ce,{className:`size-4`}),o.isPending?`Stopping`:`Stop`]}),(0,X.jsxs)(v,{disabled:s.isPending,onClick:()=>s.mutate(n),size:`sm`,variant:`outline`,children:[(0,X.jsx)(g,{className:`size-4`}),s.isPending?`Rebooting`:`Reboot`]})]}),t[19]=n,t[20]=s,t[21]=r,t[22]=o,t[23]=D):D=t[23];let O;t[24]!==r||t[25]!==c.isPending?(O=(r===`stopped`||r===`running`)&&(0,X.jsxs)(v,{disabled:c.isPending,onClick:()=>u(!0),size:`sm`,variant:`destructive`,children:[(0,X.jsx)(_,{className:`size-4`}),c.isPending?`Terminating`:`Terminate`]}),t[24]=r,t[25]=c.isPending,t[26]=O):O=t[26];let k;t[27]!==E||t[28]!==D||t[29]!==O?(k=(0,X.jsxs)(`div`,{className:`flex flex-wrap gap-2`,children:[E,D,O]}),t[27]=E,t[28]=D,t[29]=O,t[30]=k):k=t[30];let A=`Are you sure you want to terminate the instance "${n}"? This action cannot be undone.`,j;t[31]!==p||t[32]!==l||t[33]!==A||t[34]!==c.isPending?(j=(0,X.jsx)(W,{confirmLabel:`Terminate`,description:A,isPending:c.isPending,onConfirm:p,onOpenChange:u,open:l,pendingLabel:`Terminating…`,title:`Terminate Instance`}),t[31]=p,t[32]=l,t[33]=A,t[34]=c.isPending,t[35]=j):j=t[35];let M;return t[36]!==k||t[37]!==j||t[38]!==h||t[39]!==y||t[40]!==b||t[41]!==S?(M=(0,X.jsxs)(`div`,{className:`rounded-lg border bg-card p-4`,children:[m,h,y,b,S,k,j]}),t[36]=k,t[37]=j,t[38]=h,t[39]=y,t[40]=b,t[41]=S,t[42]=M):M=t[42],M}function he(){let{id:e}=te.useParams(),{isAdmin:t}=O(),{data:n}=o(ee(e)),r=n.Reservations?.[0]?.Instances?.[0],{data:g}=o(N(r?.ImageId)),_=g?.Images?.[0],{data:x}=o(P),{data:S}=a({...oe,enabled:t,refetchInterval:5e3}),C=S?.vms?.find(t=>t.instance_id===e),w=b(),T=y(),[E,D]=(0,Y.useState)(!1),[k,A]=(0,Y.useState)(``),[j,M]=(0,Y.useState)(!1),[W,q]=(0,Y.useState)(null),[ce,J]=(0,Y.useState)(!1),Z=[...new Set(x.InstanceTypes?.flatMap(e=>e.InstanceType?[e.InstanceType]:[]))].toSorted();if(!r?.InstanceId)return(0,X.jsxs)(X.Fragment,{children:[(0,X.jsx)(u,{to:`/ec2/describe-instances`,children:`Back to instances`}),(0,X.jsx)(`p`,{className:`text-muted-foreground`,children:`Instance not found.`})]});let pe=c(r.LaunchTime),Q=r.State?.Name===`running`,$=r.State?.Name===`stopped`;async function he(){try{await w.mutateAsync({instanceId:e,instanceType:k}),D(!1),A(``)}catch{}}async function ge(){try{let t=await T.mutateAsync(e);q(t.Output?atob(t.Output):`(no output)`),M(!0)}catch{}}return(0,X.jsxs)(X.Fragment,{children:[(0,X.jsx)(u,{to:`/ec2/describe-instances`,children:`Back to instances`}),w.error&&(0,X.jsx)(i,{error:w.error,msg:`Failed to modify instance`}),T.error&&(0,X.jsx)(i,{error:T.error,msg:`Failed to get console output`}),(0,X.jsxs)(`div`,{className:`space-y-6`,children:[(0,X.jsx)(ne,{actions:(0,X.jsxs)(`div`,{className:`flex items-center gap-2`,children:[Q&&(0,X.jsxs)(v,{disabled:T.isPending,onClick:ge,size:`sm`,variant:`outline`,children:[(0,X.jsx)(ue,{className:`size-4`}),T.isPending?`Fetching…`:`View Console`]}),(Q||$)&&(0,X.jsxs)(v,{onClick:()=>J(!0),size:`sm`,variant:`outline`,children:[(0,X.jsx)(se,{className:`size-4`}),`Create Image`]}),$&&(0,X.jsxs)(v,{onClick:()=>{A(r.InstanceType??``),D(!0)},size:`sm`,variant:`outline`,children:[(0,X.jsx)(le,{className:`size-4`}),`Modify Instance`]}),(0,X.jsx)(ie,{state:r.State?.Name})]}),subtitle:`Instance Details`,title:r.InstanceId}),(0,X.jsx)(me,{instanceId:r.InstanceId,state:r.State?.Name}),j&&W!==null&&(0,X.jsxs)(G,{children:[(0,X.jsx)(G.Header,{children:(0,X.jsxs)(`div`,{className:`flex items-center justify-between`,children:[`Console Output`,(0,X.jsx)(v,{onClick:()=>{M(!1),q(null)},size:`sm`,variant:`ghost`,children:`Hide`})]})}),(0,X.jsx)(`div`,{className:`p-4`,children:(0,X.jsx)(`pre`,{className:`max-h-96 overflow-y-auto rounded bg-muted p-3 font-mono text-xs whitespace-pre-wrap`,children:W})})]}),(0,X.jsxs)(G,{children:[(0,X.jsx)(G.Header,{children:`Instance Information`}),(0,X.jsxs)(G.Content,{children:[(0,X.jsx)(K,{label:`Instance ID`,value:r.InstanceId}),(0,X.jsx)(K,{label:`Instance Type`,value:r.InstanceType}),(0,X.jsx)(K,{label:`Instance State`,value:r.State?.Name}),(0,X.jsx)(K,{label:`State Code`,value:r.State?.Code?.toString()}),(0,X.jsx)(K,{label:`Launch Time`,value:pe}),(0,X.jsx)(K,{label:`Availability Zone`,value:r.Placement?.AvailabilityZone}),(0,X.jsx)(K,{label:`Placement Group`,value:r.Placement?.GroupName?(0,X.jsx)(s,{className:`text-primary hover:underline`,to:`/ec2/describe-placement-groups`,children:r.Placement.GroupName}):void 0})]})]}),(r.PrivateIpAddress||r.PublicIpAddress||r.VpcId||r.SubnetId||r.SecurityGroups&&r.SecurityGroups.length>0)&&(0,X.jsxs)(G,{children:[(0,X.jsx)(G.Header,{children:`Network & Security`}),(0,X.jsxs)(G.Content,{children:[(0,X.jsx)(K,{label:`Private IP Address`,value:r.PrivateIpAddress}),(0,X.jsx)(K,{label:`Public IP Address`,value:r.PublicIpAddress}),(0,X.jsx)(K,{label:`Private DNS`,value:r.PrivateDnsName}),(0,X.jsx)(K,{label:`Public DNS`,value:r.PublicDnsName}),(0,X.jsx)(K,{label:`VPC ID`,value:r.VpcId?(0,X.jsx)(s,{className:`text-primary hover:underline`,params:{id:r.VpcId},to:`/ec2/describe-vpcs/$id`,children:r.VpcId}):void 0}),(0,X.jsx)(K,{label:`Subnet ID`,value:r.SubnetId?(0,X.jsx)(s,{className:`text-primary hover:underline`,params:{id:r.SubnetId},to:`/ec2/describe-subnets/$id`,children:r.SubnetId}):void 0}),(0,X.jsx)(K,{label:`Key Name`,value:r.KeyName}),(0,X.jsx)(K,{label:`Security Groups`,value:r.SecurityGroups?.map(e=>e.GroupName).join(`, `)})]})]}),(0,X.jsx)(fe,{instanceId:r.InstanceId}),t&&C?.gpu&&(0,X.jsxs)(G,{children:[(0,X.jsx)(G.Header,{children:`GPU`}),(0,X.jsxs)(G.Content,{children:[(0,X.jsx)(K,{label:`Model`,value:C.gpu.model}),(0,X.jsx)(K,{label:`VRAM`,value:l(C.gpu.vram_mib)}),(0,X.jsx)(K,{label:`Attachment`,value:C.gpu.profile?`MIG slice`:`PCIe passthrough`}),C.gpu.profile&&(0,X.jsx)(K,{label:`Profile`,value:C.gpu.profile}),C.gpu.mdev_path&&(0,X.jsx)(K,{label:`Mdev path`,value:C.gpu.mdev_path}),C.gpu.pci_address&&(0,X.jsx)(K,{label:`PCI address`,value:C.gpu.pci_address})]})]}),r.BlockDeviceMappings&&r.BlockDeviceMappings.length>0&&(0,X.jsxs)(G,{children:[(0,X.jsx)(G.Header,{children:`Block Device Mappings`}),r.BlockDeviceMappings.map(e=>(0,X.jsxs)(G.Content,{children:[(0,X.jsx)(K,{label:`Device Name`,value:e.DeviceName}),(0,X.jsx)(K,{label:`Volume ID`,value:e.Ebs?.VolumeId?(0,X.jsx)(s,{className:`text-primary hover:underline`,params:{id:e.Ebs.VolumeId},to:`/ec2/describe-volumes/$id`,children:e.Ebs.VolumeId}):void 0}),(0,X.jsx)(K,{label:`Status`,value:e.Ebs?.Status}),(0,X.jsx)(K,{label:`Attach Time`,value:c(e.Ebs?.AttachTime)}),(0,X.jsx)(K,{label:`Delete on Termination`,value:e.Ebs?.DeleteOnTermination?`Yes`:`No`})]},e.DeviceName))]}),_&&(0,X.jsx)(ae,{image:_})]}),(0,X.jsx)(re,{onOpenChange:e=>{e||(D(!1),A(``))},open:E,children:(0,X.jsxs)(R,{children:[(0,X.jsxs)(U,{children:[(0,X.jsx)(z,{children:`Modify Instance`}),(0,X.jsxs)(L,{children:[`Select a new instance type for "`,r.InstanceId,`". The instance must be stopped to modify its attributes.`]})]}),(0,X.jsxs)(I,{children:[(0,X.jsx)(F,{children:(0,X.jsx)(`label`,{htmlFor:`instanceType`,children:`Instance Type`})}),(0,X.jsxs)(h,{onValueChange:e=>A(e??``),value:k,children:[(0,X.jsx)(m,{className:`w-full`,id:`instanceType`,children:(0,X.jsx)(d,{})}),(0,X.jsx)(p,{children:Z.map(e=>(0,X.jsx)(f,{value:e,children:e},e))})]})]}),(0,X.jsxs)(V,{children:[(0,X.jsx)(H,{children:`Cancel`}),(0,X.jsx)(B,{disabled:w.isPending||!k||k===r.InstanceType,onClick:he,children:w.isPending?`Saving…`:`Save Changes`})]})]})}),(0,X.jsx)(de,{instanceId:r.InstanceId,onOpenChange:J,open:ce})]})}export{he as component};
\ No newline at end of file
diff --git a/spinifex/services/spinifexui/frontend/dist/assets/_id4.js b/spinifex/services/spinifexui/frontend/dist/assets/_id4.js
index 42efe1562..e4dcbd48c 100644
--- a/spinifex/services/spinifexui/frontend/dist/assets/_id4.js
+++ b/spinifex/services/spinifexui/frontend/dist/assets/_id4.js
@@ -1 +1 @@
-import{t as e}from"./error-banner.js";import{t}from"./useSuspenseQuery.js";import{t as n}from"./back-link.js";import{c as r,i,n as a,s as o,t as s}from"./select.js";import{t as c}from"./trash-2.js";import{C as l,P as u,i as d}from"./ec2.js";import{Ho as f,Lo as p,Ro as m,Uo as h,Xa as g,Y as _,ao as v,cs as y,ht as b,i as x}from"./index.js";import{t as S}from"./page-heading.js";import{t as C}from"./delete-confirmation-dialog.js";import{n as w,t as T}from"./detail-row.js";var E=y(h()),D=f();function O(){let{id:f}=x.useParams(),h=m(),{data:y}=t(_(f)),{data:O}=t(b),k=d(),A=u(),j=l(),[M,N]=(0,E.useState)(!1),[P,F]=(0,E.useState)(``),I=y.InternetGateways?.[0],L=O.Vpcs??[],R=async()=>{try{await j.mutateAsync(f),h({to:`/ec2/describe-internet-gateways`})}finally{N(!1)}},z=async()=>{P&&await k.mutateAsync({internetGatewayId:f,vpcId:P})},B=async e=>{await A.mutateAsync({internetGatewayId:f,vpcId:e})};if(!I?.InternetGatewayId)return(0,D.jsxs)(D.Fragment,{children:[(0,D.jsx)(n,{to:`/ec2/describe-internet-gateways`,children:`Back to Internet Gateways`}),(0,D.jsx)(`p`,{className:`text-muted-foreground`,children:`Internet Gateway not found.`})]});let V=I.Attachments?.[0],H=!!V?.VpcId,U=v(I.Tags);return(0,D.jsxs)(D.Fragment,{children:[(0,D.jsx)(n,{to:`/ec2/describe-internet-gateways`,children:`Back to Internet Gateways`}),(j.error??k.error??A.error)&&(0,D.jsx)(e,{error:j.error??k.error??A.error??void 0,msg:`Internet Gateway action failed`}),(0,D.jsxs)(`div`,{className:`space-y-6`,children:[(0,D.jsx)(S,{actions:(0,D.jsxs)(g,{disabled:H||j.isPending,onClick:()=>N(!0),size:`sm`,variant:`destructive`,children:[(0,D.jsx)(c,{className:`size-4`}),`Delete`]}),subtitle:`Internet Gateway Details`,title:U?`${I.InternetGatewayId} (${U})`:I.InternetGatewayId}),(0,D.jsxs)(w,{children:[(0,D.jsx)(w.Header,{children:`Internet Gateway Information`}),(0,D.jsxs)(w.Content,{children:[(0,D.jsx)(T,{label:`Internet Gateway ID`,value:I.InternetGatewayId}),(0,D.jsx)(T,{label:`Owner ID`,value:I.OwnerId??`—`}),(0,D.jsx)(T,{label:`State`,value:V?.State??`detached`})]})]}),(0,D.jsxs)(w,{children:[(0,D.jsx)(w.Header,{children:`VPC Attachment`}),(0,D.jsx)(w.Content,{children:H?(0,D.jsxs)(`div`,{className:`flex items-center justify-between`,children:[(0,D.jsx)(p,{className:`text-primary hover:underline`,params:{id:V.VpcId??``},to:`/ec2/describe-vpcs/$id`,children:V.VpcId}),(0,D.jsx)(g,{disabled:A.isPending,onClick:async()=>await B(V.VpcId??``),size:`sm`,variant:`outline`,children:`Detach`})]}):(0,D.jsxs)(`div`,{className:`flex items-end gap-2`,children:[(0,D.jsx)(`div`,{className:`flex-1`,children:(0,D.jsxs)(s,{onValueChange:e=>F(e??``),value:P,children:[(0,D.jsx)(o,{className:`w-full`,children:(0,D.jsx)(r,{placeholder:`Select a VPC`})}),(0,D.jsx)(a,{children:L.map(e=>{let t=v(e.Tags);return(0,D.jsx)(i,{value:e.VpcId??``,children:t?`${e.VpcId} (${t})`:`${e.VpcId} (${e.CidrBlock})`},e.VpcId)})})]})}),(0,D.jsx)(g,{disabled:!P||k.isPending,onClick:z,size:`sm`,children:`Attach`})]})})]})]}),(0,D.jsx)(C,{description:`Are you sure you want to delete the Internet Gateway "${I.InternetGatewayId}"? This action cannot be undone.`,isPending:j.isPending,onConfirm:R,onOpenChange:N,open:M,title:`Delete Internet Gateway`})]})}export{O as component};
\ No newline at end of file
+import{n as e,o as t,r as n}from"./compiler-runtime.js";import{t as r}from"./error-banner.js";import{t as i}from"./useSuspenseQuery.js";import{Or as a}from"./awsClient.js";import{A as o}from"./SignatureV4.js";import{t as s}from"./back-link.js";import{c,i as l,n as u,s as d,t as f}from"./select.js";import{t as p}from"./trash-2.js";import{t as m}from"./button.js";import{F as h,a as g,w as _}from"./ec2.js";import{Ir as v,Lt as y,_t as b,i as x}from"./index.js";import{t as S}from"./page-heading.js";import{t as C}from"./delete-confirmation-dialog.js";import{n as w,t as T}from"./detail-row.js";var E=t(n()),D=e();function O(){let{id:e}=x.useParams(),t=v(),{data:n}=i(b(e)),{data:O}=i(y),k=g(),A=h(),j=_(),[M,N]=(0,E.useState)(!1),[P,F]=(0,E.useState)(``),I=n.InternetGateways?.[0],L=O.Vpcs??[],R=async()=>{try{await j.mutateAsync(e),t({to:`/ec2/describe-internet-gateways`})}finally{N(!1)}},z=async()=>{P&&await k.mutateAsync({internetGatewayId:e,vpcId:P})},B=async t=>{await A.mutateAsync({internetGatewayId:e,vpcId:t})};if(!I?.InternetGatewayId)return(0,D.jsxs)(D.Fragment,{children:[(0,D.jsx)(s,{to:`/ec2/describe-internet-gateways`,children:`Back to Internet Gateways`}),(0,D.jsx)(`p`,{className:`text-muted-foreground`,children:`Internet Gateway not found.`})]});let V=I.Attachments?.[0],H=!!V?.VpcId,U=o(I.Tags);return(0,D.jsxs)(D.Fragment,{children:[(0,D.jsx)(s,{to:`/ec2/describe-internet-gateways`,children:`Back to Internet Gateways`}),(j.error??k.error??A.error)&&(0,D.jsx)(r,{error:j.error??k.error??A.error??void 0,msg:`Internet Gateway action failed`}),(0,D.jsxs)(`div`,{className:`space-y-6`,children:[(0,D.jsx)(S,{actions:(0,D.jsxs)(m,{disabled:H||j.isPending,onClick:()=>N(!0),size:`sm`,variant:`destructive`,children:[(0,D.jsx)(p,{className:`size-4`}),`Delete`]}),subtitle:`Internet Gateway Details`,title:U?`${I.InternetGatewayId} (${U})`:I.InternetGatewayId}),(0,D.jsxs)(w,{children:[(0,D.jsx)(w.Header,{children:`Internet Gateway Information`}),(0,D.jsxs)(w.Content,{children:[(0,D.jsx)(T,{label:`Internet Gateway ID`,value:I.InternetGatewayId}),(0,D.jsx)(T,{label:`Owner ID`,value:I.OwnerId??`—`}),(0,D.jsx)(T,{label:`State`,value:V?.State??`detached`})]})]}),(0,D.jsxs)(w,{children:[(0,D.jsx)(w.Header,{children:`VPC Attachment`}),(0,D.jsx)(w.Content,{children:H?(0,D.jsxs)(`div`,{className:`flex items-center justify-between`,children:[(0,D.jsx)(a,{className:`text-primary hover:underline`,params:{id:V.VpcId??``},to:`/ec2/describe-vpcs/$id`,children:V.VpcId}),(0,D.jsx)(m,{disabled:A.isPending,onClick:async()=>await B(V.VpcId??``),size:`sm`,variant:`outline`,children:`Detach`})]}):(0,D.jsxs)(`div`,{className:`flex items-end gap-2`,children:[(0,D.jsx)(`div`,{className:`flex-1`,children:(0,D.jsxs)(f,{onValueChange:e=>F(e??``),value:P,children:[(0,D.jsx)(d,{className:`w-full`,children:(0,D.jsx)(c,{placeholder:`Select a VPC`})}),(0,D.jsx)(u,{children:L.map(e=>{let t=o(e.Tags);return(0,D.jsx)(l,{value:e.VpcId??``,children:t?`${e.VpcId} (${t})`:`${e.VpcId} (${e.CidrBlock})`},e.VpcId)})})]})}),(0,D.jsx)(m,{disabled:!P||k.isPending,onClick:z,size:`sm`,children:`Attach`})]})})]})]}),(0,D.jsx)(C,{description:`Are you sure you want to delete the Internet Gateway "${I.InternetGatewayId}"? This action cannot be undone.`,isPending:j.isPending,onConfirm:R,onOpenChange:N,open:M,title:`Delete Internet Gateway`})]})}export{O as component};
\ No newline at end of file
diff --git a/spinifex/services/spinifexui/frontend/dist/assets/_id5.js b/spinifex/services/spinifexui/frontend/dist/assets/_id5.js
index d6b3ba743..2e47fcf07 100644
--- a/spinifex/services/spinifexui/frontend/dist/assets/_id5.js
+++ b/spinifex/services/spinifexui/frontend/dist/assets/_id5.js
@@ -1 +1 @@
-import{t as e}from"./error-banner.js";import{t}from"./useSuspenseQuery.js";import{t as n}from"./back-link.js";import{t as r}from"./trash-2.js";import{w as i}from"./ec2.js";import{Ho as a,Ro as o,Uo as s,Xa as c,Z as l,a as u,cs as d,no as f}from"./index.js";import{t as p}from"./page-heading.js";import{t as m}from"./delete-confirmation-dialog.js";import{n as h,t as g}from"./detail-row.js";var _=d(s()),v=a();function y(){let{id:a}=u.useParams(),s=o(),{data:d}=t(l(a)),y=d.KeyPairs?.[0],b=i(),[x,S]=(0,_.useState)(!1),C=async()=>{try{await b.mutateAsync(a),s({to:`/ec2/describe-key-pairs`})}finally{S(!1)}};if(!y?.KeyPairId)return(0,v.jsxs)(v.Fragment,{children:[(0,v.jsx)(n,{to:`/ec2/describe-key-pairs`,children:`Back to key pairs`}),(0,v.jsx)(`p`,{className:`text-muted-foreground`,children:`Key pair not found.`})]});let w=f(y.CreateTime);return(0,v.jsxs)(v.Fragment,{children:[(0,v.jsx)(n,{to:`/ec2/describe-key-pairs`,children:`Back to key pairs`}),b.error&&(0,v.jsx)(e,{error:b.error,msg:`Failed to delete key pair`}),(0,v.jsxs)(`div`,{className:`space-y-6`,children:[(0,v.jsx)(p,{actions:(0,v.jsxs)(c,{onClick:()=>S(!0),size:`sm`,variant:`destructive`,children:[(0,v.jsx)(r,{className:`size-4`}),`Delete`]}),subtitle:`Key Pair Details`,title:y.KeyName??``}),(0,v.jsxs)(h,{children:[(0,v.jsx)(h.Header,{children:`Key Pair Information`}),(0,v.jsxs)(h.Content,{children:[(0,v.jsx)(g,{label:`Key Pair ID`,value:y.KeyPairId}),(0,v.jsx)(g,{label:`Key Name`,value:y.KeyName}),(0,v.jsx)(g,{label:`Key Type`,value:y.KeyType}),(0,v.jsx)(g,{label:`Key Fingerprint`,value:y.KeyFingerprint}),(0,v.jsx)(g,{label:`Creation Date`,value:w})]})]}),y.Tags&&y.Tags.length>0&&(0,v.jsxs)(h,{children:[(0,v.jsx)(h.Header,{children:`Tags`}),(0,v.jsx)(h.Content,{children:y.Tags.map(e=>(0,v.jsx)(g,{label:e.Key??``,value:e.Value},e.Key))})]})]}),(0,v.jsx)(m,{description:`Are you sure you want to delete the key pair "${y.KeyName}"? This action cannot be undone. Any instances using this key pair will no longer be able to be accessed with it.`,isPending:b.isPending,onConfirm:C,onOpenChange:S,open:x,title:`Delete Key Pair`})]})}export{y as component};
\ No newline at end of file
+import{n as e,o as t,r as n}from"./compiler-runtime.js";import{t as r}from"./error-banner.js";import{t as i}from"./useSuspenseQuery.js";import{D as a}from"./SignatureV4.js";import{t as o}from"./back-link.js";import{t as s}from"./trash-2.js";import{t as c}from"./button.js";import{T as l}from"./ec2.js";import{Ir as u,a as d,yt as f}from"./index.js";import{t as p}from"./page-heading.js";import{t as m}from"./delete-confirmation-dialog.js";import{n as h,t as g}from"./detail-row.js";var _=t(n()),v=e();function y(){let{id:e}=d.useParams(),t=u(),{data:n}=i(f(e)),y=n.KeyPairs?.[0],b=l(),[x,S]=(0,_.useState)(!1),C=async()=>{try{await b.mutateAsync(e),t({to:`/ec2/describe-key-pairs`})}finally{S(!1)}};if(!y?.KeyPairId)return(0,v.jsxs)(v.Fragment,{children:[(0,v.jsx)(o,{to:`/ec2/describe-key-pairs`,children:`Back to key pairs`}),(0,v.jsx)(`p`,{className:`text-muted-foreground`,children:`Key pair not found.`})]});let w=a(y.CreateTime);return(0,v.jsxs)(v.Fragment,{children:[(0,v.jsx)(o,{to:`/ec2/describe-key-pairs`,children:`Back to key pairs`}),b.error&&(0,v.jsx)(r,{error:b.error,msg:`Failed to delete key pair`}),(0,v.jsxs)(`div`,{className:`space-y-6`,children:[(0,v.jsx)(p,{actions:(0,v.jsxs)(c,{onClick:()=>S(!0),size:`sm`,variant:`destructive`,children:[(0,v.jsx)(s,{className:`size-4`}),`Delete`]}),subtitle:`Key Pair Details`,title:y.KeyName??``}),(0,v.jsxs)(h,{children:[(0,v.jsx)(h.Header,{children:`Key Pair Information`}),(0,v.jsxs)(h.Content,{children:[(0,v.jsx)(g,{label:`Key Pair ID`,value:y.KeyPairId}),(0,v.jsx)(g,{label:`Key Name`,value:y.KeyName}),(0,v.jsx)(g,{label:`Key Type`,value:y.KeyType}),(0,v.jsx)(g,{label:`Key Fingerprint`,value:y.KeyFingerprint}),(0,v.jsx)(g,{label:`Creation Date`,value:w})]})]}),y.Tags&&y.Tags.length>0&&(0,v.jsxs)(h,{children:[(0,v.jsx)(h.Header,{children:`Tags`}),(0,v.jsx)(h.Content,{children:y.Tags.map(e=>(0,v.jsx)(g,{label:e.Key??``,value:e.Value},e.Key))})]})]}),(0,v.jsx)(m,{description:`Are you sure you want to delete the key pair "${y.KeyName}"? This action cannot be undone. Any instances using this key pair will no longer be able to be accessed with it.`,isPending:b.isPending,onConfirm:C,onOpenChange:S,open:x,title:`Delete Key Pair`})]})}export{y as component};
\ No newline at end of file
diff --git a/spinifex/services/spinifexui/frontend/dist/assets/_id6.js b/spinifex/services/spinifexui/frontend/dist/assets/_id6.js
index 1f2639ecc..dd65487b1 100644
--- a/spinifex/services/spinifexui/frontend/dist/assets/_id6.js
+++ b/spinifex/services/spinifexui/frontend/dist/assets/_id6.js
@@ -1 +1 @@
-import{t as e}from"./error-banner.js";import{t}from"./useSuspenseQuery.js";import{t as n}from"./back-link.js";import{c as r,i,n as a,s as o,t as s}from"./select.js";import{t as c}from"./trash-2.js";import{a as l,d as u,f as d,o as f,r as p,s as m,t as h,u as g}from"./elbv2.js";import{A as _,Fo as v,Ho as y,I as b,N as x,O as S,Ro as C,Uo as w,Xa as T,Xi as E,ao as D,cs as O,dt as k,j as A,k as j,no as ee,o as te,wo as ne}from"./index.js";import{i as M,n as N,r as re,t as P}from"./zod.js";import{t as ie}from"./page-heading.js";import{a as F,r as I,t as L}from"./field.js";import{a as R,i as z,l as B,n as V,o as H,r as U,s as W,t as G}from"./alert-dialog.js";import{t as K}from"./delete-confirmation-dialog.js";import{n as q,t as J}from"./detail-row.js";import{t as ae}from"./state-badge.js";import{r as oe,t as se}from"./elbv22.js";import{a as ce,i as Y,n as le,o as ue,r as X,t as de}from"./tabs.js";var fe=ne(`pencil`,[[`path`,{d:`M21.174 6.812a1 1 0 0 0-3.986-3.987L3.842 16.174a2 2 0 0 0-.5.83l-1.321 4.352a.5.5 0 0 0 .623.622l4.353-1.32a2 2 0 0 0 .83-.497z`,key:`1a8usu`}],[`path`,{d:`m15 5 4 4`,key:`1mk7zo`}]]),Z=O(w(),1),Q=v(),$=y();function pe(e){let t=(0,Q.c)(33),{form:n,targetGroups:i}=e,{control:c,register:l,formState:u}=n,{errors:d}=u,f;t[0]===Symbol.for(`react.memo_cache_sentinel`)?(f=(0,$.jsx)(F,{children:(0,$.jsx)(`label`,{htmlFor:`listener-protocol`,children:`Protocol`})}),t[0]=f):f=t[0];let p;t[1]===c?p=t[2]:(p=(0,$.jsxs)(L,{children:[f,(0,$.jsx)(N,{control:c,name:`protocol`,render:he})]}),t[1]=c,t[2]=p);let m;t[3]===Symbol.for(`react.memo_cache_sentinel`)?(m=(0,$.jsx)(F,{children:(0,$.jsx)(`label`,{htmlFor:`listener-port`,children:`Port`})}),t[3]=m):m=t[3];let h=!!d.port,g;t[4]===l?g=t[5]:(g=l(`port`,{valueAsNumber:!0}),t[4]=l,t[5]=g);let _;t[6]!==h||t[7]!==g?(_=(0,$.jsx)(E,{"aria-invalid":h,id:`listener-port`,inputMode:`numeric`,placeholder:`80`,type:`number`,...g}),t[6]=h,t[7]=g,t[8]=_):_=t[8];let v;t[9]===d.port?v=t[10]:(v=(0,$.jsx)(I,{errors:[d.port]}),t[9]=d.port,t[10]=v);let y;t[11]!==_||t[12]!==v?(y=(0,$.jsxs)(L,{children:[m,_,v]}),t[11]=_,t[12]=v,t[13]=y):y=t[13];let b;t[14]===Symbol.for(`react.memo_cache_sentinel`)?(b=(0,$.jsx)(F,{children:(0,$.jsx)(`label`,{htmlFor:`listener-default-tg`,children:`Default target group`})}),t[14]=b):b=t[14];let x;t[15]!==d.defaultTargetGroupArn||t[16]!==i?(x=e=>{let{field:t}=e;return(0,$.jsxs)(s,{onValueChange:t.onChange,value:t.value??``,children:[(0,$.jsx)(o,{"aria-invalid":!!d.defaultTargetGroupArn,className:`w-full`,id:`listener-default-tg`,children:(0,$.jsx)(r,{placeholder:`Select target group`})}),(0,$.jsx)(a,{children:i.map(me)})]})},t[15]=d.defaultTargetGroupArn,t[16]=i,t[17]=x):x=t[17];let S;t[18]!==c||t[19]!==x?(S=(0,$.jsx)(N,{control:c,name:`defaultTargetGroupArn`,render:x}),t[18]=c,t[19]=x,t[20]=S):S=t[20];let C;t[21]===d.defaultTargetGroupArn?C=t[22]:(C=(0,$.jsx)(I,{errors:[d.defaultTargetGroupArn]}),t[21]=d.defaultTargetGroupArn,t[22]=C);let w;t[23]===i.length?w=t[24]:(w=i.length===0&&(0,$.jsx)(`p`,{className:`mt-1 text-xs text-muted-foreground`,children:`No target groups available in this VPC. Create one first.`}),t[23]=i.length,t[24]=w);let T;t[25]!==S||t[26]!==C||t[27]!==w?(T=(0,$.jsxs)(L,{children:[b,S,C,w]}),t[25]=S,t[26]=C,t[27]=w,t[28]=T):T=t[28];let D;return t[29]!==T||t[30]!==p||t[31]!==y?(D=(0,$.jsxs)($.Fragment,{children:[p,y,T]}),t[29]=T,t[30]=p,t[31]=y,t[32]=D):D=t[32],D}function me(e){return(0,$.jsxs)(i,{value:e.TargetGroupArn??``,children:[e.TargetGroupName,` · `,e.Protocol,`:`,e.Port]},e.TargetGroupArn)}function he(e){let{field:t}=e;return(0,$.jsxs)(s,{onValueChange:t.onChange,value:t.value,children:[(0,$.jsx)(o,{className:`w-full`,id:`listener-protocol`,children:(0,$.jsx)(r,{})}),(0,$.jsx)(a,{children:(0,$.jsx)(i,{value:`HTTP`,children:`HTTP`})})]})}function ge({listenerArn:n,targetGroups:r}){let{data:i}=t(S(n)),a=p(),o=d(),s=m(),[l,u]=(0,Z.useState)(!1),[f,h]=(0,Z.useState)(),[g,_]=(0,Z.useState)(),v=i.Rules??[],y=new Map;for(let e of r)e.TargetGroupArn&&e.TargetGroupName&&y.set(e.TargetGroupArn,e.TargetGroupName);return(0,$.jsxs)(`div`,{className:`space-y-3`,children:[a.error&&(0,$.jsx)(e,{error:a.error,msg:`Failed to create rule`}),o.error&&(0,$.jsx)(e,{error:o.error,msg:`Failed to modify rule`}),s.error&&(0,$.jsx)(e,{error:s.error,msg:`Failed to delete rule`}),(0,$.jsxs)(`div`,{className:`flex items-center justify-between`,children:[(0,$.jsxs)(`p`,{className:`text-xs text-muted-foreground`,children:[v.length,` rule`,v.length===1?``:`s`,` (including default)`]}),(0,$.jsx)(T,{onClick:()=>u(!0),size:`sm`,children:`Add rule`})]}),(0,$.jsx)(`div`,{className:`overflow-x-auto rounded-lg border bg-card`,children:(0,$.jsxs)(`table`,{className:`w-full text-sm`,children:[(0,$.jsx)(`thead`,{children:(0,$.jsxs)(`tr`,{className:`border-b text-left text-muted-foreground`,children:[(0,$.jsx)(`th`,{className:`px-4 py-2 font-medium`,children:`Priority`}),(0,$.jsx)(`th`,{className:`px-4 py-2 font-medium`,children:`Conditions`}),(0,$.jsx)(`th`,{className:`px-4 py-2 font-medium`,children:`Forward to`}),(0,$.jsx)(`th`,{className:`px-4 py-2 font-medium`,children:(0,$.jsx)(`span`,{className:`sr-only`,children:`Actions`})})]})}),(0,$.jsx)(`tbody`,{children:v.map(e=>(0,$.jsxs)(`tr`,{className:`border-b last:border-0`,children:[(0,$.jsx)(`td`,{className:`px-4 py-2`,children:e.Priority}),(0,$.jsx)(`td`,{className:`px-4 py-2 text-xs`,children:_e(e.Conditions,e.IsDefault===!0)}),(0,$.jsx)(`td`,{className:`px-4 py-2 text-xs`,children:ye(e,y)}),(0,$.jsx)(`td`,{className:`px-4 py-2 text-right`,children:e.IsDefault===!0?(0,$.jsx)(`span`,{className:`text-xs text-muted-foreground`,children:`Edit via listener`}):(0,$.jsxs)(`div`,{className:`flex justify-end gap-1`,children:[(0,$.jsx)(T,{"aria-label":`Edit rule ${e.Priority}`,onClick:()=>h(e),size:`sm`,variant:`ghost`,children:(0,$.jsx)(fe,{className:`size-4`})}),(0,$.jsx)(T,{"aria-label":`Delete rule ${e.Priority}`,onClick:()=>_(e),size:`sm`,variant:`ghost`,children:(0,$.jsx)(c,{className:`size-4`})})]})})]},e.RuleArn??e.Priority??`default`))})]})}),(0,$.jsx)(xe,{isPending:a.isPending,mode:`add`,onOpenChange:u,onSubmit:async e=>{try{await a.mutateAsync({listenerArn:n,priority:e.priority,conditions:e.conditions.map(be),forwardTargetGroupArn:e.forwardTargetGroupArn}),u(!1)}catch{}},open:l,targetGroups:r}),(0,$.jsx)(xe,{initialRule:f,isPending:o.isPending,mode:`edit`,onOpenChange:e=>!e&&h(void 0),onSubmit:async e=>{if(f?.RuleArn)try{await o.mutateAsync({ruleArn:f.RuleArn,listenerArn:n,conditions:e.conditions.map(be),forwardTargetGroupArn:e.forwardTargetGroupArn}),h(void 0)}catch{}},open:f!==void 0,targetGroups:r}),(0,$.jsx)(K,{description:g?`Delete rule with priority ${g.Priority}? This cannot be undone.`:``,isPending:s.isPending,onConfirm:async()=>{if(g?.RuleArn)try{await s.mutateAsync({ruleArn:g.RuleArn,listenerArn:n})}finally{_(void 0)}},onOpenChange:e=>!e&&_(void 0),open:g!==void 0,title:`Delete rule`})]})}function _e(e,t){return t?`default`:!e||e.length===0?`—`:e.map(e=>{let t=ve(e);return`${e.Field}: ${t.join(`, `)}`}).join(` AND `)}function ve(e){if(e.Field===void 0)return e.Values??[];switch(e.Field){case`host-header`:return e.HostHeaderConfig?.Values??e.Values??[];case`path-pattern`:return e.PathPatternConfig?.Values??e.Values??[];case`http-header`:{let t=e.HttpHeaderConfig?.HttpHeaderName??`?`;return(e.HttpHeaderConfig?.Values??[]).map(e=>`${t}=${e}`)}case`http-request-method`:return e.HttpRequestMethodConfig?.Values??[];case`source-ip`:return e.SourceIpConfig?.Values??[];case`query-string`:return(e.QueryStringConfig?.Values??[]).map(e=>e.Key?`${e.Key}=${e.Value??``}`:e.Value??``);default:return e.Values??[]}}function ye(e,t){let n=e.Actions?.[0];return n?n.Type===`forward`&&n.TargetGroupArn?t.get(n.TargetGroupArn)??n.TargetGroupArn:n.Type??`—`:`—`}function be(e){let t=e.rawValues.split(/[\n,]/).map(e=>e.trim()).filter(e=>e.length>0);return e.field===`query-string`?{field:e.field,values:[],queryStringPairs:t.map(e=>{let t=e.indexOf(`=`);return t===-1?{value:e}:{key:e.slice(0,t),value:e.slice(t+1)}})}:{field:e.field,values:t,httpHeaderName:e.httpHeaderName}}function xe({open:e,onOpenChange:t,onSubmit:n,isPending:c,targetGroups:l,mode:u,initialRule:d}){let f=[{field:`path-pattern`,rawValues:``}],p=M({resolver:P(oe),defaultValues:{priority:1,conditions:f,forwardTargetGroupArn:``}}),{fields:m,append:h,remove:g}=re({control:p.control,name:`conditions`});(0,Z.useEffect)(()=>{if(u===`add`){e||p.reset({priority:1,conditions:f,forwardTargetGroupArn:``});return}if(!d)return;let t=Number(d.Priority??1),n=d.Conditions?.map(e=>Ee(e))??f;p.reset({priority:Number.isFinite(t)?t:1,conditions:n.length>0?n:f,forwardTargetGroupArn:d.Actions?.[0]?.TargetGroupArn??``})},[d,e,u]);let _=p.handleSubmit(n);return(0,$.jsx)(G,{onOpenChange:t,open:e,children:(0,$.jsxs)(z,{className:`grid-cols-[minmax(0,1fr)]`,style:{maxWidth:`40rem`,width:`calc(100vw - 2rem)`},children:[(0,$.jsxs)(W,{children:[(0,$.jsx)(B,{children:u===`add`?`Add rule`:`Edit rule`}),(0,$.jsx)(R,{children:`Routing rules match incoming requests and forward them to a target group. Rules evaluate in priority order; the listener default applies when no rule matches.`})]}),(0,$.jsxs)(`form`,{className:`min-w-0 space-y-4`,onSubmit:e=>{e.preventDefault(),_()},children:[(0,$.jsxs)(L,{children:[(0,$.jsx)(F,{children:(0,$.jsx)(`label`,{htmlFor:`rule-priority`,children:`Priority`})}),(0,$.jsx)(E,{"aria-invalid":!!p.formState.errors.priority,id:`rule-priority`,inputMode:`numeric`,type:`number`,...p.register(`priority`,{valueAsNumber:!0})}),(0,$.jsx)(I,{errors:[p.formState.errors.priority]})]}),(0,$.jsxs)(`div`,{className:`space-y-3`,children:[(0,$.jsxs)(`div`,{className:`flex items-center justify-between`,children:[(0,$.jsx)(F,{children:`Conditions`}),(0,$.jsx)(T,{disabled:m.length>=5,onClick:()=>h({field:`path-pattern`,rawValues:``}),size:`sm`,type:`button`,variant:`outline`,children:`Add condition`})]}),m.map((e,t)=>(0,$.jsx)(Se,{form:p,index:t,onRemove:m.length>1?()=>g(t):void 0},e.id))]}),(0,$.jsxs)(L,{children:[(0,$.jsx)(F,{children:(0,$.jsx)(`label`,{htmlFor:`rule-forward-tg`,children:`Forward to target group`})}),(0,$.jsx)(N,{control:p.control,name:`forwardTargetGroupArn`,render:({field:e})=>(0,$.jsxs)(s,{onValueChange:e.onChange,value:e.value??``,children:[(0,$.jsx)(o,{"aria-invalid":!!p.formState.errors.forwardTargetGroupArn,className:`w-full`,id:`rule-forward-tg`,children:(0,$.jsx)(r,{placeholder:`Select target group`})}),(0,$.jsx)(a,{children:l.map(e=>(0,$.jsxs)(i,{value:e.TargetGroupArn??``,children:[e.TargetGroupName,` · `,e.Protocol,`:`,e.Port]},e.TargetGroupArn))})]})}),(0,$.jsx)(I,{errors:[p.formState.errors.forwardTargetGroupArn]})]})]}),(0,$.jsxs)(H,{children:[(0,$.jsx)(U,{disabled:c,children:`Cancel`}),(0,$.jsx)(V,{disabled:c,onClick:_,children:De(u,c)})]})]})})}function Se(e){let t=(0,Q.c)(54),{form:n,index:l,onRemove:u}=e,d,f,p,m,h,g,_,v,y,b;if(t[0]!==n||t[1]!==l||t[2]!==u){let e=n.watch(`conditions.${l}.field`);p=n.formState.errors.conditions?.[l],v=`space-y-2 rounded-md border p-3`;let x=`rule-cond-${l}-field`,S;t[13]===x?S=t[14]:(S=(0,$.jsx)(F,{children:(0,$.jsx)(`label`,{htmlFor:x,children:`Type`})}),t[13]=x,t[14]=S);let C=`conditions.${l}.field`,w;t[15]===l?w=t[16]:(w=e=>{let{field:t}=e;return(0,$.jsxs)(s,{onValueChange:t.onChange,value:t.value,children:[(0,$.jsx)(o,{className:`w-full`,id:`rule-cond-${l}-field`,children:(0,$.jsx)(r,{})}),(0,$.jsxs)(a,{children:[(0,$.jsx)(i,{value:`path-pattern`,children:`Path pattern`}),(0,$.jsx)(i,{value:`host-header`,children:`Host header`}),(0,$.jsx)(i,{value:`http-header`,children:`HTTP header`}),(0,$.jsx)(i,{value:`http-request-method`,children:`HTTP method`}),(0,$.jsx)(i,{value:`source-ip`,children:`Source IP CIDR`}),(0,$.jsx)(i,{value:`query-string`,children:`Query string`})]})]})},t[15]=l,t[16]=w);let D;t[17]!==n.control||t[18]!==C||t[19]!==w?(D=(0,$.jsx)(N,{control:n.control,name:C,render:w}),t[17]=n.control,t[18]=C,t[19]=w,t[20]=D):D=t[20];let O;t[21]!==D||t[22]!==S?(O=(0,$.jsxs)(`div`,{className:`flex-1`,children:[S,D]}),t[21]=D,t[22]=S,t[23]=O):O=t[23];let k;t[24]!==l||t[25]!==u?(k=u&&(0,$.jsx)(T,{"aria-label":`Remove condition ${l+1}`,className:`self-end`,onClick:u,size:`sm`,type:`button`,variant:`ghost`,children:(0,$.jsx)(c,{className:`size-4`})}),t[24]=l,t[25]=u,t[26]=k):k=t[26],t[27]!==O||t[28]!==k?(y=(0,$.jsxs)(`div`,{className:`flex gap-2`,children:[O,k]}),t[27]=O,t[28]=k,t[29]=y):y=t[29],b=e===`http-header`&&(0,$.jsxs)(L,{children:[(0,$.jsx)(F,{children:(0,$.jsx)(`label`,{htmlFor:`rule-cond-${l}-hdr-name`,children:`Header name`})}),(0,$.jsx)(E,{"aria-invalid":!!p?.httpHeaderName,id:`rule-cond-${l}-hdr-name`,placeholder:`X-Tenant`,...n.register(`conditions.${l}.httpHeaderName`)}),(0,$.jsx)(I,{errors:[p?.httpHeaderName]})]}),f=L;let A=`rule-cond-${l}-values`,j;t[30]===Symbol.for(`react.memo_cache_sentinel`)?(j=(0,$.jsx)(`span`,{className:`text-xs text-muted-foreground`,children:`(comma or newline separated, max 5)`}),t[30]=j):j=t[30],t[31]===A?_=t[32]:(_=(0,$.jsx)(F,{children:(0,$.jsxs)(`label`,{htmlFor:A,children:[`Values`,` `,j]})}),t[31]=A,t[32]=_),d=E,m=!!p?.rawValues,h=`rule-cond-${l}-values`,g=Ce(e),t[0]=n,t[1]=l,t[2]=u,t[3]=d,t[4]=f,t[5]=p,t[6]=m,t[7]=h,t[8]=g,t[9]=_,t[10]=v,t[11]=y,t[12]=b}else d=t[3],f=t[4],p=t[5],m=t[6],h=t[7],g=t[8],_=t[9],v=t[10],y=t[11],b=t[12];let x;t[33]!==n||t[34]!==l?(x=n.register(`conditions.${l}.rawValues`),t[33]=n,t[34]=l,t[35]=x):x=t[35];let S;t[36]!==d||t[37]!==m||t[38]!==h||t[39]!==g||t[40]!==x?(S=(0,$.jsx)(d,{"aria-invalid":m,id:h,placeholder:g,...x}),t[36]=d,t[37]=m,t[38]=h,t[39]=g,t[40]=x,t[41]=S):S=t[41];let C=p?.rawValues,w;t[42]===C?w=t[43]:(w=(0,$.jsx)(I,{errors:[C]}),t[42]=C,t[43]=w);let D;t[44]!==f||t[45]!==w||t[46]!==_||t[47]!==S?(D=(0,$.jsxs)(f,{children:[_,S,w]}),t[44]=f,t[45]=w,t[46]=_,t[47]=S,t[48]=D):D=t[48];let O;return t[49]!==D||t[50]!==v||t[51]!==y||t[52]!==b?(O=(0,$.jsxs)(`div`,{className:v,children:[y,b,D]}),t[49]=D,t[50]=v,t[51]=y,t[52]=b,t[53]=O):O=t[53],O}function Ce(e){switch(e){case`path-pattern`:return`/api/*`;case`host-header`:return`*.example.com`;case`http-header`:return`acme`;case`http-request-method`:return`GET, POST`;case`source-ip`:return`10.0.0.0/8`;case`query-string`:return`key=value`;default:return``}}var we=new Set([`host-header`,`path-pattern`,`http-header`,`http-request-method`,`source-ip`,`query-string`]);function Te(e){return e!==void 0&&we.has(e)}function Ee(e){let t=ve(e);return{field:Te(e.Field)?e.Field:`path-pattern`,httpHeaderName:e.HttpHeaderConfig?.HttpHeaderName,rawValues:t.join(`, `)}}function De(e,t){return t?e===`add`?`Adding…`:`Saving…`:e===`add`?`Add rule`:`Save changes`}function Oe({loadBalancerArn:n,vpcId:r}){let{data:i}=t(j(n)),{data:a}=t(b),o=h(),s=l(),u=g(),[d,f]=(0,Z.useState)(!1),[p,m]=(0,Z.useState)(),[_,v]=(0,Z.useState)(),[y,x]=(0,Z.useState)(),S=i.Listeners??[],C=a.TargetGroups??[],w=C.filter(e=>e.VpcId===r),E=new Map;for(let e of C)e.TargetGroupArn&&e.TargetGroupName&&E.set(e.TargetGroupArn,e.TargetGroupName);let D=e=>{let t=e.DefaultActions?.[0];if(!t)return`—`;if(t.Type===`forward`&&t.TargetGroupArn){let e=E.get(t.TargetGroupArn);return e?`forward → ${e}`:`forward → ${t.TargetGroupArn}`}return t.Type??`—`};return(0,$.jsxs)(`div`,{className:`space-y-3`,children:[o.error&&(0,$.jsx)(e,{error:o.error,msg:`Failed to create listener`}),u.error&&(0,$.jsx)(e,{error:u.error,msg:`Failed to modify listener`}),s.error&&(0,$.jsx)(e,{error:s.error,msg:`Failed to delete listener`}),(0,$.jsxs)(`div`,{className:`flex items-center justify-between`,children:[(0,$.jsxs)(`p`,{className:`text-xs text-muted-foreground`,children:[S.length,` listener`,S.length===1?``:`s`]}),(0,$.jsx)(T,{onClick:()=>f(!0),size:`sm`,children:`Add listener`})]}),S.length===0?(0,$.jsx)(`p`,{className:`text-muted-foreground`,children:`No listeners configured.`}):(0,$.jsx)(`div`,{className:`overflow-x-auto rounded-lg border bg-card`,children:(0,$.jsxs)(`table`,{className:`w-full text-sm`,children:[(0,$.jsx)(`thead`,{children:(0,$.jsxs)(`tr`,{className:`border-b text-left text-muted-foreground`,children:[(0,$.jsx)(`th`,{className:`px-4 py-2 font-medium`,children:`Protocol`}),(0,$.jsx)(`th`,{className:`px-4 py-2 font-medium`,children:`Port`}),(0,$.jsx)(`th`,{className:`px-4 py-2 font-medium`,children:`Default action`}),(0,$.jsx)(`th`,{className:`px-4 py-2 font-medium`,children:`Listener ARN`}),(0,$.jsx)(`th`,{className:`px-4 py-2 font-medium`,children:(0,$.jsx)(`span`,{className:`sr-only`,children:`Actions`})})]})}),(0,$.jsx)(`tbody`,{children:S.map(e=>{let t=e.ListenerArn??``,n=y===t;return(0,$.jsxs)(Z.Fragment,{children:[(0,$.jsxs)(`tr`,{className:`border-b last:border-0`,children:[(0,$.jsx)(`td`,{className:`px-4 py-2`,children:e.Protocol}),(0,$.jsx)(`td`,{className:`px-4 py-2`,children:e.Port}),(0,$.jsx)(`td`,{className:`px-4 py-2 text-xs`,children:D(e)}),(0,$.jsx)(`td`,{className:`px-4 py-2 font-mono text-xs`,children:t}),(0,$.jsx)(`td`,{className:`px-4 py-2 text-right`,children:(0,$.jsxs)(`div`,{className:`flex justify-end gap-1`,children:[(0,$.jsx)(T,{"aria-label":`${n?`Hide`:`Show`} rules for listener ${e.Protocol}:${e.Port}`,onClick:()=>x(n?void 0:t),size:`sm`,variant:`ghost`,children:n?`Hide rules`:`Rules`}),(0,$.jsx)(T,{"aria-label":`Edit listener ${e.Protocol}:${e.Port}`,onClick:()=>m(e),size:`sm`,variant:`ghost`,children:(0,$.jsx)(fe,{className:`size-4`})}),(0,$.jsx)(T,{"aria-label":`Delete listener ${e.Protocol}:${e.Port}`,onClick:()=>v(e),size:`sm`,variant:`ghost`,children:(0,$.jsx)(c,{className:`size-4`})})]})})]}),n&&t&&(0,$.jsx)(`tr`,{className:`border-b bg-muted/30 last:border-0`,children:(0,$.jsx)(`td`,{className:`px-4 py-3`,colSpan:5,children:(0,$.jsx)(ge,{listenerArn:t,targetGroups:w})})})]},t)})})]})}),(0,$.jsx)(ke,{isPending:o.isPending,onOpenChange:f,onSubmit:async e=>{try{await o.mutateAsync({loadBalancerArn:n,protocol:e.protocol,port:e.port,defaultTargetGroupArn:e.defaultTargetGroupArn}),f(!1)}catch{}},open:d,targetGroups:w}),(0,$.jsx)(Ae,{isPending:u.isPending,listener:p,onOpenChange:e=>!e&&m(void 0),onSubmit:async e=>{if(p?.ListenerArn)try{await u.mutateAsync({listenerArn:p.ListenerArn,loadBalancerArn:n,protocol:e.protocol,port:e.port,defaultTargetGroupArn:e.defaultTargetGroupArn}),m(void 0)}catch{}},targetGroups:w}),(0,$.jsx)(K,{description:_?`Delete listener ${_.Protocol}:${_.Port}? This cannot be undone.`:``,isPending:s.isPending,onConfirm:async()=>{if(_?.ListenerArn)try{await s.mutateAsync(_.ListenerArn)}finally{v(void 0)}},onOpenChange:e=>!e&&v(void 0),open:_!==void 0,title:`Delete listener`})]})}function ke(e){let t=(0,Q.c)(34),{open:n,onOpenChange:r,targetGroups:i,isPending:a,onSubmit:o}=e,s;t[0]===Symbol.for(`react.memo_cache_sentinel`)?(s={resolver:P(se),defaultValues:{protocol:`HTTP`,port:80,defaultTargetGroupArn:``}},t[0]=s):s=t[0];let c=M(s),l,u;t[1]!==c||t[2]!==n?(l=()=>{n||c.reset({protocol:`HTTP`,port:80,defaultTargetGroupArn:``})},u=[n,c],t[1]=c,t[2]=n,t[3]=l,t[4]=u):(l=t[3],u=t[4]),(0,Z.useEffect)(l,u);let d;t[5]!==c||t[6]!==o?(d=c.handleSubmit(o),t[5]=c,t[6]=o,t[7]=d):d=t[7];let f=d,p;t[8]===Symbol.for(`react.memo_cache_sentinel`)?(p={maxWidth:`32rem`,width:`calc(100vw - 2rem)`},t[8]=p):p=t[8];let m;t[9]===Symbol.for(`react.memo_cache_sentinel`)?(m=(0,$.jsxs)(W,{children:[(0,$.jsx)(B,{children:`Add listener`}),(0,$.jsx)(R,{children:`Listeners forward incoming traffic to a default target group.`})]}),t[9]=m):m=t[9];let h;t[10]===f?h=t[11]:(h=e=>{e.preventDefault(),f()},t[10]=f,t[11]=h);let g;t[12]!==c||t[13]!==i?(g=(0,$.jsx)(pe,{form:c,targetGroups:i}),t[12]=c,t[13]=i,t[14]=g):g=t[14];let _;t[15]!==h||t[16]!==g?(_=(0,$.jsx)(`form`,{className:`min-w-0 space-y-4`,onSubmit:h,children:g}),t[15]=h,t[16]=g,t[17]=_):_=t[17];let v;t[18]===a?v=t[19]:(v=(0,$.jsx)(U,{disabled:a,children:`Cancel`}),t[18]=a,t[19]=v);let y=a?`Adding…`:`Add listener`,b;t[20]!==f||t[21]!==a||t[22]!==y?(b=(0,$.jsx)(V,{disabled:a,onClick:f,children:y}),t[20]=f,t[21]=a,t[22]=y,t[23]=b):b=t[23];let x;t[24]!==v||t[25]!==b?(x=(0,$.jsxs)(H,{children:[v,b]}),t[24]=v,t[25]=b,t[26]=x):x=t[26];let S;t[27]!==x||t[28]!==_?(S=(0,$.jsxs)(z,{className:`grid-cols-[minmax(0,1fr)]`,style:p,children:[m,_,x]}),t[27]=x,t[28]=_,t[29]=S):S=t[29];let C;return t[30]!==r||t[31]!==n||t[32]!==S?(C=(0,$.jsx)(G,{onOpenChange:r,open:n,children:S}),t[30]=r,t[31]=n,t[32]=S,t[33]=C):C=t[33],C}function Ae(e){let t=(0,Q.c)(34),{listener:n,onOpenChange:r,targetGroups:i,isPending:a,onSubmit:o}=e,s=n!==void 0,c;t[0]===Symbol.for(`react.memo_cache_sentinel`)?(c={resolver:P(se),defaultValues:{protocol:`HTTP`,port:80,defaultTargetGroupArn:``}},t[0]=c):c=t[0];let l=M(c),u,d;t[1]!==l||t[2]!==n?(u=()=>{n&&l.reset({protocol:`HTTP`,port:n.Port??80,defaultTargetGroupArn:n.DefaultActions?.[0]?.TargetGroupArn??``})},d=[n,l],t[1]=l,t[2]=n,t[3]=u,t[4]=d):(u=t[3],d=t[4]),(0,Z.useEffect)(u,d);let f;t[5]!==l||t[6]!==o?(f=l.handleSubmit(o),t[5]=l,t[6]=o,t[7]=f):f=t[7];let p=f,m;t[8]===Symbol.for(`react.memo_cache_sentinel`)?(m={maxWidth:`32rem`,width:`calc(100vw - 2rem)`},t[8]=m):m=t[8];let h;t[9]===Symbol.for(`react.memo_cache_sentinel`)?(h=(0,$.jsxs)(W,{children:[(0,$.jsx)(B,{children:`Edit listener`}),(0,$.jsx)(R,{children:`Update port, protocol, or default target group. Changes apply without dropping the listener.`})]}),t[9]=h):h=t[9];let g;t[10]===p?g=t[11]:(g=e=>{e.preventDefault(),p()},t[10]=p,t[11]=g);let _;t[12]!==l||t[13]!==i?(_=(0,$.jsx)(pe,{form:l,targetGroups:i}),t[12]=l,t[13]=i,t[14]=_):_=t[14];let v;t[15]!==g||t[16]!==_?(v=(0,$.jsx)(`form`,{className:`min-w-0 space-y-4`,onSubmit:g,children:_}),t[15]=g,t[16]=_,t[17]=v):v=t[17];let y;t[18]===a?y=t[19]:(y=(0,$.jsx)(U,{disabled:a,children:`Cancel`}),t[18]=a,t[19]=y);let b=a?`Saving…`:`Save changes`,x;t[20]!==p||t[21]!==a||t[22]!==b?(x=(0,$.jsx)(V,{disabled:a,onClick:p,children:b}),t[20]=p,t[21]=a,t[22]=b,t[23]=x):x=t[23];let S;t[24]!==y||t[25]!==x?(S=(0,$.jsxs)(H,{children:[y,x]}),t[24]=y,t[25]=x,t[26]=S):S=t[26];let C;t[27]!==S||t[28]!==v?(C=(0,$.jsxs)(z,{className:`grid-cols-[minmax(0,1fr)]`,style:m,children:[h,v,S]}),t[27]=S,t[28]=v,t[29]=C):C=t[29];let w;return t[30]!==r||t[31]!==s||t[32]!==C?(w=(0,$.jsx)(G,{onOpenChange:r,open:s,children:C}),t[30]=r,t[31]=s,t[32]=C,t[33]=w):w=t[33],w}function je({arn:r}){let i=C(),{data:a}=t(A(r)),{data:o}=t(_(r)),{data:s}=t(x([r])),{data:l}=t(k),d=f(),p=u(),[m,h]=(0,Z.useState)(!1),g=a.LoadBalancers?.[0];if(!g?.LoadBalancerArn)return(0,$.jsxs)($.Fragment,{children:[(0,$.jsx)(n,{to:`/ec2/describe-load-balancers`,children:`Back to load balancers`}),(0,$.jsx)(`p`,{className:`text-muted-foreground`,children:`Load balancer not found.`})]});let v=async()=>{try{await d.mutateAsync(r),i({to:`/ec2/describe-load-balancers`})}finally{h(!1)}},y=l.Subnets??[],b=e=>{let t=y.find(t=>t.SubnetId===e);if(!t)return e;let n=D(t.Tags),r=t.CidrBlock?` (${t.CidrBlock})`:``;return n?`${e} — ${n}${r}`:`${e}${r}`},S=o.Attributes??[],w=s?.TagDescriptions?.find(e=>e.ResourceArn===r)?.Tags??[];return(0,$.jsxs)($.Fragment,{children:[(0,$.jsx)(n,{to:`/ec2/describe-load-balancers`,children:`Back to load balancers`}),d.error&&(0,$.jsx)(e,{error:d.error,msg:`Failed to delete load balancer`}),(0,$.jsxs)(`div`,{className:`space-y-6`,children:[(0,$.jsx)(ie,{actions:(0,$.jsxs)(`div`,{className:`flex items-center gap-2`,children:[(0,$.jsxs)(T,{onClick:()=>h(!0),size:`sm`,variant:`destructive`,children:[(0,$.jsx)(c,{className:`size-4`}),`Delete`]}),(0,$.jsx)(ae,{state:g.State?.Code})]}),subtitle:`Load Balancer Details`,title:g.LoadBalancerName??g.LoadBalancerArn}),(0,$.jsxs)(de,{defaultValue:`overview`,children:[(0,$.jsxs)(le,{children:[(0,$.jsx)(Y,{value:`overview`,children:`Overview`}),(0,$.jsx)(Y,{value:`listeners`,children:`Listeners`}),(0,$.jsx)(Y,{value:`attributes`,children:`Attributes`}),(0,$.jsx)(Y,{value:`tags`,children:`Tags`})]}),(0,$.jsxs)(X,{value:`overview`,children:[(0,$.jsxs)(q,{children:[(0,$.jsx)(q.Header,{children:`Load balancer`}),(0,$.jsxs)(q.Content,{children:[(0,$.jsx)(J,{label:`ARN`,value:g.LoadBalancerArn}),(0,$.jsx)(J,{label:`DNS name`,value:g.DNSName}),(0,$.jsx)(J,{label:`State`,value:g.State?.Reason?`${g.State.Code} — ${g.State.Reason}`:g.State?.Code}),(0,$.jsx)(J,{label:`Type`,value:g.Type}),(0,$.jsx)(J,{label:`Scheme`,value:g.Scheme}),(0,$.jsx)(J,{label:`IP address type`,value:g.IpAddressType}),(0,$.jsx)(J,{label:`VPC`,value:g.VpcId}),(0,$.jsx)(J,{label:`Created at`,value:ee(g.CreatedTime)})]})]}),(0,$.jsxs)(q,{children:[(0,$.jsx)(q.Header,{children:`Availability zones`}),(0,$.jsx)(q.Content,{children:(g.AvailabilityZones??[]).map(e=>(0,$.jsx)(J,{label:e.ZoneName??`—`,value:e.SubnetId?b(e.SubnetId):void 0},`${e.ZoneName}-${e.SubnetId}`))})]}),(g.SecurityGroups??[]).length>0&&(0,$.jsxs)(q,{children:[(0,$.jsx)(q.Header,{children:`Security groups`}),(0,$.jsx)(q.Content,{children:(g.SecurityGroups??[]).map(e=>(0,$.jsx)(J,{label:`Security group`,value:e},e))})]})]}),(0,$.jsx)(X,{value:`listeners`,children:(0,$.jsx)(Oe,{loadBalancerArn:r,vpcId:g.VpcId})}),(0,$.jsx)(X,{value:`attributes`,children:(0,$.jsx)(ce,{attributes:S,error:p.error,isPending:p.isPending,isSuccess:p.isSuccess,onSubmit:e=>{p.mutate({loadBalancerArn:r,attributes:e})},specs:ue})}),(0,$.jsx)(X,{value:`tags`,children:w.length>0?(0,$.jsxs)(q,{children:[(0,$.jsx)(q.Header,{children:`Tags`}),(0,$.jsx)(q.Content,{children:w.map(e=>(0,$.jsx)(J,{label:e.Key??``,value:e.Value},e.Key??``))})]}):(0,$.jsx)(`p`,{className:`text-muted-foreground`,children:`No tags.`})})]})]}),(0,$.jsx)(K,{description:`Are you sure you want to delete load balancer "${g.LoadBalancerName??g.LoadBalancerArn}"? This action cannot be undone.`,isPending:d.isPending,onConfirm:v,onOpenChange:h,open:m,title:`Delete load balancer`})]})}function Me(){let e=(0,Q.c)(2),{id:t}=te.useParams(),n=decodeURIComponent(t),r;return e[0]===n?r=e[1]:(r=(0,$.jsx)(je,{arn:n}),e[0]=n,e[1]=r),r}export{Me as component};
\ No newline at end of file
+import{n as e,o as t,r as n,t as r}from"./compiler-runtime.js";import{t as i}from"./error-banner.js";import{t as a}from"./useSuspenseQuery.js";import{A as o,D as s}from"./SignatureV4.js";import{t as c}from"./back-link.js";import{c as l,i as u,n as d,s as f,t as p}from"./select.js";import{t as m}from"./trash-2.js";import{t as h}from"./button.js";import{t as g}from"./certificate-import-dialog.js";import{_,a as v,d as y,f as b,g as x,h as S,o as C,r as w,s as T,t as E,u as D}from"./elbv2.js";import{B as O,F as k,G as A,I as j,Ir as M,L as N,Nr as P,Nt as F,P as I,Rn as ee,U as te,kt as ne,o as L,z as R}from"./index.js";import{a as re,i as z,n as ie,r as B,t as V}from"./zod.js";import{t as H}from"./page-heading.js";import{a as U,r as ae,t as W}from"./field.js";import{a as G,i as K,l as q,n as J,o as Y,r as oe,s as se,t as ce}from"./alert-dialog.js";import{t as le}from"./delete-confirmation-dialog.js";import{n as X,t as Z}from"./detail-row.js";import{t as ue}from"./state-badge.js";import{c as de,i as fe,n as pe,o as me,s as he,t as ge}from"./elbv22.js";import{n as _e,r as ve,t as ye}from"./tags-editor.js";import{i as be,n as xe,r as Se,t as Ce}from"./tabs.js";var we=P(`pencil`,[[`path`,{d:`M21.174 6.812a1 1 0 0 0-3.986-3.987L3.842 16.174a2 2 0 0 0-.5.83l-1.321 4.352a.5.5 0 0 0 .623.622l4.353-1.32a2 2 0 0 0 .83-.497z`,key:`1a8usu`}],[`path`,{d:`m15 5 4 4`,key:`1mk7zo`}]]),Te=r(),Q=t(n(),1),$=e(),Ee=80,De=443;function Oe(e){let t=e.CertificateArn?.split(`/`).pop()??e.CertificateArn??``;return e.DomainName?`${e.DomainName} · ${t}`:t}function ke(e){let t=(0,Te.c)(59),{form:n,protocols:r,targetGroups:i,certificates:a,sslPolicies:o}=e,s;t[0]===a?s=t[1]:(s=a===void 0?[]:a,t[0]=a,t[1]=s);let c=s,u;t[2]===o?u=t[3]:(u=o===void 0?[]:o,t[2]=o,t[3]=u);let m=u,{control:_,register:v,getValues:y,setValue:b,formState:x}=n,{errors:S}=x,[C,w]=(0,Q.useState)(!1),T;t[4]===_?T=t[5]:(T={control:_,name:`protocol`},t[4]=_,t[5]=T);let E=re(T),D=E===`HTTPS`,O;t[6]===E?O=t[7]:(O=he(E),t[6]=E,t[7]=O);let k=O,A;t[8]!==y||t[9]!==b?(A=e=>{if(!e)return;let t=y(`port`);he(e)?(t===Ee&&b(`port`,De),e===`HTTPS`?y(`sslPolicy`)||b(`sslPolicy`,ge):b(`sslPolicy`,void 0)):(t===De&&b(`port`,Ee),b(`certificateArn`,void 0),b(`sslPolicy`,void 0))},t[8]=y,t[9]=b,t[10]=A):A=t[10];let j=A,M;t[11]===Symbol.for(`react.memo_cache_sentinel`)?(M=(0,$.jsx)(U,{children:(0,$.jsx)(`label`,{htmlFor:`listener-protocol`,children:`Protocol`})}),t[11]=M):M=t[11];let N;t[12]!==j||t[13]!==r?(N=e=>{let{field:t}=e;return(0,$.jsxs)(p,{onValueChange:e=>{t.onChange(e),j(e)},value:t.value,children:[(0,$.jsx)(f,{className:`w-full`,id:`listener-protocol`,children:(0,$.jsx)(l,{})}),(0,$.jsx)(d,{children:r.map(Ne)})]})},t[12]=j,t[13]=r,t[14]=N):N=t[14];let P;t[15]!==_||t[16]!==N?(P=(0,$.jsxs)(W,{children:[M,(0,$.jsx)(ie,{control:_,name:`protocol`,render:N})]}),t[15]=_,t[16]=N,t[17]=P):P=t[17];let F;t[18]===Symbol.for(`react.memo_cache_sentinel`)?(F=(0,$.jsx)(U,{children:(0,$.jsx)(`label`,{htmlFor:`listener-port`,children:`Port`})}),t[18]=F):F=t[18];let I=!!S.port,te=k?`443`:`80`,ne;t[19]===v?ne=t[20]:(ne=v(`port`,{valueAsNumber:!0}),t[19]=v,t[20]=ne);let L;t[21]!==I||t[22]!==te||t[23]!==ne?(L=(0,$.jsx)(ee,{"aria-invalid":I,id:`listener-port`,inputMode:`numeric`,placeholder:te,type:`number`,...ne}),t[21]=I,t[22]=te,t[23]=ne,t[24]=L):L=t[24];let R;t[25]===S.port?R=t[26]:(R=(0,$.jsx)(ae,{errors:[S.port]}),t[25]=S.port,t[26]=R);let z;t[27]!==L||t[28]!==R?(z=(0,$.jsxs)(W,{children:[F,L,R]}),t[27]=L,t[28]=R,t[29]=z):z=t[29];let B;t[30]!==c||t[31]!==_||t[32]!==S.certificateArn||t[33]!==C||t[34]!==D||t[35]!==k||t[36]!==b||t[37]!==m?(B=k&&(0,$.jsxs)($.Fragment,{children:[(0,$.jsxs)(W,{children:[(0,$.jsx)(U,{children:(0,$.jsx)(`label`,{htmlFor:`listener-certificate`,children:`Certificate`})}),(0,$.jsx)(ie,{control:_,name:`certificateArn`,render:e=>{let{field:t}=e;return(0,$.jsxs)(p,{onValueChange:t.onChange,value:t.value??``,children:[(0,$.jsx)(f,{"aria-invalid":!!S.certificateArn,className:`w-full`,id:`listener-certificate`,children:(0,$.jsx)(l,{placeholder:`Select certificate`})}),(0,$.jsx)(d,{children:c.map(Me)})]})}}),(0,$.jsx)(ae,{errors:[S.certificateArn]}),(0,$.jsxs)(`div`,{className:`mt-1 flex items-center justify-between`,children:[c.length===0&&(0,$.jsx)(`p`,{className:`text-xs text-muted-foreground`,children:`No certificates imported yet.`}),(0,$.jsx)(h,{className:`ml-auto`,onClick:()=>w(!0),size:`sm`,type:`button`,variant:`ghost`,children:`Import certificate…`})]})]}),D&&(0,$.jsxs)(W,{children:[(0,$.jsx)(U,{children:(0,$.jsx)(`label`,{htmlFor:`listener-ssl-policy`,children:`Security policy`})}),(0,$.jsx)(ie,{control:_,name:`sslPolicy`,render:e=>{let{field:t}=e;return(0,$.jsxs)(p,{onValueChange:t.onChange,value:t.value??`ELBSecurityPolicy-2016-08`,children:[(0,$.jsx)(f,{className:`w-full`,id:`listener-ssl-policy`,children:(0,$.jsx)(l,{})}),(0,$.jsx)(d,{children:m.map(je)})]})}})]}),(0,$.jsx)(g,{onImported:e=>b(`certificateArn`,e,{shouldValidate:!0}),onOpenChange:w,open:C})]}),t[30]=c,t[31]=_,t[32]=S.certificateArn,t[33]=C,t[34]=D,t[35]=k,t[36]=b,t[37]=m,t[38]=B):B=t[38];let V;t[39]===Symbol.for(`react.memo_cache_sentinel`)?(V=(0,$.jsx)(U,{children:(0,$.jsx)(`label`,{htmlFor:`listener-default-tg`,children:`Default target group`})}),t[39]=V):V=t[39];let H;t[40]!==S.defaultTargetGroupArn||t[41]!==i?(H=e=>{let{field:t}=e;return(0,$.jsxs)(p,{onValueChange:t.onChange,value:t.value??``,children:[(0,$.jsx)(f,{"aria-invalid":!!S.defaultTargetGroupArn,className:`w-full`,id:`listener-default-tg`,children:(0,$.jsx)(l,{placeholder:`Select target group`})}),(0,$.jsx)(d,{children:i.map(Ae)})]})},t[40]=S.defaultTargetGroupArn,t[41]=i,t[42]=H):H=t[42];let G;t[43]!==_||t[44]!==H?(G=(0,$.jsx)(ie,{control:_,name:`defaultTargetGroupArn`,render:H}),t[43]=_,t[44]=H,t[45]=G):G=t[45];let K;t[46]===S.defaultTargetGroupArn?K=t[47]:(K=(0,$.jsx)(ae,{errors:[S.defaultTargetGroupArn]}),t[46]=S.defaultTargetGroupArn,t[47]=K);let q;t[48]===i.length?q=t[49]:(q=i.length===0&&(0,$.jsx)(`p`,{className:`mt-1 text-xs text-muted-foreground`,children:`No target groups available in this VPC. Create one first.`}),t[48]=i.length,t[49]=q);let J;t[50]!==G||t[51]!==K||t[52]!==q?(J=(0,$.jsxs)(W,{children:[V,G,K,q]}),t[50]=G,t[51]=K,t[52]=q,t[53]=J):J=t[53];let Y;return t[54]!==P||t[55]!==z||t[56]!==B||t[57]!==J?(Y=(0,$.jsxs)($.Fragment,{children:[P,z,B,J]}),t[54]=P,t[55]=z,t[56]=B,t[57]=J,t[58]=Y):Y=t[58],Y}function Ae(e){return(0,$.jsxs)(u,{value:e.TargetGroupArn??``,children:[e.TargetGroupName,` · `,e.Protocol,`:`,e.Port]},e.TargetGroupArn)}function je(e){return(0,$.jsx)(u,{value:e.Name??``,children:e.Name},e.Name)}function Me(e){return(0,$.jsx)(u,{value:e.CertificateArn??``,children:Oe(e)},e.CertificateArn)}function Ne(e){return(0,$.jsx)(u,{value:e,children:e},e)}function Pe({listenerArn:e,targetGroups:t}){let{data:n}=a(I(e)),r=w(),o=b(),s=T(),[c,l]=(0,Q.useState)(!1),[u,d]=(0,Q.useState)(),[f,p]=(0,Q.useState)(),g=n.Rules??[],_=new Map;for(let e of t)e.TargetGroupArn&&e.TargetGroupName&&_.set(e.TargetGroupArn,e.TargetGroupName);return(0,$.jsxs)(`div`,{className:`space-y-3`,children:[r.error&&(0,$.jsx)(i,{error:r.error,msg:`Failed to create rule`}),o.error&&(0,$.jsx)(i,{error:o.error,msg:`Failed to modify rule`}),s.error&&(0,$.jsx)(i,{error:s.error,msg:`Failed to delete rule`}),(0,$.jsxs)(`div`,{className:`flex items-center justify-between`,children:[(0,$.jsxs)(`p`,{className:`text-xs text-muted-foreground`,children:[g.length,` rule`,g.length===1?``:`s`,` (including default)`]}),(0,$.jsx)(h,{onClick:()=>l(!0),size:`sm`,children:`Add rule`})]}),(0,$.jsx)(`div`,{className:`overflow-x-auto rounded-lg border bg-card`,children:(0,$.jsxs)(`table`,{className:`w-full text-sm`,children:[(0,$.jsx)(`thead`,{children:(0,$.jsxs)(`tr`,{className:`border-b text-left text-muted-foreground`,children:[(0,$.jsx)(`th`,{className:`px-4 py-2 font-medium`,children:`Priority`}),(0,$.jsx)(`th`,{className:`px-4 py-2 font-medium`,children:`Conditions`}),(0,$.jsx)(`th`,{className:`px-4 py-2 font-medium`,children:`Forward to`}),(0,$.jsx)(`th`,{className:`px-4 py-2 font-medium`,children:(0,$.jsx)(`span`,{className:`sr-only`,children:`Actions`})})]})}),(0,$.jsx)(`tbody`,{children:g.map(e=>(0,$.jsxs)(`tr`,{className:`border-b last:border-0`,children:[(0,$.jsx)(`td`,{className:`px-4 py-2`,children:e.Priority}),(0,$.jsx)(`td`,{className:`px-4 py-2 text-xs`,children:Fe(e.Conditions,e.IsDefault===!0)}),(0,$.jsx)(`td`,{className:`px-4 py-2 text-xs`,children:Le(e,_)}),(0,$.jsx)(`td`,{className:`px-4 py-2 text-right`,children:e.IsDefault===!0?(0,$.jsx)(`span`,{className:`text-xs text-muted-foreground`,children:`Edit via listener`}):(0,$.jsxs)(`div`,{className:`flex justify-end gap-1`,children:[(0,$.jsx)(h,{"aria-label":`Edit rule ${e.Priority}`,onClick:()=>d(e),size:`sm`,variant:`ghost`,children:(0,$.jsx)(we,{className:`size-4`})}),(0,$.jsx)(h,{"aria-label":`Delete rule ${e.Priority}`,onClick:()=>p(e),size:`sm`,variant:`ghost`,children:(0,$.jsx)(m,{className:`size-4`})})]})})]},e.RuleArn??e.Priority??`default`))})]})}),(0,$.jsx)(ze,{isPending:r.isPending,mode:`add`,onOpenChange:l,onSubmit:async t=>{try{await r.mutateAsync({listenerArn:e,priority:t.priority,conditions:t.conditions.map(Re),forwardTargetGroupArn:t.forwardTargetGroupArn}),l(!1)}catch{}},open:c,targetGroups:t}),(0,$.jsx)(ze,{initialRule:u,isPending:o.isPending,mode:`edit`,onOpenChange:e=>!e&&d(void 0),onSubmit:async t=>{if(u?.RuleArn)try{await o.mutateAsync({ruleArn:u.RuleArn,listenerArn:e,conditions:t.conditions.map(Re),forwardTargetGroupArn:t.forwardTargetGroupArn}),d(void 0)}catch{}},open:u!==void 0,targetGroups:t}),(0,$.jsx)(le,{description:f?`Delete rule with priority ${f.Priority}? This cannot be undone.`:``,isPending:s.isPending,onConfirm:async()=>{if(f?.RuleArn)try{await s.mutateAsync({ruleArn:f.RuleArn,listenerArn:e})}finally{p(void 0)}},onOpenChange:e=>!e&&p(void 0),open:f!==void 0,title:`Delete rule`})]})}function Fe(e,t){return t?`default`:!e||e.length===0?`—`:e.map(e=>{let t=Ie(e);return`${e.Field}: ${t.join(`, `)}`}).join(` AND `)}function Ie(e){if(e.Field===void 0)return e.Values??[];switch(e.Field){case`host-header`:return e.HostHeaderConfig?.Values??e.Values??[];case`path-pattern`:return e.PathPatternConfig?.Values??e.Values??[];case`http-header`:{let t=e.HttpHeaderConfig?.HttpHeaderName??`?`;return(e.HttpHeaderConfig?.Values??[]).map(e=>`${t}=${e}`)}case`http-request-method`:return e.HttpRequestMethodConfig?.Values??[];case`source-ip`:return e.SourceIpConfig?.Values??[];case`query-string`:return(e.QueryStringConfig?.Values??[]).map(e=>e.Key?`${e.Key}=${e.Value??``}`:e.Value??``);default:return e.Values??[]}}function Le(e,t){let n=e.Actions?.[0];return n?n.Type===`forward`&&n.TargetGroupArn?t.get(n.TargetGroupArn)??n.TargetGroupArn:n.Type??`—`:`—`}function Re(e){let t=e.rawValues.split(/[\n,]/).map(e=>e.trim()).filter(e=>e.length>0);return e.field===`query-string`?{field:e.field,values:[],queryStringPairs:t.map(e=>{let t=e.indexOf(`=`);return t===-1?{value:e}:{key:e.slice(0,t),value:e.slice(t+1)}})}:{field:e.field,values:t,httpHeaderName:e.httpHeaderName}}function ze({open:e,onOpenChange:t,onSubmit:n,isPending:r,targetGroups:i,mode:a,initialRule:o}){let s=[{field:`path-pattern`,rawValues:``}],c=z({resolver:V(fe),defaultValues:{priority:1,conditions:s,forwardTargetGroupArn:``}}),{fields:m,append:g,remove:_}=B({control:c.control,name:`conditions`});(0,Q.useEffect)(()=>{if(a===`add`){e||c.reset({priority:1,conditions:s,forwardTargetGroupArn:``});return}if(!o)return;let t=Number(o.Priority??1),n=o.Conditions?.map(e=>We(e))??s;c.reset({priority:Number.isFinite(t)?t:1,conditions:n.length>0?n:s,forwardTargetGroupArn:o.Actions?.[0]?.TargetGroupArn??``})},[o,e,a]);let v=c.handleSubmit(n);return(0,$.jsx)(ce,{onOpenChange:t,open:e,children:(0,$.jsxs)(K,{className:`grid-cols-[minmax(0,1fr)]`,style:{maxWidth:`40rem`,width:`calc(100vw - 2rem)`},children:[(0,$.jsxs)(se,{children:[(0,$.jsx)(q,{children:a===`add`?`Add rule`:`Edit rule`}),(0,$.jsx)(G,{children:`Routing rules match incoming requests and forward them to a target group. Rules evaluate in priority order; the listener default applies when no rule matches.`})]}),(0,$.jsxs)(`form`,{className:`min-w-0 space-y-4`,onSubmit:e=>{e.preventDefault(),v()},children:[(0,$.jsxs)(W,{children:[(0,$.jsx)(U,{children:(0,$.jsx)(`label`,{htmlFor:`rule-priority`,children:`Priority`})}),(0,$.jsx)(ee,{"aria-invalid":!!c.formState.errors.priority,id:`rule-priority`,inputMode:`numeric`,type:`number`,...c.register(`priority`,{valueAsNumber:!0})}),(0,$.jsx)(ae,{errors:[c.formState.errors.priority]})]}),(0,$.jsxs)(`div`,{className:`space-y-3`,children:[(0,$.jsxs)(`div`,{className:`flex items-center justify-between`,children:[(0,$.jsx)(U,{children:`Conditions`}),(0,$.jsx)(h,{disabled:m.length>=5,onClick:()=>g({field:`path-pattern`,rawValues:``}),size:`sm`,type:`button`,variant:`outline`,children:`Add condition`})]}),m.map((e,t)=>(0,$.jsx)(Be,{form:c,index:t,onRemove:m.length>1?()=>_(t):void 0},e.id))]}),(0,$.jsxs)(W,{children:[(0,$.jsx)(U,{children:(0,$.jsx)(`label`,{htmlFor:`rule-forward-tg`,children:`Forward to target group`})}),(0,$.jsx)(ie,{control:c.control,name:`forwardTargetGroupArn`,render:({field:e})=>(0,$.jsxs)(p,{onValueChange:e.onChange,value:e.value??``,children:[(0,$.jsx)(f,{"aria-invalid":!!c.formState.errors.forwardTargetGroupArn,className:`w-full`,id:`rule-forward-tg`,children:(0,$.jsx)(l,{placeholder:`Select target group`})}),(0,$.jsx)(d,{children:i.map(e=>(0,$.jsxs)(u,{value:e.TargetGroupArn??``,children:[e.TargetGroupName,` · `,e.Protocol,`:`,e.Port]},e.TargetGroupArn))})]})}),(0,$.jsx)(ae,{errors:[c.formState.errors.forwardTargetGroupArn]})]})]}),(0,$.jsxs)(Y,{children:[(0,$.jsx)(oe,{disabled:r,children:`Cancel`}),(0,$.jsx)(J,{disabled:r,onClick:v,children:Ge(a,r)})]})]})})}function Be(e){let t=(0,Te.c)(54),{form:n,index:r,onRemove:i}=e,a,o,s,c,g,_,v,y,b,x;if(t[0]!==n||t[1]!==r||t[2]!==i){let e=n.watch(`conditions.${r}.field`);s=n.formState.errors.conditions?.[r],y=`space-y-2 rounded-md border p-3`;let S=`rule-cond-${r}-field`,C;t[13]===S?C=t[14]:(C=(0,$.jsx)(U,{children:(0,$.jsx)(`label`,{htmlFor:S,children:`Type`})}),t[13]=S,t[14]=C);let w=`conditions.${r}.field`,T;t[15]===r?T=t[16]:(T=e=>{let{field:t}=e;return(0,$.jsxs)(p,{onValueChange:t.onChange,value:t.value,children:[(0,$.jsx)(f,{className:`w-full`,id:`rule-cond-${r}-field`,children:(0,$.jsx)(l,{})}),(0,$.jsxs)(d,{children:[(0,$.jsx)(u,{value:`path-pattern`,children:`Path pattern`}),(0,$.jsx)(u,{value:`host-header`,children:`Host header`}),(0,$.jsx)(u,{value:`http-header`,children:`HTTP header`}),(0,$.jsx)(u,{value:`http-request-method`,children:`HTTP method`}),(0,$.jsx)(u,{value:`source-ip`,children:`Source IP CIDR`}),(0,$.jsx)(u,{value:`query-string`,children:`Query string`})]})]})},t[15]=r,t[16]=T);let E;t[17]!==n.control||t[18]!==w||t[19]!==T?(E=(0,$.jsx)(ie,{control:n.control,name:w,render:T}),t[17]=n.control,t[18]=w,t[19]=T,t[20]=E):E=t[20];let D;t[21]!==E||t[22]!==C?(D=(0,$.jsxs)(`div`,{className:`flex-1`,children:[C,E]}),t[21]=E,t[22]=C,t[23]=D):D=t[23];let O;t[24]!==r||t[25]!==i?(O=i&&(0,$.jsx)(h,{"aria-label":`Remove condition ${r+1}`,className:`self-end`,onClick:i,size:`sm`,type:`button`,variant:`ghost`,children:(0,$.jsx)(m,{className:`size-4`})}),t[24]=r,t[25]=i,t[26]=O):O=t[26],t[27]!==D||t[28]!==O?(b=(0,$.jsxs)(`div`,{className:`flex gap-2`,children:[D,O]}),t[27]=D,t[28]=O,t[29]=b):b=t[29],x=e===`http-header`&&(0,$.jsxs)(W,{children:[(0,$.jsx)(U,{children:(0,$.jsx)(`label`,{htmlFor:`rule-cond-${r}-hdr-name`,children:`Header name`})}),(0,$.jsx)(ee,{"aria-invalid":!!s?.httpHeaderName,id:`rule-cond-${r}-hdr-name`,placeholder:`X-Tenant`,...n.register(`conditions.${r}.httpHeaderName`)}),(0,$.jsx)(ae,{errors:[s?.httpHeaderName]})]}),o=W;let k=`rule-cond-${r}-values`,A;t[30]===Symbol.for(`react.memo_cache_sentinel`)?(A=(0,$.jsx)(`span`,{className:`text-xs text-muted-foreground`,children:`(comma or newline separated, max 5)`}),t[30]=A):A=t[30],t[31]===k?v=t[32]:(v=(0,$.jsx)(U,{children:(0,$.jsxs)(`label`,{htmlFor:k,children:[`Values`,` `,A]})}),t[31]=k,t[32]=v),a=ee,c=!!s?.rawValues,g=`rule-cond-${r}-values`,_=Ve(e),t[0]=n,t[1]=r,t[2]=i,t[3]=a,t[4]=o,t[5]=s,t[6]=c,t[7]=g,t[8]=_,t[9]=v,t[10]=y,t[11]=b,t[12]=x}else a=t[3],o=t[4],s=t[5],c=t[6],g=t[7],_=t[8],v=t[9],y=t[10],b=t[11],x=t[12];let S;t[33]!==n||t[34]!==r?(S=n.register(`conditions.${r}.rawValues`),t[33]=n,t[34]=r,t[35]=S):S=t[35];let C;t[36]!==a||t[37]!==c||t[38]!==g||t[39]!==_||t[40]!==S?(C=(0,$.jsx)(a,{"aria-invalid":c,id:g,placeholder:_,...S}),t[36]=a,t[37]=c,t[38]=g,t[39]=_,t[40]=S,t[41]=C):C=t[41];let w=s?.rawValues,T;t[42]===w?T=t[43]:(T=(0,$.jsx)(ae,{errors:[w]}),t[42]=w,t[43]=T);let E;t[44]!==o||t[45]!==T||t[46]!==v||t[47]!==C?(E=(0,$.jsxs)(o,{children:[v,C,T]}),t[44]=o,t[45]=T,t[46]=v,t[47]=C,t[48]=E):E=t[48];let D;return t[49]!==E||t[50]!==y||t[51]!==b||t[52]!==x?(D=(0,$.jsxs)(`div`,{className:y,children:[b,x,E]}),t[49]=E,t[50]=y,t[51]=b,t[52]=x,t[53]=D):D=t[53],D}function Ve(e){switch(e){case`path-pattern`:return`/api/*`;case`host-header`:return`*.example.com`;case`http-header`:return`acme`;case`http-request-method`:return`GET, POST`;case`source-ip`:return`10.0.0.0/8`;case`query-string`:return`key=value`;default:return``}}var He=new Set([`host-header`,`path-pattern`,`http-header`,`http-request-method`,`source-ip`,`query-string`]);function Ue(e){return e!==void 0&&He.has(e)}function We(e){let t=Ie(e);return{field:Ue(e.Field)?e.Field:`path-pattern`,httpHeaderName:e.HttpHeaderConfig?.HttpHeaderName,rawValues:t.join(`, `)}}function Ge(e,t){return t?e===`add`?`Adding…`:`Saving…`:e===`add`?`Add rule`:`Save changes`}function Ke(e){return{protocol:me(e),port:80,defaultTargetGroupArn:``}}function qe(e){if(e.Protocol!==`HTTPS`)return`—`;let t=e.Certificates?.[0]?.CertificateArn?.split(`/`).pop(),n=e.SslPolicy;return t&&n?`${n} · ${t}`:n??t??`—`}function Je({loadBalancerArn:e,vpcId:t,lbType:n}){let r=de(n),{data:o}=a(k(e)),{data:s}=a(te),{data:c}=a(A),{data:l}=a(R),u=E(),d=v(),f=D(),[p,g]=(0,Q.useState)(!1),[_,y]=(0,Q.useState)(),[b,x]=(0,Q.useState)(),[S,C]=(0,Q.useState)(),w=o.Listeners??[],T=s.TargetGroups??[],O=T.filter(e=>e.VpcId===t),j=c.CertificateSummaryList??[],M=l.SslPolicies??[],N=new Map;for(let e of T)e.TargetGroupArn&&e.TargetGroupName&&N.set(e.TargetGroupArn,e.TargetGroupName);let P=e=>{let t=e.DefaultActions?.[0];if(!t)return`—`;if(t.Type===`forward`&&t.TargetGroupArn){let e=N.get(t.TargetGroupArn);return e?`forward → ${e}`:`forward → ${t.TargetGroupArn}`}return t.Type??`—`};return(0,$.jsxs)(`div`,{className:`space-y-3`,children:[u.error&&(0,$.jsx)(i,{error:u.error,msg:`Failed to create listener`}),f.error&&(0,$.jsx)(i,{error:f.error,msg:`Failed to modify listener`}),d.error&&(0,$.jsx)(i,{error:d.error,msg:`Failed to delete listener`}),(0,$.jsxs)(`div`,{className:`flex items-center justify-between`,children:[(0,$.jsxs)(`p`,{className:`text-xs text-muted-foreground`,children:[w.length,` listener`,w.length===1?``:`s`]}),(0,$.jsx)(h,{onClick:()=>g(!0),size:`sm`,children:`Add listener`})]}),w.length===0?(0,$.jsx)(`p`,{className:`text-muted-foreground`,children:`No listeners configured.`}):(0,$.jsx)(`div`,{className:`overflow-x-auto rounded-lg border bg-card`,children:(0,$.jsxs)(`table`,{className:`w-full text-sm`,children:[(0,$.jsx)(`thead`,{children:(0,$.jsxs)(`tr`,{className:`border-b text-left text-muted-foreground`,children:[(0,$.jsx)(`th`,{className:`px-4 py-2 font-medium`,children:`Protocol`}),(0,$.jsx)(`th`,{className:`px-4 py-2 font-medium`,children:`Port`}),(0,$.jsx)(`th`,{className:`px-4 py-2 font-medium`,children:`TLS`}),(0,$.jsx)(`th`,{className:`px-4 py-2 font-medium`,children:`Default action`}),(0,$.jsx)(`th`,{className:`px-4 py-2 font-medium`,children:`Listener ARN`}),(0,$.jsx)(`th`,{className:`px-4 py-2 font-medium`,children:(0,$.jsx)(`span`,{className:`sr-only`,children:`Actions`})})]})}),(0,$.jsx)(`tbody`,{children:w.map(e=>{let t=e.ListenerArn??``,n=S===t;return(0,$.jsxs)(Q.Fragment,{children:[(0,$.jsxs)(`tr`,{className:`border-b last:border-0`,children:[(0,$.jsx)(`td`,{className:`px-4 py-2`,children:e.Protocol}),(0,$.jsx)(`td`,{className:`px-4 py-2`,children:e.Port}),(0,$.jsx)(`td`,{className:`px-4 py-2 text-xs`,children:qe(e)}),(0,$.jsx)(`td`,{className:`px-4 py-2 text-xs`,children:P(e)}),(0,$.jsx)(`td`,{className:`px-4 py-2 font-mono text-xs`,children:t}),(0,$.jsx)(`td`,{className:`px-4 py-2 text-right`,children:(0,$.jsxs)(`div`,{className:`flex justify-end gap-1`,children:[(0,$.jsx)(h,{"aria-label":`${n?`Hide`:`Show`} rules for listener ${e.Protocol}:${e.Port}`,onClick:()=>C(n?void 0:t),size:`sm`,variant:`ghost`,children:n?`Hide rules`:`Rules`}),(0,$.jsx)(h,{"aria-label":`Edit listener ${e.Protocol}:${e.Port}`,onClick:()=>y(e),size:`sm`,variant:`ghost`,children:(0,$.jsx)(we,{className:`size-4`})}),(0,$.jsx)(h,{"aria-label":`Delete listener ${e.Protocol}:${e.Port}`,onClick:()=>x(e),size:`sm`,variant:`ghost`,children:(0,$.jsx)(m,{className:`size-4`})})]})})]}),n&&t&&(0,$.jsx)(`tr`,{className:`border-b bg-muted/30 last:border-0`,children:(0,$.jsx)(`td`,{className:`px-4 py-3`,colSpan:6,children:(0,$.jsx)(Pe,{listenerArn:t,targetGroups:O})})})]},t)})})]})}),(0,$.jsx)(Ye,{certificates:j,isPending:u.isPending,lbType:n,onOpenChange:g,onSubmit:async t=>{try{await u.mutateAsync({loadBalancerArn:e,protocol:t.protocol,port:t.port,defaultTargetGroupArn:t.defaultTargetGroupArn,certificateArn:t.certificateArn,sslPolicy:t.sslPolicy}),g(!1)}catch{}},open:p,protocols:r,sslPolicies:M,targetGroups:O}),(0,$.jsx)(Xe,{certificates:j,isPending:f.isPending,listener:_,onOpenChange:e=>!e&&y(void 0),onSubmit:async t=>{if(_?.ListenerArn)try{await f.mutateAsync({listenerArn:_.ListenerArn,loadBalancerArn:e,protocol:t.protocol,port:t.port,defaultTargetGroupArn:t.defaultTargetGroupArn,certificateArn:t.certificateArn,sslPolicy:t.sslPolicy}),y(void 0)}catch{}},protocols:r,sslPolicies:M,targetGroups:O}),(0,$.jsx)(le,{description:b?`Delete listener ${b.Protocol}:${b.Port}? This cannot be undone.`:``,isPending:d.isPending,onConfirm:async()=>{if(b?.ListenerArn)try{await d.mutateAsync(b.ListenerArn)}finally{x(void 0)}},onOpenChange:e=>!e&&x(void 0),open:b!==void 0,title:`Delete listener`})]})}function Ye(e){let t=(0,Te.c)(42),{open:n,onOpenChange:r,lbType:i,protocols:a,targetGroups:o,certificates:s,sslPolicies:c,isPending:l,onSubmit:u}=e,d;t[0]===Symbol.for(`react.memo_cache_sentinel`)?(d=V(pe),t[0]=d):d=t[0];let f;t[1]===i?f=t[2]:(f=Ke(i),t[1]=i,t[2]=f);let p;t[3]===f?p=t[4]:(p={resolver:d,defaultValues:f},t[3]=f,t[4]=p);let m=z(p),h,g;t[5]!==m||t[6]!==i||t[7]!==n?(h=()=>{n||m.reset(Ke(i))},g=[n,m,i],t[5]=m,t[6]=i,t[7]=n,t[8]=h,t[9]=g):(h=t[8],g=t[9]),(0,Q.useEffect)(h,g);let _;t[10]!==m||t[11]!==u?(_=m.handleSubmit(u),t[10]=m,t[11]=u,t[12]=_):_=t[12];let v=_,y;t[13]===Symbol.for(`react.memo_cache_sentinel`)?(y={maxWidth:`32rem`,width:`calc(100vw - 2rem)`},t[13]=y):y=t[13];let b;t[14]===Symbol.for(`react.memo_cache_sentinel`)?(b=(0,$.jsxs)(se,{children:[(0,$.jsx)(q,{children:`Add listener`}),(0,$.jsx)(G,{children:`Listeners forward incoming traffic to a default target group.`})]}),t[14]=b):b=t[14];let x;t[15]===v?x=t[16]:(x=e=>{e.preventDefault(),v()},t[15]=v,t[16]=x);let S;t[17]!==s||t[18]!==m||t[19]!==a||t[20]!==c||t[21]!==o?(S=(0,$.jsx)(ke,{certificates:s,form:m,protocols:a,sslPolicies:c,targetGroups:o}),t[17]=s,t[18]=m,t[19]=a,t[20]=c,t[21]=o,t[22]=S):S=t[22];let C;t[23]!==S||t[24]!==x?(C=(0,$.jsx)(`form`,{className:`min-w-0 space-y-4`,onSubmit:x,children:S}),t[23]=S,t[24]=x,t[25]=C):C=t[25];let w;t[26]===l?w=t[27]:(w=(0,$.jsx)(oe,{disabled:l,children:`Cancel`}),t[26]=l,t[27]=w);let T=l?`Adding…`:`Add listener`,E;t[28]!==v||t[29]!==l||t[30]!==T?(E=(0,$.jsx)(J,{disabled:l,onClick:v,children:T}),t[28]=v,t[29]=l,t[30]=T,t[31]=E):E=t[31];let D;t[32]!==w||t[33]!==E?(D=(0,$.jsxs)(Y,{children:[w,E]}),t[32]=w,t[33]=E,t[34]=D):D=t[34];let O;t[35]!==C||t[36]!==D?(O=(0,$.jsxs)(K,{className:`grid-cols-[minmax(0,1fr)]`,style:y,children:[b,C,D]}),t[35]=C,t[36]=D,t[37]=O):O=t[37];let k;return t[38]!==r||t[39]!==n||t[40]!==O?(k=(0,$.jsx)(ce,{onOpenChange:r,open:n,children:O}),t[38]=r,t[39]=n,t[40]=O,t[41]=k):k=t[41],k}function Xe(e){let t=(0,Te.c)(38),{listener:n,onOpenChange:r,protocols:i,targetGroups:a,certificates:o,sslPolicies:s,isPending:c,onSubmit:l}=e,u=n!==void 0,d;t[0]===Symbol.for(`react.memo_cache_sentinel`)?(d={resolver:V(pe),defaultValues:{protocol:`HTTP`,port:80,defaultTargetGroupArn:``}},t[0]=d):d=t[0];let f=z(d),p,m;t[1]!==f||t[2]!==n||t[3]!==i?(p=()=>{if(!n)return;let e=i.find(e=>e===n.Protocol)??i[0]??`HTTP`,t=he(e);f.reset({protocol:e,port:n.Port??80,defaultTargetGroupArn:n.DefaultActions?.[0]?.TargetGroupArn??``,certificateArn:t?n.Certificates?.[0]?.CertificateArn:void 0,sslPolicy:e===`HTTPS`?n.SslPolicy:void 0})},m=[n,f,i],t[1]=f,t[2]=n,t[3]=i,t[4]=p,t[5]=m):(p=t[4],m=t[5]),(0,Q.useEffect)(p,m);let h;t[6]!==f||t[7]!==l?(h=f.handleSubmit(l),t[6]=f,t[7]=l,t[8]=h):h=t[8];let g=h,_;t[9]===Symbol.for(`react.memo_cache_sentinel`)?(_={maxWidth:`32rem`,width:`calc(100vw - 2rem)`},t[9]=_):_=t[9];let v;t[10]===Symbol.for(`react.memo_cache_sentinel`)?(v=(0,$.jsxs)(se,{children:[(0,$.jsx)(q,{children:`Edit listener`}),(0,$.jsx)(G,{children:`Update port, protocol, or default target group. Changes apply without dropping the listener.`})]}),t[10]=v):v=t[10];let y;t[11]===g?y=t[12]:(y=e=>{e.preventDefault(),g()},t[11]=g,t[12]=y);let b;t[13]!==o||t[14]!==f||t[15]!==i||t[16]!==s||t[17]!==a?(b=(0,$.jsx)(ke,{certificates:o,form:f,protocols:i,sslPolicies:s,targetGroups:a}),t[13]=o,t[14]=f,t[15]=i,t[16]=s,t[17]=a,t[18]=b):b=t[18];let x;t[19]!==y||t[20]!==b?(x=(0,$.jsx)(`form`,{className:`min-w-0 space-y-4`,onSubmit:y,children:b}),t[19]=y,t[20]=b,t[21]=x):x=t[21];let S;t[22]===c?S=t[23]:(S=(0,$.jsx)(oe,{disabled:c,children:`Cancel`}),t[22]=c,t[23]=S);let C=c?`Saving…`:`Save changes`,w;t[24]!==g||t[25]!==c||t[26]!==C?(w=(0,$.jsx)(J,{disabled:c,onClick:g,children:C}),t[24]=g,t[25]=c,t[26]=C,t[27]=w):w=t[27];let T;t[28]!==S||t[29]!==w?(T=(0,$.jsxs)(Y,{children:[S,w]}),t[28]=S,t[29]=w,t[30]=T):T=t[30];let E;t[31]!==T||t[32]!==x?(E=(0,$.jsxs)(K,{className:`grid-cols-[minmax(0,1fr)]`,style:_,children:[v,x,T]}),t[31]=T,t[32]=x,t[33]=E):E=t[33];let D;return t[34]!==r||t[35]!==u||t[36]!==E?(D=(0,$.jsx)(ce,{onOpenChange:r,open:u,children:E}),t[34]=r,t[35]=u,t[36]=E,t[37]=D):D=t[37],D}function Ze(e,t){if(e.length!==t.length)return!1;let n=new Set(t);return e.every(e=>n.has(e))}function Qe(e){let t=(0,Te.c)(46),{available:n,current:r,onSubmit:i,isPending:a,error:o,isSuccess:s}=e,c;t[0]===r?c=t[1]:(c=[...r],t[0]=r,t[1]=c);let l=c,[u,d]=(0,Q.useState)(l),f;t[2]===Symbol.for(`react.memo_cache_sentinel`)?(f=e=>{d(t=>t.includes(e)?t.filter(t=>t!==e):[...t,e])},t[2]=f):f=t[2];let p=f,m;t[3]===l?m=t[4]:(m=()=>d(l),t[3]=l,t[4]=m);let g=m,_;t[5]!==l||t[6]!==u?(_=Ze(u,l),t[5]=l,t[6]=u,t[7]=_):_=t[7];let v=!_,y=u.length===0,b;t[8]!==v||t[9]!==y||t[10]!==i||t[11]!==u?(b=e=>{e.preventDefault(),!(!v||y)&&i(u)},t[8]=v,t[9]=y,t[10]=i,t[11]=u,t[12]=b):b=t[12];let x;t[13]===o?x=t[14]:(x=o&&(0,$.jsx)(`div`,{className:`mb-4 rounded-md border border-destructive bg-destructive/10 p-3 text-sm text-destructive`,role:`alert`,children:o.message||`Failed to update security groups`}),t[13]=o,t[14]=x);let S;t[15]!==v||t[16]!==s?(S=s&&!v&&(0,$.jsx)(`div`,{className:`mb-4 rounded-md border border-emerald-500 bg-emerald-500/10 p-3 text-sm text-emerald-700 dark:text-emerald-400`,role:`status`,children:`Security groups updated.`}),t[15]=v,t[16]=s,t[17]=S):S=t[17];let C;t[18]!==n||t[19]!==u?(C=n.length===0?(0,$.jsx)(`p`,{className:`text-sm text-muted-foreground`,children:`No security groups in this VPC.`}):n.map(e=>(0,$.jsxs)(`label`,{className:`flex items-center gap-2 text-sm`,children:[(0,$.jsx)(`input`,{"aria-label":`Security group ${e.GroupId} (${e.GroupName})`,checked:u.includes(e.GroupId??``),onChange:()=>p(e.GroupId??``),type:`checkbox`}),(0,$.jsxs)(`span`,{className:`font-mono`,children:[e.GroupId,` (`,e.GroupName,`)`]})]},e.GroupId)),t[18]=n,t[19]=u,t[20]=C):C=t[20];let w;t[21]===y?w=t[22]:(w=y&&(0,$.jsx)(`p`,{className:`text-xs text-destructive`,children:`At least one security group is required.`}),t[21]=y,t[22]=w);let T;t[23]!==C||t[24]!==w?(T=(0,$.jsxs)(`div`,{className:`flex flex-col gap-1 p-4`,children:[C,w]}),t[23]=C,t[24]=w,t[25]=T):T=t[25];let E=v?`Unsaved changes`:`No changes`,D;t[26]===E?D=t[27]:(D=(0,$.jsx)(`span`,{className:`mr-auto text-xs text-muted-foreground`,children:E}),t[26]=E,t[27]=D);let O=a||!v,k;t[28]!==g||t[29]!==O?(k=(0,$.jsx)(h,{disabled:O,onClick:g,size:`sm`,type:`button`,variant:`secondary`,children:`Reset`}),t[28]=g,t[29]=O,t[30]=k):k=t[30];let A=a||!v||y,j=a?`Saving…`:`Save changes`,M;t[31]!==A||t[32]!==j?(M=(0,$.jsx)(h,{disabled:A,size:`sm`,type:`submit`,children:j}),t[31]=A,t[32]=j,t[33]=M):M=t[33];let N;t[34]!==D||t[35]!==k||t[36]!==M?(N=(0,$.jsxs)(`div`,{className:`flex items-center justify-end gap-2 border-t p-4`,children:[D,k,M]}),t[34]=D,t[35]=k,t[36]=M,t[37]=N):N=t[37];let P;t[38]!==T||t[39]!==N?(P=(0,$.jsxs)(`div`,{className:`rounded-lg border bg-card`,children:[T,N]}),t[38]=T,t[39]=N,t[40]=P):P=t[40];let F;return t[41]!==P||t[42]!==b||t[43]!==x||t[44]!==S?(F=(0,$.jsxs)(`form`,{onSubmit:b,children:[x,S,P]}),t[41]=P,t[42]=b,t[43]=x,t[44]=S,t[45]=F):F=t[45],F}function $e(e,t){if(e.length!==t.length)return!1;let n=new Set(t);return e.every(e=>n.has(e))}function et(e){let t=o(e.Tags),n=e.AvailabilityZone?` · ${e.AvailabilityZone}`:``,r=e.CidrBlock?` (${e.CidrBlock})`:``;return`${e.SubnetId}${t?` — ${t}`:``}${r}${n}`}function tt(e){let t=(0,Te.c)(47),{available:n,current:r,onSubmit:i,isPending:a,error:o,isSuccess:s}=e,c;t[0]===r?c=t[1]:(c=[...r],t[0]=r,t[1]=c);let l=c,[u,d]=(0,Q.useState)(l),f;t[2]===Symbol.for(`react.memo_cache_sentinel`)?(f=e=>{d(t=>t.includes(e)?t.filter(t=>t!==e):[...t,e])},t[2]=f):f=t[2];let p=f,m;t[3]===l?m=t[4]:(m=()=>d(l),t[3]=l,t[4]=m);let g=m,_;t[5]!==l||t[6]!==u?(_=$e(u,l),t[5]=l,t[6]=u,t[7]=_):_=t[7];let v=!_,y=u.length===0,b;t[8]!==v||t[9]!==y||t[10]!==i||t[11]!==u?(b=e=>{e.preventDefault(),!(!v||y)&&i(u)},t[8]=v,t[9]=y,t[10]=i,t[11]=u,t[12]=b):b=t[12];let x;t[13]===Symbol.for(`react.memo_cache_sentinel`)?(x=(0,$.jsx)(`div`,{className:`mb-4 rounded-md border border-amber-500 bg-amber-500/10 p-3 text-sm text-amber-700 dark:text-amber-400`,role:`note`,children:`Applying a subnet change re-homes the load balancer by relaunching its underlying system VM. Expect a brief data-plane interruption while it restarts.`}),t[13]=x):x=t[13];let S;t[14]===o?S=t[15]:(S=o&&(0,$.jsx)(`div`,{className:`mb-4 rounded-md border border-destructive bg-destructive/10 p-3 text-sm text-destructive`,role:`alert`,children:o.message||`Failed to update subnets`}),t[14]=o,t[15]=S);let C;t[16]!==v||t[17]!==s?(C=s&&!v&&(0,$.jsx)(`div`,{className:`mb-4 rounded-md border border-emerald-500 bg-emerald-500/10 p-3 text-sm text-emerald-700 dark:text-emerald-400`,role:`status`,children:`Subnets updated.`}),t[16]=v,t[17]=s,t[18]=C):C=t[18];let w;t[19]!==n||t[20]!==u?(w=n.length===0?(0,$.jsx)(`p`,{className:`text-sm text-muted-foreground`,children:`No subnets in this VPC.`}):n.map(e=>(0,$.jsxs)(`label`,{className:`flex items-center gap-2 text-sm`,children:[(0,$.jsx)(`input`,{"aria-label":`Subnet ${e.SubnetId}`,checked:u.includes(e.SubnetId??``),onChange:()=>p(e.SubnetId??``),type:`checkbox`}),(0,$.jsx)(`span`,{className:`font-mono`,children:et(e)})]},e.SubnetId)),t[19]=n,t[20]=u,t[21]=w):w=t[21];let T;t[22]===y?T=t[23]:(T=y&&(0,$.jsx)(`p`,{className:`text-xs text-destructive`,children:`At least one subnet is required.`}),t[22]=y,t[23]=T);let E;t[24]!==T||t[25]!==w?(E=(0,$.jsxs)(`div`,{className:`flex flex-col gap-1 p-4`,children:[w,T]}),t[24]=T,t[25]=w,t[26]=E):E=t[26];let D=v?`Unsaved changes`:`No changes`,O;t[27]===D?O=t[28]:(O=(0,$.jsx)(`span`,{className:`mr-auto text-xs text-muted-foreground`,children:D}),t[27]=D,t[28]=O);let k=a||!v,A;t[29]!==g||t[30]!==k?(A=(0,$.jsx)(h,{disabled:k,onClick:g,size:`sm`,type:`button`,variant:`secondary`,children:`Reset`}),t[29]=g,t[30]=k,t[31]=A):A=t[31];let j=a||!v||y,M=a?`Saving…`:`Save changes`,N;t[32]!==j||t[33]!==M?(N=(0,$.jsx)(h,{disabled:j,size:`sm`,type:`submit`,children:M}),t[32]=j,t[33]=M,t[34]=N):N=t[34];let P;t[35]!==O||t[36]!==A||t[37]!==N?(P=(0,$.jsxs)(`div`,{className:`flex items-center justify-end gap-2 border-t p-4`,children:[O,A,N]}),t[35]=O,t[36]=A,t[37]=N,t[38]=P):P=t[38];let F;t[39]!==E||t[40]!==P?(F=(0,$.jsxs)(`div`,{className:`rounded-lg border bg-card`,children:[E,P]}),t[39]=E,t[40]=P,t[41]=F):F=t[41];let I;return t[42]!==F||t[43]!==b||t[44]!==S||t[45]!==C?(I=(0,$.jsxs)(`form`,{onSubmit:b,children:[x,S,C,F]}),t[42]=F,t[43]=b,t[44]=S,t[45]=C,t[46]=I):I=t[46],I}function nt({arn:e}){let t=M(),{data:n}=a(N(e)),{data:r}=a(j(e)),{data:l}=a(O([e])),{data:u}=a(F),{data:d}=a(ne),f=C(),p=y(),g=_(),v=S(),b=x(),[w,T]=(0,Q.useState)(!1),E=n.LoadBalancers?.[0];if(!E?.LoadBalancerArn)return(0,$.jsxs)($.Fragment,{children:[(0,$.jsx)(c,{to:`/ec2/describe-load-balancers`,children:`Back to load balancers`}),(0,$.jsx)(`p`,{className:`text-muted-foreground`,children:`Load balancer not found.`})]});let D=async()=>{try{await f.mutateAsync(e),t({to:`/ec2/describe-load-balancers`})}finally{T(!1)}},k=u.Subnets??[],A=e=>{let t=k.find(t=>t.SubnetId===e);if(!t)return e;let n=o(t.Tags),r=t.CidrBlock?` (${t.CidrBlock})`:``;return n?`${e} — ${n}${r}`:`${e}${r}`},P=r.Attributes??[],I=l?.TagDescriptions?.find(t=>t.ResourceArn===e)?.Tags??[],ee=(E.SecurityGroups??[]).filter(e=>e!==void 0),te=(d.SecurityGroups??[]).filter(e=>e.VpcId===E.VpcId),L=k.filter(e=>e.VpcId===E.VpcId),R=(E.AvailabilityZones??[]).map(e=>e.SubnetId).filter(e=>e!==void 0),re=E.Type===`network`;return(0,$.jsxs)($.Fragment,{children:[(0,$.jsx)(c,{to:`/ec2/describe-load-balancers`,children:`Back to load balancers`}),f.error&&(0,$.jsx)(i,{error:f.error,msg:`Failed to delete load balancer`}),(0,$.jsxs)(`div`,{className:`space-y-6`,children:[(0,$.jsx)(H,{actions:(0,$.jsxs)(`div`,{className:`flex items-center gap-2`,children:[(0,$.jsxs)(h,{onClick:()=>T(!0),size:`sm`,variant:`destructive`,children:[(0,$.jsx)(m,{className:`size-4`}),`Delete`]}),(0,$.jsx)(ue,{state:E.State?.Code})]}),subtitle:`Load Balancer Details`,title:E.LoadBalancerName??E.LoadBalancerArn}),(0,$.jsxs)(Ce,{defaultValue:`overview`,children:[(0,$.jsxs)(xe,{children:[(0,$.jsx)(be,{value:`overview`,children:`Overview`}),(0,$.jsx)(be,{value:`listeners`,children:`Listeners`}),(0,$.jsx)(be,{value:`subnets`,children:`Subnets`}),!re&&(0,$.jsx)(be,{value:`security-groups`,children:`Security groups`}),(0,$.jsx)(be,{value:`attributes`,children:`Attributes`}),(0,$.jsx)(be,{value:`tags`,children:`Tags`})]}),(0,$.jsxs)(Se,{value:`overview`,children:[(0,$.jsxs)(X,{children:[(0,$.jsx)(X.Header,{children:`Load balancer`}),(0,$.jsxs)(X.Content,{children:[(0,$.jsx)(Z,{label:`ARN`,value:E.LoadBalancerArn}),(0,$.jsx)(Z,{label:`DNS name`,value:E.DNSName}),(0,$.jsx)(Z,{label:`State`,value:E.State?.Reason?`${E.State.Code} — ${E.State.Reason}`:E.State?.Code}),(0,$.jsx)(Z,{label:`Type`,value:E.Type}),(0,$.jsx)(Z,{label:`Scheme`,value:E.Scheme}),(0,$.jsx)(Z,{label:`IP address type`,value:E.IpAddressType}),(0,$.jsx)(Z,{label:`VPC`,value:E.VpcId}),(0,$.jsx)(Z,{label:`Created at`,value:s(E.CreatedTime)})]})]}),(0,$.jsxs)(X,{children:[(0,$.jsx)(X.Header,{children:`Availability zones`}),(0,$.jsx)(X.Content,{children:(E.AvailabilityZones??[]).map(e=>(0,$.jsx)(Z,{label:e.ZoneName??`—`,value:e.SubnetId?A(e.SubnetId):void 0},`${e.ZoneName}-${e.SubnetId}`))})]}),(E.SecurityGroups??[]).length>0&&(0,$.jsxs)(X,{children:[(0,$.jsx)(X.Header,{children:`Security groups`}),(0,$.jsx)(X.Content,{children:(E.SecurityGroups??[]).map(e=>(0,$.jsx)(Z,{label:`Security group`,value:e},e))})]})]}),(0,$.jsx)(Se,{value:`listeners`,children:(0,$.jsx)(Je,{lbType:re?`network`:`application`,loadBalancerArn:e,vpcId:E.VpcId})}),(0,$.jsx)(Se,{value:`subnets`,children:(0,$.jsx)(tt,{available:L,current:R,error:b.error,isPending:b.isPending,isSuccess:b.isSuccess,onSubmit:t=>b.mutate({loadBalancerArn:e,subnetIds:t})})}),!re&&(0,$.jsx)(Se,{value:`security-groups`,children:(0,$.jsx)(Qe,{available:te,current:ee,error:v.error,isPending:v.isPending,isSuccess:v.isSuccess,onSubmit:t=>v.mutate({loadBalancerArn:e,securityGroupIds:t})})}),(0,$.jsx)(Se,{value:`attributes`,children:(0,$.jsx)(_e,{attributes:P,error:p.error,isPending:p.isPending,isSuccess:p.isSuccess,onSubmit:t=>{p.mutate({loadBalancerArn:e,attributes:t})},specs:ve})}),(0,$.jsx)(Se,{value:`tags`,children:(0,$.jsx)(ye,{error:g.error,isPending:g.isPending,isSuccess:g.isSuccess,onSubmit:t=>g.mutate({resourceArn:e,tags:t,initialKeys:I.map(e=>e.Key??``).filter(e=>e.length>0)}),tags:I})})]})]}),(0,$.jsx)(le,{description:`Are you sure you want to delete load balancer "${E.LoadBalancerName??E.LoadBalancerArn}"? This action cannot be undone.`,isPending:f.isPending,onConfirm:D,onOpenChange:T,open:w,title:`Delete load balancer`})]})}function rt(){let e=(0,Te.c)(2),{id:t}=L.useParams(),n=decodeURIComponent(t),r;return e[0]===n?r=e[1]:(r=(0,$.jsx)(nt,{arn:n}),e[0]=n,e[1]=r),r}export{rt as component};
\ No newline at end of file
diff --git a/spinifex/services/spinifexui/frontend/dist/assets/_id7.js b/spinifex/services/spinifexui/frontend/dist/assets/_id7.js
index 8f18ad2b4..916123b00 100644
--- a/spinifex/services/spinifexui/frontend/dist/assets/_id7.js
+++ b/spinifex/services/spinifexui/frontend/dist/assets/_id7.js
@@ -1 +1 @@
-import{t as e}from"./error-banner.js";import{t}from"./useSuspenseQuery.js";import{t as n}from"./back-link.js";import{t as r}from"./trash-2.js";import{T as i}from"./ec2.js";import{$ as a,Ho as o,Lo as s,Ro as c,Uo as l,Xa as u,ao as d,cs as f,s as p}from"./index.js";import{t as m}from"./page-heading.js";import{t as h}from"./delete-confirmation-dialog.js";import{n as g,t as _}from"./detail-row.js";import{t as v}from"./state-badge.js";var y=f(l()),b=o();function x(){let{id:o}=p.useParams(),l=c(),{data:f}=t(a(o)),x=i(),[S,C]=(0,y.useState)(!1),w=f.NatGateways?.[0],T=async()=>{try{await x.mutateAsync(o),l({to:`/ec2/describe-nat-gateways`})}finally{C(!1)}};if(!w?.NatGatewayId)return(0,b.jsxs)(b.Fragment,{children:[(0,b.jsx)(n,{to:`/ec2/describe-nat-gateways`,children:`Back to NAT Gateways`}),(0,b.jsx)(`p`,{className:`text-muted-foreground`,children:`NAT Gateway not found.`})]});let E=d(w.Tags),D=w.NatGatewayAddresses?.[0];return(0,b.jsxs)(b.Fragment,{children:[(0,b.jsx)(n,{to:`/ec2/describe-nat-gateways`,children:`Back to NAT Gateways`}),x.error&&(0,b.jsx)(e,{error:x.error,msg:`Failed to delete NAT Gateway`}),(0,b.jsxs)(`div`,{className:`space-y-6`,children:[(0,b.jsx)(m,{actions:(0,b.jsxs)(`div`,{className:`flex items-center gap-2`,children:[(0,b.jsxs)(u,{disabled:x.isPending,onClick:()=>C(!0),size:`sm`,variant:`destructive`,children:[(0,b.jsx)(r,{className:`size-4`}),`Delete`]}),(0,b.jsx)(v,{state:w.State})]}),subtitle:`NAT Gateway Details`,title:E?`${w.NatGatewayId} (${E})`:w.NatGatewayId}),(0,b.jsxs)(g,{children:[(0,b.jsx)(g.Header,{children:`NAT Gateway Information`}),(0,b.jsxs)(g.Content,{children:[(0,b.jsx)(_,{label:`NAT Gateway ID`,value:w.NatGatewayId}),(0,b.jsx)(_,{label:`State`,value:w.State}),(0,b.jsx)(_,{label:`Connectivity`,value:w.ConnectivityType}),(0,b.jsx)(_,{label:`Subnet ID`,value:w.SubnetId?(0,b.jsx)(s,{className:`text-primary hover:underline`,params:{id:w.SubnetId},to:`/ec2/describe-subnets/$id`,children:w.SubnetId}):`—`}),(0,b.jsx)(_,{label:`VPC ID`,value:w.VpcId?(0,b.jsx)(s,{className:`text-primary hover:underline`,params:{id:w.VpcId},to:`/ec2/describe-vpcs/$id`,children:w.VpcId}):`—`}),(0,b.jsx)(_,{label:`Public IP`,value:D?.PublicIp??`—`}),(0,b.jsx)(_,{label:`Allocation ID`,value:D?.AllocationId?(0,b.jsx)(s,{className:`text-primary hover:underline`,params:{id:D.AllocationId},to:`/ec2/describe-addresses/$id`,children:D.AllocationId}):`—`})]})]})]}),(0,b.jsx)(h,{description:`Are you sure you want to delete the NAT Gateway "${w.NatGatewayId}"? This action cannot be undone. The associated Elastic IP is not released.`,isPending:x.isPending,onConfirm:T,onOpenChange:C,open:S,title:`Delete NAT Gateway`})]})}export{x as component};
\ No newline at end of file
+import{n as e,o as t,r as n}from"./compiler-runtime.js";import{t as r}from"./error-banner.js";import{t as i}from"./useSuspenseQuery.js";import{Or as a}from"./awsClient.js";import{A as o}from"./SignatureV4.js";import{t as s}from"./back-link.js";import{t as c}from"./trash-2.js";import{t as l}from"./button.js";import{E as u}from"./ec2.js";import{Ir as d,s as f,xt as p}from"./index.js";import{t as m}from"./page-heading.js";import{t as h}from"./delete-confirmation-dialog.js";import{n as g,t as _}from"./detail-row.js";import{t as v}from"./state-badge.js";var y=t(n()),b=e();function x(){let{id:e}=f.useParams(),t=d(),{data:n}=i(p(e)),x=u(),[S,C]=(0,y.useState)(!1),w=n.NatGateways?.[0],T=async()=>{try{await x.mutateAsync(e),t({to:`/ec2/describe-nat-gateways`})}finally{C(!1)}};if(!w?.NatGatewayId)return(0,b.jsxs)(b.Fragment,{children:[(0,b.jsx)(s,{to:`/ec2/describe-nat-gateways`,children:`Back to NAT Gateways`}),(0,b.jsx)(`p`,{className:`text-muted-foreground`,children:`NAT Gateway not found.`})]});let E=o(w.Tags),D=w.NatGatewayAddresses?.[0];return(0,b.jsxs)(b.Fragment,{children:[(0,b.jsx)(s,{to:`/ec2/describe-nat-gateways`,children:`Back to NAT Gateways`}),x.error&&(0,b.jsx)(r,{error:x.error,msg:`Failed to delete NAT Gateway`}),(0,b.jsxs)(`div`,{className:`space-y-6`,children:[(0,b.jsx)(m,{actions:(0,b.jsxs)(`div`,{className:`flex items-center gap-2`,children:[(0,b.jsxs)(l,{disabled:x.isPending,onClick:()=>C(!0),size:`sm`,variant:`destructive`,children:[(0,b.jsx)(c,{className:`size-4`}),`Delete`]}),(0,b.jsx)(v,{state:w.State})]}),subtitle:`NAT Gateway Details`,title:E?`${w.NatGatewayId} (${E})`:w.NatGatewayId}),(0,b.jsxs)(g,{children:[(0,b.jsx)(g.Header,{children:`NAT Gateway Information`}),(0,b.jsxs)(g.Content,{children:[(0,b.jsx)(_,{label:`NAT Gateway ID`,value:w.NatGatewayId}),(0,b.jsx)(_,{label:`State`,value:w.State}),(0,b.jsx)(_,{label:`Connectivity`,value:w.ConnectivityType}),(0,b.jsx)(_,{label:`Subnet ID`,value:w.SubnetId?(0,b.jsx)(a,{className:`text-primary hover:underline`,params:{id:w.SubnetId},to:`/ec2/describe-subnets/$id`,children:w.SubnetId}):`—`}),(0,b.jsx)(_,{label:`VPC ID`,value:w.VpcId?(0,b.jsx)(a,{className:`text-primary hover:underline`,params:{id:w.VpcId},to:`/ec2/describe-vpcs/$id`,children:w.VpcId}):`—`}),(0,b.jsx)(_,{label:`Public IP`,value:D?.PublicIp??`—`}),(0,b.jsx)(_,{label:`Allocation ID`,value:D?.AllocationId?(0,b.jsx)(a,{className:`text-primary hover:underline`,params:{id:D.AllocationId},to:`/ec2/describe-addresses/$id`,children:D.AllocationId}):`—`})]})]})]}),(0,b.jsx)(h,{description:`Are you sure you want to delete the NAT Gateway "${w.NatGatewayId}"? This action cannot be undone. The associated Elastic IP is not released.`,isPending:x.isPending,onConfirm:T,onOpenChange:C,open:S,title:`Delete NAT Gateway`})]})}export{x as component};
\ No newline at end of file
diff --git a/spinifex/services/spinifexui/frontend/dist/assets/_id8.js b/spinifex/services/spinifexui/frontend/dist/assets/_id8.js
index 71b05262a..f8d94c25c 100644
--- a/spinifex/services/spinifexui/frontend/dist/assets/_id8.js
+++ b/spinifex/services/spinifexui/frontend/dist/assets/_id8.js
@@ -1 +1 @@
-import{t as e}from"./error-banner.js";import{t}from"./useSuspenseQuery.js";import{t as n}from"./back-link.js";import{t as r}from"./trash-2.js";import{E as i}from"./ec2.js";import{Ho as a,J as o,Lo as s,Ro as c,Uo as l,Xa as u,c as d,cs as f,tt as p}from"./index.js";import{t as m}from"./page-heading.js";import{t as h}from"./delete-confirmation-dialog.js";import{n as g,t as _}from"./detail-row.js";import{t as v}from"./state-badge.js";var y=f(l()),b=a();function x(){let{id:a}=d.useParams(),l=c(),{data:f}=t(p(a)),{data:x}=t(o),S=i(),[C,w]=(0,y.useState)(!1),T=f.PlacementGroups?.[0],E=x.Reservations?.flatMap(e=>e.Instances??[]).filter(e=>e.Placement?.GroupName===T?.GroupName)??[];return T?.GroupId?(0,b.jsxs)(b.Fragment,{children:[(0,b.jsx)(n,{to:`/ec2/describe-placement-groups`,children:`Back to placement groups`}),S.error&&(0,b.jsx)(e,{error:S.error,msg:`Failed to delete placement group`}),(0,b.jsxs)(`div`,{className:`space-y-6`,children:[(0,b.jsx)(m,{actions:(0,b.jsxs)(`div`,{className:`flex items-center gap-2`,children:[(0,b.jsxs)(u,{onClick:()=>w(!0),size:`sm`,variant:`destructive`,children:[(0,b.jsx)(r,{className:`size-4`}),`Delete`]}),(0,b.jsx)(v,{state:T.State})]}),subtitle:`Placement Group Details`,title:T.GroupName??T.GroupId}),(0,b.jsxs)(g,{children:[(0,b.jsx)(g.Header,{children:`Placement Group Information`}),(0,b.jsxs)(g.Content,{children:[(0,b.jsx)(_,{label:`Group ID`,value:T.GroupId}),(0,b.jsx)(_,{label:`Group Name`,value:T.GroupName}),(0,b.jsx)(_,{label:`Strategy`,value:T.Strategy}),(0,b.jsx)(_,{label:`State`,value:T.State}),(0,b.jsx)(_,{label:`Spread Level`,value:T.SpreadLevel})]})]}),(0,b.jsxs)(g,{children:[(0,b.jsx)(g.Header,{children:`Instances`}),E.length>0?E.map(e=>(0,b.jsxs)(g.Content,{children:[(0,b.jsx)(_,{label:`Instance ID`,value:e.InstanceId?(0,b.jsx)(s,{className:`text-primary hover:underline`,params:{id:e.InstanceId},to:`/ec2/describe-instances/$id`,children:e.InstanceId}):void 0}),(0,b.jsx)(_,{label:`State`,value:e.State?.Name}),(0,b.jsx)(_,{label:`Instance Type`,value:e.InstanceType}),(0,b.jsx)(_,{label:`Availability Zone`,value:e.Placement?.AvailabilityZone})]},e.InstanceId)):(0,b.jsx)(g.Content,{children:(0,b.jsx)(`p`,{className:`text-muted-foreground`,children:`No instances in this placement group.`})})]})]}),(0,b.jsx)(h,{description:`Are you sure you want to delete the placement group "${T.GroupName}"? The group must have no running instances.`,isPending:S.isPending,onConfirm:async()=>{if(T?.GroupName)try{await S.mutateAsync(T.GroupName),l({to:`/ec2/describe-placement-groups`})}finally{w(!1)}},onOpenChange:w,open:C,title:`Delete Placement Group`})]}):(0,b.jsxs)(b.Fragment,{children:[(0,b.jsx)(n,{to:`/ec2/describe-placement-groups`,children:`Back to placement groups`}),(0,b.jsx)(`p`,{className:`text-muted-foreground`,children:`Placement group not found.`})]})}export{x as component};
\ No newline at end of file
+import{n as e,o as t,r as n}from"./compiler-runtime.js";import{t as r}from"./error-banner.js";import{t as i}from"./useSuspenseQuery.js";import{Or as a}from"./awsClient.js";import{t as o}from"./back-link.js";import{t as s}from"./trash-2.js";import{t as c}from"./button.js";import{D as l}from"./ec2.js";import{Ct as u,Ir as d,c as f,gt as p}from"./index.js";import{t as m}from"./page-heading.js";import{t as h}from"./delete-confirmation-dialog.js";import{n as g,t as _}from"./detail-row.js";import{t as v}from"./state-badge.js";var y=t(n()),b=e();function x(){let{id:e}=f.useParams(),t=d(),{data:n}=i(u(e)),{data:x}=i(p),S=l(),[C,w]=(0,y.useState)(!1),T=n.PlacementGroups?.[0],E=x.Reservations?.flatMap(e=>e.Instances??[]).filter(e=>e.Placement?.GroupName===T?.GroupName)??[];return T?.GroupId?(0,b.jsxs)(b.Fragment,{children:[(0,b.jsx)(o,{to:`/ec2/describe-placement-groups`,children:`Back to placement groups`}),S.error&&(0,b.jsx)(r,{error:S.error,msg:`Failed to delete placement group`}),(0,b.jsxs)(`div`,{className:`space-y-6`,children:[(0,b.jsx)(m,{actions:(0,b.jsxs)(`div`,{className:`flex items-center gap-2`,children:[(0,b.jsxs)(c,{onClick:()=>w(!0),size:`sm`,variant:`destructive`,children:[(0,b.jsx)(s,{className:`size-4`}),`Delete`]}),(0,b.jsx)(v,{state:T.State})]}),subtitle:`Placement Group Details`,title:T.GroupName??T.GroupId}),(0,b.jsxs)(g,{children:[(0,b.jsx)(g.Header,{children:`Placement Group Information`}),(0,b.jsxs)(g.Content,{children:[(0,b.jsx)(_,{label:`Group ID`,value:T.GroupId}),(0,b.jsx)(_,{label:`Group Name`,value:T.GroupName}),(0,b.jsx)(_,{label:`Strategy`,value:T.Strategy}),(0,b.jsx)(_,{label:`State`,value:T.State}),(0,b.jsx)(_,{label:`Spread Level`,value:T.SpreadLevel})]})]}),(0,b.jsxs)(g,{children:[(0,b.jsx)(g.Header,{children:`Instances`}),E.length>0?E.map(e=>(0,b.jsxs)(g.Content,{children:[(0,b.jsx)(_,{label:`Instance ID`,value:e.InstanceId?(0,b.jsx)(a,{className:`text-primary hover:underline`,params:{id:e.InstanceId},to:`/ec2/describe-instances/$id`,children:e.InstanceId}):void 0}),(0,b.jsx)(_,{label:`State`,value:e.State?.Name}),(0,b.jsx)(_,{label:`Instance Type`,value:e.InstanceType}),(0,b.jsx)(_,{label:`Availability Zone`,value:e.Placement?.AvailabilityZone})]},e.InstanceId)):(0,b.jsx)(g.Content,{children:(0,b.jsx)(`p`,{className:`text-muted-foreground`,children:`No instances in this placement group.`})})]})]}),(0,b.jsx)(h,{description:`Are you sure you want to delete the placement group "${T.GroupName}"? The group must have no running instances.`,isPending:S.isPending,onConfirm:async()=>{if(T?.GroupName)try{await S.mutateAsync(T.GroupName),t({to:`/ec2/describe-placement-groups`})}finally{w(!1)}},onOpenChange:w,open:C,title:`Delete Placement Group`})]}):(0,b.jsxs)(b.Fragment,{children:[(0,b.jsx)(o,{to:`/ec2/describe-placement-groups`,children:`Back to placement groups`}),(0,b.jsx)(`p`,{className:`text-muted-foreground`,children:`Placement group not found.`})]})}export{x as component};
\ No newline at end of file
diff --git a/spinifex/services/spinifexui/frontend/dist/assets/_id9.js b/spinifex/services/spinifexui/frontend/dist/assets/_id9.js
index a6f608ddc..0aca25526 100644
--- a/spinifex/services/spinifexui/frontend/dist/assets/_id9.js
+++ b/spinifex/services/spinifexui/frontend/dist/assets/_id9.js
@@ -1 +1 @@
-import{t as e}from"./error-banner.js";import{t}from"./useSuspenseQuery.js";import{t as n}from"./back-link.js";import{c as r,i,n as a,s as o,t as s}from"./select.js";import{t as c}from"./trash-2.js";import{D as ee,L as te,O as ne,h as re,r as l}from"./ec2.js";import{Ho as u,Lo as d,Ro as f,Uo as p,X as m,Xa as h,Xi as g,ao as _,cs as v,dt as y,et as b,it as x,l as ie}from"./index.js";import{a as ae,i as S,n as C,t as w}from"./zod.js";import{t as T}from"./page-heading.js";import{a as E,r as D,t as O}from"./field.js";import{l as k,r as oe}from"./ec22.js";import{t as se}from"./delete-confirmation-dialog.js";import{n as A,t as j}from"./detail-row.js";import{t as M}from"./badge.js";var N=v(p()),P=u();function F(e){return e.GatewayId??e.NatGatewayId??e.NetworkInterfaceId??e.InstanceId??e.VpcPeeringConnectionId??`—`}function I(){let{id:u}=ie.useParams(),p=f(),{data:v}=t(x(u)),{data:I}=t(m),{data:L}=t(b),{data:R}=t(y),z=ne(),B=re(),V=ee(),H=l(),U=te(),[W,G]=(0,N.useState)(!1),K=v.RouteTables?.[0],q=S({resolver:w(k),defaultValues:{destinationCidrBlock:`0.0.0.0/0`,targetType:`igw`,targetId:``}}),ce=ae({control:q.control,name:`targetType`}),J=S({resolver:w(oe),defaultValues:{subnetId:``}});if(!K?.RouteTableId)return(0,P.jsxs)(P.Fragment,{children:[(0,P.jsx)(n,{to:`/ec2/describe-route-tables`,children:`Back to Route Tables`}),(0,P.jsx)(`p`,{className:`text-muted-foreground`,children:`Route Table not found.`})]});let Y=K.RouteTableId,X=_(K.Tags),Z=K.Associations?.some(e=>e.Main)??!1,Q=K.Routes??[],$=(K.Associations??[]).filter(e=>e.SubnetId),le=(I.InternetGateways??[]).filter(e=>e.Attachments?.some(e=>e.VpcId===K.VpcId)),ue=(L.NatGateways??[]).filter(e=>e.VpcId===K.VpcId&&e.State===`available`),de=new Set($.map(e=>e.SubnetId)),fe=(R.Subnets??[]).filter(e=>e.VpcId===K.VpcId&&!de.has(e.SubnetId)),pe=async()=>{try{await z.mutateAsync(Y),p({to:`/ec2/describe-route-tables`})}finally{G(!1)}},me=async e=>{await B.mutateAsync({routeTableId:Y,destinationCidrBlock:e.destinationCidrBlock,gatewayId:e.targetType===`igw`?e.targetId:void 0,natGatewayId:e.targetType===`nat`?e.targetId:void 0}),q.reset({destinationCidrBlock:`0.0.0.0/0`,targetType:`igw`,targetId:``})},he=async e=>{await H.mutateAsync({routeTableId:Y,subnetId:e.subnetId}),J.reset({subnetId:``})};return(0,P.jsxs)(P.Fragment,{children:[(0,P.jsx)(n,{to:`/ec2/describe-route-tables`,children:`Back to Route Tables`}),z.error&&(0,P.jsx)(e,{error:z.error,msg:`Failed to delete Route Table`}),(0,P.jsxs)(`div`,{className:`space-y-6`,children:[(0,P.jsx)(T,{actions:(0,P.jsxs)(`div`,{className:`flex items-center gap-2`,children:[(0,P.jsxs)(h,{disabled:Z||z.isPending,onClick:()=>G(!0),size:`sm`,variant:`destructive`,children:[(0,P.jsx)(c,{className:`size-4`}),`Delete`]}),Z&&(0,P.jsx)(M,{variant:`secondary`,children:`Main`})]}),subtitle:`Route Table Details`,title:X?`${K.RouteTableId} (${X})`:K.RouteTableId}),(0,P.jsxs)(A,{children:[(0,P.jsx)(A.Header,{children:`Route Table Information`}),(0,P.jsxs)(A.Content,{children:[(0,P.jsx)(j,{label:`Route Table ID`,value:K.RouteTableId}),(0,P.jsx)(j,{label:`Main`,value:Z?`Yes`:`No`}),(0,P.jsx)(j,{label:`VPC ID`,value:K.VpcId?(0,P.jsx)(d,{className:`text-primary hover:underline`,params:{id:K.VpcId},to:`/ec2/describe-vpcs/$id`,children:K.VpcId}):`—`}),(0,P.jsx)(j,{label:`Owner ID`,value:K.OwnerId??`—`})]})]}),(0,P.jsxs)(A,{children:[(0,P.jsx)(A.Header,{children:`Routes`}),(0,P.jsxs)(`div`,{className:`space-y-4 p-4`,children:[(0,P.jsx)(`div`,{className:`overflow-x-auto rounded-md border`,children:(0,P.jsxs)(`table`,{className:`w-full text-sm`,children:[(0,P.jsx)(`thead`,{className:`bg-muted/50 text-left text-muted-foreground`,children:(0,P.jsxs)(`tr`,{children:[(0,P.jsx)(`th`,{className:`p-2 font-medium`,children:`Destination`}),(0,P.jsx)(`th`,{className:`p-2 font-medium`,children:`Target`}),(0,P.jsx)(`th`,{className:`p-2 font-medium`,children:`Status`}),(0,P.jsx)(`th`,{className:`p-2`,children:(0,P.jsx)(`span`,{className:`sr-only`,children:`Actions`})})]})}),(0,P.jsx)(`tbody`,{children:Q.length>0?Q.map(e=>{let t=e.DestinationCidrBlock??e.DestinationPrefixListId??`—`,n=e.GatewayId===`local`,r=e.DestinationCidrBlock;return(0,P.jsxs)(`tr`,{className:`border-t`,children:[(0,P.jsx)(`td`,{className:`p-2 font-mono`,children:t}),(0,P.jsx)(`td`,{className:`p-2 font-mono`,children:F(e)}),(0,P.jsx)(`td`,{className:`p-2`,children:e.State??`—`}),(0,P.jsx)(`td`,{className:`p-2 text-right`,children:!n&&r&&(0,P.jsx)(h,{disabled:V.isPending,onClick:async()=>await V.mutateAsync({routeTableId:Y,destinationCidrBlock:r}),size:`sm`,variant:`ghost`,children:(0,P.jsx)(c,{className:`size-4`})})})]},t)}):(0,P.jsx)(`tr`,{children:(0,P.jsx)(`td`,{className:`p-2 text-muted-foreground`,colSpan:4,children:`No routes.`})})})]})}),(B.error??V.error)&&(0,P.jsx)(e,{error:B.error??V.error??void 0,msg:`Failed to update route`}),(0,P.jsxs)(`form`,{className:`grid items-end gap-3 sm:grid-cols-[1fr_1fr_1fr_auto]`,onSubmit:q.handleSubmit(me),children:[(0,P.jsxs)(O,{children:[(0,P.jsx)(E,{children:(0,P.jsx)(`label`,{htmlFor:`destinationCidrBlock`,children:`Destination`})}),(0,P.jsx)(g,{id:`destinationCidrBlock`,placeholder:`0.0.0.0/0`,...q.register(`destinationCidrBlock`)}),(0,P.jsx)(D,{errors:[q.formState.errors.destinationCidrBlock]})]}),(0,P.jsxs)(O,{children:[(0,P.jsx)(E,{children:(0,P.jsx)(`label`,{htmlFor:`targetType`,children:`Target type`})}),(0,P.jsx)(C,{control:q.control,name:`targetType`,render:({field:e})=>(0,P.jsxs)(s,{onValueChange:t=>{e.onChange(t),q.setValue(`targetId`,``)},value:e.value,children:[(0,P.jsx)(o,{className:`w-full`,id:`targetType`,children:(0,P.jsx)(r,{})}),(0,P.jsxs)(a,{children:[(0,P.jsx)(i,{value:`igw`,children:`Internet Gateway`}),(0,P.jsx)(i,{value:`nat`,children:`NAT Gateway`})]})]})})]}),(0,P.jsxs)(O,{children:[(0,P.jsx)(E,{children:(0,P.jsx)(`label`,{htmlFor:`targetId`,children:`Target`})}),(0,P.jsx)(C,{control:q.control,name:`targetId`,render:({field:e})=>(0,P.jsxs)(s,{onValueChange:t=>e.onChange(t),value:e.value??``,children:[(0,P.jsx)(o,{"aria-invalid":!!q.formState.errors.targetId,className:`w-full`,id:`targetId`,children:(0,P.jsx)(r,{placeholder:`Select target`})}),(0,P.jsx)(a,{children:ce===`igw`?le.map(e=>(0,P.jsx)(i,{value:e.InternetGatewayId??``,children:e.InternetGatewayId},e.InternetGatewayId)):ue.map(e=>(0,P.jsx)(i,{value:e.NatGatewayId??``,children:e.NatGatewayId},e.NatGatewayId))})]})}),(0,P.jsx)(D,{errors:[q.formState.errors.targetId]})]}),(0,P.jsx)(h,{disabled:B.isPending,size:`sm`,type:`submit`,children:`Add route`})]})]})]}),(0,P.jsxs)(A,{children:[(0,P.jsx)(A.Header,{children:`Subnet Associations`}),(0,P.jsxs)(`div`,{className:`space-y-4 p-4`,children:[$.length>0?(0,P.jsx)(`ul`,{className:`space-y-2`,children:$.map(e=>{let t=e.RouteTableAssociationId;return(0,P.jsxs)(`li`,{className:`flex items-center justify-between rounded-md border p-2 text-sm`,children:[(0,P.jsx)(d,{className:`font-mono text-primary hover:underline`,params:{id:e.SubnetId??``},to:`/ec2/describe-subnets/$id`,children:e.SubnetId}),(0,P.jsx)(h,{disabled:!t||U.isPending,onClick:async()=>{t&&await U.mutateAsync(t)},size:`sm`,variant:`ghost`,children:(0,P.jsx)(c,{className:`size-4`})})]},t)})}):(0,P.jsx)(`p`,{className:`text-sm text-muted-foreground`,children:`No subnet associations.`}),(H.error??U.error)&&(0,P.jsx)(e,{error:H.error??U.error??void 0,msg:`Failed to update association`}),(0,P.jsxs)(`form`,{className:`grid items-end gap-3 sm:grid-cols-[1fr_auto]`,onSubmit:J.handleSubmit(he),children:[(0,P.jsxs)(O,{children:[(0,P.jsx)(E,{children:(0,P.jsx)(`label`,{htmlFor:`subnetId`,children:`Associate subnet`})}),(0,P.jsx)(C,{control:J.control,name:`subnetId`,render:({field:e})=>(0,P.jsxs)(s,{onValueChange:t=>e.onChange(t),value:e.value??``,children:[(0,P.jsx)(o,{"aria-invalid":!!J.formState.errors.subnetId,className:`w-full`,id:`subnetId`,children:(0,P.jsx)(r,{placeholder:`Select a subnet`})}),(0,P.jsx)(a,{children:fe.map(e=>{let t=_(e.Tags);return(0,P.jsx)(i,{value:e.SubnetId??``,children:t?`${e.SubnetId} (${t})`:`${e.SubnetId} (${e.CidrBlock})`},e.SubnetId)})})]})}),(0,P.jsx)(D,{errors:[J.formState.errors.subnetId]})]}),(0,P.jsx)(h,{disabled:H.isPending,size:`sm`,type:`submit`,children:`Associate`})]})]})]})]}),(0,P.jsx)(se,{description:`Are you sure you want to delete the Route Table "${K.RouteTableId}"? This action cannot be undone.`,isPending:z.isPending,onConfirm:pe,onOpenChange:G,open:W,title:`Delete Route Table`})]})}export{I as component};
\ No newline at end of file
+import{n as e,o as t,r as n}from"./compiler-runtime.js";import{t as r}from"./error-banner.js";import{t as i}from"./useSuspenseQuery.js";import{Or as a}from"./awsClient.js";import{A as o}from"./SignatureV4.js";import{t as s}from"./back-link.js";import{c,i as l,n as u,s as d,t as f}from"./select.js";import{t as p}from"./trash-2.js";import{t as m}from"./button.js";import{O as h,g,i as ee,k as te,z as _}from"./ec2.js";import{Et as v,Ir as y,Nt as b,Rn as x,St as S,l as ne,vt as re}from"./index.js";import{a as ie,i as C,n as w,t as T}from"./zod.js";import{t as ae}from"./page-heading.js";import{a as E,r as D,t as O}from"./field.js";import{l as k,r as A}from"./ec22.js";import{t as oe}from"./delete-confirmation-dialog.js";import{n as j,t as M}from"./detail-row.js";import{t as se}from"./badge.js";var N=t(n()),P=e();function F(e){return e.GatewayId??e.NatGatewayId??e.NetworkInterfaceId??e.InstanceId??e.VpcPeeringConnectionId??`—`}function I(){let{id:e}=ne.useParams(),t=y(),{data:n}=i(v(e)),{data:I}=i(re),{data:L}=i(S),{data:R}=i(b),z=te(),B=g(),V=h(),H=ee(),U=_(),[W,G]=(0,N.useState)(!1),K=n.RouteTables?.[0],q=C({resolver:T(k),defaultValues:{destinationCidrBlock:`0.0.0.0/0`,targetType:`igw`,targetId:``}}),ce=ie({control:q.control,name:`targetType`}),J=C({resolver:T(A),defaultValues:{subnetId:``}});if(!K?.RouteTableId)return(0,P.jsxs)(P.Fragment,{children:[(0,P.jsx)(s,{to:`/ec2/describe-route-tables`,children:`Back to Route Tables`}),(0,P.jsx)(`p`,{className:`text-muted-foreground`,children:`Route Table not found.`})]});let Y=K.RouteTableId,X=o(K.Tags),Z=K.Associations?.some(e=>e.Main)??!1,Q=K.Routes??[],$=(K.Associations??[]).filter(e=>e.SubnetId),le=(I.InternetGateways??[]).filter(e=>e.Attachments?.some(e=>e.VpcId===K.VpcId)),ue=(L.NatGateways??[]).filter(e=>e.VpcId===K.VpcId&&e.State===`available`),de=new Set($.map(e=>e.SubnetId)),fe=(R.Subnets??[]).filter(e=>e.VpcId===K.VpcId&&!de.has(e.SubnetId)),pe=async()=>{try{await z.mutateAsync(Y),t({to:`/ec2/describe-route-tables`})}finally{G(!1)}},me=async e=>{await B.mutateAsync({routeTableId:Y,destinationCidrBlock:e.destinationCidrBlock,gatewayId:e.targetType===`igw`?e.targetId:void 0,natGatewayId:e.targetType===`nat`?e.targetId:void 0}),q.reset({destinationCidrBlock:`0.0.0.0/0`,targetType:`igw`,targetId:``})},he=async e=>{await H.mutateAsync({routeTableId:Y,subnetId:e.subnetId}),J.reset({subnetId:``})};return(0,P.jsxs)(P.Fragment,{children:[(0,P.jsx)(s,{to:`/ec2/describe-route-tables`,children:`Back to Route Tables`}),z.error&&(0,P.jsx)(r,{error:z.error,msg:`Failed to delete Route Table`}),(0,P.jsxs)(`div`,{className:`space-y-6`,children:[(0,P.jsx)(ae,{actions:(0,P.jsxs)(`div`,{className:`flex items-center gap-2`,children:[(0,P.jsxs)(m,{disabled:Z||z.isPending,onClick:()=>G(!0),size:`sm`,variant:`destructive`,children:[(0,P.jsx)(p,{className:`size-4`}),`Delete`]}),Z&&(0,P.jsx)(se,{variant:`secondary`,children:`Main`})]}),subtitle:`Route Table Details`,title:X?`${K.RouteTableId} (${X})`:K.RouteTableId}),(0,P.jsxs)(j,{children:[(0,P.jsx)(j.Header,{children:`Route Table Information`}),(0,P.jsxs)(j.Content,{children:[(0,P.jsx)(M,{label:`Route Table ID`,value:K.RouteTableId}),(0,P.jsx)(M,{label:`Main`,value:Z?`Yes`:`No`}),(0,P.jsx)(M,{label:`VPC ID`,value:K.VpcId?(0,P.jsx)(a,{className:`text-primary hover:underline`,params:{id:K.VpcId},to:`/ec2/describe-vpcs/$id`,children:K.VpcId}):`—`}),(0,P.jsx)(M,{label:`Owner ID`,value:K.OwnerId??`—`})]})]}),(0,P.jsxs)(j,{children:[(0,P.jsx)(j.Header,{children:`Routes`}),(0,P.jsxs)(`div`,{className:`space-y-4 p-4`,children:[(0,P.jsx)(`div`,{className:`overflow-x-auto rounded-md border`,children:(0,P.jsxs)(`table`,{className:`w-full text-sm`,children:[(0,P.jsx)(`thead`,{className:`bg-muted/50 text-left text-muted-foreground`,children:(0,P.jsxs)(`tr`,{children:[(0,P.jsx)(`th`,{className:`p-2 font-medium`,children:`Destination`}),(0,P.jsx)(`th`,{className:`p-2 font-medium`,children:`Target`}),(0,P.jsx)(`th`,{className:`p-2 font-medium`,children:`Status`}),(0,P.jsx)(`th`,{className:`p-2`,children:(0,P.jsx)(`span`,{className:`sr-only`,children:`Actions`})})]})}),(0,P.jsx)(`tbody`,{children:Q.length>0?Q.map(e=>{let t=e.DestinationCidrBlock??e.DestinationPrefixListId??`—`,n=e.GatewayId===`local`,r=e.DestinationCidrBlock;return(0,P.jsxs)(`tr`,{className:`border-t`,children:[(0,P.jsx)(`td`,{className:`p-2 font-mono`,children:t}),(0,P.jsx)(`td`,{className:`p-2 font-mono`,children:F(e)}),(0,P.jsx)(`td`,{className:`p-2`,children:e.State??`—`}),(0,P.jsx)(`td`,{className:`p-2 text-right`,children:!n&&r&&(0,P.jsx)(m,{disabled:V.isPending,onClick:async()=>await V.mutateAsync({routeTableId:Y,destinationCidrBlock:r}),size:`sm`,variant:`ghost`,children:(0,P.jsx)(p,{className:`size-4`})})})]},t)}):(0,P.jsx)(`tr`,{children:(0,P.jsx)(`td`,{className:`p-2 text-muted-foreground`,colSpan:4,children:`No routes.`})})})]})}),(B.error??V.error)&&(0,P.jsx)(r,{error:B.error??V.error??void 0,msg:`Failed to update route`}),(0,P.jsxs)(`form`,{className:`grid items-end gap-3 sm:grid-cols-[1fr_1fr_1fr_auto]`,onSubmit:q.handleSubmit(me),children:[(0,P.jsxs)(O,{children:[(0,P.jsx)(E,{children:(0,P.jsx)(`label`,{htmlFor:`destinationCidrBlock`,children:`Destination`})}),(0,P.jsx)(x,{id:`destinationCidrBlock`,placeholder:`0.0.0.0/0`,...q.register(`destinationCidrBlock`)}),(0,P.jsx)(D,{errors:[q.formState.errors.destinationCidrBlock]})]}),(0,P.jsxs)(O,{children:[(0,P.jsx)(E,{children:(0,P.jsx)(`label`,{htmlFor:`targetType`,children:`Target type`})}),(0,P.jsx)(w,{control:q.control,name:`targetType`,render:({field:e})=>(0,P.jsxs)(f,{onValueChange:t=>{e.onChange(t),q.setValue(`targetId`,``)},value:e.value,children:[(0,P.jsx)(d,{className:`w-full`,id:`targetType`,children:(0,P.jsx)(c,{})}),(0,P.jsxs)(u,{children:[(0,P.jsx)(l,{value:`igw`,children:`Internet Gateway`}),(0,P.jsx)(l,{value:`nat`,children:`NAT Gateway`})]})]})})]}),(0,P.jsxs)(O,{children:[(0,P.jsx)(E,{children:(0,P.jsx)(`label`,{htmlFor:`targetId`,children:`Target`})}),(0,P.jsx)(w,{control:q.control,name:`targetId`,render:({field:e})=>(0,P.jsxs)(f,{onValueChange:t=>e.onChange(t),value:e.value??``,children:[(0,P.jsx)(d,{"aria-invalid":!!q.formState.errors.targetId,className:`w-full`,id:`targetId`,children:(0,P.jsx)(c,{placeholder:`Select target`})}),(0,P.jsx)(u,{children:ce===`igw`?le.map(e=>(0,P.jsx)(l,{value:e.InternetGatewayId??``,children:e.InternetGatewayId},e.InternetGatewayId)):ue.map(e=>(0,P.jsx)(l,{value:e.NatGatewayId??``,children:e.NatGatewayId},e.NatGatewayId))})]})}),(0,P.jsx)(D,{errors:[q.formState.errors.targetId]})]}),(0,P.jsx)(m,{disabled:B.isPending,size:`sm`,type:`submit`,children:`Add route`})]})]})]}),(0,P.jsxs)(j,{children:[(0,P.jsx)(j.Header,{children:`Subnet Associations`}),(0,P.jsxs)(`div`,{className:`space-y-4 p-4`,children:[$.length>0?(0,P.jsx)(`ul`,{className:`space-y-2`,children:$.map(e=>{let t=e.RouteTableAssociationId;return(0,P.jsxs)(`li`,{className:`flex items-center justify-between rounded-md border p-2 text-sm`,children:[(0,P.jsx)(a,{className:`font-mono text-primary hover:underline`,params:{id:e.SubnetId??``},to:`/ec2/describe-subnets/$id`,children:e.SubnetId}),(0,P.jsx)(m,{disabled:!t||U.isPending,onClick:async()=>{t&&await U.mutateAsync(t)},size:`sm`,variant:`ghost`,children:(0,P.jsx)(p,{className:`size-4`})})]},t)})}):(0,P.jsx)(`p`,{className:`text-sm text-muted-foreground`,children:`No subnet associations.`}),(H.error??U.error)&&(0,P.jsx)(r,{error:H.error??U.error??void 0,msg:`Failed to update association`}),(0,P.jsxs)(`form`,{className:`grid items-end gap-3 sm:grid-cols-[1fr_auto]`,onSubmit:J.handleSubmit(he),children:[(0,P.jsxs)(O,{children:[(0,P.jsx)(E,{children:(0,P.jsx)(`label`,{htmlFor:`subnetId`,children:`Associate subnet`})}),(0,P.jsx)(w,{control:J.control,name:`subnetId`,render:({field:e})=>(0,P.jsxs)(f,{onValueChange:t=>e.onChange(t),value:e.value??``,children:[(0,P.jsx)(d,{"aria-invalid":!!J.formState.errors.subnetId,className:`w-full`,id:`subnetId`,children:(0,P.jsx)(c,{placeholder:`Select a subnet`})}),(0,P.jsx)(u,{children:fe.map(e=>{let t=o(e.Tags);return(0,P.jsx)(l,{value:e.SubnetId??``,children:t?`${e.SubnetId} (${t})`:`${e.SubnetId} (${e.CidrBlock})`},e.SubnetId)})})]})}),(0,P.jsx)(D,{errors:[J.formState.errors.subnetId]})]}),(0,P.jsx)(m,{disabled:H.isPending,size:`sm`,type:`submit`,children:`Associate`})]})]})]})]}),(0,P.jsx)(oe,{description:`Are you sure you want to delete the Route Table "${K.RouteTableId}"? This action cannot be undone.`,isPending:z.isPending,onConfirm:pe,onOpenChange:G,open:W,title:`Delete Route Table`})]})}export{I as component};
\ No newline at end of file
diff --git a/spinifex/services/spinifexui/frontend/dist/assets/_instanceProfileName.js b/spinifex/services/spinifexui/frontend/dist/assets/_instanceProfileName.js
new file mode 100644
index 000000000..871703450
--- /dev/null
+++ b/spinifex/services/spinifexui/frontend/dist/assets/_instanceProfileName.js
@@ -0,0 +1 @@
+import{n as e,o as t,r as n}from"./compiler-runtime.js";import{t as r}from"./error-banner.js";import{t as i}from"./useSuspenseQuery.js";import{Or as a}from"./awsClient.js";import{D as o}from"./SignatureV4.js";import{t as s}from"./back-link.js";import{t as c}from"./trash-2.js";import{t as l}from"./button.js";import{g as u,t as d,u as f}from"./iam.js";import{Ir as p,X as m,rt as h,v as g}from"./index.js";import{t as _}from"./page-heading.js";import{t as v}from"./delete-confirmation-dialog.js";import{n as y,t as b}from"./detail-row.js";var x=t(n()),S=e();function C(){let{instanceProfileName:e}=g.useParams(),t=p(),{data:n}=i(m(e)),{data:C}=i(h),w=f(),T=d(),E=u(),[D,O]=(0,x.useState)(!1),[k,A]=(0,x.useState)(!1),[j,M]=(0,x.useState)(null),N=n.InstanceProfile,P=N?.Roles??[],F=C.Roles??[],I=new Set(P.map(e=>e.RoleName)),L=F.filter(e=>e.RoleName&&!I.has(e.RoleName)),R=async()=>{try{await w.mutateAsync(e),t({to:`/iam/list-instance-profiles`})}finally{O(!1)}},z=async t=>{await T.mutateAsync({instanceProfileName:e,roleName:t}),A(!1)},B=async t=>{M(t);try{await E.mutateAsync({instanceProfileName:e,roleName:t})}finally{M(null)}};return N?(0,S.jsxs)(S.Fragment,{children:[(0,S.jsx)(s,{to:`/iam/list-instance-profiles`,children:`Back to instance profiles`}),w.error&&(0,S.jsx)(r,{error:w.error,msg:`Failed to delete instance profile`}),T.error&&(0,S.jsx)(r,{error:T.error,msg:`Failed to add role`}),E.error&&(0,S.jsx)(r,{error:E.error,msg:`Failed to remove role`}),(0,S.jsxs)(`div`,{className:`space-y-6`,children:[(0,S.jsx)(_,{actions:(0,S.jsxs)(l,{onClick:()=>O(!0),size:`sm`,variant:`destructive`,children:[(0,S.jsx)(c,{className:`size-4`}),`Delete`]}),subtitle:`Instance Profile Details`,title:N.InstanceProfileName??``}),(0,S.jsxs)(y,{children:[(0,S.jsx)(y.Header,{children:`Instance Profile Information`}),(0,S.jsxs)(y.Content,{children:[(0,S.jsx)(b,{label:`Name`,value:N.InstanceProfileName}),(0,S.jsx)(b,{label:`ID`,value:N.InstanceProfileId}),(0,S.jsx)(b,{label:`ARN`,value:N.Arn}),(0,S.jsx)(b,{label:`Path`,value:N.Path}),(0,S.jsx)(b,{label:`Created`,value:o(N.CreateDate)})]})]}),(0,S.jsxs)(y,{children:[(0,S.jsx)(y.Header,{children:(0,S.jsxs)(`div`,{className:`flex items-center justify-between`,children:[(0,S.jsx)(`span`,{children:`Roles`}),(0,S.jsx)(l,{onClick:()=>A(!k),size:`sm`,children:`Add Role`})]})}),(0,S.jsxs)(y.Content,{children:[k&&L.length>0&&(0,S.jsxs)(`div`,{className:`col-span-2 space-y-2 rounded-md border p-3`,children:[(0,S.jsx)(`p`,{className:`text-sm font-medium`,children:`Select a role to add:`}),(0,S.jsx)(`div`,{className:`space-y-1`,children:L.map(e=>(0,S.jsxs)(`button`,{className:`flex w-full items-center justify-between rounded-md p-2 text-left text-sm hover:bg-accent`,disabled:T.isPending,onClick:()=>{e.RoleName&&z(e.RoleName)},type:`button`,children:[(0,S.jsx)(`span`,{children:e.RoleName}),(0,S.jsx)(`span`,{className:`text-xs text-muted-foreground`,children:e.Arn})]},e.RoleName))})]}),k&&L.length===0&&(0,S.jsx)(`p`,{className:`col-span-2 text-sm text-muted-foreground`,children:`No roles available to add.`}),P.length>0?(0,S.jsx)(`div`,{className:`col-span-2 space-y-3`,children:P.map(e=>(0,S.jsxs)(`div`,{className:`flex items-center justify-between rounded-md border p-3`,children:[(0,S.jsx)(a,{className:`text-sm font-medium text-primary hover:underline`,params:{roleName:e.RoleName??``},to:`/iam/list-roles/$roleName`,children:e.RoleName}),(0,S.jsx)(l,{disabled:j===e.RoleName,onClick:()=>{e.RoleName&&B(e.RoleName)},size:`sm`,variant:`outline`,children:`Remove`})]},e.RoleName))}):(0,S.jsx)(`p`,{className:`col-span-2 text-sm text-muted-foreground`,children:`No roles in this instance profile.`})]})]})]}),(0,S.jsx)(v,{description:`Are you sure you want to delete the instance profile "${e}"? This action cannot be undone. You must remove all roles first.`,isPending:w.isPending,onConfirm:R,onOpenChange:O,open:D,title:`Delete Instance Profile`})]}):(0,S.jsxs)(S.Fragment,{children:[(0,S.jsx)(s,{to:`/iam/list-instance-profiles`,children:`Back to instance profiles`}),(0,S.jsx)(`p`,{className:`text-muted-foreground`,children:`Instance profile not found.`})]})}export{C as component};
\ No newline at end of file
diff --git a/spinifex/services/spinifexui/frontend/dist/assets/_policyArn.js b/spinifex/services/spinifexui/frontend/dist/assets/_policyArn.js
index 5335ea7f3..1d738a432 100644
--- a/spinifex/services/spinifexui/frontend/dist/assets/_policyArn.js
+++ b/spinifex/services/spinifexui/frontend/dist/assets/_policyArn.js
@@ -1 +1 @@
-import{t as e}from"./error-banner.js";import{t}from"./useSuspenseQuery.js";import{t as n}from"./back-link.js";import{t as r}from"./trash-2.js";import{o as i}from"./iam.js";import{C as a,Fo as o,Ho as s,Ro as c,Uo as l,Xa as u,_ as d,cs as f,no as p,w as m}from"./index.js";import{t as h}from"./page-heading.js";import{t as g}from"./delete-confirmation-dialog.js";import{n as _,t as v}from"./detail-row.js";var y=f(l()),b=o(),x=s();function S(e){let t=(0,b.c)(4),{document:n}=e,r;try{let e;t[0]===n?e=t[1]:(e=JSON.stringify(JSON.parse(n),null,2),t[0]=n,t[1]=e),r=e}catch{r=n}let i;return t[2]===r?i=t[3]:(i=(0,x.jsx)(`pre`,{className:`overflow-auto rounded-md border bg-muted p-4 font-mono text-sm`,children:r}),t[2]=r,t[3]=i),i}function C(){let{policyArn:o}=d.useParams(),s=decodeURIComponent(o),l=c(),{data:f}=t(a(s)),b=f.Policy,C=i(),[w,T]=(0,y.useState)(!1),{data:E}=t(m(s,b?.DefaultVersionId??`v1`)),D=E?.PolicyVersion?.Document?decodeURIComponent(E.PolicyVersion.Document):null;return b?(0,x.jsxs)(x.Fragment,{children:[(0,x.jsx)(n,{to:`/iam/list-policies`,children:`Back to policies`}),C.error&&(0,x.jsx)(e,{error:C.error,msg:`Failed to delete policy`}),(0,x.jsxs)(`div`,{className:`space-y-6`,children:[(0,x.jsx)(h,{actions:(0,x.jsxs)(u,{onClick:()=>T(!0),size:`sm`,variant:`destructive`,children:[(0,x.jsx)(r,{className:`size-4`}),`Delete`]}),subtitle:`Policy Details`,title:b.PolicyName??``}),(0,x.jsxs)(_,{children:[(0,x.jsx)(_.Header,{children:`Policy Information`}),(0,x.jsxs)(_.Content,{children:[(0,x.jsx)(v,{label:`Policy Name`,value:b.PolicyName}),(0,x.jsx)(v,{label:`Policy ID`,value:b.PolicyId}),(0,x.jsx)(v,{label:`ARN`,value:b.Arn}),(0,x.jsx)(v,{label:`Path`,value:b.Path}),(0,x.jsx)(v,{label:`Description`,value:b.Description??`-`}),(0,x.jsx)(v,{label:`Created`,value:p(b.CreateDate)})]})]}),D&&(0,x.jsxs)(_,{children:[(0,x.jsx)(_.Header,{children:`Policy Document`}),(0,x.jsx)(`div`,{className:`p-4`,children:(0,x.jsx)(S,{document:D})})]})]}),(0,x.jsx)(g,{description:`Are you sure you want to delete the policy "${b.PolicyName}"? This action cannot be undone.`,isPending:C.isPending,onConfirm:async()=>{try{await C.mutateAsync(s),l({to:`/iam/list-policies`})}finally{T(!1)}},onOpenChange:T,open:w,title:`Delete Policy`})]}):(0,x.jsxs)(x.Fragment,{children:[(0,x.jsx)(n,{to:`/iam/list-policies`,children:`Back to policies`}),(0,x.jsx)(`p`,{className:`text-muted-foreground`,children:`Policy not found.`})]})}export{C as component};
\ No newline at end of file
+import{n as e,o as t,r as n}from"./compiler-runtime.js";import{t as r}from"./error-banner.js";import{t as i}from"./useSuspenseQuery.js";import{D as a}from"./SignatureV4.js";import{t as o}from"./back-link.js";import{t as s}from"./trash-2.js";import{t as c}from"./button.js";import{d as l}from"./iam.js";import{Ir as u,et as d,tt as f,y as p}from"./index.js";import{t as m}from"./page-heading.js";import{t as h}from"./delete-confirmation-dialog.js";import{n as g,t as _}from"./detail-row.js";import{t as v}from"./policy-document-viewer.js";var y=t(n()),b=e();function x(){let{policyArn:e}=p.useParams(),t=decodeURIComponent(e),n=u(),{data:x}=i(d(t)),S=x.Policy,C=l(),[w,T]=(0,y.useState)(!1),{data:E}=i(f(t,S?.DefaultVersionId??`v1`)),D=E?.PolicyVersion?.Document?decodeURIComponent(E.PolicyVersion.Document):null;return S?(0,b.jsxs)(b.Fragment,{children:[(0,b.jsx)(o,{to:`/iam/list-policies`,children:`Back to policies`}),C.error&&(0,b.jsx)(r,{error:C.error,msg:`Failed to delete policy`}),(0,b.jsxs)(`div`,{className:`space-y-6`,children:[(0,b.jsx)(m,{actions:(0,b.jsxs)(c,{onClick:()=>T(!0),size:`sm`,variant:`destructive`,children:[(0,b.jsx)(s,{className:`size-4`}),`Delete`]}),subtitle:`Policy Details`,title:S.PolicyName??``}),(0,b.jsxs)(g,{children:[(0,b.jsx)(g.Header,{children:`Policy Information`}),(0,b.jsxs)(g.Content,{children:[(0,b.jsx)(_,{label:`Policy Name`,value:S.PolicyName}),(0,b.jsx)(_,{label:`Policy ID`,value:S.PolicyId}),(0,b.jsx)(_,{label:`ARN`,value:S.Arn}),(0,b.jsx)(_,{label:`Path`,value:S.Path}),(0,b.jsx)(_,{label:`Description`,value:S.Description??`-`}),(0,b.jsx)(_,{label:`Created`,value:a(S.CreateDate)})]})]}),D&&(0,b.jsxs)(g,{children:[(0,b.jsx)(g.Header,{children:`Policy Document`}),(0,b.jsx)(`div`,{className:`p-4`,children:(0,b.jsx)(v,{document:D})})]})]}),(0,b.jsx)(h,{description:`Are you sure you want to delete the policy "${S.PolicyName}"? This action cannot be undone.`,isPending:C.isPending,onConfirm:async()=>{try{await C.mutateAsync(t),n({to:`/iam/list-policies`})}finally{T(!1)}},onOpenChange:T,open:w,title:`Delete Policy`})]}):(0,b.jsxs)(b.Fragment,{children:[(0,b.jsx)(o,{to:`/iam/list-policies`,children:`Back to policies`}),(0,b.jsx)(`p`,{className:`text-muted-foreground`,children:`Policy not found.`})]})}export{x as component};
\ No newline at end of file
diff --git a/spinifex/services/spinifexui/frontend/dist/assets/_roleName.js b/spinifex/services/spinifexui/frontend/dist/assets/_roleName.js
new file mode 100644
index 000000000..8b7c25244
--- /dev/null
+++ b/spinifex/services/spinifexui/frontend/dist/assets/_roleName.js
@@ -0,0 +1 @@
+import{n as e,o as t,r as n}from"./compiler-runtime.js";import{t as r}from"./error-banner.js";import{t as i}from"./useSuspenseQuery.js";import{Or as a}from"./awsClient.js";import{D as o}from"./SignatureV4.js";import{t as s}from"./back-link.js";import{t as c}from"./trash-2.js";import{t as l}from"./button.js";import{f as u,m as d,n as f}from"./iam.js";import{$ as p,Ir as m,J as h,Z as g,b as _,nt as v}from"./index.js";import{t as y}from"./page-heading.js";import{t as b}from"./delete-confirmation-dialog.js";import{n as x,t as S}from"./detail-row.js";import{t as C}from"./policy-document-viewer.js";var w=t(n()),T=e();function E(){let{roleName:e}=_.useParams(),t=m(),{data:n}=i(v(e)),{data:E}=i(h(e)),{data:D}=i(g(e)),{data:O}=i(p),k=u(),A=f(),j=d(),[M,N]=(0,w.useState)(!1),[P,F]=(0,w.useState)(!1),[I,L]=(0,w.useState)(null),R=n.Role,z=E.AttachedPolicies??[],B=D.InstanceProfiles??[],V=O.Policies??[],H=new Set(z.map(e=>e.PolicyArn)),U=V.filter(e=>e.Arn&&!H.has(e.Arn)),W=R?.AssumeRolePolicyDocument?decodeURIComponent(R.AssumeRolePolicyDocument):null,G=async()=>{try{await k.mutateAsync(e),t({to:`/iam/list-roles`})}finally{N(!1)}},K=async t=>{await A.mutateAsync({roleName:e,policyArn:t}),F(!1)},q=async t=>{L(t);try{await j.mutateAsync({roleName:e,policyArn:t})}finally{L(null)}};return R?(0,T.jsxs)(T.Fragment,{children:[(0,T.jsx)(s,{to:`/iam/list-roles`,children:`Back to roles`}),k.error&&(0,T.jsx)(r,{error:k.error,msg:`Failed to delete role`}),A.error&&(0,T.jsx)(r,{error:A.error,msg:`Failed to attach policy`}),j.error&&(0,T.jsx)(r,{error:j.error,msg:`Failed to detach policy`}),(0,T.jsxs)(`div`,{className:`space-y-6`,children:[(0,T.jsx)(y,{actions:(0,T.jsxs)(l,{onClick:()=>N(!0),size:`sm`,variant:`destructive`,children:[(0,T.jsx)(c,{className:`size-4`}),`Delete`]}),subtitle:`Role Details`,title:R.RoleName??``}),(0,T.jsxs)(x,{children:[(0,T.jsx)(x.Header,{children:`Role Information`}),(0,T.jsxs)(x.Content,{children:[(0,T.jsx)(S,{label:`Role Name`,value:R.RoleName}),(0,T.jsx)(S,{label:`Role ID`,value:R.RoleId}),(0,T.jsx)(S,{label:`ARN`,value:R.Arn}),(0,T.jsx)(S,{label:`Path`,value:R.Path}),(0,T.jsx)(S,{label:`Description`,value:R.Description??`-`}),(0,T.jsx)(S,{label:`Created`,value:o(R.CreateDate)})]})]}),W&&(0,T.jsxs)(x,{children:[(0,T.jsx)(x.Header,{children:`Trust Policy`}),(0,T.jsx)(`div`,{className:`p-4`,children:(0,T.jsx)(C,{document:W})})]}),(0,T.jsxs)(x,{children:[(0,T.jsx)(x.Header,{children:(0,T.jsxs)(`div`,{className:`flex items-center justify-between`,children:[(0,T.jsx)(`span`,{children:`Attached Policies`}),(0,T.jsx)(l,{onClick:()=>F(!P),size:`sm`,children:`Attach Policy`})]})}),(0,T.jsxs)(x.Content,{children:[P&&U.length>0&&(0,T.jsxs)(`div`,{className:`col-span-2 space-y-2 rounded-md border p-3`,children:[(0,T.jsx)(`p`,{className:`text-sm font-medium`,children:`Select a policy to attach:`}),(0,T.jsx)(`div`,{className:`space-y-1`,children:U.map(e=>(0,T.jsxs)(`button`,{className:`flex w-full items-center justify-between rounded-md p-2 text-left text-sm hover:bg-accent`,disabled:A.isPending,onClick:()=>{e.Arn&&K(e.Arn)},type:`button`,children:[(0,T.jsx)(`span`,{children:e.PolicyName}),(0,T.jsx)(`span`,{className:`text-xs text-muted-foreground`,children:e.Arn})]},e.Arn))})]}),P&&U.length===0&&(0,T.jsx)(`p`,{className:`col-span-2 text-sm text-muted-foreground`,children:`No policies available to attach.`}),z.length>0?(0,T.jsx)(`div`,{className:`col-span-2 space-y-3`,children:z.map(e=>(0,T.jsxs)(`div`,{className:`flex items-center justify-between rounded-md border p-3`,children:[(0,T.jsxs)(`div`,{className:`space-y-1`,children:[(0,T.jsx)(`p`,{className:`text-sm font-medium`,children:e.PolicyName}),(0,T.jsx)(`p`,{className:`text-xs text-muted-foreground`,children:e.PolicyArn})]}),(0,T.jsx)(l,{disabled:I===e.PolicyArn,onClick:()=>{e.PolicyArn&&q(e.PolicyArn)},size:`sm`,variant:`outline`,children:`Detach`})]},e.PolicyArn))}):(0,T.jsx)(`p`,{className:`col-span-2 text-sm text-muted-foreground`,children:`No attached policies.`})]})]}),(0,T.jsxs)(x,{children:[(0,T.jsx)(x.Header,{children:`Instance Profiles`}),(0,T.jsx)(x.Content,{children:B.length>0?(0,T.jsx)(`div`,{className:`col-span-2 space-y-3`,children:B.map(e=>(0,T.jsxs)(a,{className:`flex items-center justify-between rounded-md border p-3 transition-colors hover:bg-accent`,params:{instanceProfileName:e.InstanceProfileName??``},to:`/iam/list-instance-profiles/$instanceProfileName`,children:[(0,T.jsx)(`p`,{className:`text-sm font-medium`,children:e.InstanceProfileName}),(0,T.jsx)(`span`,{className:`text-xs text-muted-foreground`,children:e.Arn})]},e.InstanceProfileName))}):(0,T.jsx)(`p`,{className:`col-span-2 text-sm text-muted-foreground`,children:`This role is not in any instance profile.`})})]})]}),(0,T.jsx)(b,{description:`Are you sure you want to delete the role "${e}"? This action cannot be undone. You must detach all policies and remove it from all instance profiles first.`,isPending:k.isPending,onConfirm:G,onOpenChange:N,open:M,title:`Delete Role`})]}):(0,T.jsxs)(T.Fragment,{children:[(0,T.jsx)(s,{to:`/iam/list-roles`,children:`Back to roles`}),(0,T.jsx)(`p`,{className:`text-muted-foreground`,children:`Role not found.`})]})}export{E as component};
\ No newline at end of file
diff --git a/spinifex/services/spinifexui/frontend/dist/assets/_userName.js b/spinifex/services/spinifexui/frontend/dist/assets/_userName.js
index 2bc34f847..3e997449a 100644
--- a/spinifex/services/spinifexui/frontend/dist/assets/_userName.js
+++ b/spinifex/services/spinifexui/frontend/dist/assets/_userName.js
@@ -1 +1 @@
-import{t as e}from"./error-banner.js";import{t}from"./useSuspenseQuery.js";import{t as n}from"./back-link.js";import{t as r}from"./check.js";import{t as i}from"./trash-2.js";import{t as a}from"./triangle-alert.js";import{a as o,c as s,l as c,n as l,s as u,t as d}from"./iam.js";import{Fo as f,Ho as p,Ro as m,S as h,T as g,Uo as _,Xa as v,b as y,cs as b,no as x,v as S,x as C}from"./index.js";import{t as w}from"./use-copy-to-clipboard.js";import{t as T}from"./page-heading.js";import{a as E,c as D,i as O,l as k,o as A,r as j,s as M,t as N}from"./alert-dialog.js";import{t as P}from"./delete-confirmation-dialog.js";import{n as F,t as I}from"./detail-row.js";import{t as L}from"./state-badge.js";var R=b(_()),z=f(),B=p();function V(e){let t=(0,z.c)(27),{onClose:n,accessKeyId:i,secretAccessKey:o}=e,{copied:s,copy:c}=w(),l;t[0]===Symbol.for(`react.memo_cache_sentinel`)?(l=(0,B.jsxs)(M,{children:[(0,B.jsx)(D,{children:(0,B.jsx)(a,{className:`text-destructive`})}),(0,B.jsx)(k,{children:`Save Your Access Key`}),(0,B.jsx)(E,{children:`This is your only chance to view the secret access key. Please copy it now. You won't be able to retrieve it again.`})]}),t[0]=l):l=t[0];let u;t[1]===Symbol.for(`react.memo_cache_sentinel`)?(u=(0,B.jsx)(`span`,{className:`text-muted-foreground`,children:`Access Key ID: `}),t[1]=u):u=t[1];let d;t[2]===i?d=t[3]:(d=(0,B.jsxs)(`div`,{children:[u,i]}),t[2]=i,t[3]=d);let f;t[4]===Symbol.for(`react.memo_cache_sentinel`)?(f=(0,B.jsx)(`span`,{className:`text-muted-foreground`,children:`Secret Access Key: `}),t[4]=f):f=t[4];let p;t[5]===o?p=t[6]:(p=(0,B.jsxs)(`div`,{children:[f,o]}),t[5]=o,t[6]=p);let m;t[7]!==d||t[8]!==p?(m=(0,B.jsxs)(`div`,{className:`space-y-2 rounded-md border p-4 font-mono text-sm`,children:[d,p]}),t[7]=d,t[8]=p,t[9]=m):m=t[9];let h;t[10]!==i||t[11]!==c||t[12]!==o?(h=async()=>await c(`Access Key ID: ${i}\nSecret Access Key: ${o}`),t[10]=i,t[11]=c,t[12]=o,t[13]=h):h=t[13];let g;t[14]===s?g=t[15]:(g=s?(0,B.jsxs)(B.Fragment,{children:[(0,B.jsx)(r,{className:`mr-2 size-4`}),`Copied!`]}):`Copy to Clipboard`,t[14]=s,t[15]=g);let _;t[16]!==h||t[17]!==g?(_=(0,B.jsx)(v,{className:`w-full`,onClick:h,type:`button`,variant:`outline`,children:g}),t[16]=h,t[17]=g,t[18]=_):_=t[18];let y;t[19]!==m||t[20]!==_?(y=(0,B.jsxs)(`div`,{className:`space-y-3`,children:[m,_]}),t[19]=m,t[20]=_,t[21]=y):y=t[21];let b;t[22]===n?b=t[23]:(b=(0,B.jsx)(A,{children:(0,B.jsx)(j,{onClick:n,children:`Close and Continue`})}),t[22]=n,t[23]=b);let x;return t[24]!==y||t[25]!==b?(x=(0,B.jsx)(N,{open:!0,children:(0,B.jsxs)(O,{className:`max-w-2xl`,children:[l,y,b]})}),t[24]=y,t[25]=b,t[26]=x):x=t[26],x}function H(){let{userName:r}=S.useParams(),a=m(),{data:f}=t(g(r)),{data:p}=t(y(r)),{data:_}=t(C(r)),{data:b}=t(h),w=u(),E=l(),D=o(),O=c(),k=d(),A=s(),[j,M]=(0,R.useState)(!1),[N,z]=(0,R.useState)(null),[H,U]=(0,R.useState)(!1),[W,G]=(0,R.useState)(null),[K,q]=(0,R.useState)(null),J=f.User,Y=p.AccessKeyMetadata??[],X=_.AttachedPolicies??[],Z=b.Policies??[],Q=new Set(X.map(e=>e.PolicyArn)),$=Z.filter(e=>e.Arn&&!Q.has(e.Arn)),ee=async()=>{try{await w.mutateAsync(r),a({to:`/iam/list-users`})}finally{M(!1)}},te=async()=>{let e=await E.mutateAsync(r);e.AccessKey&&z({accessKeyId:e.AccessKey.AccessKeyId??``,secretAccessKey:e.AccessKey.SecretAccessKey??``})},ne=async e=>{if(e.AccessKeyId){G(e.AccessKeyId);try{await O.mutateAsync({userName:r,accessKeyId:e.AccessKeyId,status:e.Status===`Active`?`Inactive`:`Active`})}finally{G(null)}}},re=async e=>{G(e);try{await D.mutateAsync({userName:r,accessKeyId:e})}finally{G(null)}},ie=async e=>{await k.mutateAsync({userName:r,policyArn:e}),U(!1)},ae=async e=>{q(e);try{await A.mutateAsync({userName:r,policyArn:e})}finally{q(null)}};return J?(0,B.jsxs)(B.Fragment,{children:[(0,B.jsx)(n,{to:`/iam/list-users`,children:`Back to users`}),w.error&&(0,B.jsx)(e,{error:w.error,msg:`Failed to delete user`}),E.error&&(0,B.jsx)(e,{error:E.error,msg:`Failed to create access key`}),D.error&&(0,B.jsx)(e,{error:D.error,msg:`Failed to delete access key`}),k.error&&(0,B.jsx)(e,{error:k.error,msg:`Failed to attach policy`}),A.error&&(0,B.jsx)(e,{error:A.error,msg:`Failed to detach policy`}),(0,B.jsxs)(`div`,{className:`space-y-6`,children:[(0,B.jsx)(T,{actions:(0,B.jsxs)(v,{onClick:()=>M(!0),size:`sm`,variant:`destructive`,children:[(0,B.jsx)(i,{className:`size-4`}),`Delete`]}),subtitle:`User Details`,title:J.UserName??``}),(0,B.jsxs)(F,{children:[(0,B.jsx)(F.Header,{children:`User Information`}),(0,B.jsxs)(F.Content,{children:[(0,B.jsx)(I,{label:`User Name`,value:J.UserName}),(0,B.jsx)(I,{label:`User ID`,value:J.UserId}),(0,B.jsx)(I,{label:`ARN`,value:J.Arn}),(0,B.jsx)(I,{label:`Path`,value:J.Path}),(0,B.jsx)(I,{label:`Created`,value:x(J.CreateDate)})]})]}),(0,B.jsxs)(F,{children:[(0,B.jsx)(F.Header,{children:(0,B.jsxs)(`div`,{className:`flex items-center justify-between`,children:[(0,B.jsx)(`span`,{children:`Access Keys`}),(0,B.jsx)(v,{disabled:E.isPending,onClick:te,size:`sm`,children:E.isPending?`Creating...`:`Create Access Key`})]})}),(0,B.jsx)(F.Content,{children:Y.length>0?(0,B.jsx)(`div`,{className:`col-span-2 space-y-3`,children:Y.map(e=>(0,B.jsxs)(`div`,{className:`flex items-center justify-between rounded-md border p-3`,children:[(0,B.jsxs)(`div`,{className:`space-y-1`,children:[(0,B.jsx)(`p`,{className:`font-mono text-sm`,children:e.AccessKeyId}),(0,B.jsxs)(`p`,{className:`text-xs text-muted-foreground`,children:[`Created `,x(e.CreateDate)]})]}),(0,B.jsxs)(`div`,{className:`flex items-center gap-2`,children:[(0,B.jsx)(L,{state:e.Status}),(0,B.jsx)(v,{disabled:W===e.AccessKeyId,onClick:async()=>await ne(e),size:`sm`,variant:`outline`,children:e.Status===`Active`?`Deactivate`:`Activate`}),(0,B.jsx)(v,{disabled:W===e.AccessKeyId,onClick:()=>{e.AccessKeyId&&re(e.AccessKeyId)},size:`sm`,variant:`destructive`,children:`Delete`})]})]},e.AccessKeyId))}):(0,B.jsx)(`p`,{className:`col-span-2 text-sm text-muted-foreground`,children:`No access keys.`})})]}),(0,B.jsxs)(F,{children:[(0,B.jsx)(F.Header,{children:(0,B.jsxs)(`div`,{className:`flex items-center justify-between`,children:[(0,B.jsx)(`span`,{children:`Attached Policies`}),(0,B.jsx)(v,{onClick:()=>U(!H),size:`sm`,children:`Attach Policy`})]})}),(0,B.jsxs)(F.Content,{children:[H&&$.length>0&&(0,B.jsxs)(`div`,{className:`col-span-2 space-y-2 rounded-md border p-3`,children:[(0,B.jsx)(`p`,{className:`text-sm font-medium`,children:`Select a policy to attach:`}),(0,B.jsx)(`div`,{className:`space-y-1`,children:$.map(e=>(0,B.jsxs)(`button`,{className:`flex w-full items-center justify-between rounded-md p-2 text-left text-sm hover:bg-accent`,disabled:k.isPending,onClick:()=>{e.Arn&&ie(e.Arn)},type:`button`,children:[(0,B.jsx)(`span`,{children:e.PolicyName}),(0,B.jsx)(`span`,{className:`text-xs text-muted-foreground`,children:e.Arn})]},e.Arn))})]}),H&&$.length===0&&(0,B.jsx)(`p`,{className:`col-span-2 text-sm text-muted-foreground`,children:`No policies available to attach.`}),X.length>0?(0,B.jsx)(`div`,{className:`col-span-2 space-y-3`,children:X.map(e=>(0,B.jsxs)(`div`,{className:`flex items-center justify-between rounded-md border p-3`,children:[(0,B.jsxs)(`div`,{className:`space-y-1`,children:[(0,B.jsx)(`p`,{className:`text-sm font-medium`,children:e.PolicyName}),(0,B.jsx)(`p`,{className:`text-xs text-muted-foreground`,children:e.PolicyArn})]}),(0,B.jsx)(v,{disabled:K===e.PolicyArn,onClick:()=>{e.PolicyArn&&ae(e.PolicyArn)},size:`sm`,variant:`outline`,children:`Detach`})]},e.PolicyArn))}):(0,B.jsx)(`p`,{className:`col-span-2 text-sm text-muted-foreground`,children:`No attached policies.`})]})]})]}),(0,B.jsx)(P,{description:`Are you sure you want to delete the user "${r}"? This action cannot be undone. You must detach all policies and delete all access keys first.`,isPending:w.isPending,onConfirm:ee,onOpenChange:M,open:j,title:`Delete User`}),N&&(0,B.jsx)(V,{accessKeyId:N.accessKeyId,onClose:()=>z(null),secretAccessKey:N.secretAccessKey})]}):(0,B.jsxs)(B.Fragment,{children:[(0,B.jsx)(n,{to:`/iam/list-users`,children:`Back to users`}),(0,B.jsx)(`p`,{className:`text-muted-foreground`,children:`User not found.`})]})}export{H as component};
\ No newline at end of file
+import{n as e,o as t,r as n,t as r}from"./compiler-runtime.js";import{t as i}from"./error-banner.js";import{t as a}from"./useSuspenseQuery.js";import{D as o}from"./SignatureV4.js";import{t as s}from"./back-link.js";import{t as c}from"./check.js";import{t as l}from"./trash-2.js";import{t as u}from"./triangle-alert.js";import{t as d}from"./button.js";import{_ as f,h as p,i as m,l as h,p as g,r as _}from"./iam.js";import{$ as v,Ir as y,Y as b,it as x,q as S,x as C}from"./index.js";import{t as w}from"./use-copy-to-clipboard.js";import{t as T}from"./page-heading.js";import{a as E,c as D,i as O,l as k,o as A,r as j,s as M,t as N}from"./alert-dialog.js";import{t as P}from"./delete-confirmation-dialog.js";import{n as F,t as I}from"./detail-row.js";import{t as L}from"./state-badge.js";var R=t(n()),z=r(),B=e();function V(e){let t=(0,z.c)(27),{onClose:n,accessKeyId:r,secretAccessKey:i}=e,{copied:a,copy:o}=w(),s;t[0]===Symbol.for(`react.memo_cache_sentinel`)?(s=(0,B.jsxs)(M,{children:[(0,B.jsx)(D,{children:(0,B.jsx)(u,{className:`text-destructive`})}),(0,B.jsx)(k,{children:`Save Your Access Key`}),(0,B.jsx)(E,{children:`This is your only chance to view the secret access key. Please copy it now. You won't be able to retrieve it again.`})]}),t[0]=s):s=t[0];let l;t[1]===Symbol.for(`react.memo_cache_sentinel`)?(l=(0,B.jsx)(`span`,{className:`text-muted-foreground`,children:`Access Key ID: `}),t[1]=l):l=t[1];let f;t[2]===r?f=t[3]:(f=(0,B.jsxs)(`div`,{children:[l,r]}),t[2]=r,t[3]=f);let p;t[4]===Symbol.for(`react.memo_cache_sentinel`)?(p=(0,B.jsx)(`span`,{className:`text-muted-foreground`,children:`Secret Access Key: `}),t[4]=p):p=t[4];let m;t[5]===i?m=t[6]:(m=(0,B.jsxs)(`div`,{children:[p,i]}),t[5]=i,t[6]=m);let h;t[7]!==f||t[8]!==m?(h=(0,B.jsxs)(`div`,{className:`space-y-2 rounded-md border p-4 font-mono text-sm`,children:[f,m]}),t[7]=f,t[8]=m,t[9]=h):h=t[9];let g;t[10]!==r||t[11]!==o||t[12]!==i?(g=async()=>await o(`Access Key ID: ${r}\nSecret Access Key: ${i}`),t[10]=r,t[11]=o,t[12]=i,t[13]=g):g=t[13];let _;t[14]===a?_=t[15]:(_=a?(0,B.jsxs)(B.Fragment,{children:[(0,B.jsx)(c,{className:`mr-2 size-4`}),`Copied!`]}):`Copy to Clipboard`,t[14]=a,t[15]=_);let v;t[16]!==g||t[17]!==_?(v=(0,B.jsx)(d,{className:`w-full`,onClick:g,type:`button`,variant:`outline`,children:_}),t[16]=g,t[17]=_,t[18]=v):v=t[18];let y;t[19]!==h||t[20]!==v?(y=(0,B.jsxs)(`div`,{className:`space-y-3`,children:[h,v]}),t[19]=h,t[20]=v,t[21]=y):y=t[21];let b;t[22]===n?b=t[23]:(b=(0,B.jsx)(A,{children:(0,B.jsx)(j,{onClick:n,children:`Close and Continue`})}),t[22]=n,t[23]=b);let x;return t[24]!==y||t[25]!==b?(x=(0,B.jsx)(N,{open:!0,children:(0,B.jsxs)(O,{className:`max-w-2xl`,children:[s,y,b]})}),t[24]=y,t[25]=b,t[26]=x):x=t[26],x}function H(){let{userName:e}=C.useParams(),t=y(),{data:n}=a(x(e)),{data:r}=a(S(e)),{data:c}=a(b(e)),{data:u}=a(v),w=g(),E=m(),D=h(),O=f(),k=_(),A=p(),[j,M]=(0,R.useState)(!1),[N,z]=(0,R.useState)(null),[H,U]=(0,R.useState)(!1),[W,G]=(0,R.useState)(null),[K,q]=(0,R.useState)(null),J=n.User,Y=r.AccessKeyMetadata??[],X=c.AttachedPolicies??[],Z=u.Policies??[],Q=new Set(X.map(e=>e.PolicyArn)),$=Z.filter(e=>e.Arn&&!Q.has(e.Arn)),ee=async()=>{try{await w.mutateAsync(e),t({to:`/iam/list-users`})}finally{M(!1)}},te=async()=>{let t=await E.mutateAsync(e);t.AccessKey&&z({accessKeyId:t.AccessKey.AccessKeyId??``,secretAccessKey:t.AccessKey.SecretAccessKey??``})},ne=async t=>{if(t.AccessKeyId){G(t.AccessKeyId);try{await O.mutateAsync({userName:e,accessKeyId:t.AccessKeyId,status:t.Status===`Active`?`Inactive`:`Active`})}finally{G(null)}}},re=async t=>{G(t);try{await D.mutateAsync({userName:e,accessKeyId:t})}finally{G(null)}},ie=async t=>{await k.mutateAsync({userName:e,policyArn:t}),U(!1)},ae=async t=>{q(t);try{await A.mutateAsync({userName:e,policyArn:t})}finally{q(null)}};return J?(0,B.jsxs)(B.Fragment,{children:[(0,B.jsx)(s,{to:`/iam/list-users`,children:`Back to users`}),w.error&&(0,B.jsx)(i,{error:w.error,msg:`Failed to delete user`}),E.error&&(0,B.jsx)(i,{error:E.error,msg:`Failed to create access key`}),D.error&&(0,B.jsx)(i,{error:D.error,msg:`Failed to delete access key`}),k.error&&(0,B.jsx)(i,{error:k.error,msg:`Failed to attach policy`}),A.error&&(0,B.jsx)(i,{error:A.error,msg:`Failed to detach policy`}),(0,B.jsxs)(`div`,{className:`space-y-6`,children:[(0,B.jsx)(T,{actions:(0,B.jsxs)(d,{onClick:()=>M(!0),size:`sm`,variant:`destructive`,children:[(0,B.jsx)(l,{className:`size-4`}),`Delete`]}),subtitle:`User Details`,title:J.UserName??``}),(0,B.jsxs)(F,{children:[(0,B.jsx)(F.Header,{children:`User Information`}),(0,B.jsxs)(F.Content,{children:[(0,B.jsx)(I,{label:`User Name`,value:J.UserName}),(0,B.jsx)(I,{label:`User ID`,value:J.UserId}),(0,B.jsx)(I,{label:`ARN`,value:J.Arn}),(0,B.jsx)(I,{label:`Path`,value:J.Path}),(0,B.jsx)(I,{label:`Created`,value:o(J.CreateDate)})]})]}),(0,B.jsxs)(F,{children:[(0,B.jsx)(F.Header,{children:(0,B.jsxs)(`div`,{className:`flex items-center justify-between`,children:[(0,B.jsx)(`span`,{children:`Access Keys`}),(0,B.jsx)(d,{disabled:E.isPending,onClick:te,size:`sm`,children:E.isPending?`Creating...`:`Create Access Key`})]})}),(0,B.jsx)(F.Content,{children:Y.length>0?(0,B.jsx)(`div`,{className:`col-span-2 space-y-3`,children:Y.map(e=>(0,B.jsxs)(`div`,{className:`flex items-center justify-between rounded-md border p-3`,children:[(0,B.jsxs)(`div`,{className:`space-y-1`,children:[(0,B.jsx)(`p`,{className:`font-mono text-sm`,children:e.AccessKeyId}),(0,B.jsxs)(`p`,{className:`text-xs text-muted-foreground`,children:[`Created `,o(e.CreateDate)]})]}),(0,B.jsxs)(`div`,{className:`flex items-center gap-2`,children:[(0,B.jsx)(L,{state:e.Status}),(0,B.jsx)(d,{disabled:W===e.AccessKeyId,onClick:async()=>await ne(e),size:`sm`,variant:`outline`,children:e.Status===`Active`?`Deactivate`:`Activate`}),(0,B.jsx)(d,{disabled:W===e.AccessKeyId,onClick:()=>{e.AccessKeyId&&re(e.AccessKeyId)},size:`sm`,variant:`destructive`,children:`Delete`})]})]},e.AccessKeyId))}):(0,B.jsx)(`p`,{className:`col-span-2 text-sm text-muted-foreground`,children:`No access keys.`})})]}),(0,B.jsxs)(F,{children:[(0,B.jsx)(F.Header,{children:(0,B.jsxs)(`div`,{className:`flex items-center justify-between`,children:[(0,B.jsx)(`span`,{children:`Attached Policies`}),(0,B.jsx)(d,{onClick:()=>U(!H),size:`sm`,children:`Attach Policy`})]})}),(0,B.jsxs)(F.Content,{children:[H&&$.length>0&&(0,B.jsxs)(`div`,{className:`col-span-2 space-y-2 rounded-md border p-3`,children:[(0,B.jsx)(`p`,{className:`text-sm font-medium`,children:`Select a policy to attach:`}),(0,B.jsx)(`div`,{className:`space-y-1`,children:$.map(e=>(0,B.jsxs)(`button`,{className:`flex w-full items-center justify-between rounded-md p-2 text-left text-sm hover:bg-accent`,disabled:k.isPending,onClick:()=>{e.Arn&&ie(e.Arn)},type:`button`,children:[(0,B.jsx)(`span`,{children:e.PolicyName}),(0,B.jsx)(`span`,{className:`text-xs text-muted-foreground`,children:e.Arn})]},e.Arn))})]}),H&&$.length===0&&(0,B.jsx)(`p`,{className:`col-span-2 text-sm text-muted-foreground`,children:`No policies available to attach.`}),X.length>0?(0,B.jsx)(`div`,{className:`col-span-2 space-y-3`,children:X.map(e=>(0,B.jsxs)(`div`,{className:`flex items-center justify-between rounded-md border p-3`,children:[(0,B.jsxs)(`div`,{className:`space-y-1`,children:[(0,B.jsx)(`p`,{className:`text-sm font-medium`,children:e.PolicyName}),(0,B.jsx)(`p`,{className:`text-xs text-muted-foreground`,children:e.PolicyArn})]}),(0,B.jsx)(d,{disabled:K===e.PolicyArn,onClick:()=>{e.PolicyArn&&ae(e.PolicyArn)},size:`sm`,variant:`outline`,children:`Detach`})]},e.PolicyArn))}):(0,B.jsx)(`p`,{className:`col-span-2 text-sm text-muted-foreground`,children:`No attached policies.`})]})]})]}),(0,B.jsx)(P,{description:`Are you sure you want to delete the user "${e}"? This action cannot be undone. You must detach all policies and delete all access keys first.`,isPending:w.isPending,onConfirm:ee,onOpenChange:M,open:j,title:`Delete User`}),N&&(0,B.jsx)(V,{accessKeyId:N.accessKeyId,onClose:()=>z(null),secretAccessKey:N.secretAccessKey})]}):(0,B.jsxs)(B.Fragment,{children:[(0,B.jsx)(s,{to:`/iam/list-users`,children:`Back to users`}),(0,B.jsx)(`p`,{className:`text-muted-foreground`,children:`User not found.`})]})}export{H as component};
\ No newline at end of file
diff --git a/spinifex/services/spinifexui/frontend/dist/assets/admin.js b/spinifex/services/spinifexui/frontend/dist/assets/admin.js
index 12843e390..7d675c28f 100644
--- a/spinifex/services/spinifexui/frontend/dist/assets/admin.js
+++ b/spinifex/services/spinifexui/frontend/dist/assets/admin.js
@@ -1 +1 @@
-import{Bo as e,Pr as t,_r as n}from"./index.js";var r=e({queryKey:[`admin`,`nodes`],queryFn:async()=>{let e=t();if(!e)throw Error(`Not authenticated`);return await n({action:`GetNodes`,credentials:e})},staleTime:1e4}),i=e({queryKey:[`admin`,`vms`],queryFn:async()=>{let e=t();if(!e)throw Error(`Not authenticated`);return await n({action:`GetVMs`,credentials:e})},staleTime:1e4}),a=e({queryKey:[`admin`,`storageStatus`],queryFn:async()=>{let e=t();if(!e)throw Error(`Not authenticated`);return await n({action:`GetStorageStatus`,credentials:e})},staleTime:1e4});export{a as n,i as r,r as t};
\ No newline at end of file
+import{t as e}from"./queryOptions.js";import{u as t}from"./SignatureV4.js";import{Vt as n}from"./index.js";var r=e({queryKey:[`admin`,`nodes`],queryFn:async()=>{let e=t();if(!e)throw Error(`Not authenticated`);return await n({action:`GetNodes`,credentials:e})},staleTime:1e4}),i=e({queryKey:[`admin`,`vms`],queryFn:async()=>{let e=t();if(!e)throw Error(`Not authenticated`);return await n({action:`GetVMs`,credentials:e})},staleTime:1e4}),a=e({queryKey:[`admin`,`storageStatus`],queryFn:async()=>{let e=t();if(!e)throw Error(`Not authenticated`);return await n({action:`GetStorageStatus`,credentials:e})},staleTime:1e4});export{a as n,i as r,r as t};
\ No newline at end of file
diff --git a/spinifex/services/spinifexui/frontend/dist/assets/alert-dialog.js b/spinifex/services/spinifexui/frontend/dist/assets/alert-dialog.js
index b9d7c5110..ff99ecfa3 100644
--- a/spinifex/services/spinifexui/frontend/dist/assets/alert-dialog.js
+++ b/spinifex/services/spinifexui/frontend/dist/assets/alert-dialog.js
@@ -1 +1 @@
-import{$a as e,$r as t,Fo as n,Hi as r,Ho as i,Ji as a,Qr as o,Ui as s,Uo as c,Vi as l,Xa as u,Zr as d,ai as f,ci as p,cs as m,ei as h,qi as g}from"./index.js";var _=m(c()),v=i();function y(e){let{children:n,open:r,defaultOpen:i=!1,onOpenChange:s,onOpenChangeComplete:c,actionsRef:l,handle:u,triggerId:d,defaultTriggerId:f=null}=e,p=a(!0),m=!!p,y=t.useStore(u?.store,{open:i,openProp:r,activeTriggerId:f,triggerIdProp:d,modal:!0,disablePointerDismissal:!0,nested:m,role:`alertdialog`});y.useControlledProp(`openProp`,r),y.useControlledProp(`triggerIdProp`,d),y.useSyncedValue(`nested`,m),y.useContextCallback(`onOpenChange`,s),y.useContextCallback(`onOpenChangeComplete`,c);let b=y.useState(`payload`);h({store:y,actionsRef:l,parentContext:p?.store.context,isDrawer:!1,onOpenChange:s,triggerIdProp:d});let x=_.useMemo(()=>({store:y}),[y]);return(0,v.jsx)(o.Provider,{value:!1,children:(0,v.jsx)(g.Provider,{value:x,children:typeof n==`function`?n({payload:b}):n})})}var b=n();function x(e){let t=(0,b.c)(4),n;t[0]===e?n=t[1]:({...n}=e,t[0]=e,t[1]=n);let r;return t[2]===n?r=t[3]:(r=(0,v.jsx)(y,{"data-slot":`alert-dialog`,...n}),t[2]=n,t[3]=r),r}function S(e){let t=(0,b.c)(4),n;t[0]===e?n=t[1]:({...n}=e,t[0]=e,t[1]=n);let r;return t[2]===n?r=t[3]:(r=(0,v.jsx)(f,{"data-slot":`alert-dialog-portal`,...n}),t[2]=n,t[3]=r),r}function C(t){let n=(0,b.c)(8),r,i;n[0]===t?(r=n[1],i=n[2]):({className:r,...i}=t,n[0]=t,n[1]=r,n[2]=i);let a;n[3]===r?a=n[4]:(a=e(`data-closed:fade-out-0 data-open:fade-in-0 data-closed:animate-out data-open:animate-in fixed inset-0 isolate z-50 bg-black/80 duration-100 supports-backdrop-filter:backdrop-blur-xs`,r),n[3]=r,n[4]=a);let o;return n[5]!==i||n[6]!==a?(o=(0,v.jsx)(s,{className:a,"data-slot":`alert-dialog-overlay`,...i}),n[5]=i,n[6]=a,n[7]=o):o=n[7],o}function w(t){let n=(0,b.c)(11),r,i,a;n[0]===t?(r=n[1],i=n[2],a=n[3]):({className:r,size:a,...i}=t,n[0]=t,n[1]=r,n[2]=i,n[3]=a);let o=a===void 0?`default`:a,s;n[4]===Symbol.for(`react.memo_cache_sentinel`)?(s=(0,v.jsx)(C,{}),n[4]=s):s=n[4];let c;n[5]===r?c=n[6]:(c=e(`data-closed:fade-out-0 data-open:fade-in-0 data-closed:zoom-out-95 data-open:zoom-in-95 group/alert-dialog-content data-closed:animate-out data-open:animate-in fixed top-1/2 left-1/2 z-50 grid w-full -translate-x-1/2 -translate-y-1/2 gap-3 rounded-xl bg-background p-4 ring-1 ring-foreground/10 duration-100 outline-none data-[size=default]:max-w-xs data-[size=sm]:max-w-64 data-[size=default]:sm:max-w-sm`,r),n[5]=r,n[6]=c);let l;return n[7]!==i||n[8]!==o||n[9]!==c?(l=(0,v.jsxs)(S,{children:[s,(0,v.jsx)(p,{className:c,"data-size":o,"data-slot":`alert-dialog-content`,...i})]}),n[7]=i,n[8]=o,n[9]=c,n[10]=l):l=n[10],l}function T(t){let n=(0,b.c)(8),r,i;n[0]===t?(r=n[1],i=n[2]):({className:r,...i}=t,n[0]=t,n[1]=r,n[2]=i);let a;n[3]===r?a=n[4]:(a=e(`grid grid-rows-[auto_1fr] place-items-center gap-1 text-center has-data-[slot=alert-dialog-media]:grid-rows-[auto_auto_1fr] has-data-[slot=alert-dialog-media]:gap-x-4 sm:group-data-[size=default]/alert-dialog-content:place-items-start sm:group-data-[size=default]/alert-dialog-content:text-left sm:group-data-[size=default]/alert-dialog-content:has-data-[slot=alert-dialog-media]:grid-rows-[auto_1fr]`,r),n[3]=r,n[4]=a);let o;return n[5]!==i||n[6]!==a?(o=(0,v.jsx)(`div`,{className:a,"data-slot":`alert-dialog-header`,...i}),n[5]=i,n[6]=a,n[7]=o):o=n[7],o}function E(t){let n=(0,b.c)(8),r,i;n[0]===t?(r=n[1],i=n[2]):({className:r,...i}=t,n[0]=t,n[1]=r,n[2]=i);let a;n[3]===r?a=n[4]:(a=e(`flex flex-col-reverse gap-2 group-data-[size=sm]/alert-dialog-content:grid group-data-[size=sm]/alert-dialog-content:grid-cols-2 sm:flex-row sm:justify-end`,r),n[3]=r,n[4]=a);let o;return n[5]!==i||n[6]!==a?(o=(0,v.jsx)(`div`,{className:a,"data-slot":`alert-dialog-footer`,...i}),n[5]=i,n[6]=a,n[7]=o):o=n[7],o}function D(t){let n=(0,b.c)(8),r,i;n[0]===t?(r=n[1],i=n[2]):({className:r,...i}=t,n[0]=t,n[1]=r,n[2]=i);let a;n[3]===r?a=n[4]:(a=e(`mb-2 inline-flex size-8 items-center justify-center rounded-md bg-muted sm:group-data-[size=default]/alert-dialog-content:row-span-2 *:[svg:not([class*='size-'])]:size-4`,r),n[3]=r,n[4]=a);let o;return n[5]!==i||n[6]!==a?(o=(0,v.jsx)(`div`,{className:a,"data-slot":`alert-dialog-media`,...i}),n[5]=i,n[6]=a,n[7]=o):o=n[7],o}function O(t){let n=(0,b.c)(8),r,i;n[0]===t?(r=n[1],i=n[2]):({className:r,...i}=t,n[0]=t,n[1]=r,n[2]=i);let a;n[3]===r?a=n[4]:(a=e(`text-sm font-medium sm:group-data-[size=default]/alert-dialog-content:group-has-data-[slot=alert-dialog-media]/alert-dialog-content:col-start-2`,r),n[3]=r,n[4]=a);let o;return n[5]!==i||n[6]!==a?(o=(0,v.jsx)(d,{className:a,"data-slot":`alert-dialog-title`,...i}),n[5]=i,n[6]=a,n[7]=o):o=n[7],o}function k(t){let n=(0,b.c)(8),r,i;n[0]===t?(r=n[1],i=n[2]):({className:r,...i}=t,n[0]=t,n[1]=r,n[2]=i);let a;n[3]===r?a=n[4]:(a=e(`text-xs/relaxed text-balance text-muted-foreground md:text-pretty *:[a]:underline *:[a]:underline-offset-3 *:[a]:hover:text-foreground`,r),n[3]=r,n[4]=a);let o;return n[5]!==i||n[6]!==a?(o=(0,v.jsx)(l,{className:a,"data-slot":`alert-dialog-description`,...i}),n[5]=i,n[6]=a,n[7]=o):o=n[7],o}function A(t){let n=(0,b.c)(8),r,i;n[0]===t?(r=n[1],i=n[2]):({className:r,...i}=t,n[0]=t,n[1]=r,n[2]=i);let a;n[3]===r?a=n[4]:(a=e(r),n[3]=r,n[4]=a);let o;return n[5]!==i||n[6]!==a?(o=(0,v.jsx)(u,{className:a,"data-slot":`alert-dialog-action`,...i}),n[5]=i,n[6]=a,n[7]=o):o=n[7],o}function j(t){let n=(0,b.c)(14),i,a,o,s;n[0]===t?(i=n[1],a=n[2],o=n[3],s=n[4]):({className:i,variant:o,size:s,...a}=t,n[0]=t,n[1]=i,n[2]=a,n[3]=o,n[4]=s);let c=o===void 0?`outline`:o,l=s===void 0?`default`:s,d;n[5]===i?d=n[6]:(d=e(i),n[5]=i,n[6]=d);let f;n[7]!==l||n[8]!==c?(f=(0,v.jsx)(u,{size:l,variant:c}),n[7]=l,n[8]=c,n[9]=f):f=n[9];let p;return n[10]!==a||n[11]!==d||n[12]!==f?(p=(0,v.jsx)(r,{className:d,"data-slot":`alert-dialog-cancel`,render:f,...a}),n[10]=a,n[11]=d,n[12]=f,n[13]=p):p=n[13],p}export{k as a,D as c,w as i,O as l,A as n,E as o,j as r,T as s,x as t};
\ No newline at end of file
+import{n as e,t}from"./compiler-runtime.js";import{w as n}from"./SignatureV4.js";import{t as r}from"./button.js";import{$t as i,Kt as a,Mn as o,Nn as s,Xt as c,jn as l,qt as u}from"./index.js";function d(e){return u(e,`alert-dialog`)}var f=t(),p=e();function m(e){let t=(0,f.c)(4),n;t[0]===e?n=t[1]:({...n}=e,t[0]=e,t[1]=n);let r;return t[2]===n?r=t[3]:(r=(0,p.jsx)(d,{"data-slot":`alert-dialog`,...n}),t[2]=n,t[3]=r),r}function h(e){let t=(0,f.c)(4),n;t[0]===e?n=t[1]:({...n}=e,t[0]=e,t[1]=n);let r;return t[2]===n?r=t[3]:(r=(0,p.jsx)(c,{"data-slot":`alert-dialog-portal`,...n}),t[2]=n,t[3]=r),r}function g(e){let t=(0,f.c)(8),r,i;t[0]===e?(r=t[1],i=t[2]):({className:r,...i}=e,t[0]=e,t[1]=r,t[2]=i);let a;t[3]===r?a=t[4]:(a=n(`data-closed:fade-out-0 data-open:fade-in-0 data-closed:animate-out data-open:animate-in fixed inset-0 isolate z-50 bg-black/80 duration-100 supports-backdrop-filter:backdrop-blur-xs`,r),t[3]=r,t[4]=a);let o;return t[5]!==i||t[6]!==a?(o=(0,p.jsx)(s,{className:a,"data-slot":`alert-dialog-overlay`,...i}),t[5]=i,t[6]=a,t[7]=o):o=t[7],o}function _(e){let t=(0,f.c)(11),r,a,o;t[0]===e?(r=t[1],a=t[2],o=t[3]):({className:r,size:o,...a}=e,t[0]=e,t[1]=r,t[2]=a,t[3]=o);let s=o===void 0?`default`:o,c;t[4]===Symbol.for(`react.memo_cache_sentinel`)?(c=(0,p.jsx)(g,{}),t[4]=c):c=t[4];let l;t[5]===r?l=t[6]:(l=n(`data-closed:fade-out-0 data-open:fade-in-0 data-closed:zoom-out-95 data-open:zoom-in-95 group/alert-dialog-content data-closed:animate-out data-open:animate-in fixed top-1/2 left-1/2 z-50 grid w-full -translate-x-1/2 -translate-y-1/2 gap-3 rounded-xl bg-background p-4 ring-1 ring-foreground/10 duration-100 outline-none data-[size=default]:max-w-xs data-[size=sm]:max-w-64 data-[size=default]:sm:max-w-sm`,r),t[5]=r,t[6]=l);let u;return t[7]!==a||t[8]!==s||t[9]!==l?(u=(0,p.jsxs)(h,{children:[c,(0,p.jsx)(i,{className:l,"data-size":s,"data-slot":`alert-dialog-content`,...a})]}),t[7]=a,t[8]=s,t[9]=l,t[10]=u):u=t[10],u}function v(e){let t=(0,f.c)(8),r,i;t[0]===e?(r=t[1],i=t[2]):({className:r,...i}=e,t[0]=e,t[1]=r,t[2]=i);let a;t[3]===r?a=t[4]:(a=n(`grid grid-rows-[auto_1fr] place-items-center gap-1 text-center has-data-[slot=alert-dialog-media]:grid-rows-[auto_auto_1fr] has-data-[slot=alert-dialog-media]:gap-x-4 sm:group-data-[size=default]/alert-dialog-content:place-items-start sm:group-data-[size=default]/alert-dialog-content:text-left sm:group-data-[size=default]/alert-dialog-content:has-data-[slot=alert-dialog-media]:grid-rows-[auto_1fr]`,r),t[3]=r,t[4]=a);let o;return t[5]!==i||t[6]!==a?(o=(0,p.jsx)(`div`,{className:a,"data-slot":`alert-dialog-header`,...i}),t[5]=i,t[6]=a,t[7]=o):o=t[7],o}function y(e){let t=(0,f.c)(8),r,i;t[0]===e?(r=t[1],i=t[2]):({className:r,...i}=e,t[0]=e,t[1]=r,t[2]=i);let a;t[3]===r?a=t[4]:(a=n(`flex flex-col-reverse gap-2 group-data-[size=sm]/alert-dialog-content:grid group-data-[size=sm]/alert-dialog-content:grid-cols-2 sm:flex-row sm:justify-end`,r),t[3]=r,t[4]=a);let o;return t[5]!==i||t[6]!==a?(o=(0,p.jsx)(`div`,{className:a,"data-slot":`alert-dialog-footer`,...i}),t[5]=i,t[6]=a,t[7]=o):o=t[7],o}function b(e){let t=(0,f.c)(8),r,i;t[0]===e?(r=t[1],i=t[2]):({className:r,...i}=e,t[0]=e,t[1]=r,t[2]=i);let a;t[3]===r?a=t[4]:(a=n(`mb-2 inline-flex size-8 items-center justify-center rounded-md bg-muted sm:group-data-[size=default]/alert-dialog-content:row-span-2 *:[svg:not([class*='size-'])]:size-4`,r),t[3]=r,t[4]=a);let o;return t[5]!==i||t[6]!==a?(o=(0,p.jsx)(`div`,{className:a,"data-slot":`alert-dialog-media`,...i}),t[5]=i,t[6]=a,t[7]=o):o=t[7],o}function x(e){let t=(0,f.c)(8),r,i;t[0]===e?(r=t[1],i=t[2]):({className:r,...i}=e,t[0]=e,t[1]=r,t[2]=i);let o;t[3]===r?o=t[4]:(o=n(`text-sm font-medium sm:group-data-[size=default]/alert-dialog-content:group-has-data-[slot=alert-dialog-media]/alert-dialog-content:col-start-2`,r),t[3]=r,t[4]=o);let s;return t[5]!==i||t[6]!==o?(s=(0,p.jsx)(a,{className:o,"data-slot":`alert-dialog-title`,...i}),t[5]=i,t[6]=o,t[7]=s):s=t[7],s}function S(e){let t=(0,f.c)(8),r,i;t[0]===e?(r=t[1],i=t[2]):({className:r,...i}=e,t[0]=e,t[1]=r,t[2]=i);let a;t[3]===r?a=t[4]:(a=n(`text-xs/relaxed text-balance text-muted-foreground md:text-pretty *:[a]:underline *:[a]:underline-offset-3 *:[a]:hover:text-foreground`,r),t[3]=r,t[4]=a);let o;return t[5]!==i||t[6]!==a?(o=(0,p.jsx)(l,{className:a,"data-slot":`alert-dialog-description`,...i}),t[5]=i,t[6]=a,t[7]=o):o=t[7],o}function C(e){let t=(0,f.c)(8),i,a;t[0]===e?(i=t[1],a=t[2]):({className:i,...a}=e,t[0]=e,t[1]=i,t[2]=a);let o;t[3]===i?o=t[4]:(o=n(i),t[3]=i,t[4]=o);let s;return t[5]!==a||t[6]!==o?(s=(0,p.jsx)(r,{className:o,"data-slot":`alert-dialog-action`,...a}),t[5]=a,t[6]=o,t[7]=s):s=t[7],s}function w(e){let t=(0,f.c)(14),i,a,s,c;t[0]===e?(i=t[1],a=t[2],s=t[3],c=t[4]):({className:i,variant:s,size:c,...a}=e,t[0]=e,t[1]=i,t[2]=a,t[3]=s,t[4]=c);let l=s===void 0?`outline`:s,u=c===void 0?`default`:c,d;t[5]===i?d=t[6]:(d=n(i),t[5]=i,t[6]=d);let m;t[7]!==u||t[8]!==l?(m=(0,p.jsx)(r,{size:u,variant:l}),t[7]=u,t[8]=l,t[9]=m):m=t[9];let h;return t[10]!==a||t[11]!==d||t[12]!==m?(h=(0,p.jsx)(o,{className:d,"data-slot":`alert-dialog-cancel`,render:m,...a}),t[10]=a,t[11]=d,t[12]=m,t[13]=h):h=t[13],h}export{S as a,b as c,_ as i,x as l,C as n,y as o,w as r,v as s,m as t};
\ No newline at end of file
diff --git a/spinifex/services/spinifexui/frontend/dist/assets/allocate-address.js b/spinifex/services/spinifexui/frontend/dist/assets/allocate-address.js
index 6e43d1416..bda64ac1c 100644
--- a/spinifex/services/spinifexui/frontend/dist/assets/allocate-address.js
+++ b/spinifex/services/spinifexui/frontend/dist/assets/allocate-address.js
@@ -1,3 +1,3 @@
-import{t as e}from"./error-banner.js";import{t}from"./back-link.js";import{t as n}from"./cli-command-panel.js";import{t as r}from"./ec2.js";import{Fo as i,Ho as a,Ro as o,Xi as s}from"./index.js";import{a as c,i as l,t as u}from"./zod.js";import{t as d}from"./form-actions.js";import{t as f}from"./page-heading.js";import{a as p,t as m}from"./field.js";import{n as h}from"./ec22.js";var g=i(),_=a();function v(){let i=(0,g.c)(36),a=o(),v=r(),b;i[0]===Symbol.for(`react.memo_cache_sentinel`)?(b={resolver:u(h),defaultValues:{name:``}},i[0]=b):b=i[0];let{control:x,handleSubmit:S,register:C,formState:w}=l(b),{isSubmitting:T}=w,E;i[1]===x?E=i[2]:(E={control:x},i[1]=x,i[2]=E);let D=c(E),O;i[3]!==v||i[4]!==a?(O=async e=>{let t=(await v.mutateAsync({name:e.name?.trim()??void 0})).AllocationId;a(t?{to:`/ec2/describe-addresses/$id`,params:{id:t}}:{to:`/ec2/describe-addresses`})},i[3]=v,i[4]=a,i[5]=O):O=i[5];let k=O,A,j;i[6]===Symbol.for(`react.memo_cache_sentinel`)?(A=(0,_.jsx)(t,{to:`/ec2/describe-addresses`,children:`Back to Elastic IPs`}),j=(0,_.jsx)(f,{title:`Allocate Elastic IP`}),i[6]=A,i[7]=j):(A=i[6],j=i[7]);let M;i[8]===v.error?M=i[9]:(M=v.error&&(0,_.jsx)(e,{error:v.error,msg:`Failed to allocate Elastic IP`}),i[8]=v.error,i[9]=M);let N;i[10]!==S||i[11]!==k?(N=S(k),i[10]=S,i[11]=k,i[12]=N):N=i[12];let P;i[13]===Symbol.for(`react.memo_cache_sentinel`)?(P=(0,_.jsx)(p,{children:(0,_.jsx)(`label`,{htmlFor:`name`,children:`Name (optional)`})}),i[13]=P):P=i[13];let F;i[14]===C?F=i[15]:(F=C(`name`),i[14]=C,i[15]=F);let I;i[16]===F?I=i[17]:(I=(0,_.jsxs)(m,{children:[P,(0,_.jsx)(s,{id:`name`,placeholder:`my-elastic-ip`,...F})]}),i[16]=F,i[17]=I);let L=D.name??``,R;i[18]===L?R=i[19]:(R=y(L),i[18]=L,i[19]=R);let z;i[20]===R?z=i[21]:(z=(0,_.jsx)(n,{commands:R}),i[20]=R,i[21]=z);let B;i[22]===a?B=i[23]:(B=async()=>await a({to:`/ec2/describe-addresses`}),i[22]=a,i[23]=B);let V;i[24]!==v.isPending||i[25]!==T||i[26]!==B?(V=(0,_.jsx)(d,{isPending:v.isPending,isSubmitting:T,onCancel:B,pendingLabel:`Allocating…`,submitLabel:`Allocate`}),i[24]=v.isPending,i[25]=T,i[26]=B,i[27]=V):V=i[27];let H;i[28]!==I||i[29]!==z||i[30]!==V||i[31]!==N?(H=(0,_.jsxs)(`form`,{className:`max-w-4xl space-y-6`,onSubmit:N,children:[I,z,V]}),i[28]=I,i[29]=z,i[30]=V,i[31]=N,i[32]=H):H=i[32];let U;return i[33]!==H||i[34]!==M?(U=(0,_.jsxs)(_.Fragment,{children:[A,j,M,H]}),i[33]=H,i[34]=M,i[35]=U):U=i[35],U}function y(e){let t=[{type:`bin`,value:`AWS_PROFILE=spinifex aws ec2 allocate-address`},{type:`flag`,value:` \\
+import{n as e,t}from"./compiler-runtime.js";import{t as n}from"./error-banner.js";import{t as r}from"./back-link.js";import{t as i}from"./cli-command-panel.js";import{t as a}from"./ec2.js";import{Ir as o,Rn as s}from"./index.js";import{a as c,i as l,t as u}from"./zod.js";import{t as d}from"./form-actions.js";import{t as f}from"./page-heading.js";import{a as p,t as m}from"./field.js";import{n as h}from"./ec22.js";var g=t(),_=e();function v(){let e=(0,g.c)(36),t=o(),v=a(),b;e[0]===Symbol.for(`react.memo_cache_sentinel`)?(b={resolver:u(h),defaultValues:{name:``}},e[0]=b):b=e[0];let{control:x,handleSubmit:S,register:C,formState:w}=l(b),{isSubmitting:T}=w,E;e[1]===x?E=e[2]:(E={control:x},e[1]=x,e[2]=E);let D=c(E),O;e[3]!==v||e[4]!==t?(O=async e=>{let n=(await v.mutateAsync({name:e.name?.trim()??void 0})).AllocationId;t(n?{to:`/ec2/describe-addresses/$id`,params:{id:n}}:{to:`/ec2/describe-addresses`})},e[3]=v,e[4]=t,e[5]=O):O=e[5];let k=O,A,j;e[6]===Symbol.for(`react.memo_cache_sentinel`)?(A=(0,_.jsx)(r,{to:`/ec2/describe-addresses`,children:`Back to Elastic IPs`}),j=(0,_.jsx)(f,{title:`Allocate Elastic IP`}),e[6]=A,e[7]=j):(A=e[6],j=e[7]);let M;e[8]===v.error?M=e[9]:(M=v.error&&(0,_.jsx)(n,{error:v.error,msg:`Failed to allocate Elastic IP`}),e[8]=v.error,e[9]=M);let N;e[10]!==S||e[11]!==k?(N=S(k),e[10]=S,e[11]=k,e[12]=N):N=e[12];let P;e[13]===Symbol.for(`react.memo_cache_sentinel`)?(P=(0,_.jsx)(p,{children:(0,_.jsx)(`label`,{htmlFor:`name`,children:`Name (optional)`})}),e[13]=P):P=e[13];let F;e[14]===C?F=e[15]:(F=C(`name`),e[14]=C,e[15]=F);let I;e[16]===F?I=e[17]:(I=(0,_.jsxs)(m,{children:[P,(0,_.jsx)(s,{id:`name`,placeholder:`my-elastic-ip`,...F})]}),e[16]=F,e[17]=I);let L=D.name??``,R;e[18]===L?R=e[19]:(R=y(L),e[18]=L,e[19]=R);let z;e[20]===R?z=e[21]:(z=(0,_.jsx)(i,{commands:R}),e[20]=R,e[21]=z);let B;e[22]===t?B=e[23]:(B=async()=>await t({to:`/ec2/describe-addresses`}),e[22]=t,e[23]=B);let V;e[24]!==v.isPending||e[25]!==T||e[26]!==B?(V=(0,_.jsx)(d,{isPending:v.isPending,isSubmitting:T,onCancel:B,pendingLabel:`Allocating…`,submitLabel:`Allocate`}),e[24]=v.isPending,e[25]=T,e[26]=B,e[27]=V):V=e[27];let H;e[28]!==I||e[29]!==z||e[30]!==V||e[31]!==N?(H=(0,_.jsxs)(`form`,{className:`max-w-4xl space-y-6`,onSubmit:N,children:[I,z,V]}),e[28]=I,e[29]=z,e[30]=V,e[31]=N,e[32]=H):H=e[32];let U;return e[33]!==H||e[34]!==M?(U=(0,_.jsxs)(_.Fragment,{children:[A,j,M,H]}),e[33]=H,e[34]=M,e[35]=U):U=e[35],U}function y(e){let t=[{type:`bin`,value:`AWS_PROFILE=spinifex aws ec2 allocate-address`},{type:`flag`,value:` \\
--domain`},{type:`value`,value:` vpc`}];return e.trim()&&t.push({type:`flag`,value:` \\
--tag-specifications`},{type:`value`,value:` 'ResourceType=elastic-ip,Tags=[{Key=Name,Value=${e.trim()}}]'`}),[{label:`Allocate Elastic IP`,parts:t}]}export{v as component};
\ No newline at end of file
diff --git a/spinifex/services/spinifexui/frontend/dist/assets/ami-details.js b/spinifex/services/spinifexui/frontend/dist/assets/ami-details.js
index 51365732a..479ca80e0 100644
--- a/spinifex/services/spinifexui/frontend/dist/assets/ami-details.js
+++ b/spinifex/services/spinifexui/frontend/dist/assets/ami-details.js
@@ -1 +1 @@
-import{Fo as e,Ho as t,Lo as n,no as r}from"./index.js";import{n as i,t as a}from"./detail-row.js";var o=e(),s=t();function c(e){let t=(0,o.c)(58),{image:c,showExtendedDetails:u}=e,d=u===void 0?!1:u,f;t[0]===c.CreationDate?f=t[1]:(f=r(c.CreationDate),t[0]=c.CreationDate,t[1]=f);let p=f,m=d?`AMI Information`:`AMI Details`,h;t[2]===m?h=t[3]:(h=(0,s.jsx)(i.Header,{children:m}),t[2]=m,t[3]=h);let g;t[4]!==c.ImageId||t[5]!==d?(g=c.ImageId&&!d?(0,s.jsx)(n,{className:`text-primary hover:underline`,params:{id:c.ImageId},to:`/ec2/describe-images/$id`,children:c.ImageId}):c.ImageId,t[4]=c.ImageId,t[5]=d,t[6]=g):g=t[6];let _;t[7]===g?_=t[8]:(_=(0,s.jsx)(a,{label:`AMI ID`,value:g}),t[7]=g,t[8]=_);let v;t[9]===c.Name?v=t[10]:(v=(0,s.jsx)(a,{label:`AMI Name`,value:c.Name}),t[9]=c.Name,t[10]=v);let y;t[11]===c.Description?y=t[12]:(y=(0,s.jsx)(a,{label:`Description`,value:c.Description}),t[11]=c.Description,t[12]=y);let b;t[13]===c.Architecture?b=t[14]:(b=(0,s.jsx)(a,{label:`Architecture`,value:c.Architecture}),t[13]=c.Architecture,t[14]=b);let x;t[15]===c.VirtualizationType?x=t[16]:(x=(0,s.jsx)(a,{label:`Virtualization Type`,value:c.VirtualizationType}),t[15]=c.VirtualizationType,t[16]=x);let S=c.Platform??`Linux`,C;t[17]===S?C=t[18]:(C=(0,s.jsx)(a,{label:`Platform`,value:S}),t[17]=S,t[18]=C);let w;t[19]===c.PlatformDetails?w=t[20]:(w=(0,s.jsx)(a,{label:`Platform Details`,value:c.PlatformDetails}),t[19]=c.PlatformDetails,t[20]=w);let T;t[21]===c.ImageType?T=t[22]:(T=(0,s.jsx)(a,{label:`Image Type`,value:c.ImageType}),t[21]=c.ImageType,t[22]=T);let E;t[23]===c.RootDeviceType?E=t[24]:(E=(0,s.jsx)(a,{label:`Root Device Type`,value:c.RootDeviceType}),t[23]=c.RootDeviceType,t[24]=E);let D;t[25]===c.RootDeviceName?D=t[26]:(D=(0,s.jsx)(a,{label:`Root Device Name`,value:c.RootDeviceName}),t[25]=c.RootDeviceName,t[26]=D);let O;t[27]===c.OwnerId?O=t[28]:(O=(0,s.jsx)(a,{label:`Owner`,value:c.OwnerId}),t[27]=c.OwnerId,t[28]=O);let k;t[29]===c.State?k=t[30]:(k=(0,s.jsx)(a,{label:`State`,value:c.State}),t[29]=c.State,t[30]=k);let A;t[31]!==p||t[32]!==c.Public||t[33]!==d?(A=d&&(0,s.jsxs)(s.Fragment,{children:[(0,s.jsx)(a,{label:`Creation Date`,value:p}),(0,s.jsx)(a,{label:`Public`,value:c.Public?`Yes`:`No`})]}),t[31]=p,t[32]=c.Public,t[33]=d,t[34]=A):A=t[34];let j;t[35]!==x||t[36]!==C||t[37]!==w||t[38]!==T||t[39]!==E||t[40]!==D||t[41]!==O||t[42]!==k||t[43]!==A||t[44]!==_||t[45]!==v||t[46]!==y||t[47]!==b?(j=(0,s.jsxs)(i.Content,{children:[_,v,y,b,x,C,w,T,E,D,O,k,A]}),t[35]=x,t[36]=C,t[37]=w,t[38]=T,t[39]=E,t[40]=D,t[41]=O,t[42]=k,t[43]=A,t[44]=_,t[45]=v,t[46]=y,t[47]=b,t[48]=j):j=t[48];let M;t[49]!==j||t[50]!==h?(M=(0,s.jsxs)(i,{children:[h,j]}),t[49]=j,t[50]=h,t[51]=M):M=t[51];let N;t[52]!==c.BlockDeviceMappings||t[53]!==d?(N=d&&c.BlockDeviceMappings&&c.BlockDeviceMappings.length>0&&c.BlockDeviceMappings.map(l),t[52]=c.BlockDeviceMappings,t[53]=d,t[54]=N):N=t[54];let P;return t[55]!==M||t[56]!==N?(P=(0,s.jsxs)(s.Fragment,{children:[M,N]}),t[55]=M,t[56]=N,t[57]=P):P=t[57],P}function l(e){return(0,s.jsxs)(i,{children:[(0,s.jsxs)(i.Header,{children:[`Block Device: `,e.DeviceName]}),(0,s.jsxs)(i.Content,{children:[(0,s.jsx)(a,{label:`Device Name`,value:e.DeviceName}),e.Ebs&&(0,s.jsxs)(s.Fragment,{children:[(0,s.jsx)(a,{label:`Volume Size`,value:e.Ebs.VolumeSize?`${e.Ebs.VolumeSize} GB`:void 0}),(0,s.jsx)(a,{label:`Volume Type`,value:e.Ebs.VolumeType}),(0,s.jsx)(a,{label:`Delete On Termination`,value:e.Ebs.DeleteOnTermination?`Yes`:`No`}),(0,s.jsx)(a,{label:`Encrypted`,value:e.Ebs.Encrypted?`Yes`:`No`}),e.Ebs.SnapshotId&&(0,s.jsx)(a,{label:`Snapshot ID`,value:(0,s.jsx)(n,{className:`text-primary hover:underline`,params:{id:e.Ebs.SnapshotId},to:`/ec2/describe-snapshots/$id`,children:e.Ebs.SnapshotId})})]}),e.VirtualName&&(0,s.jsx)(a,{label:`Virtual Name`,value:e.VirtualName})]})]},e.DeviceName)}export{c as t};
\ No newline at end of file
+import{n as e,t}from"./compiler-runtime.js";import{Or as n}from"./awsClient.js";import{D as r}from"./SignatureV4.js";import{n as i,t as a}from"./detail-row.js";var o=t(),s=e();function c(e){let t=(0,o.c)(58),{image:c,showExtendedDetails:u}=e,d=u===void 0?!1:u,f;t[0]===c.CreationDate?f=t[1]:(f=r(c.CreationDate),t[0]=c.CreationDate,t[1]=f);let p=f,m=d?`AMI Information`:`AMI Details`,h;t[2]===m?h=t[3]:(h=(0,s.jsx)(i.Header,{children:m}),t[2]=m,t[3]=h);let g;t[4]!==c.ImageId||t[5]!==d?(g=c.ImageId&&!d?(0,s.jsx)(n,{className:`text-primary hover:underline`,params:{id:c.ImageId},to:`/ec2/describe-images/$id`,children:c.ImageId}):c.ImageId,t[4]=c.ImageId,t[5]=d,t[6]=g):g=t[6];let _;t[7]===g?_=t[8]:(_=(0,s.jsx)(a,{label:`AMI ID`,value:g}),t[7]=g,t[8]=_);let v;t[9]===c.Name?v=t[10]:(v=(0,s.jsx)(a,{label:`AMI Name`,value:c.Name}),t[9]=c.Name,t[10]=v);let y;t[11]===c.Description?y=t[12]:(y=(0,s.jsx)(a,{label:`Description`,value:c.Description}),t[11]=c.Description,t[12]=y);let b;t[13]===c.Architecture?b=t[14]:(b=(0,s.jsx)(a,{label:`Architecture`,value:c.Architecture}),t[13]=c.Architecture,t[14]=b);let x;t[15]===c.VirtualizationType?x=t[16]:(x=(0,s.jsx)(a,{label:`Virtualization Type`,value:c.VirtualizationType}),t[15]=c.VirtualizationType,t[16]=x);let S=c.Platform??`Linux`,C;t[17]===S?C=t[18]:(C=(0,s.jsx)(a,{label:`Platform`,value:S}),t[17]=S,t[18]=C);let w;t[19]===c.PlatformDetails?w=t[20]:(w=(0,s.jsx)(a,{label:`Platform Details`,value:c.PlatformDetails}),t[19]=c.PlatformDetails,t[20]=w);let T;t[21]===c.ImageType?T=t[22]:(T=(0,s.jsx)(a,{label:`Image Type`,value:c.ImageType}),t[21]=c.ImageType,t[22]=T);let E;t[23]===c.RootDeviceType?E=t[24]:(E=(0,s.jsx)(a,{label:`Root Device Type`,value:c.RootDeviceType}),t[23]=c.RootDeviceType,t[24]=E);let D;t[25]===c.RootDeviceName?D=t[26]:(D=(0,s.jsx)(a,{label:`Root Device Name`,value:c.RootDeviceName}),t[25]=c.RootDeviceName,t[26]=D);let O;t[27]===c.OwnerId?O=t[28]:(O=(0,s.jsx)(a,{label:`Owner`,value:c.OwnerId}),t[27]=c.OwnerId,t[28]=O);let k;t[29]===c.State?k=t[30]:(k=(0,s.jsx)(a,{label:`State`,value:c.State}),t[29]=c.State,t[30]=k);let A;t[31]!==p||t[32]!==c.Public||t[33]!==d?(A=d&&(0,s.jsxs)(s.Fragment,{children:[(0,s.jsx)(a,{label:`Creation Date`,value:p}),(0,s.jsx)(a,{label:`Public`,value:c.Public?`Yes`:`No`})]}),t[31]=p,t[32]=c.Public,t[33]=d,t[34]=A):A=t[34];let j;t[35]!==x||t[36]!==C||t[37]!==w||t[38]!==T||t[39]!==E||t[40]!==D||t[41]!==O||t[42]!==k||t[43]!==A||t[44]!==_||t[45]!==v||t[46]!==y||t[47]!==b?(j=(0,s.jsxs)(i.Content,{children:[_,v,y,b,x,C,w,T,E,D,O,k,A]}),t[35]=x,t[36]=C,t[37]=w,t[38]=T,t[39]=E,t[40]=D,t[41]=O,t[42]=k,t[43]=A,t[44]=_,t[45]=v,t[46]=y,t[47]=b,t[48]=j):j=t[48];let M;t[49]!==j||t[50]!==h?(M=(0,s.jsxs)(i,{children:[h,j]}),t[49]=j,t[50]=h,t[51]=M):M=t[51];let N;t[52]!==c.BlockDeviceMappings||t[53]!==d?(N=d&&c.BlockDeviceMappings&&c.BlockDeviceMappings.length>0&&c.BlockDeviceMappings.map(l),t[52]=c.BlockDeviceMappings,t[53]=d,t[54]=N):N=t[54];let P;return t[55]!==M||t[56]!==N?(P=(0,s.jsxs)(s.Fragment,{children:[M,N]}),t[55]=M,t[56]=N,t[57]=P):P=t[57],P}function l(e){return(0,s.jsxs)(i,{children:[(0,s.jsxs)(i.Header,{children:[`Block Device: `,e.DeviceName]}),(0,s.jsxs)(i.Content,{children:[(0,s.jsx)(a,{label:`Device Name`,value:e.DeviceName}),e.Ebs&&(0,s.jsxs)(s.Fragment,{children:[(0,s.jsx)(a,{label:`Volume Size`,value:e.Ebs.VolumeSize?`${e.Ebs.VolumeSize} GB`:void 0}),(0,s.jsx)(a,{label:`Volume Type`,value:e.Ebs.VolumeType}),(0,s.jsx)(a,{label:`Delete On Termination`,value:e.Ebs.DeleteOnTermination?`Yes`:`No`}),(0,s.jsx)(a,{label:`Encrypted`,value:e.Ebs.Encrypted?`Yes`:`No`}),e.Ebs.SnapshotId&&(0,s.jsx)(a,{label:`Snapshot ID`,value:(0,s.jsx)(n,{className:`text-primary hover:underline`,params:{id:e.Ebs.SnapshotId},to:`/ec2/describe-snapshots/$id`,children:e.Ebs.SnapshotId})})]}),e.VirtualName&&(0,s.jsx)(a,{label:`Virtual Name`,value:e.VirtualName})]})]},e.DeviceName)}export{c as t};
\ No newline at end of file
diff --git a/spinifex/services/spinifexui/frontend/dist/assets/awsClient.js b/spinifex/services/spinifexui/frontend/dist/assets/awsClient.js
new file mode 100644
index 000000000..37f34a35e
--- /dev/null
+++ b/spinifex/services/spinifexui/frontend/dist/assets/awsClient.js
@@ -0,0 +1,5 @@
+const __vite__mapDeps=(i,m=__vite__mapDeps,d=(m.f||(m.f=["assets/index2.browser.js","assets/compiler-runtime.js","assets/index.browser.js"])))=>i.map(i=>d[i]);
+import{i as e,n as t,o as n,r}from"./compiler-runtime.js";import{A as i,F as a,H as o,N as s,U as c,i as l,p as u,s as d,t as f,u as p}from"./useStore.js";import{a as ee,i as m,n as te,o as ne,r as re,s as ie,t as ae,u as oe}from"./SignatureV4.js";import{C as se,D as ce,E as le,F as ue,I as de,O as fe,S as pe,T as me,_ as he,a as ge,b as _e,g as ve,h as ye,i as be,m as xe,n as Se,p as Ce,r as we,t as Te,u as Ee,v as De,w as Oe,x as ke,y as Ae}from"./index.browser.js";import{n as eee,r as je}from"./index2.browser.js";var Me=`Error preloading route! ☝️`,Ne=e((e=>{var t=r();function n(e){var t=`https://react.dev/errors/`+e;if(1{function n(){if(!(typeof __REACT_DEVTOOLS_GLOBAL_HOOK__>`u`||typeof __REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE!=`function`))try{__REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE(n)}catch(e){console.error(e)}}n(),t.exports=Ne()})),Fe=n(r(),1);t();var Ie=n(Pe(),1);function Le(e,t){let n=l(),r=o(t),{activeProps:ee,inactiveProps:m,activeOptions:te,to:ne,preload:re,preloadDelay:ie,preloadIntentProximity:ae,hashScrollIntoView:oe,replace:se,startTransition:ce,resetScroll:le,viewTransition:ue,children:de,target:fe,disabled:pe,style:me,className:he,onClick:ge,onBlur:_e,onFocus:ve,onMouseEnter:ye,onMouseLeave:be,onTouchStart:xe,ignoreBlocker:Se,params:Ce,search:we,hash:Te,state:Ee,mask:De,reloadDocument:Oe,unsafeRelative:ke,from:Ae,_fromLocation:eee,...je}=e,Ne=d(),Pe=Fe.useMemo(()=>e,[n,e.from,e._fromLocation,e.hash,e.to,e.search,e.params,e.state,e.mask,e.unsafeRelative]),Le=f(n.stores.location,e=>e,(e,t)=>e.href===t.href),qe=Fe.useMemo(()=>{let e={_fromLocation:Le,...Pe};return n.buildLocation(e)},[n,Le,Pe]),Je=qe.maskedLocation?qe.maskedLocation.publicHref:qe.publicHref,Ye=qe.maskedLocation?qe.maskedLocation.external:qe.external,Xe=Fe.useMemo(()=>Ke(Je,Ye,n.history,pe),[pe,Ye,Je,n.history]),Ze=Fe.useMemo(()=>{if(Xe?.external)return a(Xe.href,n.protocolAllowlist)?void 0:Xe.href;if(!tee(ne)&&!(typeof ne!=`string`||ne.indexOf(`:`)===-1))try{return new URL(ne),a(ne,n.protocolAllowlist)?void 0:ne}catch{}},[ne,Xe,n.protocolAllowlist]),Qe=Fe.useMemo(()=>{if(Ze)return!1;if(te?.exact){if(!p(Le.pathname,qe.pathname,n.basepath))return!1}else{let e=u(Le.pathname,n.basepath),t=u(qe.pathname,n.basepath);if(!(e.startsWith(t)&&(e.length===t.length||e[t.length]===`/`)))return!1}return(te?.includeSearch??!0)&&!i(Le.search,qe.search,{partial:!te?.exact,ignoreUndefined:!te?.explicitUndefined})?!1:te?.includeHash?Ne&&Le.hash===qe.hash:!0},[te?.exact,te?.explicitUndefined,te?.includeHash,te?.includeSearch,Le,Ze,Ne,qe.hash,qe.pathname,qe.search,n.basepath]),$e=Qe?s(ee,{})??ze:Re,et=Qe?Re:s(m,{})??Re,tt=[he,$e.className,et.className].filter(Boolean).join(` `),nt=(me||$e.style||et.style)&&{...me,...$e.style,...et.style},[rt,it]=Fe.useState(!1),at=Fe.useRef(!1),ot=e.reloadDocument||Ze?!1:re??n.options.defaultPreload,st=ie??n.options.defaultPreloadDelay??0,ct=Fe.useCallback(()=>{n.preloadRoute({...Pe,_builtLocation:qe}).catch(e=>{console.warn(e),console.warn(Me)})},[n,Pe,qe]);c(r,Fe.useCallback(e=>{e?.isIntersecting&&ct()},[ct]),We,{disabled:!!pe||ot!==`viewport`}),Fe.useEffect(()=>{at.current||!pe&&ot===`render`&&(ct(),at.current=!0)},[pe,ct,ot]);let lt=e=>{let t=e.currentTarget.getAttribute(`target`),r=fe===void 0?t:fe;if(!pe&&!nee(e)&&!e.defaultPrevented&&(!r||r===`_self`)&&e.button===0){e.preventDefault(),(0,Ie.flushSync)(()=>{it(!0)});let t=n.subscribe(`onResolved`,()=>{t(),it(!1)});n.navigate({...Pe,replace:se,resetScroll:le,hashScrollIntoView:oe,startTransition:ce,viewTransition:ue,ignoreBlocker:Se})}};if(Ze)return{...je,ref:r,href:Ze,...de&&{children:de},...fe&&{target:fe},...pe&&{disabled:pe},...me&&{style:me},...he&&{className:he},...ge&&{onClick:ge},..._e&&{onBlur:_e},...ve&&{onFocus:ve},...ye&&{onMouseEnter:ye},...be&&{onMouseLeave:be},...xe&&{onTouchStart:xe}};let ut=e=>{if(pe||ot!==`intent`)return;if(!st){ct();return}let t=e.currentTarget;if(Ue.has(t))return;let n=setTimeout(()=>{Ue.delete(t),ct()},st);Ue.set(t,n)},dt=e=>{pe||ot!==`intent`||ct()},ft=e=>{if(pe||!ot||!st)return;let t=e.currentTarget,n=Ue.get(t);n&&(clearTimeout(n),Ue.delete(t))};return{...je,...$e,...et,href:Xe?.href,ref:r,onClick:Ge([ge,lt]),onBlur:Ge([_e,ft]),onFocus:Ge([ve,ut]),onMouseEnter:Ge([ye,ut]),onMouseLeave:Ge([be,ft]),onTouchStart:Ge([xe,dt]),disabled:!!pe,target:fe,...nt&&{style:nt},...tt&&{className:tt},...pe&&Be,...Qe&&Ve,...Ne&&rt&&He}}var Re={},ze={className:`active`},Be={role:`link`,"aria-disabled":!0},Ve={"data-status":`active`,"aria-current":`page`},He={"data-transitioning":`transitioning`},Ue=new WeakMap,We={rootMargin:`100px`},Ge=e=>t=>{for(let n of e)if(n){if(t.defaultPrevented)return;n(t)}};function Ke(e,t,n,r){if(!r)return t?{href:e,external:!0}:{href:n.createHref(e)||`/`,external:!1}}function tee(e){if(typeof e!=`string`)return!1;let t=e.charCodeAt(0);return t===47?e.charCodeAt(1)!==47:t===46}var qe=Fe.forwardRef((e,t)=>{let{_asChild:n,...r}=e,{type:i,...a}=Le(r,t),o=typeof r.children==`function`?r.children({isActive:a[`data-status`]===`active`}):r.children;if(!n){let{disabled:e,...t}=a;return Fe.createElement(`a`,t,o)}return Fe.createElement(n,a,o)});function nee(e){return!!(e.metaKey||e.altKey||e.ctrlKey||e.shiftKey)}var Je;(function(e){e.HTTP=`http`,e.HTTPS=`https`})(Je||={});var Ye;(function(e){e.MD5=`md5`,e.CRC32=`crc32`,e.CRC32C=`crc32c`,e.SHA1=`sha1`,e.SHA256=`sha256`})(Ye||={});var Xe=`__smithy_context`,Ze=e=>e.__smithy_context||={},Qe=class{statusCode;reason;headers;body;constructor(e){this.statusCode=e.statusCode,this.reason=e.reason,this.headers=e.headers||{},this.body=e.body}static isInstance(e){if(!e)return!1;let t=e;return typeof t.statusCode==`number`&&typeof t.headers==`object`}},$e=RegExp(`^(?!.*-$)(?!-)[a-zA-Z0-9-]{1,63}$`),et=(e,t=!1)=>{if(!t)return $e.test(e);let n=e.split(`.`);for(let e of n)if(!et(e))return!1;return!0};function tt(e){let t={};if(e=e.replace(/^\?/,``),e)for(let n of e.split(`&`)){let[e,r=null]=n.split(`=`);e=decodeURIComponent(e),r&&=decodeURIComponent(r),e in t?Array.isArray(t[e])?t[e].push(r):t[e]=[t[e],r]:t[e]=r}return t}var nt=e=>{if(typeof e==`string`)return nt(new URL(e));let{hostname:t,pathname:n,port:r,protocol:i,search:a}=e,o;return a&&(o=tt(a)),{hostname:t,port:r?parseInt(r):void 0,protocol:i,path:n,query:o}},rt=e=>{if(typeof e==`object`){if(`url`in e){let t=nt(e.url);if(e.headers){t.headers={};for(let n in e.headers)t.headers[n.toLowerCase()]=e.headers[n].join(`, `)}return t}return e}return nt(e)},it=async e=>void 0,at=async e=>{let t=e?.Bucket||``;if(typeof e.Bucket==`string`&&(e.Bucket=t.replace(/#/g,`%23`).replace(/\?/g,`%3F`)),ut(t)){if(e.ForcePathStyle===!0)throw Error(`Path-style addressing cannot be used with ARN buckets`)}else (!lt(t)||t.indexOf(`.`)!==-1&&!String(e.Endpoint).startsWith(`http:`)||t.toLowerCase()!==t||t.length<3)&&(e.ForcePathStyle=!0);return e.DisableMultiRegionAccessPoints&&(e.disableMultiRegionAccessPoints=!0,e.DisableMRAP=!0),e},ot=/^[a-z0-9][a-z0-9\.\-]{1,61}[a-z0-9]$/,st=/(\d+\.){3}\d+/,ct=/\.\./,lt=e=>ot.test(e)&&!st.test(e)&&!ct.test(e),ut=e=>{let[t,n,r,,,i]=e.split(`:`),a=t===`arn`&&e.split(`:`).length>=6,o=!!(a&&n&&r&&i);if(a&&!o)throw Error(`Invalid ARN: ${e} was an invalid ARN.`);return o},dt=(e,t,n,r=!1)=>{let i=async()=>{let i;return i=r?n.clientContextParams?.[e]??n[e]??n[t]:n[e]??n[t],typeof i==`function`?i():i};return e===`credentialScope`||t===`CredentialScope`?async()=>{let e=typeof n.credentials==`function`?await n.credentials():n.credentials;return e?.credentialScope??e?.CredentialScope}:e===`accountId`||t===`AccountId`?async()=>{let e=typeof n.credentials==`function`?await n.credentials():n.credentials;return e?.accountId??e?.AccountId}:e===`endpoint`||t===`endpoint`?async()=>{if(n.isCustomEndpoint===!1)return;let e=await i();if(e&&typeof e==`object`){if(`url`in e)return e.url.href;if(`hostname`in e){let{protocol:t,hostname:n,port:r,path:i}=e;return`${t}//${n}${r?`:`+r:``}${i}`}}return e}:i};function ft(e){return async(t,n,r,i)=>{if(!r.isCustomEndpoint){let t;t=r.serviceConfiguredEndpoint?await r.serviceConfiguredEndpoint():await e(r.serviceId),t&&(r.endpoint=()=>Promise.resolve(rt(t)),r.isCustomEndpoint=!0)}let a=await pt(t,n,r);if(typeof r.endpointProvider!=`function`)throw Error(`config.endpointProvider is not set.`);let o=r.endpointProvider(a,i);if(r.isCustomEndpoint&&r.endpoint){let e=await r.endpoint();if(e?.headers){o.headers??={};for(let[t,n]of Object.entries(e.headers))o.headers[t]=Array.isArray(n)?n:[n]}}return o}}var pt=async(e,t,n)=>{let r={},i=t?.getEndpointParameterInstructions?.()||{};for(let[t,a]of Object.entries(i))switch(a.type){case`staticContextParams`:r[t]=a.value;break;case`contextParams`:r[t]=e[a.name];break;case`clientContextParams`:case`builtInParams`:r[t]=await dt(a.name,t,n,a.type!==`builtInParams`)();break;case`operationContextParams`:r[t]=a.get(e);break;default:throw Error(`Unrecognized endpoint parameter instruction: `+JSON.stringify(a))}return Object.keys(i).length===0&&Object.assign(r,n),String(n.serviceId).toLowerCase()===`s3`&&await at(r),r},mt=(e,t)=>{let n=[];if(e&&n.push(e),t)for(let e of t)n.push(e);return n},ht=(e,t)=>`${e||`anonymous`}${t&&t.length>0?` (a.k.a. ${t.join(`,`)})`:``}`,gt=()=>{let e=[],t=[],n=!1,r=new Set,i=e=>e.sort((e,t)=>_t[t.step]-_t[e.step]||vt[t.priority||`normal`]-vt[e.priority||`normal`]),a=n=>{let i=!1,a=e=>{let t=mt(e.name,e.aliases);if(t.includes(n)){i=!0;for(let e of t)r.delete(e);return!1}return!0};return e=e.filter(a),t=t.filter(a),i},o=n=>{let i=!1,a=e=>{if(e.middleware===n){i=!0;for(let t of mt(e.name,e.aliases))r.delete(t);return!1}return!0};return e=e.filter(a),t=t.filter(a),i},s=n=>(e.forEach(e=>{n.add(e.middleware,{...e})}),t.forEach(e=>{n.addRelativeTo(e.middleware,{...e})}),n.identifyOnResolve?.(u.identifyOnResolve()),n),c=e=>{let t=[];return e.before.forEach(e=>{e.before.length===0&&e.after.length===0?t.push(e):t.push(...c(e))}),t.push(e),e.after.reverse().forEach(e=>{e.before.length===0&&e.after.length===0?t.push(e):t.push(...c(e))}),t},l=(n=!1)=>{let r=[],a=[],o={};return e.forEach(e=>{let t={...e,before:[],after:[]};for(let e of mt(t.name,t.aliases))o[e]=t;r.push(t)}),t.forEach(e=>{let t={...e,before:[],after:[]};for(let e of mt(t.name,t.aliases))o[e]=t;a.push(t)}),a.forEach(e=>{if(e.toMiddleware){let t=o[e.toMiddleware];if(t===void 0){if(n)return;throw Error(`${e.toMiddleware} is not found when adding ${ht(e.name,e.aliases)} middleware ${e.relation} ${e.toMiddleware}`)}e.relation===`after`&&t.after.push(e),e.relation===`before`&&t.before.push(e)}}),i(r).map(c).reduce((e,t)=>(e.push(...t),e),[])},u={add:(t,n={})=>{let{name:i,override:a,aliases:o}=n,s={step:`initialize`,priority:`normal`,middleware:t,...n},c=mt(i,o);if(c.length>0){if(c.some(e=>r.has(e))){if(!a)throw Error(`Duplicate middleware name '${ht(i,o)}'`);for(let t of c){let n=e.findIndex(e=>e.name===t||e.aliases?.some(e=>e===t));if(n===-1)continue;let r=e[n];if(r.step!==s.step||s.priority!==r.priority)throw Error(`"${ht(r.name,r.aliases)}" middleware with ${r.priority} priority in ${r.step} step cannot be overridden by "${ht(i,o)}" middleware with ${s.priority} priority in ${s.step} step.`);e.splice(n,1)}}for(let e of c)r.add(e)}e.push(s)},addRelativeTo:(e,n)=>{let{name:i,override:a,aliases:o}=n,s={middleware:e,...n},c=mt(i,o);if(c.length>0){if(c.some(e=>r.has(e))){if(!a)throw Error(`Duplicate middleware name '${ht(i,o)}'`);for(let e of c){let n=t.findIndex(t=>t.name===e||t.aliases?.some(t=>t===e));if(n===-1)continue;let r=t[n];if(r.toMiddleware!==s.toMiddleware||r.relation!==s.relation)throw Error(`"${ht(r.name,r.aliases)}" middleware ${r.relation} "${r.toMiddleware}" middleware cannot be overridden by "${ht(i,o)}" middleware ${s.relation} "${s.toMiddleware}" middleware.`);t.splice(n,1)}}for(let e of c)r.add(e)}t.push(s)},clone:()=>s(gt()),use:e=>{e.applyToStack(u)},remove:e=>typeof e==`string`?a(e):o(e),removeByTag:n=>{let i=!1,a=e=>{let{tags:t,name:a,aliases:o}=e;if(t&&t.includes(n)){let e=mt(a,o);for(let t of e)r.delete(t);return i=!0,!1}return!0};return e=e.filter(a),t=t.filter(a),i},concat:e=>{let t=s(gt());return t.use(e),t.identifyOnResolve(n||t.identifyOnResolve()||(e.identifyOnResolve?.()??!1)),t},applyToStack:s,identify:()=>l(!0).map(e=>{let t=e.step??e.relation+` `+e.toMiddleware;return ht(e.name,e.aliases)+` - `+t}),identifyOnResolve(e){return typeof e==`boolean`&&(n=e),n},resolve:(e,t)=>{for(let n of l().map(e=>e.middleware).reverse())e=n(e,t);return n&&console.log(u.identify()),e}};return u},_t={initialize:5,serialize:4,build:3,finalizeRequest:2,deserialize:1},vt={high:3,normal:2,low:1},yt=e=>()=>Promise.reject(e),bt=class{config;middlewareStack=gt();initConfig;handlers;constructor(e){this.config=e;let{protocol:t,protocolSettings:n}=e;n&&typeof t==`function`&&(e.protocol=new t(n))}send(e,t,n){let r=typeof t==`function`?void 0:t,i=typeof t==`function`?t:n,a=r===void 0&&this.config.cacheMiddleware===!0,o;if(a){this.handlers||=new WeakMap;let t=this.handlers;t.has(e.constructor)?o=t.get(e.constructor):(o=e.resolveMiddleware(this.middlewareStack,this.config,r),t.set(e.constructor,o))}else delete this.handlers,o=e.resolveMiddleware(this.middlewareStack,this.config,r);if(i)o(e).then(e=>i(null,e.output),e=>i(e)).catch(()=>{});else return o(e).then(e=>e.output)}destroy(){this.config?.requestHandler?.destroy?.(),delete this.handlers}},xt=e=>typeof e==`function`?e():e,St=(e,t,n,r,i)=>({name:t,namespace:e,traits:n,input:r,output:i}),Ct=e=>(t,n)=>async r=>{let{response:i}=await t(r),{operationSchema:a}=Ze(n),[,o,s,c,l,u]=a??[];try{return{response:i,output:await e.protocol.deserializeResponse(St(o,s,c,l,u),{...e,...n},i)}}catch(e){if(Object.defineProperty(e,"$response",{value:i,enumerable:!1,writable:!1,configurable:!1}),!(`$metadata`in e)){let t=`Deserialization error: to see the raw response, inspect the hidden field {error}.$response on this object.`;try{e.message+=`
+ Deserialization error: to see the raw response, inspect the hidden field {error}.$response on this object.`}catch{!n.logger||n.logger?.constructor?.name===`NoOpLogger`?console.warn(t):n.logger?.warn?.(t)}e.$responseBodyText!==void 0&&e.$response&&(e.$response.body=e.$responseBodyText);try{if(Qe.isInstance(i)){let{headers:t={},statusCode:n}=i,r=Object.entries(t);e.$metadata={httpStatusCode:n,requestId:wt(/^x-[\w-]+-request-?id$/,r),extendedRequestId:wt(/^x-[\w-]+-id-2$/,r),cfId:wt(/^x-[\w-]+-cf-id$/,r)}}}catch{}}throw e}},wt=(e,t)=>(t.find(([t])=>t.match(e))||[void 0,void 0])[1],Tt=e=>(t,n)=>async r=>{let{operationSchema:i}=Ze(n),[,a,o,s,c,l]=i??[],u=n.endpointV2?async()=>rt(n.endpointV2):e.endpoint,d=await e.protocol.serializeRequest(St(a,o,s,c,l),r.input,{...e,...n,endpoint:u});return t({...r,request:d})},Et={name:`deserializerMiddleware`,step:`deserialize`,tags:[`DESERIALIZER`],override:!0},Dt={name:`serializerMiddleware`,step:`serialize`,tags:[`SERIALIZER`],override:!0};function Ot(e){return{applyToStack:t=>{t.add(Tt(e),Dt),t.add(Ct(e),Et),e.protocol.setSerdeContext(e)}}}var kt=[];function At(e){if(typeof e==`object`)return e;if(e|=0,kt[e])return kt[e];let t={},n=0;for(let r of[`httpLabel`,`idempotent`,`idempotencyToken`,`sensitive`,`httpPayload`,`httpResponseCode`,`httpQueryParams`])(e>>n++&1)==1&&(t[r]=1);return kt[e]=t}var jt={it:Symbol.for(`@smithy/nor-struct-it`),ns:Symbol.for(`@smithy/ns`)},Mt=[],Nt={},h=class e{ref;memberName;static symbol=Symbol.for(`@smithy/nor`);symbol=e.symbol;name;schema;_isMemberSchema;traits;memberTraits;normalizedTraits;constructor(t,n){this.ref=t,this.memberName=n;let r=[],i=t,a=t;for(this._isMemberSchema=!1;Ft(i);)r.push(i[1]),i=i[0],a=xt(i),this._isMemberSchema=!0;if(r.length>0){this.memberTraits={};for(let e=r.length-1;e>=0;--e){let t=r[e];Object.assign(this.memberTraits,At(t))}}else this.memberTraits=0;if(a instanceof e){let e=this.memberTraits;Object.assign(this,a),this.memberTraits=Object.assign({},e,a.getMemberTraits(),this.getMemberTraits()),this.normalizedTraits=void 0,this.memberName=n??a.memberName;return}if(this.schema=xt(a),It(this.schema)?(this.name=`${this.schema[1]}#${this.schema[2]}`,this.traits=this.schema[3]):(this.name=this.memberName??String(a),this.traits=0),this._isMemberSchema&&!n)throw Error(`@smithy/core/schema - NormalizedSchema member init ${this.getName(!0)} missing member name.`)}static[Symbol.hasInstance](e){let t=this.prototype.isPrototypeOf(e);return!t&&typeof e==`object`&&e?e.symbol===this.symbol:t}static of(t){let n=typeof t==`function`||typeof t==`object`&&!!t;if(typeof t==`number`){if(Mt[t])return Mt[t]}else if(typeof t==`string`){if(Nt[t])return Nt[t]}else if(n&&t[jt.ns])return t[jt.ns];let r=xt(t);if(r instanceof e)return r;if(Ft(r)){let[n,i]=r;if(n instanceof e)return Object.assign(n.getMergedTraits(),At(i)),n;throw Error(`@smithy/core/schema - may not init unwrapped member schema=${JSON.stringify(t,null,2)}.`)}let i=new e(r);return n?t[jt.ns]=i:typeof r==`string`?Nt[r]=i:typeof r==`number`?Mt[r]=i:i}getSchema(){let e=this.schema;return Array.isArray(e)&&e[0]===0?e[4]:e}getName(e=!1){let{name:t}=this;return!e&&t&&t.includes(`#`)?t.split(`#`)[1]:t||void 0}getMemberName(){return this.memberName}isMemberSchema(){return this._isMemberSchema}isListSchema(){let e=this.getSchema();return typeof e==`number`?e>=64&&e<128:e[0]===1}isMapSchema(){let e=this.getSchema();return typeof e==`number`?e>=128&&e<=255:e[0]===2}isStructSchema(){let e=this.getSchema();if(typeof e!=`object`)return!1;let t=e[0];return t===3||t===-3||t===4}isUnionSchema(){let e=this.getSchema();return typeof e==`object`?e[0]===4:!1}isBlobSchema(){let e=this.getSchema();return e===21||e===42}isTimestampSchema(){let e=this.getSchema();return typeof e==`number`&&e>=4&&e<=7}isUnitSchema(){return this.getSchema()===`unit`}isDocumentSchema(){return this.getSchema()===15}isStringSchema(){return this.getSchema()===0}isBooleanSchema(){return this.getSchema()===2}isNumericSchema(){return this.getSchema()===1}isBigIntegerSchema(){return this.getSchema()===17}isBigDecimalSchema(){return this.getSchema()===19}isStreaming(){let{streaming:e}=this.getMergedTraits();return!!e||this.getSchema()===42}isIdempotencyToken(){return!!this.getMergedTraits().idempotencyToken}getMergedTraits(){return this.normalizedTraits??={...this.getOwnTraits(),...this.getMemberTraits()}}getMemberTraits(){return At(this.memberTraits)}getOwnTraits(){return At(this.traits)}getKeySchema(){let[e,t]=[this.isDocumentSchema(),this.isMapSchema()];if(!e&&!t)throw Error(`@smithy/core/schema - cannot get key for non-map: ${this.getName(!0)}`);let n=this.getSchema();return Pt([e?15:n[4]??0,0],`key`)}getValueSchema(){let e=this.getSchema(),[t,n,r]=[this.isDocumentSchema(),this.isMapSchema(),this.isListSchema()],i=typeof e==`number`?63&e:e&&typeof e==`object`&&(n||r)?e[3+e[0]]:t?15:void 0;if(i!=null)return Pt([i,0],n?`value`:`member`);throw Error(`@smithy/core/schema - ${this.getName(!0)} has no value member.`)}getMemberSchema(e){let t=this.getSchema();if(this.isStructSchema()&&t[4].includes(e)){let n=t[4].indexOf(e),r=t[5][n];return Pt(Ft(r)?r:[r,0],e)}if(this.isDocumentSchema())return Pt([15,0],e);throw Error(`@smithy/core/schema - ${this.getName(!0)} has no member=${e}.`)}getMemberSchemas(){let e={};try{for(let[t,n]of this.structIterator())e[t]=n}catch{}return e}getEventStreamMember(){if(this.isStructSchema()){for(let[e,t]of this.structIterator())if(t.isStreaming()&&t.isStructSchema())return e}return``}*structIterator(){if(this.isUnitSchema())return;if(!this.isStructSchema())throw Error(`@smithy/core/schema - cannot iterate non-struct schema.`);let e=this.getSchema(),t=e[4].length,n=e[jt.it];if(n&&t===n.length){yield*n;return}n=Array(t);for(let r=0;rArray.isArray(e)&&e.length===2,It=e=>Array.isArray(e)&&e.length>=5,Lt=class e{namespace;schemas;exceptions;static registries=new Map;constructor(e,t=new Map,n=new Map){this.namespace=e,this.schemas=t,this.exceptions=n}static for(t){return e.registries.has(t)||e.registries.set(t,new e(t)),e.registries.get(t)}copyFrom(e){let{schemas:t,exceptions:n}=this;for(let[n,r]of e.schemas)t.has(n)||t.set(n,r);for(let[t,r]of e.exceptions)n.has(t)||n.set(t,r)}register(t,n){let r=this.normalizeShapeId(t);for(let t of[this,e.for(r.split(`#`)[0])])t.schemas.set(r,n)}getSchema(e){let t=this.normalizeShapeId(e);if(!this.schemas.has(t)){if(!e.includes(`#`)){let t=`#`+e,n=[];for(let[e,r]of this.schemas.entries())e.endsWith(t)&&n.push(r);if(n.length===1)return n[0]}throw Error(`@smithy/core/schema - schema not found for ${t}`)}return this.schemas.get(t)}registerError(t,n){let r=t,i=r[1];for(let t of[this,e.for(i)])t.schemas.set(i+`#`+r[2],r),t.exceptions.set(r,n)}getErrorCtor(t){let n=t;return this.exceptions.has(n)?this.exceptions.get(n):e.for(n[1]).exceptions.get(n)}getBaseException(){for(let e of this.exceptions.keys())if(Array.isArray(e)){let[,t,n]=e,r=t+`#`+n;if(r.startsWith(`smithy.ts.sdk.synthetic.`)&&r.endsWith(`ServiceException`))return e}}find(e){for(let t of this.schemas.values())if(e(t))return t}clear(){this.schemas.clear(),this.exceptions.clear()}normalizeShapeId(e){return e.includes(`#`)?e:this.namespace+`#`+e}},Rt=`***SensitiveInformation***`;function zt(e,t){if(t==null)return t;let n=h.of(e);if(n.getMergedTraits().sensitive)return Rt;if(n.isListSchema()){if(n.getValueSchema().getMergedTraits().sensitive)return Rt}else if(n.isMapSchema()){if(n.getKeySchema().getMergedTraits().sensitive||n.getValueSchema().getMergedTraits().sensitive)return Rt}else if(n.isStructSchema()&&typeof t==`object`){let e=t,r={};for(let[t,i]of n.structIterator())e[t]!=null&&(r[t]=zt(i,e[t]));return r}return t}var Bt=class{middlewareStack=gt();schema;static classBuilder(){return new Vt}resolveMiddlewareWithContext(e,t,n,{middlewareFn:r,clientName:i,commandName:a,inputFilterSensitiveLog:o,outputFilterSensitiveLog:s,smithyContext:c,additionalContext:l,CommandCtor:u}){for(let i of r.bind(this)(u,e,t,n))this.middlewareStack.use(i);let d=e.concat(this.middlewareStack),{logger:f}=t,p={logger:f,clientName:i,commandName:a,inputFilterSensitiveLog:o,outputFilterSensitiveLog:s,[Xe]:{commandInstance:this,...c},...l},{requestHandler:ee}=t,m=n??{};return c.eventStream&&(m={isEventStream:!0,...m}),d.resolve(e=>ee.handle(e.request,m),p)}},Vt=class{_init=()=>{};_ep={};_middlewareFn=()=>[];_commandName=``;_clientName=``;_additionalContext={};_smithyContext={};_inputFilterSensitiveLog=void 0;_outputFilterSensitiveLog=void 0;_serializer=null;_deserializer=null;_operationSchema;init(e){this._init=e}ep(e){return this._ep=e,this}m(e){return this._middlewareFn=e,this}s(e,t,n={}){return this._smithyContext={service:e,operation:t,...n},this}c(e={}){return this._additionalContext=e,this}n(e,t){return this._clientName=e,this._commandName=t,this}f(e=e=>e,t=e=>e){return this._inputFilterSensitiveLog=e,this._outputFilterSensitiveLog=t,this}ser(e){return this._serializer=e,this}de(e){return this._deserializer=e,this}sc(e){return this._operationSchema=e,this._smithyContext.operationSchema=e,this}build(){let e=this,t;return t=class extends Bt{input;static getEndpointParameterInstructions(){return e._ep}constructor(...[t]){super(),this.input=t??{},e._init(this),this.schema=e._operationSchema}resolveMiddleware(n,r,i){let a=e._operationSchema,o=a?.[4]??a?.input,s=a?.[5]??a?.output;return this.resolveMiddlewareWithContext(n,r,i,{CommandCtor:t,middlewareFn:e._middlewareFn,clientName:e._clientName,commandName:e._commandName,inputFilterSensitiveLog:e._inputFilterSensitiveLog??(a?zt.bind(null,o):e=>e),outputFilterSensitiveLog:e._outputFilterSensitiveLog??(a?zt.bind(null,s):e=>e),smithyContext:e._smithyContext,additionalContext:e._additionalContext})}serialize=e._serializer;deserialize=e._deserializer}}},Ht=class e extends Error{$fault;$response;$retryable;$metadata;constructor(e){super(e.message),Object.setPrototypeOf(this,Object.getPrototypeOf(this).constructor.prototype),this.name=e.name,this.$fault=e.$fault,this.$metadata=e.$metadata}static isInstance(t){if(!t)return!1;let n=t;return e.prototype.isPrototypeOf(n)||!!n.$fault&&!!n.$metadata&&(n.$fault===`client`||n.$fault===`server`)}static[Symbol.hasInstance](t){if(!t)return!1;let n=t;return this===e?e.isInstance(t):e.isInstance(t)?n.name&&this.name?this.prototype.isPrototypeOf(t)||n.name===this.name:this.prototype.isPrototypeOf(t):!1}},Ut=(e,t={})=>(Object.entries(t).filter(([,e])=>e!==void 0).forEach(([t,n])=>{(e[t]==null||e[t]===``)&&(e[t]=n)}),e.message=e.message||e.Message||`UnknownError`,delete e.Message,e),Wt=e=>{switch(e){case`standard`:return{retryMode:`standard`,connectionTimeout:3100};case`in-region`:return{retryMode:`standard`,connectionTimeout:1100};case`cross-region`:return{retryMode:`standard`,connectionTimeout:3100};case`mobile`:return{retryMode:`standard`,connectionTimeout:3e4};default:return{}}},Gt=Object.values(Ye),Kt=e=>{let t=[];for(let n in Ye){let r=Ye[n];e[r]!==void 0&&t.push({algorithmId:()=>r,checksumConstructor:()=>e[r]})}for(let[n,r]of Object.entries(e.checksumAlgorithms??{}))t.push({algorithmId:()=>n,checksumConstructor:()=>r});return{addChecksumAlgorithm(n){e.checksumAlgorithms=e.checksumAlgorithms??{};let r=n.algorithmId(),i=n.checksumConstructor();Gt.includes(r)?e.checksumAlgorithms[r.toUpperCase()]=i:e.checksumAlgorithms[r]=i,t.push(n)},checksumAlgorithms(){return t}}},qt=e=>{let t={};return e.checksumAlgorithms().forEach(e=>{let n=e.algorithmId();Gt.includes(n)&&(t[n]=e.checksumConstructor())}),t},Jt=e=>({setRetryStrategy(t){e.retryStrategy=t},retryStrategy(){return e.retryStrategy}}),Yt=e=>{let t={};return t.retryStrategy=e.retryStrategy(),t},Xt=e=>Object.assign(Kt(e),Jt(e)),Zt=e=>Object.assign(qt(e),Yt(e)),Qt=e=>{let t=`#text`;for(let n in e)e.hasOwnProperty(n)&&e[n][t]!==void 0?e[n]=e[n][t]:typeof e[n]==`object`&&e[n]!==null&&(e[n]=Qt(e[n]));return e},$t=class{trace(){}debug(){}info(){}warn(){}error(){}};function en(e,t,n){e.__smithy_context?e.__smithy_context.features||(e.__smithy_context.features={}):e.__smithy_context={features:{}},e.__smithy_context.features[t]=n}function tn(e){let t=ft(e);return({config:e,instructions:n})=>(r,i)=>async a=>{e.isCustomEndpoint&&en(i,`ENDPOINT_OVERRIDE`,`N`);let o=await t(a.input,{getEndpointParameterInstructions(){return n}},{...e},i);i.endpointV2=o,i.authSchemes=o.properties?.authSchemes;let s=i.authSchemes?.[0];if(s){i.signing_region=s.signingRegion,i.signing_service=s.signingName;let e=Ze(i)?.selectedHttpAuthScheme?.httpAuthOption;e&&(e.signingProperties=Object.assign(e.signingProperties||{},{signing_region:s.signingRegion,signingRegion:s.signingRegion,signing_service:s.signingName,signingName:s.signingName,signingRegionSet:s.signingRegionSet},s.properties))}return r({...a})}}var nn={step:`serialize`,tags:[`ENDPOINT_PARAMETERS`,`ENDPOINT_V2`,`ENDPOINT`],name:`endpointV2Middleware`,override:!0,relation:`before`,toMiddleware:{name:`serializerMiddleware`,step:`serialize`,tags:[`SERIALIZER`],override:!0}.name};function rn(e){let t=tn(e);return(e,n)=>({applyToStack:r=>{r.addRelativeTo(t({config:e,instructions:n}),nn)}})}function an(e){return t=>{let n=t.tls??!0,{endpoint:r,useDualstackEndpoint:i,useFipsEndpoint:a}=t,o=Object.assign(t,{endpoint:r==null?void 0:async()=>rt(await re(r)()),tls:n,isCustomEndpoint:!!r,useDualstackEndpoint:re(i??!1),useFipsEndpoint:re(a??!1)}),s;return o.serviceConfiguredEndpoint=async()=>(t.serviceId&&!s&&(s=e(t.serviceId)),s),o}}var on=class e{nodes;root;conditions;results;constructor(e,t,n,r){this.nodes=e,this.root=t,this.conditions=n,this.results=r}static from(t,n,r,i){return new e(t,n,r,i)}},sn=class{capacity;data=new Map;parameters=[];constructor({size:e,params:t}){this.capacity=e??50,t&&(this.parameters=t)}get(e,t){let n=this.hash(e);if(n===!1)return t();if(!this.data.has(n)){if(this.data.size>this.capacity+10){let e=this.data.keys(),t=0;for(;;){let{value:n,done:r}=e.next();if(this.data.delete(n),r||++t>10)break}}this.data.set(n,t())}return this.data.get(n)}size(){return this.data.size}hash(e){let t=``,{parameters:n}=this;if(n.length===0)return!1;for(let r of n){let n=String(e[r]??``);if(n.includes(`|;`))return!1;t+=n+`|;`}return t}},cn=class extends Error{constructor(e){super(e),this.name=`EndpointError`}},ln=`endpoints`;function un(e){return typeof e!=`object`||!e?e:`ref`in e?`$${un(e.ref)}`:`fn`in e?`${e.fn}(${(e.argv||[]).map(un).join(`, `)})`:JSON.stringify(e,null,2)}var dn={},fn=(e,t)=>e===t;function pn(...e){for(let t of e)if(t!=null)return t}var mn=e=>{let t=e.split(`.`),n=[];for(let r of t){let t=r.indexOf(`[`);if(t!==-1){if(r.indexOf(`]`)!==r.length-1)throw new cn(`Path: '${e}' does not end with ']'`);let i=r.slice(t+1,-1);if(Number.isNaN(parseInt(i)))throw new cn(`Invalid array index: '${i}' in path: '${e}'`);t!==0&&n.push(r.slice(0,t)),n.push(i)}else n.push(r)}return n},hn=(e,t)=>mn(t).reduce((n,r)=>{if(typeof n!=`object`)throw new cn(`Index '${r}' in '${t}' not found in '${JSON.stringify(e)}'`);if(Array.isArray(n)){let e=parseInt(r);return n[e<0?n.length+e:e]}return n[r]},e),gn=e=>e!=null;function _n(e,t,n){return e?t:n}var ree=e=>!e,iee=RegExp(`^(?:25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]\\d|\\d)(?:\\.(?:25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]\\d|\\d)){3}$`),vn=e=>iee.test(e)||e.startsWith(`[`)&&e.endsWith(`]`),yn={[Je.HTTP]:80,[Je.HTTPS]:443},aee=e=>{let t=(()=>{try{if(e instanceof URL)return e;if(typeof e==`object`&&`hostname`in e){let{hostname:t,port:n,protocol:r=``,path:i=``,query:a={}}=e,o=new URL(`${r}//${t}${n?`:${n}`:``}${i}`);return o.search=Object.entries(a).map(([e,t])=>`${e}=${t}`).join(`&`),o}return new URL(e)}catch{return null}})();if(!t)return console.error(`Unable to parse ${JSON.stringify(e)} as a whatwg URL.`),null;let n=t.href,{host:r,hostname:i,pathname:a,protocol:o,search:s}=t;if(s)return null;let c=o.slice(0,-1);if(!Object.values(Je).includes(c))return null;let l=vn(i);return{scheme:c,authority:`${r}${n.includes(`${r}:${yn[c]}`)||typeof e==`string`&&e.includes(`${r}:${yn[c]}`)?`:${yn[c]}`:``}`,path:a,normalizedPath:a.endsWith(`/`)?a:`${a}/`,isIp:l}};function oee(e,t,n){if(n===1)return[e];if(e===``)return[``];let r=e.split(t);return n===0?r:r.slice(0,n-1).concat(r.slice(1).join(t))}var see={booleanEquals:fn,coalesce:pn,getAttr:hn,isSet:gn,isValidHostLabel:et,ite:_n,not:ree,parseURL:aee,split:oee,stringEquals:(e,t)=>e===t,substring:(e,t,n,r)=>e==null||t>=n||e.lengthencodeURIComponent(e).replace(/[!*'()]/g,e=>`%${e.charCodeAt(0).toString(16).toUpperCase()}`)},bn=(e,t)=>{let n=[],{referenceRecord:r,endpointParams:i}=t,a=0;for(;at.referenceRecord[e]??t.endpointParams[e],xn=(e,t,n)=>{if(typeof e==`string`)return bn(e,n);if(e.fn)return Cn.callFunction(e,n);if(e.ref)return cee(e,n);throw new cn(`'${t}': ${String(e)} is not a string, function or reference.`)},Sn=({fn:e,argv:t},n)=>{let r=Array(t.length);for(let e=0;e{let{assign:n}=e;if(n&&n in t.referenceRecord)throw new cn(`'${n}' is already defined in Reference Record.`);let r=Sn(e,t);t.logger?.debug?.(`${ln} evaluateCondition: ${un(e)} = ${un(r)}`);let i=r===``?!0:!!r;return n==null?{result:i}:{result:i,toAssign:{name:n,value:r}}},Tn=(e,t)=>Object.entries(e??{}).reduce((e,[n,r])=>(e[n]=r.map(e=>{let r=xn(e,`Header value entry`,t);if(typeof r!=`string`)throw new cn(`Header '${n}' value '${r}' is not a string`);return r}),e),{}),En=(e,t)=>Object.entries(e).reduce((e,[n,r])=>(e[n]=On.getEndpointProperty(r,t),e),{}),Dn=(e,t)=>{if(Array.isArray(e))return e.map(e=>Dn(e,t));switch(typeof e){case`string`:return bn(e,t);case`object`:if(e===null)throw new cn(`Unexpected endpoint property: ${e}`);return On.getEndpointProperties(e,t);case`boolean`:return e;default:throw new cn(`Unexpected endpoint property type: ${typeof e}`)}},On={getEndpointProperty:Dn,getEndpointProperties:En},kn=(e,t)=>{let n=xn(e,`Endpoint URL`,t);if(typeof n==`string`)try{return new URL(n)}catch(e){throw console.error(`Failed to construct URL with ${n}`,e),e}throw new cn(`Endpoint URL must be a string, got ${typeof n}`)},An=1e8,jn=(e,t)=>{let{nodes:n,root:r,results:i,conditions:a}=e,o=r,s={},c={referenceRecord:s,endpointParams:t.endpointParams,logger:t.logger};for(;o!==1&&o!==-1&&o=0===f.result?r:i}if(o>=An){let e=i[o-An];if(e[0]===-1){let[,t]=e;throw new cn(xn(t,`Error`,c))}let[t,n,r]=e;return{url:kn(t,c),properties:En(n,c),headers:Tn(r??{},c)}}throw new cn(`No matching endpoint.`)},Mn=ft(it),Nn=an(it);tn(it);var Pn=rn(it),Fn=async(e=new Uint8Array,t)=>{if(e instanceof Uint8Array)return Te.mutate(e);if(!e)return Te.mutate(new Uint8Array);let n=t.streamCollector(e);return Te.mutate(await n)};function In(e){return encodeURIComponent(e).replace(/[!'()*]/g,function(e){return`%`+e.charCodeAt(0).toString(16).toUpperCase()})}var Ln=class{serdeContext;setSerdeContext(e){this.serdeContext=e}},Rn=`modulepreload`,zn=function(e){return`/`+e},Bn={},Vn=function(e,t,n){let r=Promise.resolve();if(t&&t.length>0){let e=document.getElementsByTagName(`link`),i=document.querySelector(`meta[property=csp-nonce]`),a=i?.nonce||i?.getAttribute(`nonce`);function o(e){return Promise.all(e.map(e=>Promise.resolve(e).then(e=>({status:`fulfilled`,value:e}),e=>({status:`rejected`,reason:e}))))}r=o(t.map(t=>{if(t=zn(t,n),t in Bn)return;Bn[t]=!0;let r=t.endsWith(`.css`),i=r?`[rel="stylesheet"]`:``;if(n)for(let n=e.length-1;n>=0;n--){let i=e[n];if(i.href===t&&(!r||i.rel===`stylesheet`))return}else if(document.querySelector(`link[href="${t}"]${i}`))return;let o=document.createElement(`link`);if(o.rel=r?`stylesheet`:Rn,r||(o.as=`script`),o.crossOrigin=``,o.href=t,a&&o.setAttribute(`nonce`,a),document.head.appendChild(o),r)return new Promise((e,n)=>{o.addEventListener(`load`,e),o.addEventListener(`error`,()=>n(Error(`Unable to preload CSS for ${t}`)))})}))}function i(e){let t=new Event(`vite:preloadError`,{cancelable:!0});if(t.payload=e,window.dispatchEvent(t),!t.defaultPrevented)throw e}return r.then(t=>{for(let e of t||[])e.status===`rejected`&&i(e.reason);return e().catch(i)})},Hn=class extends Ln{options;compositeErrorRegistry;constructor(e){super(),this.options=e,this.compositeErrorRegistry=Lt.for(e.defaultNamespace);for(let t of e.errorTypeRegistries??[])this.compositeErrorRegistry.copyFrom(t)}getRequestType(){return m}getResponseType(){return Qe}setSerdeContext(e){this.serdeContext=e,this.serializer.setSerdeContext(e),this.deserializer.setSerdeContext(e),this.getPayloadCodec()&&this.getPayloadCodec().setSerdeContext(e)}updateServiceEndpoint(e,t){if(`url`in t){e.protocol=t.url.protocol,e.hostname=t.url.hostname,e.port=t.url.port?Number(t.url.port):void 0,e.path=t.url.pathname,e.fragment=t.url.hash||void 0,e.username=t.url.username||void 0,e.password=t.url.password||void 0,e.query||={};for(let[n,r]of t.url.searchParams.entries())e.query[n]=r;if(t.headers)for(let n in t.headers)e.headers[n]=t.headers[n].join(`, `);return e}else{if(e.protocol=t.protocol,e.hostname=t.hostname,e.port=t.port?Number(t.port):void 0,e.path=t.path,e.query={...t.query},t.headers)for(let n in t.headers)e.headers[n]=t.headers[n];return e}}setHostPrefix(e,t,n){if(this.serdeContext?.disableHostPrefix)return;let r=h.of(t.input),i=At(t.traits??{});if(i.endpoint){let t=i.endpoint?.[0];if(typeof t==`string`){for(let[e,i]of r.structIterator()){if(!i.getMergedTraits().hostLabel)continue;let r=n[e];if(typeof r!=`string`)throw Error(`@smithy/core/schema - ${e} in input must be a string as hostLabel.`);t=t.replace(`{${e}}`,r)}e.hostname=t+e.hostname}}}deserializeMetadata(e){return{httpStatusCode:e.statusCode,requestId:e.headers[`x-amzn-requestid`]??e.headers[`x-amzn-request-id`]??e.headers[`x-amz-request-id`],extendedRequestId:e.headers[`x-amz-id-2`],cfId:e.headers[`x-amz-cf-id`]}}async serializeEventStream({eventStream:e,requestSchema:t,initialRequest:n}){return(await this.loadEventStreamCapability()).serializeEventStream({eventStream:e,requestSchema:t,initialRequest:n})}async deserializeEventStream({response:e,responseSchema:t,initialResponseContainer:n}){return(await this.loadEventStreamCapability()).deserializeEventStream({response:e,responseSchema:t,initialResponseContainer:n})}async loadEventStreamCapability(){let{EventStreamSerde:e}=await Vn(async()=>{let{EventStreamSerde:e}=await import(`./index2.browser.js`).then(e=>e.t);return{EventStreamSerde:e}},__vite__mapDeps([0,1,2]));return new e({marshaller:this.getEventStreamMarshaller(),serializer:this.serializer,deserializer:this.deserializer,serdeContext:this.serdeContext,defaultContentType:this.getDefaultContentType()})}getDefaultContentType(){throw Error(`@smithy/core/protocols - ${this.constructor.name} getDefaultContentType() implementation missing.`)}async deserializeHttpMessage(e,t,n,r,i){return[]}getEventStreamMarshaller(){let e=this.serdeContext;if(!e.eventStreamMarshaller)throw Error(`@smithy/core - HttpProtocol: eventStreamMarshaller missing in serdeContext.`);return e.eventStreamMarshaller}},Un=class extends Hn{async serializeRequest(e,t,n){let r=t&&typeof t==`object`?t:{},i=this.serializer,a={},o={},s=await n.endpoint(),c=h.of(e?.input),l=[],u=[],d=!1,f,p=new m({protocol:``,hostname:``,port:void 0,path:``,fragment:void 0,query:a,headers:o,body:void 0});if(s){this.updateServiceEndpoint(p,s),this.setHostPrefix(p,e,r);let t=At(e.traits);if(t.http){p.method=t.http[0];let[e,n]=t.http[1].split(`?`);p.path==`/`?p.path=e:p.path+=e;let r=new URLSearchParams(n??``);for(let[e,t]of r)a[e]=t}}for(let[e,t]of c.structIterator()){let n=t.getMergedTraits()??{},s=r[e];if(s==null&&!t.isIdempotencyToken()){if(n.httpLabel&&(p.path.includes(`{${e}+}`)||p.path.includes(`{${e}}`)))throw Error(`No value provided for input HTTP label: ${e}.`);continue}if(n.httpPayload)t.isStreaming()?t.isStructSchema()?r[e]&&(f=await this.serializeEventStream({eventStream:r[e],requestSchema:c})):f=s:(i.write(t,s),f=i.flush());else if(n.httpLabel){i.write(t,s);let n=i.flush();p.path.includes(`{${e}+}`)?p.path=p.path.replace(`{${e}+}`,n.split(`/`).map(In).join(`/`)):p.path.includes(`{${e}}`)&&(p.path=p.path.replace(`{${e}}`,In(n)))}else if(n.httpHeader)i.write(t,s),o[n.httpHeader.toLowerCase()]=String(i.flush());else if(typeof n.httpPrefixHeaders==`string`)for(let e in s){let r=s[e],a=n.httpPrefixHeaders+e;i.write([t.getValueSchema(),{httpHeader:a}],r),o[a.toLowerCase()]=i.flush()}else n.httpQuery||n.httpQueryParams?this.serializeQuery(t,s,a):(d=!0,l.push(e),u.push(t))}if(d&&r){let[e,t]=(c.getName(!0)??`#Unknown`).split(`#`),n=c.getSchema()[6],a=[3,e,t,c.getMergedTraits(),l,u,void 0];n?a[6]=n:a.pop(),i.write(a,r),f=i.flush()}return p.headers=o,p.query=a,p.body=f,p}serializeQuery(e,t,n){let r=this.serializer,i=e.getMergedTraits();if(i.httpQueryParams){for(let r in t)if(!(r in n)){let a=t[r],o=e.getValueSchema();Object.assign(o.getMergedTraits(),{...i,httpQuery:r,httpQueryParams:void 0}),this.serializeQuery(o,a,n)}return}if(e.isListSchema()){let a=!!e.getMergedTraits().sparse,o=[];for(let n of t){r.write([e.getValueSchema(),i],n);let t=r.flush();(a||t!==void 0)&&o.push(t)}n[i.httpQuery]=o}else r.write([e,i],t),n[i.httpQuery]=r.flush()}async deserializeResponse(e,t,n){let r=this.deserializer,i=h.of(e.output),a={};if(n.statusCode>=300){let i=await Fn(n.body,t);throw i.byteLength>0&&Object.assign(a,await r.read(15,i)),await this.handleError(e,t,n,a,this.deserializeMetadata(n)),Error(`@smithy/core/protocols - HTTP Protocol error handler failed to throw.`)}for(let e in n.headers){let t=n.headers[e];delete n.headers[e],n.headers[e.toLowerCase()]=t}let o=await this.deserializeHttpMessage(i,t,n,a);if(o.length){let e=await Fn(n.body,t);if(e.byteLength>0){let t=await r.read(i,e);for(let e of o)t[e]!=null&&(a[e]=t[e])}}else o.discardResponseBody&&await Fn(n.body,t);return a.$metadata=this.deserializeMetadata(n),a}async deserializeHttpMessage(e,t,n,r,i){let a;a=r instanceof Set?i:r;let o=!0,s=this.deserializer,c=h.of(e),l=[];for(let[e,r]of c.structIterator()){let i=r.getMemberTraits();if(i.httpPayload){if(o=!1,r.isStreaming())r.isStructSchema()?a[e]=await this.deserializeEventStream({response:n,responseSchema:c}):a[e]=be(n.body);else if(n.body){let i=await Fn(n.body,t);i.byteLength>0&&(a[e]=await s.read(r,i))}}else if(i.httpHeader){let t=String(i.httpHeader).toLowerCase(),o=n.headers[t];if(o!=null)if(r.isListSchema()){let n=r.getValueSchema();n.getMergedTraits().httpHeader=t;let i;i=n.isTimestampSchema()&&n.getSchema()===4?ye(o,`,`,2):xe(o);let c=[];for(let e of i)c.push(await s.read(n,e.trim()));a[e]=c}else a[e]=await s.read(r,o)}else if(i.httpPrefixHeaders!==void 0){a[e]={};for(let t in n.headers)if(t.startsWith(i.httpPrefixHeaders)){let o=n.headers[t],c=r.getValueSchema();c.getMergedTraits().httpHeader=t,a[e][t.slice(i.httpPrefixHeaders.length)]=await s.read(c,o)}}else i.httpResponseCode?a[e]=n.statusCode:l.push(e)}return l.discardResponseBody=o,l}},Wn=class extends Hn{async serializeRequest(e,t,n){let r=this.serializer,i={},a={},o=await n.endpoint(),s=h.of(e?.input),c=s.getSchema(),l,u=t&&typeof t==`object`?t:{},d=new m({protocol:``,hostname:``,port:void 0,path:`/`,fragment:void 0,query:i,headers:a,body:void 0});if(o&&(this.updateServiceEndpoint(d,o),this.setHostPrefix(d,e,u)),u){let e=s.getEventStreamMember();if(e){if(u[e]){let t={};for(let[n,i]of s.structIterator())n!==e&&u[n]&&(r.write(i,u[n]),t[n]=r.flush());l=await this.serializeEventStream({eventStream:u[e],requestSchema:s,initialRequest:t})}}else r.write(c,u),l=r.flush()}return d.headers=Object.assign(d.headers,a),d.query=i,d.body=l,d.method=`POST`,d}async deserializeResponse(e,t,n){let r=this.deserializer,i=h.of(e.output),a={};if(n.statusCode>=300){let i=await Fn(n.body,t);throw i.byteLength>0&&Object.assign(a,await r.read(15,i)),await this.handleError(e,t,n,a,this.deserializeMetadata(n)),Error(`@smithy/core/protocols - RPC Protocol error handler failed to throw.`)}for(let e in n.headers){let t=n.headers[e];delete n.headers[e],n.headers[e.toLowerCase()]=t}let o=i.getEventStreamMember();if(o)a[o]=await this.deserializeEventStream({response:n,responseSchema:i,initialResponseContainer:a});else{let e=await Fn(n.body,t);e.byteLength>0&&Object.assign(a,await r.read(i,e))}return a.$metadata=this.deserializeMetadata(n),a}};function Gn(e,t){if(t.timestampFormat.useTrait&&e.isTimestampSchema()&&(e.getSchema()===5||e.getSchema()===6||e.getSchema()===7))return e.getSchema();let{httpLabel:n,httpPrefixHeaders:r,httpHeader:i,httpQuery:a}=e.getMergedTraits();return(t.httpBindings?typeof r==`string`||i?6:a||n?5:void 0:void 0)??t.timestampFormat.default}var Kn=class extends Ln{settings;constructor(e){super(),this.settings=e}read(e,t){let n=h.of(e);if(n.isListSchema())return xe(t).map(e=>this.read(n.getValueSchema(),e));if(n.isBlobSchema())return(this.serdeContext?.base64Decoder??fe)(t);if(n.isTimestampSchema())switch(Gn(n,this.settings)){case 5:return he(t);case 6:return De(t);case 7:return ve(t);default:return console.warn(`Missing timestamp format, parsing value with Date constructor:`,t),new Date(t)}if(n.isStringSchema()){let e=n.getMergedTraits().mediaType,r=t;if(e)return n.getMergedTraits().httpHeader&&(r=this.base64ToUtf8(r)),(e===`application/json`||e.endsWith(`+json`))&&(r=_e.from(r)),r}return n.isNumericSchema()?Number(t):n.isBigIntegerSchema()?BigInt(t):n.isBigDecimalSchema()?new Ce(t,`bigDecimal`):n.isBooleanSchema()?String(t).toLowerCase()===`true`:t}base64ToUtf8(e){return(this.serdeContext?.utf8Encoder??me)((this.serdeContext?.base64Decoder??fe)(e))}},qn=class extends Ln{codecDeserializer;stringDeserializer;constructor(e,t){super(),this.codecDeserializer=e,this.stringDeserializer=new Kn(t)}setSerdeContext(e){this.stringDeserializer.setSerdeContext(e),this.codecDeserializer.setSerdeContext(e),this.serdeContext=e}read(e,t){let n=h.of(e),r=n.getMergedTraits(),i=this.serdeContext?.utf8Encoder??me;if(r.httpHeader||r.httpResponseCode)return this.stringDeserializer.read(n,i(t));if(r.httpPayload){if(n.isBlobSchema()){let e=this.serdeContext?.utf8Decoder??ce;return typeof t==`string`?e(t):t}else if(n.isStringSchema())return`byteLength`in t?i(t):t}return this.codecDeserializer.read(n,t)}},Jn=class extends Ln{settings;stringBuffer=``;constructor(e){super(),this.settings=e}write(e,t){let n=h.of(e);switch(typeof t){case`object`:if(t===null){this.stringBuffer=`null`;return}if(n.isTimestampSchema()){if(!(t instanceof Date))throw Error(`@smithy/core/protocols - received non-Date value ${t} when schema expected Date in ${n.getName(!0)}`);switch(Gn(n,this.settings)){case 5:this.stringBuffer=t.toISOString().replace(`.000Z`,`Z`);break;case 6:this.stringBuffer=ke(t);break;case 7:this.stringBuffer=String(t.getTime()/1e3);break;default:console.warn(`Missing timestamp format, using epoch seconds`,t),this.stringBuffer=String(t.getTime()/1e3)}return}if(n.isBlobSchema()&&`byteLength`in t){this.stringBuffer=(this.serdeContext?.base64Encoder??le)(t);return}if(n.isListSchema()&&Array.isArray(t)){let e=``;for(let r of t){this.write([n.getValueSchema(),n.getMergedTraits()],r);let t=this.flush(),i=n.getValueSchema().isTimestampSchema()?t:Ae(t);e!==``&&(e+=`, `),e+=i}this.stringBuffer=e;return}this.stringBuffer=JSON.stringify(t,null,2);break;case`string`:let e=n.getMergedTraits().mediaType,r=t;if(e&&((e===`application/json`||e.endsWith(`+json`))&&(r=_e.from(r)),n.getMergedTraits().httpHeader)){this.stringBuffer=(this.serdeContext?.base64Encoder??le)(r.toString());return}this.stringBuffer=t;break;default:n.isIdempotencyToken()?this.stringBuffer=Se():this.stringBuffer=String(t)}}flush(){let e=this.stringBuffer;return this.stringBuffer=``,e}},Yn=class{codecSerializer;stringSerializer;buffer;constructor(e,t,n=new Jn(t)){this.codecSerializer=e,this.stringSerializer=n}setSerdeContext(e){this.codecSerializer.setSerdeContext(e),this.stringSerializer.setSerdeContext(e)}write(e,t){let n=h.of(e),r=n.getMergedTraits();if(r.httpHeader||r.httpLabel||r.httpQuery){this.stringSerializer.write(n,t),this.buffer=this.stringSerializer.flush();return}return this.codecSerializer.write(n,t)}flush(){if(this.buffer!==void 0){let e=this.buffer;return this.buffer=void 0,e}return this.codecSerializer.flush()}},Xn=e=>({setHttpHandler(t){e.httpHandler=t},httpHandler(){return e.httpHandler},updateHttpClientConfig(t,n){e.httpHandler?.updateHttpClientConfig(t,n)},httpHandlerConfigs(){return e.httpHandler.httpHandlerConfigs()}}),Zn=e=>({httpHandler:e.httpHandler()}),Qn=`content-length`;function $n(e){return t=>async n=>{let r=n.request;if(m.isInstance(r)){let{body:t,headers:n}=r;if(t&&Object.keys(n).map(e=>e.toLowerCase()).indexOf(Qn)===-1)try{let n=e(t);r.headers={...r.headers,[Qn]:String(n)}}catch{}}return t({...n,request:r})}}var er={step:`build`,tags:[`SET_CONTENT_LENGTH`,`CONTENT_LENGTH`],name:`contentLengthMiddleware`,override:!0},tr=e=>({applyToStack:t=>{t.add($n(e.bodyLengthChecker),er)}});function nr(e){let t=[];for(let n of Object.keys(e).sort()){let r=e[n];if(n=te(n),Array.isArray(r))for(let e=0,i=r.length;ee?.body instanceof ReadableStream,or=[`BandwidthLimitExceeded`,`EC2ThrottledException`,`LimitExceededException`,`PriorRequestNotComplete`,`ProvisionedThroughputExceededException`,`RequestLimitExceeded`,`RequestThrottled`,`RequestThrottledException`,`SlowDown`,`ThrottledException`,`Throttling`,`ThrottlingException`,`TooManyRequestsException`,`TransactionInProgressException`],sr=[`TimeoutError`,`RequestTimeout`,`RequestTimeoutException`],cr=[500,502,503,504],lr=[`ECONNRESET`,`ECONNREFUSED`,`EPIPE`,`ETIMEDOUT`],ur=[`EHOSTUNREACH`,`ENETUNREACH`,`ENOTFOUND`,`EAI_AGAIN`],dr=e=>e?.$retryable!==void 0,fr=e=>e.$metadata?.clockSkewCorrected,pr=e=>{let t=new Set([`Failed to fetch`,`NetworkError when attempting to fetch resource`,`The Internet connection appears to be offline`,`Load failed`,`Network request failed`]);return e&&e instanceof TypeError?t.has(e.message):!1},mr=e=>e.$metadata?.httpStatusCode===429||or.includes(e.name)||e.$retryable?.throttling==1,hr=(e,t=0)=>dr(e)||fr(e)||e.name===`InvalidSignatureException`&&e.message?.includes(`Signature expired`)||sr.includes(e.name)||lr.includes(e?.code||``)||ur.includes(e?.code||``)||cr.includes(e.$metadata?.httpStatusCode||0)||pr(e)||_r(e)||e.cause!==void 0&&t<=10&&hr(e.cause,t+1),gr=e=>{if(e.$metadata?.httpStatusCode!==void 0){let t=e.$metadata.httpStatusCode;return 500<=t&&t<=599&&!hr(e)}return!1};function _r(e){return e.code===`ERR_HTTP2_STREAM_ERROR`&&e.message.includes(`NGHTTP2_REFUSED_STREAM`)}var vr=20*1e3,yr=`amz-sdk-invocation-id`,br=`amz-sdk-request`;function xr(e,t){if(Qe.isInstance(e))for(let n of Object.keys(e.headers)){let r=n.toLowerCase();if(r===`retry-after`){let r=e.headers[n],i=NaN;if(r.endsWith(`GMT`))try{i=(Oe(r).getTime()-Date.now())/1e3}catch(e){t?.trace?.(`Failed to parse retry-after header`),t?.trace?.(e)}else r.match(/ GMT, ((\d+)|(\d+\.\d+))$/)?i=Number(r.match(/ GMT, ([\d.]+)$/)?.[1]):r.match(/^((\d+)|(\d+\.\d+))$/)?i=Number(r):Date.parse(r)>=Date.now()&&(i=(Date.parse(r)-Date.now())/1e3);return isNaN(i)?void 0:new Date(Date.now()+i*1e3)}else if(r===`x-amz-retry-after`){let r=e.headers[n],i=Number(r);if(isNaN(i)){t?.trace?.(`Failed to parse x-amz-retry-after=${r}`);return}return new Date(Date.now()+i)}}}var Sr=e=>e instanceof Error?e:e instanceof Object?Object.assign(Error(),e):Error(typeof e==`string`?e:`AWS SDK error wrapper for ${e}`);function Cr(e){return t=>(n,r)=>async i=>{let a=await t.retryStrategy(),o=await t.maxAttempts();if(Tr(a)){a=a;let s=await a.acquireInitialRetryToken((r.partition_id??``)+(r.__retryLongPoll?`:longpoll`:``)),c=Error(),l=0,u=0,{request:d}=i,f=m.isInstance(d);for(f&&(d.headers[yr]=we());;)try{f&&(d.headers[br]=`attempt=${l+1}; max=${o}`);let{response:e,output:t}=await n(i);return a.recordSuccess(s),t.$metadata.attempts=l+1,t.$metadata.totalRetryDelay=u,{response:e,output:t}}catch(n){let i=Er(n,t.logger);if(c=Sr(n),f&&e(d))throw(r.logger instanceof $t?console:r.logger)?.warn(`An error was encountered in a non-retryable streaming request.`),c;try{s=await a.refreshRetryTokenForRetry(s,i)}catch{throw c.$metadata||={},c.$metadata.attempts=l+1,c.$metadata.totalRetryDelay=u,c}l=s.getRetryCount();let o=s.getRetryDelay();u+=(s?.$retryLog?.acquisitionDelay??0)+o,o>0&&await wr(o)}}else return a=a,a?.mode&&(r.userAgent=[...r.userAgent||[],[`cfg/retry-mode`,a.mode]]),a.retry(n,i)}}var wr=e=>new Promise(t=>setTimeout(t,e)),Tr=e=>e.acquireInitialRetryToken!==void 0&&e.refreshRetryTokenForRetry!==void 0&&e.recordSuccess!==void 0,Er=(e,t)=>{let n={error:e,errorType:Dr(e)},r=xr(e.$response,t);return r&&(n.retryAfterHint=r),n},Dr=e=>mr(e)?`THROTTLING`:hr(e)?`TRANSIENT`:gr(e)?`SERVER_ERROR`:`CLIENT_ERROR`,Or={name:`retryMiddleware`,tags:[`RETRY`],step:`finalizeRequest`,priority:`high`,override:!0};function kr(e){let t=Cr(e);return e=>({applyToStack:n=>{n.add(t(e),Or)}})}var lee=class e{static setTimeoutFn=setTimeout;beta;minCapacity;minFillRate;scaleConstant;smooth;enabled=!1;availableTokens=0;lastMaxRate=0;measuredTxRate=0;requestCount=0;fillRate;lastThrottleTime;lastTimestamp=0;lastTxRateBucket;maxCapacity;timeWindow=0;constructor(e){this.beta=e?.beta??.7,this.minCapacity=e?.minCapacity??1,this.minFillRate=e?.minFillRate??.5,this.scaleConstant=e?.scaleConstant??.4,this.smooth=e?.smooth??.8,this.lastThrottleTime=this.getCurrentTimeInSeconds(),this.lastTxRateBucket=Math.floor(this.getCurrentTimeInSeconds()),this.fillRate=this.minFillRate,this.maxCapacity=this.minCapacity}async getSendToken(){return this.acquireTokenBucket(1)}updateClientSendingRate(e){let t;this.updateMeasuredRate();let n=e;if(n?.errorType===`THROTTLING`||mr(n?.error??e)){let e=this.enabled?Math.min(this.measuredTxRate,this.fillRate):this.measuredTxRate;this.lastMaxRate=e,this.calculateTimeWindow(),this.lastThrottleTime=this.getCurrentTimeInSeconds(),t=this.cubicThrottle(e),this.enableTokenBucket()}else this.calculateTimeWindow(),t=this.cubicSuccess(this.getCurrentTimeInSeconds());let r=Math.min(t,2*this.measuredTxRate);this.updateTokenBucketRate(r)}getCurrentTimeInSeconds(){return Date.now()/1e3}async acquireTokenBucket(t){if(this.enabled){for(this.refillTokenBucket();t>this.availableTokens;){let n=(t-this.availableTokens)/this.fillRate*1e3;await new Promise(t=>e.setTimeoutFn(t,n)),this.refillTokenBucket()}this.availableTokens-=t}}refillTokenBucket(){let e=this.getCurrentTimeInSeconds();if(!this.lastTimestamp){this.lastTimestamp=e;return}let t=(e-this.lastTimestamp)*this.fillRate;this.availableTokens=Math.min(this.maxCapacity,this.availableTokens+t),this.lastTimestamp=e}calculateTimeWindow(){this.timeWindow=this.getPrecise((this.lastMaxRate*(1-this.beta)/this.scaleConstant)**(1/3))}cubicThrottle(e){return this.getPrecise(e*this.beta)}cubicSuccess(e){return this.getPrecise(this.scaleConstant*(e-this.lastThrottleTime-this.timeWindow)**3+this.lastMaxRate)}enableTokenBucket(){this.enabled=!0}updateTokenBucketRate(e){this.refillTokenBucket(),this.fillRate=Math.max(e,this.minFillRate),this.maxCapacity=Math.max(e,this.minCapacity),this.availableTokens=Math.min(this.availableTokens,this.maxCapacity)}updateMeasuredRate(){let e=this.getCurrentTimeInSeconds(),t=Math.floor(e*2)/2;if(this.requestCount++,t>this.lastTxRateBucket){let e=this.requestCount/(t-this.lastTxRateBucket);this.measuredTxRate=this.getPrecise(e*this.smooth+this.measuredTxRate*(1-this.smooth)),this.requestCount=0,this.lastTxRateBucket=t}}getPrecise(e){return parseFloat(e.toFixed(8))}},Ar=class e{static v2026=typeof process<`u`&&{}.SMITHY_NEW_RETRIES_2026===`true`;static delay(){return e.v2026?50:100}static throttlingDelay(){return e.v2026?1e3:500}static cost(){return e.v2026?14:5}static throttlingCost(){return e.v2026?5:10}static modifiedCostType(){return e.v2026?`THROTTLING`:`TRANSIENT`}},uee=class{x=Ar.delay();computeNextBackoffDelay(e){let t=Math.random()*Math.min(this.x*2**e,vr);return Math.floor(t)}setDelayBase(e){this.x=e}},jr=class{delay;count;cost;longPoll;$retryLog={acquisitionDelay:0};constructor(e,t,n,r){this.delay=e,this.count=t,this.cost=n,this.longPoll=r}getRetryCount(){return this.count}getRetryDelay(){return Math.min(vr,this.delay)}getRetryCost(){return this.cost}isLongPoll(){return this.longPoll}},Mr;(function(e){e.STANDARD=`standard`,e.ADAPTIVE=`adaptive`})(Mr||={});var Nr=Mr.STANDARD,Pr={incompatible:1,attempts:2,capacity:3},Fr=class{mode=Mr.STANDARD;retryBackoffStrategy;capacity=500;maxAttemptsProvider;baseDelay;constructor(e){typeof e==`number`?this.maxAttemptsProvider=async()=>e:typeof e==`function`?this.maxAttemptsProvider=e:e&&typeof e==`object`&&(this.maxAttemptsProvider=async()=>e.maxAttempts,this.baseDelay=e.baseDelay,this.retryBackoffStrategy=e.backoff),this.maxAttemptsProvider??=async()=>3,this.baseDelay??=Ar.delay(),this.retryBackoffStrategy??=new uee}async acquireInitialRetryToken(e){return new jr(Ar.delay(),0,void 0,Ar.v2026&&e.includes(`:longpoll`))}async refreshRetryTokenForRetry(e,t){let n=await this.getMaxAttempts(),r=this.retryCode(e,t,n),i=r===0,a=e.isLongPoll?.();if(i||a){let n=t.errorType;this.retryBackoffStrategy.setDelayBase(n===`THROTTLING`?Ar.throttlingDelay():this.baseDelay);let o=this.retryBackoffStrategy.computeNextBackoffDelay(e.getRetryCount()),s=o;if(t.retryAfterHint instanceof Date&&(s=Math.max(o,Math.min(t.retryAfterHint.getTime()-Date.now(),o+5e3))),i){let t=this.getCapacityCost(n);this.capacity-=t;let r=new jr(0,e.getRetryCount()+1,t,e.isLongPoll?.()??!1);return await new Promise(e=>setTimeout(e,s)),r.$retryLog.acquisitionDelay=s,r}else{let e=Ar.v2026&&r===Pr.capacity&&a?s:0;e>0&&await new Promise(t=>setTimeout(t,e))}}throw Error(`No retry token available`)}recordSuccess(e){this.capacity=Math.min(500,this.capacity+(e.getRetryCost()??1))}getCapacity(){return this.capacity}async maxAttempts(){return this.maxAttemptsProvider()}async getMaxAttempts(){try{return await this.maxAttemptsProvider()}catch{return console.warn(`Max attempts provider could not resolve. Using default of 3`),3}}retryCode(e,t,n){let r=e.getRetryCount()+1,i=this.isRetryableError(t.errorType)?0:Pr.incompatible,a=r=this.getCapacityCost(t.errorType)?0:Pr.capacity;return i||a||o}getCapacityCost(e){return e===Ar.modifiedCostType()?Ar.throttlingCost():Ar.cost()}isRetryableError(e){return e===`THROTTLING`||e===`TRANSIENT`}},dee=class{mode=Mr.ADAPTIVE;rateLimiter;standardRetryStrategy;constructor(e,t){let{rateLimiter:n}=t??{};this.rateLimiter=n??new lee,this.standardRetryStrategy=t?new Fr({maxAttempts:typeof e==`number`?e:3,...t}):new Fr(e)}async acquireInitialRetryToken(e){let t=await this.standardRetryStrategy.acquireInitialRetryToken(e);return await this.rateLimiter.getSendToken(),t}async refreshRetryTokenForRetry(e,t){this.rateLimiter.updateClientSendingRate(t);let n=await this.standardRetryStrategy.refreshRetryTokenForRetry(e,t);return await this.rateLimiter.getSendToken(),n}recordSuccess(e){this.rateLimiter.updateClientSendingRate({}),this.standardRetryStrategy.recordSuccess(e)}async maxAttemptsProvider(){return this.standardRetryStrategy.maxAttempts()}},Ir=(e,t)=>{let{retryStrategy:n,retryMode:r}=e,{defaultMaxAttempts:i=3,defaultBaseDelay:a=Ar.delay()}=t??{},o=re(e.maxAttempts??i),s=n?Promise.resolve(n):void 0,c=async()=>{let e=await o();return await re(r)()===Mr.ADAPTIVE?new dee(o,{maxAttempts:e,baseDelay:a}):new Fr({maxAttempts:e,baseDelay:a})};return Object.assign(e,{maxAttempts:o,retryStrategy:()=>s??=c()})},Lr=kr(ar);Ar.v2026||=typeof process==`object`&&{}.AWS_NEW_RETRIES_2026===`true`;function Rr(e,t,n){e.__aws_sdk_context?e.__aws_sdk_context.features||(e.__aws_sdk_context.features={}):e.__aws_sdk_context={features:{}},e.__aws_sdk_context.features[t]=n}function zr(e){return e}var Br=e=>t=>async n=>{if(!m.isInstance(n.request))return t(n);let{request:r}=n,{handlerProtocol:i=``}=e.requestHandler.metadata||{};if(i.indexOf(`h2`)>=0&&!r.headers[`:authority`])delete r.headers.host,r.headers[`:authority`]=r.hostname+(r.port?`:`+r.port:``);else if(!r.headers.host){let e=r.hostname;r.port!=null&&(e+=`:${r.port}`),r.headers.host=e}return t(n)},Vr={name:`hostHeaderMiddleware`,step:`build`,priority:`low`,tags:[`HOST`],override:!0},Hr=e=>({applyToStack:t=>{t.add(Br(e),Vr)}}),Ur=()=>(e,t)=>async n=>{try{let r=await e(n),{clientName:i,commandName:a,logger:o,dynamoDbDocumentClientOptions:s={}}=t,{overrideInputFilterSensitiveLog:c,overrideOutputFilterSensitiveLog:l}=s,u=c??t.inputFilterSensitiveLog,d=l??t.outputFilterSensitiveLog,{$metadata:f,...p}=r.output;return o?.info?.({clientName:i,commandName:a,input:u(n.input),output:d(p),metadata:f}),r}catch(e){let{clientName:r,commandName:i,logger:a,dynamoDbDocumentClientOptions:o={}}=t,{overrideInputFilterSensitiveLog:s}=o,c=s??t.inputFilterSensitiveLog;throw a?.error?.({clientName:r,commandName:i,input:c(n.input),error:e,metadata:e.$metadata}),e}},Wr={name:`loggerMiddleware`,tags:[`LOGGER`],step:`initialize`,override:!0},Gr=e=>({applyToStack:e=>{e.add(Ur(),Wr)}}),Kr=e=>({applyToStack:e=>{}}),qr=(e,t)=>{if(!t||t.length===0)return e;let n=[];for(let r of t)for(let t of e)t.schemeId.split(`#`)[1]===r&&n.push(t);for(let t of e)n.find(({schemeId:e})=>e===t.schemeId)||n.push(t);return n};function Jr(e){let t=new Map;for(let n of e)t.set(n.schemeId,n);return t}var Yr=(e,t)=>(n,r)=>async i=>{let a=qr(e.httpAuthSchemeProvider(await t.httpAuthSchemeParametersProvider(e,r,i.input)),e.authSchemePreference?await e.authSchemePreference():[]),o=Jr(e.httpAuthSchemes),s=Ze(r),c=[];for(let n of a){let i=o.get(n.schemeId);if(!i){c.push(`HttpAuthScheme \`${n.schemeId}\` was not enabled for this service.`);continue}let a=i.identityProvider(await t.identityProviderConfigProvider(e));if(!a){c.push(`HttpAuthScheme \`${n.schemeId}\` did not have an IdentityProvider configured.`);continue}let{identityProperties:l={},signingProperties:u={}}=n.propertiesExtractor?.(e,r)||{};n.identityProperties=Object.assign(n.identityProperties||{},l),n.signingProperties=Object.assign(n.signingProperties||{},u),s.selectedHttpAuthScheme={httpAuthOption:n,identity:await a(n.identityProperties),signer:i.signer};break}if(!s.selectedHttpAuthScheme)throw Error(c.join(`
+`));return n(i)},Xr={step:`serialize`,tags:[`HTTP_AUTH_SCHEME`],name:`httpAuthSchemeMiddleware`,override:!0,relation:`before`,toMiddleware:`endpointV2Middleware`},Zr=(e,{httpAuthSchemeParametersProvider:t,identityProviderConfigProvider:n})=>({applyToStack:r=>{r.addRelativeTo(Yr(e,{httpAuthSchemeParametersProvider:t,identityProviderConfigProvider:n}),Xr)}}),Qr=e=>e=>{throw e},$r=(e,t)=>{},ei=e=>(e,t)=>async n=>{if(!m.isInstance(n.request))return e(n);let r=Ze(t).selectedHttpAuthScheme;if(!r)throw Error(`No HttpAuthScheme was selected: unable to sign request`);let{httpAuthOption:{signingProperties:i={}},identity:a,signer:o}=r,s=await e({...n,request:await o.sign(n.request,a,i)}).catch((o.errorHandler||Qr)(i));return(o.successHandler||$r)(s.response,i),s},ti={step:`finalizeRequest`,tags:[`HTTP_SIGNING`],name:`httpSigningMiddleware`,aliases:[`apiKeyMiddleware`,`tokenMiddleware`,`awsAuthMiddleware`],override:!0,relation:`after`,toMiddleware:`retryMiddleware`},ni=e=>({applyToStack:t=>{t.addRelativeTo(ei(e),ti)}}),ri=e=>{if(typeof e==`function`)return e;let t=Promise.resolve(e);return()=>t},ii=class{authSchemes=new Map;constructor(e){for(let t in e){let n=e[t];n!==void 0&&this.authSchemes.set(t,n)}}getIdentityProvider(e){return this.authSchemes.get(e)}},ai=(e=>function(t){return oi(t)&&t.expiration.getTime()-Date.now()e.expiration!==void 0,si=(e,t,n)=>{if(e===void 0)return;let r=typeof e==`function`?e:async()=>Promise.resolve(e),i,a,o,s=!1,c=async e=>{a||=r(e);try{i=await a,o=!0,s=!1}finally{a=void 0}return i};return t===void 0?async e=>((!o||e?.forceRefresh)&&(i=await c(e)),i):async e=>((!o||e?.forceRefresh)&&(i=await c(e)),s?i:n(i)?(t(i)&&await c(e),i):(s=!0,i))};function ci(e){return e===void 0?!0:typeof e==`string`&&e.length<=50}function li(e){let t=ri(e.userAgentAppId??void 0),{customUserAgent:n}=e;return Object.assign(e,{customUserAgent:typeof n==`string`?[[n]]:n,userAgentAppId:async()=>{let n=await t();if(!ci(n)){let t=e.logger?.constructor?.name===`NoOpLogger`||!e.logger?console:e.logger;typeof n==`string`?n.length>50&&t?.warn(`The provided userAgentAppId exceeds the maximum length of 50 characters.`):t?.warn(`userAgentAppId must be a string or undefined.`)}return n}})}var ui={partitions:[{id:`aws`,outputs:{dnsSuffix:`amazonaws.com`,dualStackDnsSuffix:`api.aws`,implicitGlobalRegion:`us-east-1`,name:`aws`,supportsDualStack:!0,supportsFIPS:!0},regionRegex:`^(us|eu|ap|sa|ca|me|af|il|mx)\\-\\w+\\-\\d+$`,regions:{"af-south-1":{description:`Africa (Cape Town)`},"ap-east-1":{description:`Asia Pacific (Hong Kong)`},"ap-east-2":{description:`Asia Pacific (Taipei)`},"ap-northeast-1":{description:`Asia Pacific (Tokyo)`},"ap-northeast-2":{description:`Asia Pacific (Seoul)`},"ap-northeast-3":{description:`Asia Pacific (Osaka)`},"ap-south-1":{description:`Asia Pacific (Mumbai)`},"ap-south-2":{description:`Asia Pacific (Hyderabad)`},"ap-southeast-1":{description:`Asia Pacific (Singapore)`},"ap-southeast-2":{description:`Asia Pacific (Sydney)`},"ap-southeast-3":{description:`Asia Pacific (Jakarta)`},"ap-southeast-4":{description:`Asia Pacific (Melbourne)`},"ap-southeast-5":{description:`Asia Pacific (Malaysia)`},"ap-southeast-6":{description:`Asia Pacific (New Zealand)`},"ap-southeast-7":{description:`Asia Pacific (Thailand)`},"aws-global":{description:`aws global region`},"ca-central-1":{description:`Canada (Central)`},"ca-west-1":{description:`Canada West (Calgary)`},"eu-central-1":{description:`Europe (Frankfurt)`},"eu-central-2":{description:`Europe (Zurich)`},"eu-north-1":{description:`Europe (Stockholm)`},"eu-south-1":{description:`Europe (Milan)`},"eu-south-2":{description:`Europe (Spain)`},"eu-west-1":{description:`Europe (Ireland)`},"eu-west-2":{description:`Europe (London)`},"eu-west-3":{description:`Europe (Paris)`},"il-central-1":{description:`Israel (Tel Aviv)`},"me-central-1":{description:`Middle East (UAE)`},"me-south-1":{description:`Middle East (Bahrain)`},"mx-central-1":{description:`Mexico (Central)`},"sa-east-1":{description:`South America (Sao Paulo)`},"us-east-1":{description:`US East (N. Virginia)`},"us-east-2":{description:`US East (Ohio)`},"us-west-1":{description:`US West (N. California)`},"us-west-2":{description:`US West (Oregon)`}}},{id:`aws-cn`,outputs:{dnsSuffix:`amazonaws.com.cn`,dualStackDnsSuffix:`api.amazonwebservices.com.cn`,implicitGlobalRegion:`cn-northwest-1`,name:`aws-cn`,supportsDualStack:!0,supportsFIPS:!0},regionRegex:`^cn\\-\\w+\\-\\d+$`,regions:{"aws-cn-global":{description:`aws-cn global region`},"cn-north-1":{description:`China (Beijing)`},"cn-northwest-1":{description:`China (Ningxia)`}}},{id:`aws-eusc`,outputs:{dnsSuffix:`amazonaws.eu`,dualStackDnsSuffix:`api.amazonwebservices.eu`,implicitGlobalRegion:`eusc-de-east-1`,name:`aws-eusc`,supportsDualStack:!0,supportsFIPS:!0},regionRegex:`^eusc\\-(de)\\-\\w+\\-\\d+$`,regions:{"eusc-de-east-1":{description:`AWS European Sovereign Cloud (Germany)`}}},{id:`aws-iso`,outputs:{dnsSuffix:`c2s.ic.gov`,dualStackDnsSuffix:`api.aws.ic.gov`,implicitGlobalRegion:`us-iso-east-1`,name:`aws-iso`,supportsDualStack:!0,supportsFIPS:!0},regionRegex:`^us\\-iso\\-\\w+\\-\\d+$`,regions:{"aws-iso-global":{description:`aws-iso global region`},"us-iso-east-1":{description:`US ISO East`},"us-iso-west-1":{description:`US ISO WEST`}}},{id:`aws-iso-b`,outputs:{dnsSuffix:`sc2s.sgov.gov`,dualStackDnsSuffix:`api.aws.scloud`,implicitGlobalRegion:`us-isob-east-1`,name:`aws-iso-b`,supportsDualStack:!0,supportsFIPS:!0},regionRegex:`^us\\-isob\\-\\w+\\-\\d+$`,regions:{"aws-iso-b-global":{description:`aws-iso-b global region`},"us-isob-east-1":{description:`US ISOB East (Ohio)`},"us-isob-west-1":{description:`US ISOB West`}}},{id:`aws-iso-e`,outputs:{dnsSuffix:`cloud.adc-e.uk`,dualStackDnsSuffix:`api.cloud-aws.adc-e.uk`,implicitGlobalRegion:`eu-isoe-west-1`,name:`aws-iso-e`,supportsDualStack:!0,supportsFIPS:!0},regionRegex:`^eu\\-isoe\\-\\w+\\-\\d+$`,regions:{"aws-iso-e-global":{description:`aws-iso-e global region`},"eu-isoe-west-1":{description:`EU ISOE West`}}},{id:`aws-iso-f`,outputs:{dnsSuffix:`csp.hci.ic.gov`,dualStackDnsSuffix:`api.aws.hci.ic.gov`,implicitGlobalRegion:`us-isof-south-1`,name:`aws-iso-f`,supportsDualStack:!0,supportsFIPS:!0},regionRegex:`^us\\-isof\\-\\w+\\-\\d+$`,regions:{"aws-iso-f-global":{description:`aws-iso-f global region`},"us-isof-east-1":{description:`US ISOF EAST`},"us-isof-south-1":{description:`US ISOF SOUTH`}}},{id:`aws-us-gov`,outputs:{dnsSuffix:`amazonaws.com`,dualStackDnsSuffix:`api.aws`,implicitGlobalRegion:`us-gov-west-1`,name:`aws-us-gov`,supportsDualStack:!0,supportsFIPS:!0},regionRegex:`^us\\-gov\\-\\w+\\-\\d+$`,regions:{"aws-us-gov-global":{description:`aws-us-gov global region`},"us-gov-east-1":{description:`AWS GovCloud (US-East)`},"us-gov-west-1":{description:`AWS GovCloud (US-West)`}}}],version:`1.1`},di=``,fi=e=>{let{partitions:t}=ui;for(let n of t){let{regions:t,outputs:r}=n;for(let[n,i]of Object.entries(t))if(n===e)return{...r,...i}}for(let n of t){let{regionRegex:t,outputs:r}=n;if(new RegExp(t).test(e))return{...r}}let n=t.find(e=>e.id===`aws`);if(!n)throw Error(`Provided region was not found in the partition array or regex, and default partition with id 'aws' doesn't exist.`);return{...n.outputs}},pi=()=>di,mi=/\d{12}\.ddb/;async function hi(e,t,n){if(n.request?.headers?.[`smithy-protocol`]===`rpc-v2-cbor`&&Rr(e,`PROTOCOL_RPC_V2_CBOR`,`M`),typeof t.retryStrategy==`function`){let n=await t.retryStrategy();if(typeof n.mode==`string`)switch(n.mode){case Mr.ADAPTIVE:Rr(e,`RETRY_MODE_ADAPTIVE`,`F`);break;case Mr.STANDARD:Rr(e,`RETRY_MODE_STANDARD`,`E`);break}}if(typeof t.accountIdEndpointMode==`function`){let n=e.endpointV2;switch(String(n?.url?.hostname).match(mi)&&Rr(e,`ACCOUNT_ID_ENDPOINT`,`O`),await t.accountIdEndpointMode?.()){case`disabled`:Rr(e,`ACCOUNT_ID_MODE_DISABLED`,`Q`);break;case`preferred`:Rr(e,`ACCOUNT_ID_MODE_PREFERRED`,`P`);break;case`required`:Rr(e,`ACCOUNT_ID_MODE_REQUIRED`,`R`);break}}let r=e.__smithy_context?.selectedHttpAuthScheme?.identity;if(r?.$source){let t=r;t.accountId&&Rr(e,`RESOLVED_ACCOUNT_ID`,`T`);for(let[n,r]of Object.entries(t.$source??{}))Rr(e,n,r)}}var gi=`user-agent`,_i=`x-amz-user-agent`,vi=/[^!$%&'*+\-.^_`|~\w]/g,yi=/[^!$%&'*+\-.^_`|~\w#]/g,bi=1024;function xi(e){let t=``;for(let n in e){let r=e[n];if(t.length+r.length+1<=bi){t.length?t+=`,`+r:t+=r;continue}break}return t}var Si=e=>(t,n)=>async r=>{let{request:i}=r;if(!m.isInstance(i))return t(r);let{headers:a}=i,o=n?.userAgent?.map(Ci)||[],s=(await e.defaultUserAgentProvider()).map(Ci);await hi(n,e,r);let c=n;s.push(`m/${xi(Object.assign({},n.__smithy_context?.features,c.__aws_sdk_context?.features))}`);let l=e?.customUserAgent?.map(Ci)||[],u=await e.userAgentAppId();u&&s.push(Ci([`app`,`${u}`]));let d=pi(),f=(d?[d]:[]).concat([...s,...o,...l]).join(` `),p=[...s.filter(e=>e.startsWith(`aws-sdk-`)),...l].join(` `);return e.runtime===`browser`?a[_i]=f:(p&&(a[_i]=a[`x-amz-user-agent`]?`${a[gi]} ${p}`:p),a[gi]=f),t({...r,request:i})},Ci=e=>{let t=e[0].split(`/`).map(e=>e.replace(vi,`-`)).join(`/`),n=e[1]?.replace(yi,`-`),r=t.indexOf(`/`),i=t.substring(0,r),a=t.substring(r+1);return i===`api`&&(a=a.toLowerCase()),[i,a,n].filter(e=>e&&e.length>0).reduce((e,t,n)=>{switch(n){case 0:return t;case 1:return`${e}/${t}`;default:return`${e}#${t}`}},``)},wi={name:`getUserAgentMiddleware`,step:`build`,priority:`low`,tags:[`SET_USER_AGENT`,`USER_AGENT`],override:!0},Ti=e=>({applyToStack:t=>{t.add(Si(e),wi)}}),Ei=({serviceId:e,clientVersion:t})=>async n=>{let r=typeof window<`u`?window.navigator:void 0,i=r?.userAgent??``,a=r?.userAgentData?.platform??Di.os(i)??`other`,o=r?.userAgentData?.brands??[],s=o[o.length-1],c=s?.brand??Di.browser(i)??`unknown`,l=s?.version??`unknown`,u=[[`aws-sdk-js`,t],[`ua`,`2.1`],[`os/${a}`,void 0],[`lang/js`],[`md/browser`,`${c}_${l}`]];e&&u.push([`api/${e}`,t]);let d=await n?.userAgentAppId?.();return d&&u.push([`app/${d}`]),u},Di={os(e){if(/iPhone|iPad|iPod/.test(e))return`iOS`;if(/Macintosh|Mac OS X/.test(e))return`macOS`;if(/Windows NT/.test(e))return`Windows`;if(/Android/.test(e))return`Android`;if(/Linux/.test(e))return`Linux`},browser(e){if(/EdgiOS|EdgA|Edg\//.test(e))return`Microsoft Edge`;if(/Firefox\//.test(e))return`Firefox`;if(/Chrome\//.test(e))return`Chrome`;if(/Safari\//.test(e))return`Safari`}},Oi=(e,t=!1)=>{if(t){for(let t of e.split(`.`))if(!Oi(t))return!1;return!0}return!(!et(e)||e.length<3||e.length>63||e!==e.toLowerCase()||vn(e))},ki=`:`,Ai=`/`,ji={isVirtualHostableS3Bucket:Oi,parseArn:e=>{let t=e.split(ki);if(t.length<6)return null;let[n,r,i,a,o,...s]=t;return n!==`arn`||r===``||i===``||s.join(ki)===``?null:{partition:r,service:i,region:a,accountId:o,resourceId:s.map(e=>e.split(Ai)).flat()}},partition:fi};dn.aws=ji;var Mi=(e,t,n)=>{let r,i,a,o=!1,s=async()=>{i||=e();try{r=await i,a=!0,o=!1}finally{i=void 0}return r};return t===void 0?async e=>((!a||e?.forceRefresh)&&(r=await s()),r):async e=>((!a||e?.forceRefresh)&&(r=await s()),o?r:n&&!n(r)?(o=!0,r):(t(r)&&await s(),r))},Ni=new Set,Pi=(e,t=et)=>{if(!Ni.has(e)&&!t(e))if(e===`*`)console.warn(`@smithy/config-resolver WARN - Please use the caller region instead of "*". See "sigv4a" in https://github.com/aws/aws-sdk-js-v3/blob/main/supplemental-docs/CLIENTS.md.`);else throw Error(`Region not accepted: region="${e}" is not a valid hostname component.`);else Ni.add(e)},Fi=e=>typeof e==`string`&&(e.startsWith(`fips-`)||e.endsWith(`-fips`)),Ii=e=>Fi(e)?[`fips-aws-global`,`aws-fips`].includes(e)?`us-east-1`:e.replace(/fips-(dkr-|prod-)?|-fips/,``):e,Li=e=>{let{region:t,useFipsEndpoint:n}=e;if(!t)throw Error(`Region is missing`);return Object.assign(e,{region:async()=>{let e=Ii(typeof t==`function`?await t():t);return Pi(e),e},useFipsEndpoint:async()=>Fi(typeof t==`string`?t:await t())?!0:typeof n==`function`?n():Promise.resolve(!!n)})},Ri=[`in-region`,`cross-region`,`mobile`,`standard`,`legacy`],zi=({defaultsMode:e}={})=>Mi(async()=>{let t=typeof e==`function`?await e():e;switch(t?.toLowerCase()){case`auto`:return Promise.resolve(Bi()?`mobile`:`standard`);case`mobile`:case`in-region`:case`cross-region`:case`standard`:case`legacy`:return Promise.resolve(t?.toLocaleLowerCase());case void 0:return Promise.resolve(`legacy`);default:throw Error(`Invalid parameter for "defaultsMode", expect ${Ri.join(`, `)}, got ${t}`)}}),Bi=()=>{let e=window?.navigator;if(e?.connection){let{effectiveType:t,rtt:n,downlink:r}=e?.connection;if(typeof t==`string`&&t!==`4g`||Number(n)>100||Number(r)<10)return!0}return e?.userAgentData?.mobile||typeof e?.maxTouchPoints==`number`&&e?.maxTouchPoints>1},Vi=e=>({setRegion(t){e.region=t},region(){return e.region}}),Hi=e=>({region:e.region()}),Ui=e=>Qe.isInstance(e)?e.headers?.date??e.headers?.Date:void 0,Wi=e=>new Date(Date.now()+e),Gi=(e,t)=>Math.abs(Wi(t).getTime()-e)>=3e5,Ki=(e,t)=>{let n=Date.parse(e);return Gi(n,t)?n-Date.now():t},qi=(e,t)=>{if(!t)throw Error(`Property \`${e}\` is not resolved for AWS SDK SigV4Auth`);return t},Ji=async e=>{let t=qi(`context`,e.context),n=qi(`config`,e.config),r=t.endpointV2?.properties?.authSchemes?.[0];return{config:n,signer:await qi(`signer`,n.signer)(r),signingRegion:e?.signingRegion,signingRegionSet:e?.signingRegionSet,signingName:e?.signingName}},Yi=class{async sign(e,t,n){if(!m.isInstance(e))throw Error("The request is not an instance of `HttpRequest` and cannot be signed");let r=await Ji(n),{config:i,signer:a}=r,{signingRegion:o,signingName:s}=r,c=n.context;if(c?.authSchemes?.length??!1){let[e,t]=c.authSchemes;e?.name===`sigv4a`&&t?.name===`sigv4`&&(o=t?.signingRegion??o,s=t?.signingName??s)}return n._preRequestSystemClockOffset=i.systemClockOffset,await a.sign(e,{signingDate:Wi(i.systemClockOffset),signingRegion:o,signingService:s})}errorHandler(e){return t=>{let n=t,r=n.ServerTime??Ui(n.$response);if(r){let t=qi(`config`,e.config),i=e._preRequestSystemClockOffset,a=Ki(r,t.systemClockOffset);(a!==t.systemClockOffset||i!==void 0&&i!==a)&&n.$metadata&&(t.systemClockOffset=a,n.$metadata.clockSkewCorrected=!0)}throw t}}successHandler(e,t){let n=Ui(e);if(n){let e=qi(`config`,t.config);e.systemClockOffset=Ki(n,e.systemClockOffset)}}},Xi=class extends Yi{async sign(e,t,n){if(!m.isInstance(e))throw Error("The request is not an instance of `HttpRequest` and cannot be signed");let{config:r,signer:i,signingRegion:a,signingRegionSet:o,signingName:s}=await Ji(n),c=(await r.sigv4aSigningRegionSet?.()??o??[a]).join(`,`);return n._preRequestSystemClockOffset=r.systemClockOffset,await i.sign(e,{signingDate:Wi(r.systemClockOffset),signingRegion:c,signingService:s})}},Zi=e=>(e.sigv4aSigningRegionSet=ri(e.sigv4aSigningRegionSet),e),Qi=e=>{let t=e.credentials,n=!!e.credentials,r;Object.defineProperty(e,"credentials",{set(i){i&&i!==t&&i!==r&&(n=!0),t=i;let a=ea(e,$i(e,{credentials:t,credentialDefaultProvider:e.credentialDefaultProvider}));if(n&&!a.attributed){let e=typeof t==`object`&&!!t;r=async t=>{let n=await a(t);return e&&(!n.$source||Object.keys(n.$source).length===0)?ir(n,`CREDENTIALS_CODE`,`e`):n},r.memoized=a.memoized,r.configBound=a.configBound,r.attributed=!0}else r=a},get(){return r},enumerable:!0,configurable:!0}),e.credentials=t;let{signingEscapePath:i=!0,systemClockOffset:a=e.systemClockOffset||0,sha256:o}=e,s;return s=e.signer?ri(e.signer):e.regionInfoProvider?()=>ri(e.region)().then(async t=>[await e.regionInfoProvider(t,{useFipsEndpoint:await e.useFipsEndpoint(),useDualstackEndpoint:await e.useDualstackEndpoint()})||{},t]).then(([t,n])=>{let{signingRegion:r,signingService:a}=t;e.signingRegion=e.signingRegion||r||n,e.signingName=e.signingName||a||e.serviceId;let s={...e,credentials:e.credentials,region:e.signingRegion,service:e.signingName,sha256:o,uriEscapePath:i};return new(e.signerConstructor||ae)(s)}):async t=>{t=Object.assign({},{name:`sigv4`,signingName:e.signingName||e.defaultSigningName,signingRegion:await ri(e.region)(),properties:{}},t);let n=t.signingRegion,r=t.signingName;e.signingRegion=e.signingRegion||n,e.signingName=e.signingName||r||e.serviceId;let a={...e,credentials:e.credentials,region:e.signingRegion,service:e.signingName,sha256:o,uriEscapePath:i};return new(e.signerConstructor||ae)(a)},Object.assign(e,{systemClockOffset:a,signingEscapePath:i,signer:s})};function $i(e,{credentials:t,credentialDefaultProvider:n}){let r;return r=t?t?.memoized?t:si(t,ai,oi):n?ri(n(Object.assign({},e,{parentClientConfig:e}))):async()=>{throw Error("@aws-sdk/core::resolveAwsSdkSigV4Config - `credentials` not provided and no credentialDefaultProvider was configured.")},r.memoized=!0,r}function ea(e,t){if(t.configBound)return t;let n=async n=>t({...n,callerClientConfig:e});return n.memoized=t.memoized,n.configBound=!0,n}var ta=async(e,t,n)=>({operation:Ze(t).operation,region:await re(e.region)()||(()=>{throw Error("expected `region` to be configured for `aws.auth#sigv4`")})()});function na(e){return{schemeId:`aws.auth#sigv4`,signingProperties:{name:`acm`,region:e.region},propertiesExtractor:(e,t)=>({signingProperties:{config:e,context:t}})}}var ra=e=>{let t=[];switch(e.operation){default:t.push(na(e))}return t},ia=e=>{let t=Qi(e);return Object.assign(t,{authSchemePreference:re(e.authSchemePreference??[])})},aa=e=>Object.assign(e,{useDualstackEndpoint:e.useDualstackEndpoint??!1,useFipsEndpoint:e.useFipsEndpoint??!1,defaultSigningName:`acm`}),oa={UseFIPS:{type:`builtInParams`,name:`useFipsEndpoint`},Endpoint:{type:`builtInParams`,name:`endpoint`},Region:{type:`builtInParams`,name:`region`},UseDualStack:{type:`builtInParams`,name:`useDualstackEndpoint`}},sa={name:`@aws-sdk/client-acm`,description:`AWS SDK for JavaScript Acm Client for Node.js, Browser and React Native`,version:`3.1063.0`,scripts:{build:`concurrently 'yarn:build:types' 'yarn:build:es' && yarn build:cjs`,"build:cjs":`node ../../scripts/compilation/inline client-acm`,"build:es":`tsc -p tsconfig.es.json`,"build:include:deps":`yarn g:turbo run build -F="$npm_package_name"`,"build:types":`tsc -p tsconfig.types.json`,"build:types:downlevel":`downlevel-dts dist-types dist-types/ts3.4`,clean:`premove dist-cjs dist-es dist-types tsconfig.cjs.tsbuildinfo tsconfig.es.tsbuildinfo tsconfig.types.tsbuildinfo`,"extract:docs":`api-extractor run --local`,"generate:client":`node ../../scripts/generate-clients/single-service --solo acm`,"test:e2e":`yarn g:vitest run -c vitest.config.e2e.mts`,"test:e2e:watch":`yarn g:vitest watch -c vitest.config.e2e.mts`,"test:index":`tsc --noEmit ./test/index-types.ts && node ./test/index-objects.spec.mjs`},main:`./dist-cjs/index.js`,types:`./dist-types/index.d.ts`,module:`./dist-es/index.js`,sideEffects:!1,dependencies:{"@aws-crypto/sha256-browser":`5.2.0`,"@aws-crypto/sha256-js":`5.2.0`,"@aws-sdk/core":`^3.974.18`,"@aws-sdk/credential-provider-node":`^3.972.52`,"@aws-sdk/types":`^3.973.11`,"@smithy/core":`^3.24.6`,"@smithy/fetch-http-handler":`^5.4.6`,"@smithy/node-http-handler":`^4.7.6`,"@smithy/types":`^4.14.3`,tslib:`^2.6.2`},devDependencies:{"@tsconfig/node20":`20.1.8`,"@types/node":`^20.14.8`,concurrently:`7.0.0`,"downlevel-dts":`0.10.1`,premove:`4.0.0`,typescript:`~5.8.3`},engines:{node:`>=20.0.0`},typesVersions:{"<4.5":{"dist-types/*":[`dist-types/ts3.4/*`]}},files:[`dist-*/**`],author:{name:`AWS SDK for JavaScript Team`,url:`https://aws.amazon.com/sdk-for-javascript/`},license:`Apache-2.0`,browser:{"./dist-es/runtimeConfig":`./dist-es/runtimeConfig.browser`},"react-native":{"./dist-es/runtimeConfig":`./dist-es/runtimeConfig.native`},homepage:`https://github.com/aws/aws-sdk-js-v3/tree/main/clients/client-acm`,repository:{type:`git`,url:`https://github.com/aws/aws-sdk-js-v3.git`,directory:`clients/client-acm`}};function ca(e,t){return new Request(e,t)}function la(e=0){return new Promise((t,n)=>{e&&setTimeout(()=>{let t=Error(`Request did not complete within ${e} ms`);t.name=`TimeoutError`,n(t)},e)})}var ua={supported:void 0},da=class e{config;configProvider;static create(t){return typeof t?.handle==`function`?t:new e(t)}constructor(e){typeof e==`function`?this.configProvider=e().then(e=>e||{}):(this.config=e??{},this.configProvider=Promise.resolve(this.config)),ua.supported===void 0&&(ua.supported=typeof Request<`u`&&`keepalive`in ca(`https://[::1]`))}destroy(){}async handle(e,{abortSignal:t,requestTimeout:n}={}){this.config||=await this.configProvider;let r=n??this.config.requestTimeout,i=this.config.keepAlive===!0,a=this.config.credentials;if(t?.aborted){let e=fa(t);return Promise.reject(e)}let o=e.path,s=nr(e.query||{});s&&(o+=`?${s}`),e.fragment&&(o+=`#${e.fragment}`);let c=``;(e.username!=null||e.password!=null)&&(c=`${e.username??``}:${e.password??``}@`);let{port:l,method:u}=e,d=`${e.protocol}//${c}${e.hostname}${l?`:${l}`:``}${o}`,f=u===`GET`||u===`HEAD`?void 0:e.body,p={body:f,headers:new Headers(e.headers),method:u,credentials:a};this.config?.cache&&(p.cache=this.config.cache),f&&(p.duplex=`half`),typeof AbortController<`u`&&(p.signal=t),ua.supported&&(p.keepalive=i),typeof this.config.requestInit==`function`&&Object.assign(p,this.config.requestInit(e));let ee=()=>{},m=ca(d,p),te=[fetch(m).then(e=>{let t=e.headers,n={};for(let e of t.entries())n[e[0]]=e[1];return e.body==null?e.blob().then(t=>({response:new Qe({headers:n,reason:e.statusText,statusCode:e.status,body:t})})):{response:new Qe({headers:n,reason:e.statusText,statusCode:e.status,body:e.body})}}),la(r)];return t&&te.push(new Promise((e,n)=>{let r=()=>{n(fa(t))};if(typeof t.addEventListener==`function`){let e=t;e.addEventListener(`abort`,r,{once:!0}),ee=()=>e.removeEventListener(`abort`,r)}else t.onabort=r})),Promise.race(te).finally(ee)}updateHttpClientConfig(e,t){this.config=void 0,this.configProvider=this.configProvider.then(n=>(n[e]=t,n))}httpHandlerConfigs(){return this.config??{}}};function fa(e){let t=e&&typeof e==`object`&&`reason`in e?e.reason:void 0;if(t){if(t instanceof Error){let e=Error(`Request aborted`);return e.name=`AbortError`,e.cause=t,e}let e=Error(String(t));return e.name=`AbortError`,e}let n=Error(`Request aborted`);return n.name=`AbortError`,n}var pa=async e=>typeof Blob==`function`&&e instanceof Blob||e.constructor?.name===`Blob`?Blob.prototype.arrayBuffer===void 0?ma(e):new Uint8Array(await e.arrayBuffer()):ha(e);async function ma(e){let t=fe(await ga(e));return new Uint8Array(t)}async function ha(e){let t=[],n=e.getReader(),r=!1,i=0;for(;!r;){let{done:e,value:a}=await n.read();a&&(t.push(a),i+=a.length),r=e}let a=new Uint8Array(i),o=0;for(let e of t)a.set(e,o),o+=e.length;return a}function ga(e){return new Promise((t,n)=>{let r=new FileReader;r.onloadend=()=>{if(r.readyState!==2)return n(Error(`Reader aborted too early`));let e=r.result??``,i=e.indexOf(`,`),a=i>-1?i+1:e.length;t(e.substring(a))},r.onabort=()=>n(Error(`Read aborted`)),r.onerror=()=>n(r.error),r.readAsDataURL(e)})}var _a=class{queryCompat;errorRegistry;constructor(e=!1){this.queryCompat=e}resolveRestContentType(e,t){let n=t.getMemberSchemas(),r=Object.values(n).find(e=>!!e.getMergedTraits().httpPayload);if(r)return r.getMergedTraits().mediaType||(r.isStringSchema()?`text/plain`:r.isBlobSchema()?`application/octet-stream`:e);if(!t.isUnitSchema()&&Object.values(n).find(e=>{let{httpQuery:t,httpQueryParams:n,httpHeader:r,httpLabel:i,httpPrefixHeaders:a}=e.getMergedTraits();return!t&&!n&&!r&&!i&&a===void 0}))return e}async getErrorSchemaOrThrowBaseException(e,t,n,r,i,a){let o=e;e.includes(`#`)&&([,o]=e.split(`#`));let s={$metadata:i,$fault:n.statusCode<500?`client`:`server`};if(!this.errorRegistry)throw Error(`@aws-sdk/core/protocols - error handler not initialized.`);try{return{errorSchema:a?.(this.errorRegistry,o)??this.errorRegistry.getSchema(e),errorMetadata:s}}catch{r.message=r.message??r.Message??`UnknownError`;let e=this.errorRegistry,t=e.getBaseException();if(t){let n=e.getErrorCtor(t)??Error;throw this.decorateServiceException(Object.assign(new n({name:o}),s),r)}let n=r,i=n?.message??n?.Message??n?.Error?.Message??n?.Error?.message;throw this.decorateServiceException(Object.assign(Error(i),{name:o},s),r)}}compose(e,t,n){let r=n;t.includes(`#`)&&([r]=t.split(`#`));let i=Lt.for(r),a=Lt.for(`smithy.ts.sdk.synthetic.`+n);e.copyFrom(i),e.copyFrom(a),this.errorRegistry=e}decorateServiceException(e,t={}){if(this.queryCompat){let n=e.Message??t.Message,r=Ut(e,t);n&&(r.message=n);let i=r.Error??{};i.Type=r.Error?.Type,i.Code=r.Error?.Code,i.Message=r.Error?.message??r.Error?.Message??n,r.Error=i;let a=r.$metadata.requestId;return a&&(r.RequestId=a),r}return Ut(e,t)}setQueryCompatError(e,t){let n=t.headers?.[`x-amzn-query-error`];if(e!==void 0&&n!=null){let[t,r]=n.split(`;`),i=Object.keys(e),a={Code:t,Type:r};e.Code=t,e.Type=r;for(let t=0;th.of(e).getMergedTraits().awsQueryError?.[0]===t)}}},va=class{serdeContext;setSerdeContext(e){this.serdeContext=e}},ya=class{from;to;keys;constructor(e,t){this.from=e,this.to=t;let n=Object.keys(this.from),r=new Set(n);r.delete(`__type`),this.keys=r}mark(e){this.keys.delete(e)}hasUnknown(){return this.keys.size===1&&Object.keys(this.to).length===0}writeUnknown(){if(this.hasUnknown()){let e=this.keys.values().next().value,t=this.from[e];this.to.$unknown=[e,t]}}};function ba(e,t,n){if(n?.source){let e=n.source;if(typeof t==`number`&&(t>2**53-1||t<-(2**53-1)||e!==String(t)))return e.includes(`.`)?new Ce(e,`bigDecimal`):BigInt(e)}return t}var xa=(e,t)=>Fn(e,t).then(e=>(t?.utf8Encoder??me)(e)),Sa=(e,t)=>xa(e,t).then(e=>{if(e.length)try{return JSON.parse(e)}catch(t){throw t?.name===`SyntaxError`&&Object.defineProperty(t,"$responseBodyText",{value:e}),t}return{}}),Ca=(e,t)=>Object.keys(e).find(e=>e.toLowerCase()===t.toLowerCase()),wa=e=>{let t=e;return typeof t==`number`&&(t=t.toString()),t.indexOf(`,`)>=0&&(t=t.split(`,`)[0]),t.indexOf(`:`)>=0&&(t=t.split(`:`)[0]),t.indexOf(`#`)>=0&&(t=t.split(`#`)[1]),t},Ta=(e,t)=>Da(e,t,[`header`,`code`,`type`]),Ea=(e,t,n=!1)=>Da(e,t,n?[`code`,`header`,`type`]:[`type`,`code`,`header`]),Da=({headers:e},t,n)=>{for(;n.length>0;)switch(n.shift()){case`header`:let n=Ca(e??{},`x-amzn-errortype`);if(n!==void 0)return wa(e[n]);break;case`code`:let r=Ca(t??{},`code`);if(r&&t[r]!==void 0)return wa(t[r]);break;case`type`:if(t?.__type!==void 0)return wa(t.__type);break}},Oa=class extends va{settings;constructor(e){super(),this.settings=e}async read(e,t){return this._read(e,typeof t==`string`?JSON.parse(t,ba):await Sa(t,this.serdeContext))}readObject(e,t){return this._read(e,t)}_read(e,t){let n=typeof t==`object`&&!!t,r=h.of(e);if(n){if(r.isStructSchema()){let e=t,n=r.isUnionSchema(),i={},a,{jsonName:o}=this.settings;o&&(a={});let s;n&&(s=new ya(e,i));for(let[t,c]of r.structIterator()){let r=t;o&&(r=c.getMergedTraits().jsonName??r,a[r]=t),n&&s.mark(r),e[r]!=null&&(i[t]=this._read(c,e[r]))}if(n)s.writeUnknown();else if(typeof e.__type==`string`)for(let t in e){let n=e[t],r=o?a[t]??t:t;r in i||(i[r]=n)}return i}if(Array.isArray(t)&&r.isListSchema()){let e=r.getValueSchema(),n=[];for(let r of t)n.push(this._read(e,r));return n}if(r.isMapSchema()){let e=r.getValueSchema(),n={};for(let r in t)n[r]=this._read(e,t[r]);return n}}if(r.isBlobSchema()&&typeof t==`string`)return fe(t);let i=r.getMergedTraits().mediaType;if(r.isStringSchema()&&typeof t==`string`&&i)return i===`application/json`||i.endsWith(`+json`)?_e.from(t):t;if(r.isTimestampSchema()&&t!=null)switch(Gn(r,this.settings)){case 5:return se(t);case 6:return Oe(t);case 7:return pe(t);default:return console.warn(`Missing timestamp format, parsing value with Date constructor:`,t),new Date(t)}if(r.isBigIntegerSchema()&&(typeof t==`number`||typeof t==`string`))return BigInt(t);if(r.isBigDecimalSchema()&&t!=null){if(t instanceof Ce)return t;let e=t;return e.type===`bigDecimal`&&`string`in e?new Ce(e.string,e.type):new Ce(String(t),`bigDecimal`)}if(r.isNumericSchema()&&typeof t==`string`){switch(t){case`Infinity`:return 1/0;case`-Infinity`:return-1/0;case`NaN`:return NaN}return t}if(r.isDocumentSchema())if(n){let e=Array.isArray(t)?[]:{};for(let n in t){let i=t[n];i instanceof Ce?e[n]=i:e[n]=this._read(r,i)}return e}else return structuredClone(t);return t}},ka=class{values=new Map;counter=0;stage=0;createReplacer(){if(this.stage===1)throw Error(`@aws-sdk/core/protocols - JsonReplacer already created.`);if(this.stage===2)throw Error(`@aws-sdk/core/protocols - JsonReplacer exhausted.`);return this.stage=1,(e,t)=>{if(t instanceof Ce){let e=`${`Νnv`+ this.counter++}_`+t.string;return this.values.set(`"${e}"`,t.string),e}if(typeof t==`bigint`){let e=t.toString(),n=`${`Νb`+ this.counter++}_`+e;return this.values.set(`"${n}"`,e),n}return t}}replaceInJson(e){if(this.stage===0)throw Error(`@aws-sdk/core/protocols - JsonReplacer not created yet.`);if(this.stage===2)throw Error(`@aws-sdk/core/protocols - JsonReplacer exhausted.`);if(this.stage=2,this.counter===0)return e;for(let[t,n]of this.values)e=e.replace(t,n);return e}},Aa=class extends va{settings;buffer;useReplacer=!1;rootSchema;constructor(e){super(),this.settings=e}write(e,t){this.rootSchema=h.of(e),this.buffer=this._write(this.rootSchema,t)}flush(){let{rootSchema:e,useReplacer:t}=this;if(this.rootSchema=void 0,this.useReplacer=!1,e?.isStructSchema()||e?.isDocumentSchema()){if(!t)return JSON.stringify(this.buffer);let e=new ka;return e.replaceInJson(JSON.stringify(this.buffer,e.createReplacer(),0))}return this.buffer}writeDiscriminatedDocument(e,t){this.write(e,t),typeof this.buffer==`object`&&(this.buffer.__type=h.of(e).getName(!0))}_write(e,t,n){let r=typeof t==`object`&&!!t,i=h.of(e);if(r){if(i.isStructSchema()){let e=t,n={},{jsonName:r}=this.settings,a;r&&(a={});let o=0;for(let[t,s]of i.structIterator()){let c=this._write(s,e[t],i);if(c!==void 0){let e=t;r&&(e=s.getMergedTraits().jsonName??t,a[t]=e),n[e]=c,o++}}if(i.isUnionSchema()&&o===0){let{$unknown:t}=e;if(Array.isArray(t)){let[e,r]=t;n[e]=this._write(15,r)}}else if(typeof e.__type==`string`)for(let t in e){let i=e[t],o=r?a[t]??t:t;o in n||(n[o]=this._write(15,i))}return n}if(Array.isArray(t)&&i.isListSchema()){let e=i.getValueSchema(),n=[],r=!!i.getMergedTraits().sparse;for(let i of t)(r||i!=null)&&n.push(this._write(e,i));return n}if(i.isMapSchema()){let e=i.getValueSchema(),n={},r=!!i.getMergedTraits().sparse;for(let i in t){let a=t[i];(r||a!=null)&&(n[i]=this._write(e,a))}return n}if(t instanceof Uint8Array&&(i.isBlobSchema()||i.isDocumentSchema()))return i===this.rootSchema?t:(this.serdeContext?.base64Encoder??le)(t);if(t instanceof Date&&(i.isTimestampSchema()||i.isDocumentSchema()))switch(Gn(i,this.settings)){case 5:return t.toISOString().replace(`.000Z`,`Z`);case 6:return ke(t);case 7:return t.getTime()/1e3;default:return console.warn(`Missing timestamp format, using epoch seconds`,t),t.getTime()/1e3}t instanceof Ce&&(this.useReplacer=!0)}if(!(t===null&&n?.isStructSchema())){if(i.isStringSchema()){if(t===void 0&&i.isIdempotencyToken())return Se();let e=i.getMergedTraits().mediaType;return t!=null&&e&&(e===`application/json`||e.endsWith(`+json`))?_e.from(t):t}if(typeof t==`number`&&i.isNumericSchema())return Math.abs(t)===1/0||isNaN(t)?String(t):t;if(typeof t==`string`&&i.isBlobSchema())return i===this.rootSchema?t:(this.serdeContext?.base64Encoder??le)(t);if(typeof t==`bigint`&&(this.useReplacer=!0),i.isDocumentSchema())if(r){let e=Array.isArray(t)?[]:{};for(let n in t){let r=t[n];r instanceof Ce?(this.useReplacer=!0,e[n]=r):e[n]=this._write(i,r)}return e}else return structuredClone(t);return t}}},ja=class extends va{settings;constructor(e){super(),this.settings=e}createSerializer(){let e=new Aa(this.settings);return e.setSerdeContext(this.serdeContext),e}createDeserializer(){let e=new Oa(this.settings);return e.setSerdeContext(this.serdeContext),e}},Ma=class extends Wn{serializer;deserializer;serviceTarget;codec;mixin;awsQueryCompatible;constructor({defaultNamespace:e,errorTypeRegistries:t,serviceTarget:n,awsQueryCompatible:r,jsonCodec:i}){super({defaultNamespace:e,errorTypeRegistries:t}),this.serviceTarget=n,this.codec=i??new ja({timestampFormat:{useTrait:!0,default:7},jsonName:!1}),this.serializer=this.codec.createSerializer(),this.deserializer=this.codec.createDeserializer(),this.awsQueryCompatible=!!r,this.mixin=new _a(this.awsQueryCompatible)}async serializeRequest(e,t,n){let r=await super.serializeRequest(e,t,n);return r.path.endsWith(`/`)||(r.path+=`/`),r.headers[`content-type`]=`application/x-amz-json-${this.getJsonRpcVersion()}`,r.headers[`x-amz-target`]=`${this.serviceTarget}.${e.name}`,this.awsQueryCompatible&&(r.headers[`x-amzn-query-mode`]=`true`),(xt(e.input)===`unit`||!r.body)&&(r.body=`{}`),r}getPayloadCodec(){return this.codec}async handleError(e,t,n,r,i){let{awsQueryCompatible:a}=this;a&&this.mixin.setQueryCompatError(r,n);let o=Ea(n,r,a)??`Unknown`;this.mixin.compose(this.compositeErrorRegistry,o,this.options.defaultNamespace);let{errorSchema:s,errorMetadata:c}=await this.mixin.getErrorSchemaOrThrowBaseException(o,this.options.defaultNamespace,n,r,i,a?this.mixin.findQueryCompatibleError:void 0),l=h.of(s),u=r.message??r.Message??`UnknownError`,d=new((this.compositeErrorRegistry.getErrorCtor(s))??Error)({}),f={},p=this.codec.createDeserializer();for(let[e,t]of l.structIterator())r[e]!=null&&(f[e]=p.readObject(t,r[e]));throw a&&this.mixin.queryCompatOutput(r,f),this.mixin.decorateServiceException(Object.assign(d,c,{$fault:l.getMergedTraits().error,message:u},f),r)}},Na=class extends Ma{constructor({defaultNamespace:e,errorTypeRegistries:t,serviceTarget:n,awsQueryCompatible:r,jsonCodec:i}){super({defaultNamespace:e,errorTypeRegistries:t,serviceTarget:n,awsQueryCompatible:r,jsonCodec:i})}getShapeId(){return`aws.protocols#awsJson1_1`}getJsonRpcVersion(){return`1.1`}getDefaultContentType(){return`application/x-amz-json-1.1`}},Pa=class extends Un{serializer;deserializer;codec;mixin=new _a;constructor({defaultNamespace:e,errorTypeRegistries:t}){super({defaultNamespace:e,errorTypeRegistries:t});let n={timestampFormat:{useTrait:!0,default:7},httpBindings:!0,jsonName:!0};this.codec=new ja(n),this.serializer=new Yn(this.codec.createSerializer(),n),this.deserializer=new qn(this.codec.createDeserializer(),n)}getShapeId(){return`aws.protocols#restJson1`}getPayloadCodec(){return this.codec}setSerdeContext(e){this.codec.setSerdeContext(e),super.setSerdeContext(e)}async serializeRequest(e,t,n){let r=await super.serializeRequest(e,t,n),i=h.of(e.input);if(!r.headers[`content-type`]){let e=this.mixin.resolveRestContentType(this.getDefaultContentType(),i);e&&(r.headers[`content-type`]=e)}return r.body==null&&r.headers[`content-type`]===this.getDefaultContentType()&&(r.body=`{}`),r}async deserializeResponse(e,t,n){let r=await super.deserializeResponse(e,t,n),i=h.of(e.output);for(let[e,t]of i.structIterator())t.getMemberTraits().httpPayload&&!(e in r)&&(r[e]=null);return r}async handleError(e,t,n,r,i){let a=Ta(n,r)??`Unknown`;this.mixin.compose(this.compositeErrorRegistry,a,this.options.defaultNamespace);let{errorSchema:o,errorMetadata:s}=await this.mixin.getErrorSchemaOrThrowBaseException(a,this.options.defaultNamespace,n,r,i),c=h.of(o),l=r.message??r.Message??`UnknownError`,u=new((this.compositeErrorRegistry.getErrorCtor(o))??Error)({});await this.deserializeHttpMessage(o,t,n,r);let d={},f=this.codec.createDeserializer();for(let[e,t]of c.structIterator()){let n=t.getMergedTraits().jsonName??e;d[e]=f.readObject(t,r[n])}throw this.mixin.decorateServiceException(Object.assign(u,s,{$fault:c.getMergedTraits().error,message:l},d),r)}getDefaultContentType(){return`application/json`}},Fa=/[&<>"]/g,Ia={"&":`&`,"<":`<`,">":`>`,'"':`"`};function La(e){return e.replace(Fa,e=>Ia[e])}var Ra=/[&"'<>\r\n\u0085\u2028]/g,za={"&":`&`,'"':`"`,"'":`'`,"<":`<`,">":`>`,"\r":`
`,"\n":`
`,"
":`
`,"\u2028":`
`};function Ba(e){return e.replace(Ra,e=>za[e])}var Va=class{value;constructor(e){this.value=e}toString(){return Ba(``+this.value)}},Ha=class e{name;children;attributes={};static of(t,n,r){let i=new e(t);return n!==void 0&&i.addChildNode(new Va(n)),r!==void 0&&i.withName(r),i}constructor(e,t=[]){this.name=e,this.children=t}withName(e){return this.name=e,this}addAttribute(e,t){return this.attributes[e]=t,this}addChildNode(e){return this.children.push(e),this}removeAttribute(e){return delete this.attributes[e],this}n(e){return this.name=e,this}c(e){return this.children.push(e),this}a(e,t){return t!=null&&(this.attributes[e]=t),this}cc(t,n,r=n){if(t[n]!=null){let i=e.of(n,t[n]).withName(r);this.c(i)}}l(e,t,n,r){e[t]!=null&&r().map(e=>{e.withName(n),this.c(e)})}lc(t,n,r,i){if(t[n]!=null){let t=i(),n=new e(r);t.map(e=>{n.c(e)}),this.c(n)}}toString(){let e=!!this.children.length,t=`<${this.name}`,n=this.attributes;for(let e of Object.keys(n)){let r=n[e];r!=null&&(t+=` ${e}="${La(``+r)}"`)}return t+=e?`>${this.children.map(e=>e.toString()).join(``)}${this.name}>`:`/>`}},Ua;function Wa(e){Ua||=new DOMParser;let t=Ua.parseFromString(e,`application/xml`);if(t.getElementsByTagName(`parsererror`).length>0)throw Error(`DOMParser XML parsing error.`);let n=e=>{if(e.nodeType===Node.TEXT_NODE&&e.textContent?.trim())return e.textContent;if(e.nodeType===Node.ELEMENT_NODE){let t=e;if(t.attributes.length===0&&t.childNodes.length===0)return``;let r={},i=Array.from(t.attributes);for(let e of i)r[`${e.name}`]=e.value;let a=Array.from(t.childNodes);for(let e of a){let o=n(e);if(o!=null){let t=e.nodeName;if(a.length===1&&i.length===0&&t===`#text`)return o;r[t]?Array.isArray(r[t])?r[t].push(o):r[t]=[r[t],o]:r[t]=o}else if(a.length===1&&i.length===0)return t.textContent}return r}return null};return{[t.documentElement.nodeName]:n(t.documentElement)}}var Ga=class extends va{settings;stringDeserializer;constructor(e){super(),this.settings=e,this.stringDeserializer=new Kn(e)}setSerdeContext(e){this.serdeContext=e,this.stringDeserializer.setSerdeContext(e)}read(e,t,n){let r=h.of(e),i=r.getMemberSchemas();if(r.isStructSchema()&&r.isMemberSchema()&&Object.values(i).find(e=>!!e.getMemberTraits().eventPayload)){let e={},n=Object.keys(i)[0];return i[n].isBlobSchema()?e[n]=t:e[n]=this.read(i[n],t),e}let a=(this.serdeContext?.utf8Encoder??me)(t),o=this.parseXml(a);return this.readSchema(e,n?o[n]:o)}readSchema(e,t){let n=h.of(e);if(n.isUnitSchema())return;let r=n.getMergedTraits();if(n.isListSchema()&&!Array.isArray(t))return this.readSchema(n,[t]);if(t==null)return t;if(typeof t==`object`){let e=!!r.xmlFlattened;if(n.isListSchema()){let r=n.getValueSchema(),i=[],a=r.getMergedTraits().xmlName??`member`,o=e?t:(t[0]??t)[a];if(o==null)return i;let s=Array.isArray(o)?o:[o];for(let e of s)i.push(this.readSchema(r,e));return i}let i={};if(n.isMapSchema()){let r=n.getKeySchema(),a=n.getValueSchema(),o;o=e?Array.isArray(t)?t:[t]:Array.isArray(t.entry)?t.entry:[t.entry];let s=r.getMergedTraits().xmlName??`key`,c=a.getMergedTraits().xmlName??`value`;for(let e of o){let t=e[s],n=e[c];i[t]=this.readSchema(a,n)}return i}if(n.isStructSchema()){let e=n.isUnionSchema(),r;e&&(r=new ya(t,i));for(let[a,o]of n.structIterator()){let n=o.getMergedTraits(),s=n.httpPayload?n.xmlName??o.getName():o.getMemberTraits().xmlName??a;e&&r.mark(s),t[s]!=null&&(i[a]=this.readSchema(o,t[s]))}return e&&r.writeUnknown(),i}if(n.isDocumentSchema())return t;throw Error(`@aws-sdk/core/protocols - xml deserializer unhandled schema type for ${n.getName(!0)}`)}return n.isListSchema()?[]:n.isMapSchema()||n.isStructSchema()?{}:this.stringDeserializer.read(n,t)}parseXml(e){if(e.length){let t;try{t=Wa(e)}catch(t){throw t&&typeof t==`object`&&Object.defineProperty(t,"$responseBodyText",{value:e}),t}let n=`#text`,r=Object.keys(t)[0],i=t[r];return i[n]&&(i[r]=i[n],delete i[n]),Qt(i)}return{}}},Ka=class extends va{settings;buffer;constructor(e){super(),this.settings=e}write(e,t,n=``){this.buffer===void 0&&(this.buffer=``);let r=h.of(e);if(n&&!n.endsWith(`.`)&&(n+=`.`),r.isBlobSchema())(typeof t==`string`||t instanceof Uint8Array)&&(this.writeKey(n),this.writeValue((this.serdeContext?.base64Encoder??le)(t)));else if(r.isBooleanSchema()||r.isNumericSchema()||r.isStringSchema())t==null?r.isIdempotencyToken()&&(this.writeKey(n),this.writeValue(Se())):(this.writeKey(n),this.writeValue(String(t)));else if(r.isBigIntegerSchema())t!=null&&(this.writeKey(n),this.writeValue(String(t)));else if(r.isBigDecimalSchema())t!=null&&(this.writeKey(n),this.writeValue(t instanceof Ce?t.string:String(t)));else if(r.isTimestampSchema()){if(t instanceof Date)switch(this.writeKey(n),Gn(r,this.settings)){case 5:this.writeValue(t.toISOString().replace(`.000Z`,`Z`));break;case 6:this.writeValue(ke(t));break;case 7:this.writeValue(String(t.getTime()/1e3));break}}else if(r.isDocumentSchema())Array.isArray(t)?this.write(79,t,n):t instanceof Date?this.write(4,t,n):t instanceof Uint8Array?this.write(21,t,n):t&&typeof t==`object`?this.write(143,t,n):(this.writeKey(n),this.writeValue(String(t)));else if(r.isListSchema()){if(Array.isArray(t))if(t.length===0)this.settings.serializeEmptyLists&&(this.writeKey(n),this.writeValue(``));else{let e=r.getValueSchema(),i=this.settings.flattenLists||r.getMergedTraits().xmlFlattened,a=1;for(let r of t){if(r==null)continue;let t=e.getMergedTraits(),o=this.getKey(`member`,t.xmlName,t.ec2QueryName),s=i?`${n}${a}`:`${n}${o}.${a}`;this.write(e,r,s),++a}}}else if(r.isMapSchema()){if(t&&typeof t==`object`){let e=r.getKeySchema(),i=r.getValueSchema(),a=r.getMergedTraits().xmlFlattened,o=1;for(let r in t){let s=t[r];if(s==null)continue;let c=e.getMergedTraits(),l=this.getKey(`key`,c.xmlName,c.ec2QueryName),u=a?`${n}${o}.${l}`:`${n}entry.${o}.${l}`,d=i.getMergedTraits(),f=this.getKey(`value`,d.xmlName,d.ec2QueryName),p=a?`${n}${o}.${f}`:`${n}entry.${o}.${f}`;this.write(e,r,u),this.write(i,s,p),++o}}}else if(r.isStructSchema()){if(t&&typeof t==`object`){let e=!1;for(let[i,a]of r.structIterator()){if(t[i]==null&&!a.isIdempotencyToken())continue;let r=a.getMergedTraits(),o=this.getKey(i,r.xmlName,r.ec2QueryName,`struct`),s=`${n}${o}`;this.write(a,t[i],s),e=!0}if(!e&&r.isUnionSchema()){let{$unknown:e}=t;if(Array.isArray(e)){let[t,r]=e,i=`${n}${t}`;this.write(15,r,i)}}}}else if(!r.isUnitSchema())throw Error(`@aws-sdk/core/protocols - QuerySerializer unrecognized schema type ${r.getName(!0)}`)}flush(){if(this.buffer===void 0)throw Error(`@aws-sdk/core/protocols - QuerySerializer cannot flush with nothing written to buffer.`);let e=this.buffer;return delete this.buffer,e}getKey(e,t,n,r){let{ec2:i,capitalizeKeys:a}=this.settings;if(i&&n)return n;let o=t??e;return a&&r===`struct`?o[0].toUpperCase()+o.slice(1):o}writeKey(e){e.endsWith(`.`)&&(e=e.slice(0,e.length-1)),this.buffer+=`&${In(e)}=`}writeValue(e){this.buffer+=In(e)}},qa=class extends Wn{options;serializer;deserializer;mixin=new _a;constructor(e){super({defaultNamespace:e.defaultNamespace,errorTypeRegistries:e.errorTypeRegistries}),this.options=e;let t={timestampFormat:{useTrait:!0,default:5},httpBindings:!1,xmlNamespace:e.xmlNamespace,serviceNamespace:e.defaultNamespace,serializeEmptyLists:!0};this.serializer=new Ka(t),this.deserializer=new Ga(t)}getShapeId(){return`aws.protocols#awsQuery`}setSerdeContext(e){this.serializer.setSerdeContext(e),this.deserializer.setSerdeContext(e)}getPayloadCodec(){throw Error(`AWSQuery protocol has no payload codec.`)}async serializeRequest(e,t,n){let r=await super.serializeRequest(e,t,n);return r.path.endsWith(`/`)||(r.path+=`/`),r.headers[`content-type`]=`application/x-www-form-urlencoded`,(xt(e.input)===`unit`||!r.body)&&(r.body=``),r.body=`Action=${e.name.split(`#`)[1]??e.name}&Version=${this.options.version}`+r.body,r.body.endsWith(`&`)&&(r.body=r.body.slice(-1)),r}async deserializeResponse(e,t,n){let r=this.deserializer,i=h.of(e.output),a={};if(n.statusCode>=300){let i=await Fn(n.body,t);i.byteLength>0&&Object.assign(a,await r.read(15,i)),await this.handleError(e,t,n,a,this.deserializeMetadata(n))}for(let e in n.headers){let t=n.headers[e];delete n.headers[e],n.headers[e.toLowerCase()]=t}let o=e.name.split(`#`)[1]??e.name,s=i.isStructSchema()&&this.useNestedResult()?o+`Result`:void 0,c=await Fn(n.body,t);return c.byteLength>0&&Object.assign(a,await r.read(i,c,s)),a.$metadata=this.deserializeMetadata(n),a}useNestedResult(){return!0}async handleError(e,t,n,r,i){let a=this.loadQueryErrorCode(n,r)??`Unknown`;this.mixin.compose(this.compositeErrorRegistry,a,this.options.defaultNamespace);let o=this.loadQueryError(r)??{},s=this.loadQueryErrorMessage(r);o.message=s,o.Error={Type:o.Type,Code:o.Code,Message:s};let{errorSchema:c,errorMetadata:l}=await this.mixin.getErrorSchemaOrThrowBaseException(a,this.options.defaultNamespace,n,o,i,this.mixin.findQueryCompatibleError),u=h.of(c),d=new((this.compositeErrorRegistry.getErrorCtor(c))??Error)({}),f={Type:o.Error.Type,Code:o.Error.Code,Error:o.Error};for(let[e,t]of u.structIterator()){let n=t.getMergedTraits().xmlName??e,i=o[n]??r[n];f[e]=this.deserializer.readSchema(t,i)}throw this.mixin.decorateServiceException(Object.assign(d,l,{$fault:u.getMergedTraits().error,message:s},f),r)}loadQueryErrorCode(e,t){let n=(t.Errors?.[0]?.Error??t.Errors?.Error??t.Error)?.Code;if(n!==void 0)return n;if(e.statusCode==404)return`NotFound`}loadQueryError(e){return e.Errors?.[0]?.Error??e.Errors?.Error??e.Error}loadQueryErrorMessage(e){let t=this.loadQueryError(e);return t?.message??t?.Message??e.message??e.Message??`Unknown`}getDefaultContentType(){return`application/x-www-form-urlencoded`}},Ja=class extends qa{options;constructor(e){super(e),this.options=e,Object.assign(this.serializer.settings,{capitalizeKeys:!0,flattenLists:!0,serializeEmptyLists:!1,ec2:!0})}getShapeId(){return`aws.protocols#ec2Query`}useNestedResult(){return!1}},Ya=(e,t)=>{if(t?.Error?.Code!==void 0)return t.Error.Code;if(t?.Code!==void 0)return t.Code;if(e.statusCode==404)return`NotFound`},Xa=class extends va{settings;stringBuffer;byteBuffer;buffer;constructor(e){super(),this.settings=e}write(e,t){let n=h.of(e);if(n.isStringSchema()&&typeof t==`string`)this.stringBuffer=t;else if(n.isBlobSchema())this.byteBuffer=`byteLength`in t?t:(this.serdeContext?.base64Decoder??fe)(t);else{this.buffer=this.writeStruct(n,t,void 0);let e=n.getMergedTraits();e.httpPayload&&!e.xmlName&&this.buffer.withName(n.getName())}}flush(){if(this.byteBuffer!==void 0){let e=this.byteBuffer;return delete this.byteBuffer,e}if(this.stringBuffer!==void 0){let e=this.stringBuffer;return delete this.stringBuffer,e}let e=this.buffer;return this.settings.xmlNamespace&&(e?.attributes?.xmlns||e.addAttribute(`xmlns`,this.settings.xmlNamespace)),delete this.buffer,e.toString()}writeStruct(e,t,n){let r=e.getMergedTraits(),i=e.isMemberSchema()&&!r.httpPayload?e.getMemberTraits().xmlName??e.getMemberName():r.xmlName??e.getName();if(!i||!e.isStructSchema())throw Error(`@aws-sdk/core/protocols - xml serializer, cannot write struct with empty name or non-struct, schema=${e.getName(!0)}.`);let a=Ha.of(i),[o,s]=this.getXmlnsAttribute(e,n);for(let[n,r]of e.structIterator()){let e=t[n];if(e!=null||r.isIdempotencyToken()){if(r.getMergedTraits().xmlAttribute){a.addAttribute(r.getMergedTraits().xmlName??n,this.writeSimple(r,e));continue}if(r.isListSchema())this.writeList(r,e,a,s);else if(r.isMapSchema())this.writeMap(r,e,a,s);else if(r.isStructSchema())a.addChildNode(this.writeStruct(r,e,s));else{let t=Ha.of(r.getMergedTraits().xmlName??r.getMemberName());this.writeSimpleInto(r,e,t,s),a.addChildNode(t)}}}let{$unknown:c}=t;if(c&&e.isUnionSchema()&&Array.isArray(c)&&Object.keys(t).length===1){let[e,n]=c,r=Ha.of(e);if(typeof n!=`string`)if(t instanceof Ha||t instanceof Va)a.addChildNode(t);else throw Error(`@aws-sdk - $unknown union member in XML requires value of type string, @aws-sdk/xml-builder::XmlNode or XmlText.`);this.writeSimpleInto(0,n,r,s),a.addChildNode(r)}return s&&a.addAttribute(o,s),a}writeList(e,t,n,r){if(!e.isMemberSchema())throw Error(`@aws-sdk/core/protocols - xml serializer, cannot write non-member list: ${e.getName(!0)}`);let i=e.getMergedTraits(),a=e.getValueSchema(),o=a.getMergedTraits(),s=!!o.sparse,c=!!i.xmlFlattened,[l,u]=this.getXmlnsAttribute(e,r),d=(t,n)=>{if(a.isListSchema())this.writeList(a,Array.isArray(n)?n:[n],t,u);else if(a.isMapSchema())this.writeMap(a,n,t,u);else if(a.isStructSchema()){let r=this.writeStruct(a,n,u);t.addChildNode(r.withName(c?i.xmlName??e.getMemberName():o.xmlName??`member`))}else{let r=Ha.of(c?i.xmlName??e.getMemberName():o.xmlName??`member`);this.writeSimpleInto(a,n,r,u),t.addChildNode(r)}};if(c)for(let e of t)(s||e!=null)&&d(n,e);else{let r=Ha.of(i.xmlName??e.getMemberName());u&&r.addAttribute(l,u);for(let e of t)(s||e!=null)&&d(r,e);n.addChildNode(r)}}writeMap(e,t,n,r,i=!1){if(!e.isMemberSchema())throw Error(`@aws-sdk/core/protocols - xml serializer, cannot write non-member map: ${e.getName(!0)}`);let a=e.getMergedTraits(),o=e.getKeySchema(),s=o.getMergedTraits().xmlName??`key`,c=e.getValueSchema(),l=c.getMergedTraits(),u=l.xmlName??`value`,d=!!l.sparse,f=!!a.xmlFlattened,[p,ee]=this.getXmlnsAttribute(e,r),m=(e,t,n)=>{let r=Ha.of(s,t),[i,a]=this.getXmlnsAttribute(o,ee);a&&r.addAttribute(i,a),e.addChildNode(r);let l=Ha.of(u);c.isListSchema()?this.writeList(c,n,l,ee):c.isMapSchema()?this.writeMap(c,n,l,ee,!0):c.isStructSchema()?l=this.writeStruct(c,n,ee):this.writeSimpleInto(c,n,l,ee),e.addChildNode(l)};if(f)for(let r in t){let i=t[r];if(d||i!=null){let t=Ha.of(a.xmlName??e.getMemberName());m(t,r,i),n.addChildNode(t)}}else{let r;i||(r=Ha.of(a.xmlName??e.getMemberName()),ee&&r.addAttribute(p,ee),n.addChildNode(r));for(let e in t){let a=t[e];if(d||a!=null){let t=Ha.of(`entry`);m(t,e,a),(i?n:r).addChildNode(t)}}}}writeSimple(e,t){if(t===null)throw Error(`@aws-sdk/core/protocols - (XML serializer) cannot write null value.`);let n=h.of(e),r=null;if(t&&typeof t==`object`)if(n.isBlobSchema())r=(this.serdeContext?.base64Encoder??le)(t);else if(n.isTimestampSchema()&&t instanceof Date)switch(Gn(n,this.settings)){case 5:r=t.toISOString().replace(`.000Z`,`Z`);break;case 6:r=ke(t);break;case 7:r=String(t.getTime()/1e3);break;default:console.warn(`Missing timestamp format, using http date`,t),r=ke(t);break}else if(n.isBigDecimalSchema()&&t)return t instanceof Ce?t.string:String(t);else if(n.isMapSchema()||n.isListSchema())throw Error(`@aws-sdk/core/protocols - xml serializer, cannot call _write() on List/Map schema, call writeList or writeMap() instead.`);else throw Error(`@aws-sdk/core/protocols - xml serializer, unhandled schema type for object value and schema: ${n.getName(!0)}`);if((n.isBooleanSchema()||n.isNumericSchema()||n.isBigIntegerSchema()||n.isBigDecimalSchema())&&(r=String(t)),n.isStringSchema()&&(r=t===void 0&&n.isIdempotencyToken()?Se():String(t)),r===null)throw Error(`Unhandled schema-value pair ${n.getName(!0)}=${t}`);return r}writeSimpleInto(e,t,n,r){let i=this.writeSimple(e,t),a=h.of(e),o=new Va(i),[s,c]=this.getXmlnsAttribute(a,r);c&&n.addAttribute(s,c),n.addChildNode(o)}getXmlnsAttribute(e,t){let[n,r]=e.getMergedTraits().xmlNamespace??[];return r&&r!==t?[n?`xmlns:${n}`:`xmlns`,r]:[void 0,void 0]}},Za=class extends va{settings;constructor(e){super(),this.settings=e}createSerializer(){let e=new Xa(this.settings);return e.setSerdeContext(this.serdeContext),e}createDeserializer(){let e=new Ga(this.settings);return e.setSerdeContext(this.serdeContext),e}},Qa=class extends Un{codec;serializer;deserializer;mixin=new _a;constructor(e){super(e);let t={timestampFormat:{useTrait:!0,default:5},httpBindings:!0,xmlNamespace:e.xmlNamespace,serviceNamespace:e.defaultNamespace};this.codec=new Za(t),this.serializer=new Yn(this.codec.createSerializer(),t),this.deserializer=new qn(this.codec.createDeserializer(),t)}getPayloadCodec(){return this.codec}getShapeId(){return`aws.protocols#restXml`}async serializeRequest(e,t,n){let r=await super.serializeRequest(e,t,n),i=h.of(e.input);if(!r.headers[`content-type`]){let e=this.mixin.resolveRestContentType(this.getDefaultContentType(),i);e&&(r.headers[`content-type`]=e)}return typeof r.body==`string`&&r.headers[`content-type`]===this.getDefaultContentType()&&!r.body.startsWith(``+r.body),r}async deserializeResponse(e,t,n){return super.deserializeResponse(e,t,n)}async handleError(e,t,n,r,i){let a=Ya(n,r)??`Unknown`;if(this.mixin.compose(this.compositeErrorRegistry,a,this.options.defaultNamespace),r.Error&&typeof r.Error==`object`)for(let e of Object.keys(r.Error))r[e]=r.Error[e],e.toLowerCase()===`message`&&(r.message=r.Error[e]);r.RequestId&&!i.requestId&&(i.requestId=r.RequestId);let{errorSchema:o,errorMetadata:s}=await this.mixin.getErrorSchemaOrThrowBaseException(a,this.options.defaultNamespace,n,r,i),c=h.of(o),l=r.Error?.message??r.Error?.Message??r.message??r.Message??`UnknownError`,u=new((this.compositeErrorRegistry.getErrorCtor(o))??Error)({});await this.deserializeHttpMessage(o,t,n,r);let d={},f=this.codec.createDeserializer();for(let[e,t]of c.structIterator()){let n=t.getMergedTraits().xmlName??e,i=r.Error?.[n]??r[n];d[e]=f.readSchema(t,i)}throw this.mixin.decorateServiceException(Object.assign(u,s,{$fault:c.getMergedTraits().error,message:l},d),r)}getDefaultContentType(){return`application/xml`}hasUnstructuredPayloadBinding(e){for(let[,t]of e.structIterator())if(t.getMergedTraits().httpPayload)return!(t.isStructSchema()||t.isMapSchema()||t.isListSchema());return!1}},$a=`ref`,eo=-1,to=!0,no=`isSet`,ro=`PartitionResult`,io=`booleanEquals`,ao=`getAttr`,oo={[$a]:`Endpoint`},so={[$a]:ro},co={},lo=[{[$a]:`Region`}],uo={conditions:[[no,[oo]],[no,lo],[`aws.partition`,lo,ro],[io,[{[$a]:`UseFIPS`},to]],[io,[{[$a]:`UseDualStack`},to]],[io,[{fn:ao,argv:[so,`supportsDualStack`]},to]],[io,[{fn:ao,argv:[so,`supportsFIPS`]},to]],[`stringEquals`,[{fn:ao,argv:[so,`name`]},`aws-us-gov`]]],results:[[eo],[eo,`Invalid Configuration: FIPS and custom endpoint are not supported`],[eo,`Invalid Configuration: Dualstack and custom endpoint are not supported`],[oo,co],[`https://acm-fips.{Region}.{PartitionResult#dualStackDnsSuffix}`,co],[eo,`FIPS and DualStack are enabled, but this partition does not support one or both`],[`https://acm.{Region}.amazonaws.com`,co],[`https://acm-fips.{Region}.{PartitionResult#dnsSuffix}`,co],[eo,`FIPS is enabled but this partition does not support FIPS`],[`https://acm.{Region}.{PartitionResult#dualStackDnsSuffix}`,co],[eo,`DualStack is enabled but this partition does not support DualStack`],[`https://acm.{Region}.{PartitionResult#dnsSuffix}`,co],[eo,`Invalid Configuration: Missing Region`]]},fo=2,po=new Int32Array([-1,1,-1,0,13,3,1,4,100000012,2,5,100000012,3,8,6,4,7,100000011,5,100000009,100000010,4,11,9,6,10,100000008,7,100000006,100000007,5,12,100000005,6,100000004,100000005,3,100000001,14,4,100000002,100000003]),mo=on.from(po,fo,uo.conditions,uo.results),ho=new sn({size:50,params:[`Endpoint`,`Region`,`UseDualStack`,`UseFIPS`]}),go=(e,t={})=>ho.get(e,()=>jn(mo,{endpointParams:e,logger:t.logger}));dn.aws=ji;var _o=class e extends Ht{constructor(t){super(t),Object.setPrototypeOf(this,e.prototype)}},vo=class e extends _o{name=`AccessDeniedException`;$fault=`client`;Message;constructor(t){super({name:`AccessDeniedException`,$fault:`client`,...t}),Object.setPrototypeOf(this,e.prototype),this.Message=t.Message}},yo=class e extends _o{name=`InvalidArnException`;$fault=`client`;constructor(t){super({name:`InvalidArnException`,$fault:`client`,...t}),Object.setPrototypeOf(this,e.prototype)}},bo=class e extends _o{name=`InvalidParameterException`;$fault=`client`;constructor(t){super({name:`InvalidParameterException`,$fault:`client`,...t}),Object.setPrototypeOf(this,e.prototype)}},xo=class e extends _o{name=`InvalidTagException`;$fault=`client`;constructor(t){super({name:`InvalidTagException`,$fault:`client`,...t}),Object.setPrototypeOf(this,e.prototype)}},fee=class e extends _o{name=`ResourceNotFoundException`;$fault=`client`;constructor(t){super({name:`ResourceNotFoundException`,$fault:`client`,...t}),Object.setPrototypeOf(this,e.prototype)}},pee=class e extends _o{name=`TagPolicyException`;$fault=`client`;constructor(t){super({name:`TagPolicyException`,$fault:`client`,...t}),Object.setPrototypeOf(this,e.prototype)}},mee=class e extends _o{name=`ThrottlingException`;$fault=`client`;throttlingReasons;constructor(t){super({name:`ThrottlingException`,$fault:`client`,...t}),Object.setPrototypeOf(this,e.prototype),this.throttlingReasons=t.throttlingReasons}},hee=class e extends _o{name=`TooManyTagsException`;$fault=`client`;constructor(t){super({name:`TooManyTagsException`,$fault:`client`,...t}),Object.setPrototypeOf(this,e.prototype)}},gee=class e extends _o{name=`ConflictException`;$fault=`client`;constructor(t){super({name:`ConflictException`,$fault:`client`,...t}),Object.setPrototypeOf(this,e.prototype)}},_ee=class e extends _o{name=`ResourceInUseException`;$fault=`client`;constructor(t){super({name:`ResourceInUseException`,$fault:`client`,...t}),Object.setPrototypeOf(this,e.prototype)}},vee=class e extends _o{name=`RequestInProgressException`;$fault=`client`;constructor(t){super({name:`RequestInProgressException`,$fault:`client`,...t}),Object.setPrototypeOf(this,e.prototype)}},yee=class e extends _o{name=`LimitExceededException`;$fault=`client`;constructor(t){super({name:`LimitExceededException`,$fault:`client`,...t}),Object.setPrototypeOf(this,e.prototype)}},bee=class e extends _o{name=`InvalidArgsException`;$fault=`client`;constructor(t){super({name:`InvalidArgsException`,$fault:`client`,...t}),Object.setPrototypeOf(this,e.prototype)}},xee=class e extends _o{name=`ValidationException`;$fault=`client`;constructor(t){super({name:`ValidationException`,$fault:`client`,...t}),Object.setPrototypeOf(this,e.prototype)}},See=class e extends _o{name=`InvalidDomainValidationOptionsException`;$fault=`client`;constructor(t){super({name:`InvalidDomainValidationOptionsException`,$fault:`client`,...t}),Object.setPrototypeOf(this,e.prototype)}},Cee=class e extends _o{name=`InvalidStateException`;$fault=`client`;constructor(t){super({name:`InvalidStateException`,$fault:`client`,...t}),Object.setPrototypeOf(this,e.prototype)}},wee=`AccessDeniedException`,Tee=`Certificate`,Eee=`CreatedAt`,So=`CertificateArn`,Dee=`CertificateChain`,Oee=`ConflictException`,kee=`CertificateSummary`,Co=`CertificateSummaryList`,Aee=`CertificateStatuses`,jee=`DomainName`,Mee=`Exported`,Nee=`ExtendedKeyUsages`,Pee=`ExportOption`,Fee=`Filters`,Iee=`HasAdditionalSubjectAlternativeNames`,Lee=`ImportedAt`,Ree=`InvalidArgsException`,zee=`InvalidArnException`,Bee=`IssuedAt`,Vee=`ImportCertificate`,Hee=`ImportCertificateRequest`,Uee=`ImportCertificateResponse`,Wee=`InvalidDomainValidationOptionsException`,Gee=`InvalidParameterException`,Kee=`InvalidStateException`,qee=`InvalidTagException`,Jee=`InUse`,Yee=`Includes`,Xee=`Key`,Zee=`KeyAlgorithm`,Qee=`KeyUsages`,$ee=`ListCertificates`,ete=`ListCertificatesRequest`,tte=`ListCertificatesResponse`,nte=`LimitExceededException`,rte=`Message`,ite=`ManagedBy`,ate=`MaxItems`,ote=`NotAfter`,ste=`NotBefore`,wo=`NextToken`,cte=`PrivateKey`,lte=`PrivateKeyBlob`,ute=`RevokedAt`,dte=`RenewalEligibility`,fte=`RequestInProgressException`,pte=`ResourceInUseException`,mte=`ResourceNotFoundException`,hte=`Status`,gte=`SubjectAlternativeNameSummaries`,_te=`SortBy`,vte=`SortOrder`,yte=`Type`,bte=`ThrottlingException`,xte=`TagList`,Ste=`TooManyTagsException`,Cte=`TagPolicyException`,wte=`ThrottlingReason`,Tte=`ThrottlingReasonList`,Ete=`Tags`,Dte=`Tag`,Ote=`Value`,kte=`ValidationException`,To=`awsQueryError`,Eo=`client`,Do=`error`,Ate=`extendedKeyUsage`,jte=`exportOption`,Oo=`httpError`,Mte=`keyTypes`,Nte=`keyUsage`,ko=`message`,Pte=`managedBy`,Fte=`reason`,Ite=`resource`,Ao=`smithy.ts.sdk.synthetic.com.amazonaws.acm`,Lte=`throttlingReasons`,g=`com.amazonaws.acm`,jo=Lt.for(Ao),Rte=[-3,Ao,`ACMServiceException`,0,[],[]];jo.registerError(Rte,_o);var Mo=Lt.for(g),zte=[-3,g,wee,{[To]:[`AccessDenied`,403],[Do]:Eo,[Oo]:403},[rte],[0]];Mo.registerError(zte,vo);var Bte=[-3,g,Oee,{[Do]:Eo},[ko],[0]];Mo.registerError(Bte,gee);var Vte=[-3,g,Ree,{[Do]:Eo},[ko],[0]];Mo.registerError(Vte,bee);var Hte=[-3,g,zee,{[Do]:Eo},[ko],[0]];Mo.registerError(Hte,yo);var Ute=[-3,g,Wee,{[Do]:Eo},[ko],[0]];Mo.registerError(Ute,See);var Wte=[-3,g,Gee,{[Do]:Eo},[ko],[0]];Mo.registerError(Wte,bo);var Gte=[-3,g,Kee,{[Do]:Eo},[ko],[0]];Mo.registerError(Gte,Cee);var Kte=[-3,g,qee,{[Do]:Eo},[ko],[0]];Mo.registerError(Kte,xo);var qte=[-3,g,nte,{[Do]:Eo},[ko],[0]];Mo.registerError(qte,yee);var Jte=[-3,g,fte,{[Do]:Eo},[ko],[0]];Mo.registerError(Jte,vee);var Yte=[-3,g,pte,{[Do]:Eo},[ko],[0]];Mo.registerError(Yte,_ee);var Xte=[-3,g,mte,{[Do]:Eo},[ko],[0]];Mo.registerError(Xte,fee);var Zte=[-3,g,Cte,{[Do]:Eo},[ko],[0]];Mo.registerError(Zte,pee);var Qte=[-3,g,bte,{[To]:[`Throttling`,400],[Do]:Eo,[Oo]:400},[ko,Lte],[0,()=>Ko]];Mo.registerError(Qte,mee);var $te=[-3,g,Ste,{[Do]:Eo},[ko],[0]];Mo.registerError($te,hee);var No=[-3,g,kte,{[To]:[`ValidationError`,400],[Do]:Eo,[Oo]:400},[ko],[0]];Mo.registerError(No,xee);var Po=[jo,Mo],Fo=[0,g,lte,8,21],Io=[3,g,kee,0,[So,jee,gte,Iee,hte,yte,Zee,Qee,Nee,Pee,Jee,Mee,dte,ste,ote,Eee,Bee,Lee,ute,ite],[0,0,64,2,0,0,0,64,64,0,2,2,0,4,4,4,4,4,4,0]],Lo=[3,g,Fee,0,[Ate,Nte,Mte,jte,Pte],[64,64,64,0,0]],Ro=[3,g,Hee,0,[Tee,cte,So,Dee,Ete],[21,[()=>Fo,0],0,21,()=>Go],2],zo=[3,g,Uee,0,[So],[0]],Bo=[3,g,ete,0,[Aee,Yee,wo,ate,_te,vte],[64,()=>Lo,0,1,0,0]],Vo=[3,g,tte,0,[wo,Co],[0,()=>Wo]],Ho=[3,g,Dte,0,[Xee,Ote],[0,0],1],Uo=[3,g,wte,0,[Fte,Ite],[0,0]],Wo=[1,g,Co,0,()=>Io],Go=[1,g,xte,0,()=>Ho],Ko=[1,g,Tte,0,()=>Uo],qo=[9,g,Vee,0,()=>Ro,()=>zo],Jo=[9,g,$ee,0,()=>Bo,()=>Vo],Yo=e=>({apiVersion:`2015-12-08`,base64Decoder:e?.base64Decoder??fe,base64Encoder:e?.base64Encoder??le,disableHostPrefix:e?.disableHostPrefix??!1,endpointProvider:e?.endpointProvider??go,extensions:e?.extensions??[],httpAuthSchemeProvider:e?.httpAuthSchemeProvider??ra,httpAuthSchemes:e?.httpAuthSchemes??[{schemeId:`aws.auth#sigv4`,identityProvider:e=>e.getIdentityProvider(`aws.auth#sigv4`),signer:new Yi}],logger:e?.logger??new $t,protocol:e?.protocol??Na,protocolSettings:e?.protocolSettings??{defaultNamespace:`com.amazonaws.acm`,errorTypeRegistries:Po,version:`2015-12-08`,serviceTarget:`CertificateManager`},serviceId:e?.serviceId??`ACM`,urlParser:e?.urlParser??nt,utf8Decoder:e?.utf8Decoder??ce,utf8Encoder:e?.utf8Encoder??me}),Xo=e=>{let t=zi(e),n=()=>t().then(Wt),r=Yo(e);return{...r,...e,runtime:`browser`,defaultsMode:t,bodyLengthChecker:e?.bodyLengthChecker??Ee,credentialDefaultProvider:e?.credentialDefaultProvider??(e=>()=>Promise.reject(Error(`Credential is missing`))),defaultUserAgentProvider:e?.defaultUserAgentProvider??Ei({serviceId:r.serviceId,clientVersion:sa.version}),maxAttempts:e?.maxAttempts??3,region:e?.region??yt(`Region is missing`),requestHandler:da.create(e?.requestHandler??n),retryMode:e?.retryMode??(async()=>(await n()).retryMode||Nr),sha256:e?.sha256??ee,streamCollector:e?.streamCollector??pa,useDualstackEndpoint:e?.useDualstackEndpoint??(()=>Promise.resolve(!1)),useFipsEndpoint:e?.useFipsEndpoint??(()=>Promise.resolve(!1))}},Zo=e=>{let t=e.httpAuthSchemes,n=e.httpAuthSchemeProvider,r=e.credentials;return{setHttpAuthScheme(e){let n=t.findIndex(t=>t.schemeId===e.schemeId);n===-1?t.push(e):t.splice(n,1,e)},httpAuthSchemes(){return t},setHttpAuthSchemeProvider(e){n=e},httpAuthSchemeProvider(){return n},setCredentials(e){r=e},credentials(){return r}}},Qo=e=>({httpAuthSchemes:e.httpAuthSchemes(),httpAuthSchemeProvider:e.httpAuthSchemeProvider(),credentials:e.credentials()}),$o=(e,t)=>{let n=Object.assign(Vi(e),Xt(e),Xn(e),Zo(e));return t.forEach(e=>e.configure(n)),Object.assign(e,Hi(n),Zt(n),Zn(n),Qo(n))},es=class extends bt{config;constructor(...[e]){let t=Xo(e||{});super(t),this.initConfig=t;let n=$o(ia(Nn(zr(Li(Ir(li(aa(t))))))),e?.extensions||[]);this.config=n,this.middlewareStack.use(Ot(this.config)),this.middlewareStack.use(Ti(this.config)),this.middlewareStack.use(Lr(this.config)),this.middlewareStack.use(tr(this.config)),this.middlewareStack.use(Hr(this.config)),this.middlewareStack.use(Gr(this.config)),this.middlewareStack.use(Kr(this.config)),this.middlewareStack.use(Zr(this.config,{httpAuthSchemeParametersProvider:ta,identityProviderConfigProvider:async e=>new ii({"aws.auth#sigv4":e.credentials})})),this.middlewareStack.use(ni(this.config))}destroy(){super.destroy()}},ts=async(e,t,n)=>({operation:Ze(t).operation,region:await re(e.region)()||(()=>{throw Error("expected `region` to be configured for `aws.auth#sigv4`")})()});function ns(e){return{schemeId:`aws.auth#sigv4`,signingProperties:{name:`ec2`,region:e.region},propertiesExtractor:(e,t)=>({signingProperties:{config:e,context:t}})}}var rs=e=>{let t=[];switch(e.operation){default:t.push(ns(e))}return t},is=e=>{let t=Qi(e);return Object.assign(t,{authSchemePreference:re(e.authSchemePreference??[])})},as=e=>Object.assign(e,{useDualstackEndpoint:e.useDualstackEndpoint??!1,useFipsEndpoint:e.useFipsEndpoint??!1,defaultSigningName:`ec2`}),os={UseFIPS:{type:`builtInParams`,name:`useFipsEndpoint`},Endpoint:{type:`builtInParams`,name:`endpoint`},Region:{type:`builtInParams`,name:`region`},UseDualStack:{type:`builtInParams`,name:`useDualstackEndpoint`}},ss={name:`@aws-sdk/client-ec2`,description:`AWS SDK for JavaScript Ec2 Client for Node.js, Browser and React Native`,version:`3.1063.0`,scripts:{build:`concurrently 'yarn:build:types' 'yarn:build:es' && yarn build:cjs`,"build:cjs":`node ../../scripts/compilation/inline client-ec2`,"build:es":`tsc -p tsconfig.es.json`,"build:include:deps":`yarn g:turbo run build -F="$npm_package_name"`,"build:types":`tsc -p tsconfig.types.json`,"build:types:downlevel":`downlevel-dts dist-types dist-types/ts3.4`,clean:`premove dist-cjs dist-es dist-types tsconfig.cjs.tsbuildinfo tsconfig.es.tsbuildinfo tsconfig.types.tsbuildinfo`,"extract:docs":`api-extractor run --local`,"generate:client":`node ../../scripts/generate-clients/single-service --solo ec2`,test:`yarn g:vitest run --passWithNoTests`,"test:index":`tsc --noEmit ./test/index-types.ts && node ./test/index-objects.spec.mjs`,"test:e2e":`yarn g:vitest run -c vitest.config.e2e.mts --mode development`,"test:e2e:watch":`yarn g:vitest watch -c vitest.config.e2e.mts`,"test:integration":`yarn g:vitest run --passWithNoTests -c vitest.config.integ.mts`,"test:integration:watch":`yarn g:vitest run --passWithNoTests -c vitest.config.integ.mts`,"test:watch":`yarn g:vitest watch --passWithNoTests`},main:`./dist-cjs/index.js`,types:`./dist-types/index.d.ts`,module:`./dist-es/index.js`,sideEffects:!1,dependencies:{"@aws-crypto/sha256-browser":`5.2.0`,"@aws-crypto/sha256-js":`5.2.0`,"@aws-sdk/core":`^3.974.18`,"@aws-sdk/credential-provider-node":`^3.972.52`,"@aws-sdk/middleware-sdk-ec2":`^3.972.32`,"@aws-sdk/types":`^3.973.11`,"@smithy/core":`^3.24.6`,"@smithy/fetch-http-handler":`^5.4.6`,"@smithy/node-http-handler":`^4.7.6`,"@smithy/types":`^4.14.3`,tslib:`^2.6.2`},devDependencies:{"@smithy/snapshot-testing":`^2.1.7`,"@tsconfig/node20":`20.1.8`,"@types/node":`^20.14.8`,concurrently:`7.0.0`,"downlevel-dts":`0.10.1`,premove:`4.0.0`,typescript:`~5.8.3`,vitest:`^4.0.17`},engines:{node:`>=20.0.0`},typesVersions:{"<4.5":{"dist-types/*":[`dist-types/ts3.4/*`]}},files:[`dist-*/**`],author:{name:`AWS SDK for JavaScript Team`,url:`https://aws.amazon.com/sdk-for-javascript/`},license:`Apache-2.0`,browser:{"./dist-es/runtimeConfig":`./dist-es/runtimeConfig.browser`},"react-native":{"./dist-es/runtimeConfig":`./dist-es/runtimeConfig.native`},homepage:`https://github.com/aws/aws-sdk-js-v3/tree/main/clients/client-ec2`,repository:{type:`git`,url:`https://github.com/aws/aws-sdk-js-v3.git`,directory:`clients/client-ec2`}},cs=`ref`,ls=-1,us=!0,ds=`isSet`,fs=`PartitionResult`,ps=`booleanEquals`,ms=`getAttr`,hs={[cs]:`Endpoint`},gs={[cs]:fs},_s={},vs=[{[cs]:`Region`}],ys={conditions:[[ds,[hs]],[ds,vs],[`aws.partition`,vs,fs],[ps,[{[cs]:`UseFIPS`},us]],[ps,[{[cs]:`UseDualStack`},us]],[ps,[{fn:ms,argv:[gs,`supportsDualStack`]},us]],[ps,[{fn:ms,argv:[gs,`supportsFIPS`]},us]],[`stringEquals`,[{fn:ms,argv:[gs,`name`]},`aws-us-gov`]]],results:[[ls],[ls,`Invalid Configuration: FIPS and custom endpoint are not supported`],[ls,`Invalid Configuration: Dualstack and custom endpoint are not supported`],[hs,_s],[`https://ec2-fips.{Region}.{PartitionResult#dualStackDnsSuffix}`,_s],[ls,`FIPS and DualStack are enabled, but this partition does not support one or both`],[`https://ec2.{Region}.amazonaws.com`,_s],[`https://ec2-fips.{Region}.{PartitionResult#dnsSuffix}`,_s],[ls,`FIPS is enabled but this partition does not support FIPS`],[`https://ec2.{Region}.{PartitionResult#dualStackDnsSuffix}`,_s],[ls,`DualStack is enabled but this partition does not support DualStack`],[`https://ec2.{Region}.{PartitionResult#dnsSuffix}`,_s],[ls,`Invalid Configuration: Missing Region`]]},bs=2,xs=new Int32Array([-1,1,-1,0,13,3,1,4,100000012,2,5,100000012,3,8,6,4,7,100000011,5,100000009,100000010,4,11,9,6,10,100000008,7,100000006,100000007,5,12,100000005,6,100000004,100000005,3,100000001,14,4,100000002,100000003]),Ss=on.from(xs,bs,ys.conditions,ys.results),Cs=new sn({size:50,params:[`Endpoint`,`Region`,`UseDualStack`,`UseFIPS`]}),ws=(e,t={})=>Cs.get(e,()=>jn(Ss,{endpointParams:e,logger:t.logger}));dn.aws=ji;var Ts=class e extends Ht{constructor(t){super(t),Object.setPrototypeOf(this,e.prototype)}},Es=`Address`,Ds=`AllocateAddressRequest`,Os=`AllocateAddressResult`,ks=`AssociateAddressRequest`,As=`AssociateAddressResult`,js=`AllAvailabilityZones`,Ms=`AllocateAddress`,Ns=`ApplianceArn`,Ps=`AssociateAddress`,Fs=`AttachedAppliances`,Is=`AttributeBooleanValue`,Ls=`AssociateCarrierIpAddress`,Rs=`AdditionalFlexibleNetworkInterfaces`,zs=`AvailableIpAddressCount`,Bs=`AssignIpv6AddressOnCreation`,Vs=`AttachInternetGateway`,Hs=`AttachInternetGatewayRequest`,Us=`AssociateIamInstanceProfile`,Ws=`AssociateIamInstanceProfileRequest`,Gs=`AssociateIamInstanceProfileResult`,Ks=`AllocationIdList`,qs=`AssociationIdList`,Js=`AdditionalInfo`,Ys=`AllocationId`,Xs=`AllocationIds`,Zs=`AssociationId`,Qs=`AssociationIds`,$s=`AttachmentId`,ec=`AmiLaunchIndex`,tc=`AttachmentLimitType`,nc=`AddressList`,rc=`AvailabilityMode`,ic=`AssociatePublicIpAddress`,ac=`AmazonProvidedIpv6CidrBlock`,oc=`AutoProvisionZones`,sc=`AutoRecoverySupported`,cc=`AssociateRouteTableRequest`,lc=`AssociateRouteTableResult`,uc=`AssociateRouteTable`,dc=`AllowReassociation`,fc=`AllRegions`,pc=`AssociatedResource`,mc=`AutoRecovery`,hc=`AssociationState`,gc=`AuthorizeSecurityGroupEgress`,_c=`AuthorizeSecurityGroupEgressRequest`,vc=`AuthorizeSecurityGroupEgressResult`,yc=`AuthorizeSecurityGroupIngressRequest`,bc=`AuthorizeSecurityGroupIngressResult`,xc=`AuthorizeSecurityGroupIngress`,Sc=`AutoScalingIps`,Cc=`AmdSevSnp`,wc=`AttachmentState`,Tc=`ArchitectureTypeList`,Ec=`AttachTime`,Dc=`AttachVolumeRequest`,Oc=`AttributeValue`,kc=`AttachVolume`,Ac=`AvailabilityZone`,jc=`AvailabilityZoneAddress`,Mc=`AvailabilityZoneAddresses`,Nc=`AvailabilityZoneGeography`,Pc=`AvailabilityZoneGeographyList`,Fc=`AvailabilityZoneId`,Ic=`AvailabilityZoneList`,Lc=`AvailabilityZoneMessage`,Rc=`AvailabilityZoneMessageList`,zc=`AvailabilityZoneSubGeography`,Bc=`AvailabilityZoneSubGeographyList`,Vc=`AvailabilityZones`,Hc=`Accelerators`,Uc=`Addresses`,Wc=`Affinity`,Gc=`Arn`,Kc=`Architecture`,qc=`Associations`,Jc=`Association`,Yc=`Attribute`,Xc=`Attachment`,Zc=`Attachments`,Qc=`BlobAttributeValue`,$c=`BaselineBandwidthInGbps`,el=`BaselineBandwidthInMbps`,tl=`BlockDeviceMapping`,nl=`BlockDeviceMappingList`,rl=`BlockDeviceMappingRequestList`,il=`BlockDeviceMappings`,al=`BlockDurationMinutes`,ol=`BaselineIops`,sl=`BootMode`,cl=`BootModeTypeList`,ll=`BareMetal`,ul=`BlockPublicAccessStates`,dl=`BurstablePerformanceSupported`,fl=`BaselineThroughputInMBps`,pl=`BandwidthWeighting`,ml=`BandwidthWeightingTypeList`,hl=`BandwidthWeightings`,gl=`CidrBlock`,_l=`CidrBlockAssociationSet`,vl=`CapacityBlockId`,yl=`CidrBlockState`,bl=`CoreCountList`,xl=`CoreCount`,Sl=`CpuCredits`,Cl=`CompletionDurationMinutes`,wl=`CreationDate`,Tl=`CarrierGatewayId`,El=`CurrentGeneration`,Dl=`CarrierIp`,Ol=`CurrentInstanceBootMode`,kl=`CreateInternetGateway`,Al=`CreateInternetGatewayRequest`,jl=`CreateInternetGatewayResult`,Ml=`CreateImageRequest`,Nl=`CreateImageResult`,Pl=`CidrIp`,Fl=`CidrIpv6`,Il=`CidrIpv4`,Ll=`CoreInfo`,Rl=`CreateImage`,zl=`CreateKeyPair`,Bl=`CreateKeyPairRequest`,Vl=`CoreNetworkArn`,Hl=`CreateNatGateway`,Ul=`CreateNatGatewayRequest`,Wl=`CreateNatGatewayResult`,Gl=`CpuOptions`,Kl=`CustomerOwnedIp`,ql=`CustomerOwnedIpv4Pool`,Jl=`CpuOptionsRequest`,Yl=`CreatePlacementGroup`,Xl=`CreatePlacementGroupRequest`,Zl=`CreatePlacementGroupResult`,Ql=`CapacityReservationId`,$l=`CapacityReservationPreference`,eu=`CreateRouteRequest`,tu=`CapacityReservationResourceGroupArn`,nu=`CreateRouteResult`,ru=`CapacityReservationSpecificationResponse`,iu=`CapacityReservationSpecification`,au=`CapacityReservationTarget`,ou=`CapacityReservationTargetResponse`,su=`CreateRouteTableRequest`,cu=`CreateRouteTableResult`,lu=`CreateRouteTable`,uu=`CreateRoute`,du=`CreateSecurityGroup`,fu=`CreateSecurityGroupRequest`,pu=`CreateSecurityGroupResult`,mu=`CopySnapshotRequest`,hu=`CopySnapshotRequestPSU`,gu=`CopySnapshotResult`,_u=`CreateSnapshotRequest`,vu=`CreateSubnetRequest`,yu=`CreateSubnetResult`,bu=`CreditSpecificationRequest`,xu=`CopySnapshot`,Su=`CreditSpecification`,Cu=`CreateSnapshot`,wu=`CreateSubnet`,Tu=`CurrentState`,Eu=`ClientToken`,Du=`ConnectionTrackingConfiguration`,Ou=`ConnectionTrackingSpecification`,ku=`ConnectionTrackingSpecificationRequest`,Au=`ConnectionTrackingSpecificationResponse`,ju=`CompletionTime`,Mu=`ConnectivityType`,Nu=`CreateTime`,Pu=`CreateVolumeRequest`,Fu=`CreateVpcRequest`,Iu=`CreateVpcResult`,Lu=`CreateVolume`,Ru=`CreateVpc`,zu=`Code`,Bu=`Configured`,Vu=`Count`,Hu=`DescribeAddressesRequest`,Uu=`DescribeAddressesResult`,Wu=`DisassociateAddressRequest`,Gu=`DisableApiStop`,Ku=`DisableApiTermination`,qu=`DestinationAvailabilityZone`,Ju=`DescribeAvailabilityZonesRequest`,Yu=`DescribeAvailabilityZonesResult`,Xu=`DescribeAvailabilityZones`,Zu=`DescribeAddresses`,Qu=`DisassociateAddress`,$u=`DestinationCidrBlock`,ed=`DefaultConnectionTrackingConfiguration`,td=`DefaultCores`,nd=`DataEncryptionKeyId`,rd=`DefaultEnaQueueCountPerInterface`,id=`DefaultForAz`,ad=`DedicatedHostsSupported`,od=`DeviceIndex`,sd=`DestinationIpv6CidrBlock`,cd=`DeleteInternetGateway`,ld=`DeleteInternetGatewayRequest`,ud=`DescribeInternetGatewaysRequest`,dd=`DescribeInternetGatewaysResult`,fd=`DetachInternetGatewayRequest`,pd=`DescribeInternetGateways`,md=`DetachInternetGateway`,hd=`DisassociateIamInstanceProfile`,gd=`DescribeIamInstanceProfileAssociations`,_d=`DescribeIamInstanceProfileAssociationsRequest`,vd=`DescribeIamInstanceProfileAssociationsResult`,yd=`DisassociateIamInstanceProfileRequest`,bd=`DisassociateIamInstanceProfileResult`,xd=`DiskInfoList`,Sd=`DescribeImagesRequest`,Cd=`DescribeImagesResult`,wd=`DescribeInstancesRequest`,Td=`DescribeInstancesResult`,Ed=`DescribeInstanceTypesRequest`,Dd=`DescribeInstanceTypesResult`,Od=`DescribeInstanceTypes`,kd=`DescribeImages`,Ad=`DescribeInstances`,jd=`DiskInfo`,Md=`DeleteKeyPair`,Nd=`DeleteKeyPairRequest`,Pd=`DeleteKeyPairResult`,Fd=`DescribeKeyPairsRequest`,Id=`DescribeKeyPairsResult`,Ld=`DescribeKeyPairs`,Rd=`DisableLniAtDeviceIndex`,zd=`DeviceName`,Bd=`DefaultNetworkCardIndex`,Vd=`DeleteNatGateway`,Hd=`DeleteNatGatewayRequest`,Ud=`DeleteNatGatewayResult`,Wd=`DescribeNatGatewaysRequest`,Gd=`DescribeNatGatewaysResult`,Kd=`DescribeNatGateways`,qd=`DestinationOutpostArn`,Jd=`DhcpOptionsId`,Yd=`DeleteOnTermination`,Xd=`DeletePlacementGroup`,Zd=`DeletePlacementGroupRequest`,Qd=`DescribePlacementGroupsRequest`,$d=`DescribePlacementGroupsResult`,ef=`DescribePlacementGroups`,tf=`DestinationPrefixListId`,nf=`DeregistrationProtection`,_=`DryRun`,rf=`DeleteRouteRequest`,af=`DescribeRegionsRequest`,of=`DescribeRegionsResult`,sf=`DeleteRouteTable`,cf=`DeleteRouteTableRequest`,lf=`DescribeRouteTablesRequest`,uf=`DescribeRouteTablesResult`,df=`DisassociateRouteTableRequest`,ff=`DescribeRouteTables`,pf=`DisassociateRouteTable`,mf=`DestinationRegion`,hf=`DeleteRoute`,gf=`DescribeRegions`,_f=`DeleteSecurityGroup`,vf=`DeleteSecurityGroupRequest`,yf=`DeleteSecurityGroupResult`,bf=`DescribeSecurityGroupsRequest`,xf=`DescribeSecurityGroupsResult`,Sf=`DescribeSecurityGroups`,Cf=`DeleteSnapshotRequest`,wf=`DeleteSubnetRequest`,Tf=`DescribeSnapshotsRequest`,Ef=`DescribeSnapshotsResult`,Df=`DescribeSubnetsRequest`,Of=`DescribeSubnetsResult`,kf=`DeleteSnapshot`,Af=`DeleteSubnet`,jf=`DescribeSnapshots`,Mf=`DescribeSubnets`,Nf=`DefaultTcpEstablishedTimeout`,Pf=`DefaultThreadsPerCore`,Ff=`DeprecationTime`,If=`DeleteTime`,Lf=`DefaultUdpStreamTimeout`,Rf=`DefaultUdpTimeout`,zf=`DefaultVCpus`,Bf=`DeleteVolumeRequest`,Vf=`DeleteVpcRequest`,Hf=`DescribeVolumesRequest`,Uf=`DescribeVolumesResult`,Wf=`DescribeVpcsRequest`,Gf=`DescribeVpcsResult`,Kf=`DetachVolumeRequest`,qf=`DeleteVolume`,Jf=`DeleteVpc`,Yf=`DescribeVolumes`,Xf=`DescribeVpcs`,Zf=`DetachVolume`,Qf=`Description`,$f=`Device`,ep=`Disks`,tp=`Domain`,np=`EbsBlockDevice`,rp=`ExecutableByStringList`,ip=`ExecutableBy`,ap=`EbsCardIndex`,op=`EbsCardInfoList`,sp=`EbsCardInfo`,cp=`EbsCards`,lp=`EncryptionControl`,up=`EnableDns64`,dp=`ElasticFileSystem`,fp=`ElasticFileSystemExclusion`,pp=`ElasticGpuAssociation`,mp=`ElasticGpuAssociationId`,hp=`ElasticGpuAssociationList`,gp=`ElasticGpuAssociationState`,_p=`ElasticGpuAssociationTime`,vp=`ElasticGpuAssociations`,yp=`ElasticGpuId`,bp=`ElasticGpuSpecification`,xp=`ElasticGpuSpecifications`,Sp=`ElasticInferenceAccelerator`,Cp=`ElasticInferenceAcceleratorAssociation`,wp=`ElasticInferenceAcceleratorAssociationId`,Tp=`ElasticInferenceAcceleratorAssociationList`,Ep=`ElasticInferenceAcceleratorAssociationState`,Dp=`ElasticInferenceAcceleratorAssociationTime`,Op=`ElasticInferenceAcceleratorArn`,kp=`ElasticInferenceAcceleratorAssociations`,Ap=`ElasticInferenceAccelerators`,jp=`EbsInstanceBlockDevice`,Mp=`EbsInstanceBlockDeviceSpecification`,Np=`EncryptionInTransitSupported`,Pp=`EbsInfo`,Fp=`EfaInfo`,Ip=`EnableLniAtDeviceIndex`,Lp=`EbsOptimized`,Rp=`EbsOptimizedInfo`,zp=`EgressOnlyInternetGateway`,Bp=`EgressOnlyInternetGatewayExclusion`,Vp=`EgressOnlyInternetGatewayId`,Hp=`EnclaveOptionsRequest`,Up=`EbsOptimizedSupport`,Wp=`EnclaveOptions`,Gp=`EnablePrimaryIpv6`,Kp=`EnaQueueCount`,qp=`EnableResourceNameDnsAAAARecord`,Jp=`EnableResourceNameDnsAAAARecordOnLaunch`,Yp=`EnableResourceNameDnsARecord`,Xp=`EnableResourceNameDnsARecordOnLaunch`,Zp=`EnaSrdEnabled`,Qp=`EnaSrdSpecification`,$p=`EnaSrdSpecificationRequest`,em=`EnaSrdSupported`,tm=`EnaSrdUdpEnabled`,nm=`EnaSrdUdpSpecification`,rm=`EnaSrdUdpSpecificationRequest`,im=`EfaSupported`,am=`EncryptionSupport`,om=`EnaSupport`,sm=`EndTime`,cm=`ExecutableUsers`,lm=`Ebs`,um=`Enabled`,dm=`Encrypted`,fm=`Endpoint`,pm=`Force`,mm=`FailureCode`,hm=`FpgaDeviceInfo`,gm=`FpgaDeviceInfoList`,_m=`FpgaDeviceMemoryInfo`,vm=`FlexibleEnaQueuesSupport`,ym=`FpgaInfo`,bm=`FilterList`,xm=`FailureMessage`,Sm=`FromPort`,Cm=`FastRestored`,wm=`FullSnapshotSizeInBytes`,Tm=`FreeTierEligible`,Em=`Filters`,Dm=`Filter`,Om=`Fpgas`,km=`Groups`,Am=`GroupArn`,jm=`GetConsoleOutput`,Mm=`GetConsoleOutputRequest`,Nm=`GetConsoleOutputResult`,Pm=`GroupDescription`,Fm=`GpuDeviceInfo`,Im=`GpuDeviceInfoList`,Lm=`GpuDeviceMemoryInfo`,Rm=`GatewayId`,zm=`GroupIdentifierList`,Bm=`GroupIdStringList`,Vm=`GpuInfo`,Hm=`GroupId`,Um=`GroupIds`,Wm=`GroupIdentifier`,Gm=`GroupLongName`,Km=`GroupName`,qm=`GroupNameStringList`,Jm=`GroupNames`,Ym=`GroupOwnerId`,Xm=`GpuPartitionSize`,Zm=`Geography`,Qm=`Gpus`,$m=`HiddenByDefault`,eh=`HttpEndpoint`,th=`HostId`,nh=`HibernationOptions`,rh=`HibernationOptionsRequest`,ih=`HttpProtocolIpv6`,ah=`HttpPutResponseHopLimit`,oh=`HostResourceGroupArn`,sh=`HibernationSupported`,ch=`HttpTokens`,lh=`HostnameType`,uh=`Hibernate`,dh=`Hypervisor`,fh=`Id`,ph=`Ipv6Addresses`,mh=`Ipv6AddressAttribute`,hh=`Ipv6AddressCount`,gh=`InstanceAttachmentEnaSrdSpecification`,_h=`InstanceAttachmentEnaSrdUdpSpecification`,vh=`InferenceAcceleratorInfo`,yh=`Ipv4AddressesPerInterface`,bh=`Ipv6AddressesPerInterface`,xh=`Ipv4AddressesPerSecondaryInterface`,Sh=`ImageAllowed`,Ch=`IpAddress`,wh=`Ipv6Address`,Th=`InstanceBlockDeviceMapping`,Eh=`InstanceBlockDeviceMappingList`,Dh=`InstanceBlockDeviceMappingSpecification`,Oh=`InstanceBlockDeviceMappingSpecificationList`,kh=`Ipv6CidrBlock`,Ah=`Ipv6CidrBlockAssociationSet`,jh=`Ipv6CidrBlockNetworkBorderGroup`,Mh=`Ipv6CidrBlockState`,Nh=`IncludeDeprecated`,Ph=`InferenceDeviceInfo`,Fh=`InferenceDeviceInfoList`,Ih=`InferenceDeviceMemoryInfo`,Lh=`IncludeDisabled`,Rh=`IsDefault`,zh=`IsEgress`,Bh=`InternetGateway`,Vh=`InternetGatewayAttachment`,Hh=`InternetGatewayAttachmentList`,Uh=`InternetGatewayBlockMode`,Wh=`InternetGatewayExclusion`,Gh=`InternetGatewayId`,Kh=`InternetGatewayIdList`,qh=`InternetGatewayIds`,Jh=`InternetGatewayList`,Yh=`InternetGateways`,v=`InstanceId`,Xh=`InstanceIpv6Address`,Zh=`InstanceIpv6AddressList`,Qh=`InstanceInterruptionBehavior`,$h=`IamInstanceProfile`,eg=`IamInstanceProfileAssociation`,tg=`IamInstanceProfileAssociationSet`,ng=`IamInstanceProfileAssociations`,rg=`Ipv6IpamPoolId`,ig=`Ipv4IpamPoolId`,ag=`InstanceIpv4PrefixList`,og=`InstanceIpv6PrefixList`,sg=`IamInstanceProfileSpecification`,cg=`InstanceIpv4Prefix`,lg=`InstanceIpv6Prefix`,ug=`InstanceInitiatedShutdownBehavior`,dg=`ImageIdStringList`,ene=`InstanceIdStringList`,fg=`ImageId`,tne=`ImageIds`,pg=`InstanceIds`,nne=`ImportKeyPair`,rne=`ImportKeyPairRequest`,ine=`ImportKeyPairResult`,ane=`ImageLocation`,one=`ImageList`,sne=`InstanceLifecycle`,cne=`InstanceList`,lne=`IpList`,une=`InstanceMaintenanceOptions`,dne=`InstanceMaintenanceOptionsRequest`,fne=`InstanceMarketOptionsRequest`,pne=`InstanceMetadataOptionsRequest`,mne=`InstanceMetadataOptionsResponse`,hne=`InstanceMarketOptions`,mg=`IncludeManagedResources`,hg=`InstanceMetadataTags`,gg=`Ipv6Native`,gne=`InstanceNetworkInterface`,_ne=`InstanceNetworkInterfaceAssociation`,vne=`InstanceNetworkInterfaceAttachment`,yne=`InstanceNetworkInterfaceList`,bne=`InstanceNetworkInterfaceSpecification`,xne=`InstanceNetworkInterfaceSpecificationList`,_g=`Ipv6NetmaskLength`,vg=`Ipv4NetmaskLength`,Sne=`InstanceNetworkPerformanceOptions`,Cne=`InstanceNetworkPerformanceOptionsRequest`,wne=`ImageOwnerAlias`,Tne=`IpOwnerId`,Ene=`InstanceOwnerId`,Dne=`InstanceOwningService`,One=`Ipv6PrefixCount`,kne=`Ipv4PrefixCount`,Ane=`IpPermissionsEgress`,jne=`IpamPoolId`,Mne=`InstancePrivateIpAddress`,Nne=`InstancePrivateIpAddressList`,Pne=`IsPrimaryIpv6`,Fne=`IncludePublicKey`,Ine=`IpPermissionList`,Lne=`Ipv4PrefixList`,Rne=`Ipv6PrefixList`,zne=`Ipv4PrefixSpecificationRequest`,Bne=`Ipv6PrefixSpecificationRequest`,yg=`Ipv6Prefixes`,bg=`IpPermissions`,Vne=`IpPermission`,xg=`IpProtocol`,Sg=`Ipv6Prefix`,Cg=`Ipv6Pool`,wg=`Ipv4Prefixes`,Tg=`Ipv4Prefix`,Hne=`IsPrimary`,Une=`IpRangeList`,Wne=`Ipv6RangeList`,Gne=`IpRanges`,Kne=`IpRange`,qne=`Ipv6Ranges`,Jne=`Ipv6Range`,Yne=`InstanceStateChange`,Xne=`InstanceStateChangeList`,Zne=`InstanceSecondaryInterfaceAttachment`,Qne=`InstanceSecondaryInterfaceList`,$ne=`InstanceSecondaryInterfacePrivateIpAddress`,ere=`InstanceSecondaryInterfacePrivateIpAddressList`,tre=`InstanceSecondaryInterfacePrivateIpAddressListRequest`,nre=`InstanceSecondaryInterfacePrivateIpAddressRequest`,rre=`InstanceSecondaryInterfaceSpecificationListRequest`,ire=`InstanceSecondaryInterfaceSpecificationRequest`,are=`InstanceSecondaryInterface`,Eg=`InstanceStorageInfo`,ore=`InstanceStorageSupported`,sre=`ImdsSupport`,cre=`InstanceState`,Dg=`IpSource`,lre=`Ipv6Supported`,Og=`InstanceType`,ure=`InstanceTypeInfoList`,dre=`InstanceTypeInfo`,fre=`ImageType`,kg=`InstanceTenancy`,Ag=`InstanceTypes`,jg=`InterfaceType`,pre=`IncludeUnsupportedInRegion`,mre=`Images`,hre=`Image`,gre=`Instance`,_re=`Instances`,Mg=`Iops`,vre=`Key`,yre=`KeyFormat`,Ng=`KeyFingerprint`,Pg=`KernelId`,Fg=`KmsKeyId`,bre=`KeyMaterial`,Ig=`KeyName`,xre=`KeyNameStringList`,Sre=`KeyNames`,Cre=`KeyPairs`,Lg=`KeyPairId`,wre=`KeyPairIdStringList`,Tre=`KeyPairIds`,Ere=`KeyPairInfo`,Dre=`KeyPairList`,Ore=`KeyPair`,Rg=`KeyType`,kre=`Kernel`,zg=`LicenseConfigurationArn`,Are=`LicenseConfigurationRequest`,jre=`LicenseConfiguration`,Mre=`LambdaExclusion`,Nre=`LogicalGpuCount`,Bg=`LocalGatewayId`,Vg=`LinkedGroupId`,Pre=`LicenseList`,Fre=`LastLaunchedTime`,Ire=`LicenseSpecifications`,Lre=`LicenseSpecificationListRequest`,Rre=`LicenseSpecification`,zre=`LaunchTemplate`,Bre=`LaunchTemplateId`,Vre=`LaunchTemplateName`,Hre=`LaunchTemplateSpecification`,Ure=`LaunchTime`,Wre=`Latest`,Gre=`Lambda`,Kre=`Licenses`,qre=`Location`,Hg=`MultiAttachEnabled`,Ug=`MediaAcceleratorInfo`,Wg=`MacAddress`,Gg=`MaximumBandwidthInMbps`,Kg=`MapCustomerOwnedIpOnLaunch`,Jre=`MaxCount`,Yre=`MinCount`,Xre=`MediaDeviceInfo`,Zre=`MediaDeviceInfoList`,Qre=`MediaDeviceMemoryInfo`,$re=`MaximumEbsAttachments`,eie=`MaximumEbsCards`,tie=`MaximumEfaInterfaces`,nie=`MaximumEnaQueueCount`,rie=`MaximumEnaQueueCountPerInterface`,qg=`MaximumIops`,iie=`ModifyInstanceAttributeRequest`,aie=`ModifyInstanceAttributeValue`,oie=`ModifyInstanceAttribute`,Jg=`MemoryInfo`,sie=`MaximumNetworkCards`,Yg=`MaximumNetworkInterfaces`,Xg=`MetadataOptions`,Zg=`MaintenanceOptions`,Qg=`MapPublicIpOnLaunch`,cie=`MaxPrice`,$g=`MaxResults`,lie=`ModifySubnetAttributeRequest`,uie=`ModifySubnetAttribute`,die=`MaximumSecondaryNetworkInterfaces`,e_=`ModificationState`,fie=`MarketType`,t_=`MaximumThroughputInMBps`,pie=`ModifyVolumeRequest`,mie=`ModifyVolumeResult`,hie=`ModifyVolume`,gie=`Main`,n_=`Manufacturer`,_ie=`Managed`,r_=`Message`,vie=`Messages`,i_=`Mode`,a_=`Monitoring`,o_=`Name`,s_=`NetworkBorderGroup`,yie=`NetworkCards`,c_=`NetworkCardIndex`,bie=`NetworkCardInfoList`,xie=`NetworkCardInfo`,l_=`NoDevice`,Sie=`NeuronDeviceCoreInfo`,Cie=`NeuronDeviceInfo`,wie=`NeuronDeviceInfoList`,Tie=`NeuronDeviceMemoryInfo`,Eie=`NeuronDevices`,Die=`NitroEnclavesSupport`,u_=`NatGateway`,Oie=`NatGatewayAddresses`,kie=`NatGatewayAttachedAppliance`,Aie=`NatGatewayAttachedApplianceList`,jie=`NatGatewayAddressList`,Mie=`NatGatewayAddress`,Nie=`NatGatewayExclusion`,d_=`NatGatewayId`,Pie=`NatGatewayIdStringList`,Fie=`NatGatewayIds`,Iie=`NatGatewayList`,Lie=`NatGateways`,f_=`NetworkInterfaceId`,Rie=`NetworkInterfaceOwnerId`,p_=`NetworkInterfaces`,m_=`NetworkInfo`,h_=`NeuronInfo`,g_=`NetworkPerformanceOptions`,__=`NetworkPerformance`,zie=`NoReboot`,v_=`NvmeSupport`,y_=`NitroTpmInfo`,Bie=`NitroTpmSupport`,Vie=`NitroTpmSupportedVersionsList`,y=`NextToken`,b_=`NestedVirtualization`,Hie=`Origin`,x_=`OutpostArn`,Uie=`OwnerAlias`,S_=`OwnerId`,C_=`OptInStatus`,Wie=`OriginalIops`,Gie=`OwnerIds`,Kie=`OriginalMultiAttachEnabled`,w_=`OdbNetworkArn`,qie=`OperatorRequest`,Jie=`OperatorResponse`,Yie=`OwnerStringList`,Xie=`OriginalSize`,Zie=`OriginalThroughput`,Qie=`OriginalVolumeType`,T_=`Operator`,$ie=`Output`,eae=`Owners`,E_=`Owner`,D_=`Principal`,O_=`ProvisionedBandwidth`,tae=`PeakBandwidthInGbps`,nae=`ProductCodeId`,rae=`ProductCodeList`,iae=`ProductCodeType`,k_=`PartitionCount`,aae=`ProductCode`,A_=`ProductCodes`,oae=`PrivateDnsHostnameTypeOnLaunch`,j_=`PrivateDnsName`,M_=`PrivateDnsNameOptions`,N_=`PrivateDnsNameOptionsOnLaunch`,sae=`PrivateDnsNameOptionsRequest`,cae=`PrivateDnsNameOptionsResponse`,P_=`PublicDnsName`,F_=`PlatformDetails`,I_=`PlacementGroup`,L_=`PlacementGroupInfo`,lae=`PlacementGroupIdStringList`,uae=`PlacementGroupList`,dae=`PlacementGroupStrategyList`,fae=`PlacementGroups`,R_=`PublicIp`,pae=`PrivateIpAddressCount`,mae=`PrivateIpAddressSpecification`,hae=`PrivateIpAddressSpecificationList`,z_=`PrivateIpAddress`,B_=`PrivateIpAddresses`,gae=`PublicIpAddress`,V_=`PublicIpv4Pool`,_ae=`PublicIpStringList`,vae=`PrimaryIpv6`,yae=`PrivateIp`,H_=`ProcessorInfo`,bae=`PublicIps`,xae=`PublicKey`,Sae=`PublicKeyMaterial`,U_=`PrefixListId`,Cae=`PrefixListIdList`,wae=`PrefixListIds`,Tae=`PartitionNumber`,W_=`PeeringStatus`,Eae=`PhcSupport`,Dae=`PreviousState`,Oae=`ProvisionTime`,kae=`PresignedUrl`,Aae=`PropagatingVgw`,jae=`PropagatingVgwList`,Mae=`PropagatingVgws`,Nae=`ParentZoneId`,Pae=`ParentZoneName`,G_=`Platform`,K_=`Placement`,q_=`Primary`,J_=`Progress`,Fae=`Provisioned`,Iae=`Public`,Y_=`Return`,Lae=`ReleaseAddressRequest`,Rae=`ReleaseAddress`,zae=`RestorableByUserIds`,Bae=`RestorableBy`,X_=`RootDeviceName`,Z_=`RootDeviceType`,Vae=`RootDeviceTypeList`,Hae=`RestoreExpiryTime`,Uae=`ResourceExclusions`,Wae=`RegionGeography`,Gae=`ReferencedGroupId`,Kae=`ReferencedGroupInfo`,qae=`RegionGeographyList`,Jae=`RunInstancesMonitoringEnabled`,Yae=`RebootInstancesRequest`,Xae=`RunInstancesRequest`,Zae=`RunInstancesUserData`,Q_=`RamdiskId`,Qae=`ReservationId`,$ae=`RebootInstances`,eoe=`RequesterId`,toe=`RunInstances`,noe=`RegionList`,roe=`ReservationList`,ioe=`RouteList`,aoe=`RebootMigrationSupport`,ooe=`RebootMigration`,$_=`RegionName`,soe=`RegionNameStringList`,coe=`RegionNames`,loe=`ReferencedSecurityGroup`,uoe=`RevokeSecurityGroupEgress`,doe=`RevokeSecurityGroupEgressRequest`,foe=`RevokeSecurityGroupEgressResult`,poe=`RevokeSecurityGroupIngressRequest`,moe=`RevokeSecurityGroupIngressResult`,hoe=`RevokeSecurityGroupIngress`,goe=`RevokedSecurityGroupRule`,_oe=`RevokedSecurityGroupRuleList`,ev=`RevokedSecurityGroupRules`,voe=`RouteTableAssociation`,yoe=`RouteTableAssociationId`,boe=`RouteTableAssociationList`,xoe=`RouteTableAssociationState`,tv=`RouteTableId`,Soe=`RouteTableIdStringList`,Coe=`RouteTableIds`,woe=`RouteTableList`,Toe=`ResourceType`,Eoe=`RequestTime`,nv=`RouteTable`,Doe=`RouteTables`,Ooe=`Ramdisk`,koe=`Regions`,Aoe=`Region`,joe=`Requested`,Moe=`Reservations`,Noe=`Reservation`,Poe=`Route`,Foe=`Routes`,Ioe=`SecondaryAllocationIds`,Loe=`SecondaryAllocationId`,Roe=`SupportedAdditionalProcessorFeatureList`,zoe=`SubnetArn`,Boe=`SupportedArchitectures`,Voe=`SupportedBootModes`,Hoe=`SubnetCidrBlockState`,Uoe=`SustainedClockSpeedInGhz`,rv=`SourceDestCheck`,Woe=`SpreadDomain`,Goe=`SupportedFeatures`,Koe=`SubGeography`,iv=`SecurityGroupArn`,av=`SecurityGroupId`,qoe=`SecurityGroupIdStringList`,Joe=`SecurityGroupIds`,Yoe=`SecurityGroupList`,ov=`SecurityGroupRules`,Xoe=`SecurityGroupRuleArn`,sv=`SecurityGroupRuleIds`,Zoe=`SecurityGroupRuleIdList`,cv=`SecurityGroupRuleId`,Qoe=`SecurityGroupRuleList`,$oe=`SecurityGroupRule`,ese=`SecurityGroupStringList`,lv=`SecurityGroups`,uv=`SecurityGroup`,dv=`SubnetIds`,fv=`SubnetIpv6CidrBlockAssociation`,pv=`SubnetIpv6CidrBlockAssociationSet`,mv=`SizeInGB`,hv=`SourceImageId`,gv=`SecondaryInterfaceId`,_v=`SourceInstanceId`,vv=`SizeInMiB`,yv=`SpotInstanceRequestId`,bv=`SourceImageRegion`,xv=`StartInstancesRequest`,Sv=`StartInstancesResult`,Cv=`StopInstancesRequest`,wv=`StopInstancesResult`,Tv=`SupportedInRegion`,Ev=`SnapshotIdStringList`,Dv=`SubnetIdStringList`,Ov=`SpotInstanceType`,kv=`SecondaryInterfaces`,Av=`SecondaryInterface`,jv=`SnapshotId`,Mv=`SnapshotIds`,Nv=`StartingInstances`,Pv=`StartInstances`,Fv=`StoppingInstances`,Iv=`StopInstances`,Lv=`SubnetId`,Rv=`SnapshotLocation`,zv=`SnapshotList`,Bv=`SpreadLevel`,Vv=`SubnetList`,Hv=`ServiceManaged`,Uv=`SpotMarketOptions`,Wv=`StatusMessage`,Gv=`StateMessage`,Kv=`SecondaryNetworkId`,qv=`SriovNetSupport`,Jv=`SecondaryNetworkSupported`,Yv=`SpotOptions`,Xv=`SkipOsShutdown`,Zv=`SecondaryPrivateIpAddresses`,Qv=`SecondaryPrivateIpAddressCount`,$v=`SecondaryPrivateIpAddress`,ey=`SupportedRootDeviceTypes`,ty=`SourceRegion`,ny=`StateReason`,ry=`SourceSecurityGroupName`,iy=`SourceSecurityGroupOwnerId`,ay=`SourceSnapshotId`,oy=`SecondarySubnetId`,sy=`SupportedStrategies`,cy=`StartTime`,ly=`StateTransitionReason`,uy=`SseType`,dy=`StorageTier`,fy=`SupportedUsageClasses`,py=`SensitiveUserData`,my=`SourceVolumeId`,hy=`SupportedVirtualizationTypes`,gy=`SupportedVersions`,_y=`Size`,vy=`Snapshots`,yy=`Snapshot`,by=`State`,xy=`Status`,Sy=`Strategy`,Cy=`Subnet`,wy=`Subnets`,Ty=`Tags`,Ey=`TcpEstablishedTimeout`,Dy=`TotalFpgaMemoryInMiB`,Oy=`TransitGatewayId`,ky=`TotalGpuMemoryInMiB`,Ay=`TerminateInstances`,jy=`TotalInferenceMemoryInMiB`,My=`TerminateInstancesRequest`,Ny=`TerminateInstancesResult`,Py=`TargetIops`,Fy=`TerminatingInstances`,Iy=`TagList`,Ly=`TargetMultiAttachEnabled`,Ry=`TotalMediaMemoryInMiB`,zy=`TotalNeuronDeviceMemoryInMiB`,By=`ToPort`,Vy=`ThreadsPerCore`,Hy=`ThreadsPerCoreList`,Uy=`TagSpecifications`,Wy=`TotalSizeInGB`,Gy=`TagSpecificationList`,Ky=`TagSpecification`,qy=`TargetSize`,Jy=`TpmSupport`,Yy=`TargetThroughput`,Xy=`TransferType`,Zy=`TargetVolumeType`,Qy=`Tag`,$y=`Tenancy`,eb=`Throughput`,tb=`Timestamp`,nb=`Type`,rb=`UsageClassTypeList`,ib=`UserData`,ab=`UserId`,ob=`UserIdGroupPairs`,sb=`UserIdGroupPairList`,cb=`UserIdGroupPair`,lb=`UnknownIpPermissions`,ub=`UsageOperation`,db=`UsageOperationUpdateTime`,fb=`UdpStreamTimeout`,pb=`UdpTimeout`,mb=`Value`,hb=`VolumeAttachmentList`,gb=`VolumeAttachment`,_b=`VpcCidrBlockAssociation`,vb=`VpcCidrBlockAssociationSet`,yb=`VpcCidrBlockState`,bb=`VCpuInfo`,xb=`ValidCores`,Sb=`VpcEncryptionControl`,Cb=`VpcEncryptionControlConfiguration`,wb=`VpcEncryptionControlExclusion`,Tb=`VpcEncryptionControlExclusions`,Eb=`VpcEncryptionControlId`,Db=`VpcEndpointId`,Ob=`VpcId`,kb=`VpcIpv6CidrBlockAssociation`,Ab=`VpcIpv6CidrBlockAssociationSet`,jb=`VolumeInitializationRate`,Mb=`VolumeIdStringList`,Nb=`VpcIdStringList`,Pb=`VolumeId`,Fb=`VolumeIds`,Ib=`VpcIds`,Lb=`VpcLattice`,Rb=`VpcLatticeExclusion`,zb=`VolumeList`,Bb=`VpcList`,Vb=`VolumeModification`,Hb=`VirtualName`,Ub=`VolumeOwnerId`,Wb=`VpcPeeringConnectionId`,Gb=`VpcPeeringExclusion`,Kb=`VirtualPrivateGateway`,qb=`VirtualPrivateGatewayExclusion`,Jb=`VpcPeering`,Yb=`ValueStringList`,Xb=`VolumeSize`,Zb=`VolumeType`,Qb=`VirtualizationTypeList`,$b=`ValidThreadsPerCore`,ex=`VirtualizationType`,tx=`ValidUntil`,nx=`Values`,rx=`Version`,ix=`Volumes`,ax=`Volume`,ox=`Vpc`,sx=`Vpcs`,cx=`WorkloadsList`,lx=`Workloads`,ux=`ZoneId`,dx=`ZoneIdStringList`,fx=`ZoneIds`,px=`ZoneName`,mx=`ZoneNameStringList`,hx=`ZoneNames`,gx=`ZoneType`,_x=`attachedApplianceSet`,vx=`applianceArn`,yx=`additionalFlexibleNetworkInterfaces`,bx=`allocationId`,xx=`availableIpAddressCount`,Sx=`assignIpv6AddressOnCreation`,Cx=`additionalInfo`,wx=`associationId`,Tx=`attachmentId`,Ex=`amiLaunchIndex`,Dx=`attachmentLimitType`,Ox=`availabilityMode`,kx=`associatePublicIpAddress`,Ax=`amazonProvidedIpv6CidrBlock`,jx=`autoProvisionZones`,Mx=`autoRecoverySupported`,Nx=`allowReassociation`,Px=`associatedResource`,Fx=`autoRecovery`,Ix=`associationState`,Lx=`autoScalingIps`,Rx=`amdSevSnp`,zx=`addressesSet`,Bx=`associationSet`,Vx=`attachmentSet`,Hx=`attachmentState`,Ux=`attachTime`,Wx=`availabilityZone`,Gx=`availabilityZoneId`,Kx=`availabilityZoneInfo`,qx=`accelerators`,Jx=`affinity`,Yx=`arn`,Xx=`architecture`,Zx=`association`,Qx=`attachment`,$x=`attribute`,eS=`baselineBandwidthInGbps`,tS=`baselineBandwidthInMbps`,nS=`blockDeviceMapping`,rS=`baselineIops`,iS=`bootMode`,aS=`bareMetal`,oS=`blockPublicAccessStates`,sS=`burstablePerformanceSupported`,cS=`baselineThroughputInMBps`,lS=`bandwidthWeighting`,uS=`bandwidthWeightings`,dS=`cidrBlock`,fS=`cidrBlockAssociationSet`,pS=`capacityBlockId`,mS=`cidrBlockState`,hS=`coreCount`,gS=`completionDurationMinutes`,_S=`creationDate`,vS=`carrierGatewayId`,yS=`currentGeneration`,bS=`carrierIp`,xS=`currentInstanceBootMode`,SS=`cidrIp`,CS=`cidrIpv6`,wS=`cidrIpv4`,TS=`coreInfo`,ES=`coreNetworkArn`,DS=`cpuOptions`,OS=`customerOwnedIp`,kS=`customerOwnedIpv4Pool`,AS=`capacityReservationId`,jS=`capacityReservationPreference`,MS=`capacityReservationResourceGroupArn`,NS=`capacityReservationSpecification`,PS=`capacityReservationTarget`,FS=`currentState`,IS=`clientToken`,LS=`connectionTrackingConfiguration`,RS=`completionTime`,zS=`connectivityType`,BS=`createTime`,VS=`code`,HS=`configured`,US=`count`,WS=`disableApiTermination`,GS=`destinationCidrBlock`,KS=`defaultCores`,qS=`dataEncryptionKeyId`,JS=`defaultEnaQueueCountPerInterface`,YS=`defaultForAz`,XS=`dedicatedHostsSupported`,ZS=`deviceIndex`,QS=`destinationIpv6CidrBlock`,$S=`deviceName`,eC=`defaultNetworkCardIndex`,tC=`dnsName`,nC=`dhcpOptionsId`,rC=`deleteOnTermination`,iC=`destinationPrefixListId`,aC=`deregistrationProtection`,b=`dryRun`,oC=`destinationRegion`,sC=`defaultTcpEstablishedTimeout`,cC=`defaultThreadsPerCore`,lC=`deprecationTime`,uC=`deleteTime`,dC=`defaultUdpStreamTimeout`,fC=`defaultUdpTimeout`,pC=`defaultVCpus`,mC=`description`,hC=`device`,gC=`disks`,_C=`domain`,vC=`ebsCardIndex`,yC=`ebsCardSet`,bC=`encryptionControl`,xC=`enableDns64`,SC=`elasticFileSystem`,CC=`elasticGpuAssociationId`,wC=`elasticGpuAssociationState`,TC=`elasticGpuAssociationSet`,EC=`elasticGpuAssociationTime`,DC=`elasticGpuId`,OC=`elasticInferenceAcceleratorArn`,kC=`elasticInferenceAcceleratorAssociationId`,AC=`elasticInferenceAcceleratorAssociationState`,jC=`elasticInferenceAcceleratorAssociationSet`,MC=`elasticInferenceAcceleratorAssociationTime`,NC=`encryptionInTransitSupported`,PC=`ebsInfo`,FC=`efaInfo`,IC=`enableLniAtDeviceIndex`,LC=`ebsOptimized`,RC=`ebsOptimizedInfo`,zC=`egressOnlyInternetGateway`,BC=`egressOnlyInternetGatewayId`,VC=`ebsOptimizedSupport`,HC=`enclaveOptions`,UC=`enaQueueCount`,x=`ec2QueryName`,WC=`enableResourceNameDnsAAAARecord`,GC=`enableResourceNameDnsARecord`,KC=`enaSrdEnabled`,qC=`enaSrdSpecification`,JC=`enaSrdSupported`,YC=`enaSrdUdpEnabled`,XC=`enaSrdUdpSpecification`,ZC=`efaSupported`,QC=`encryptionSupport`,$C=`enaSupport`,ew=`endTime`,tw=`ebs`,nw=`enabled`,rw=`encrypted`,iw=`failureCode`,aw=`flexibleEnaQueuesSupport`,ow=`fpgaInfo`,sw=`failureMessage`,cw=`fromPort`,lw=`fastRestored`,uw=`fullSnapshotSizeInBytes`,dw=`freeTierEligible`,fw=`force`,pw=`fpgas`,mw=`groupArn`,hw=`groupDescription`,gw=`gatewayId`,_w=`gpuInfo`,vw=`groupId`,yw=`groupLongName`,bw=`groupName`,xw=`groupOwnerId`,Sw=`gpuPartitionSize`,Cw=`geographySet`,ww=`groupSet`,Tw=`gpus`,Ew=`groups`,Dw=`hypervisor`,Ow=`hiddenByDefault`,kw=`httpEndpoint`,Aw=`hostId`,jw=`hibernationOptions`,Mw=`httpProtocolIpv6`,Nw=`httpPutResponseHopLimit`,Pw=`hostResourceGroupArn`,Fw=`hibernationSupported`,Iw=`httpTokens`,Lw=`hostnameType`,Rw=`id`,zw=`ipv6AddressAttribute`,Bw=`ipv6AddressCount`,Vw=`inferenceAcceleratorInfo`,Hw=`ipv4AddressesPerInterface`,Uw=`ipv6AddressesPerInterface`,Ww=`ipv4AddressesPerSecondaryInterface`,Gw=`ipv6AddressesSet`,Kw=`imageAllowed`,qw=`ipAddress`,Jw=`ipv6Address`,Yw=`ipv6CidrBlock`,Xw=`ipv6CidrBlockAssociationSet`,Zw=`ipv6CidrBlockState`,Qw=`isDefault`,$w=`isEgress`,eT=`internetGateway`,tT=`internetGatewayBlockMode`,nT=`internetGatewayId`,rT=`internetGatewaySet`,iT=`instanceId`,aT=`iamInstanceProfile`,oT=`iamInstanceProfileAssociation`,sT=`iamInstanceProfileAssociationSet`,cT=`instanceInitiatedShutdownBehavior`,lT=`imageId`,uT=`imageLocation`,dT=`instanceLifecycle`,fT=`instanceMetadataTags`,pT=`ipv6Native`,mT=`imageOwnerAlias`,hT=`imageOwnerId`,gT=`instanceOwnerId`,_T=`ipOwnerId`,vT=`instanceOwningService`,yT=`ipPermissionsEgress`,bT=`isPrimaryIpv6`,xT=`ipv4PrefixSet`,ST=`ipv6PrefixSet`,CT=`ipProtocol`,wT=`ipPermissions`,TT=`ipv4Prefix`,ET=`ipv6Pool`,DT=`ipv6Prefix`,OT=`isPublic`,kT=`isPrimary`,AT=`ipRanges`,jT=`ipv6Ranges`,MT=`instanceStorageInfo`,NT=`instanceStorageSupported`,PT=`imagesSet`,FT=`imageState`,IT=`imdsSupport`,LT=`instancesSet`,RT=`instanceState`,zT=`ipSource`,BT=`ipv6Supported`,VT=`instanceType`,HT=`instanceTypeSet`,UT=`idempotencyToken`,WT=`imageType`,GT=`instanceTenancy`,KT=`interfaceType`,qT=`iops`,S=`item`,JT=`key`,YT=`keyFingerprint`,XT=`kernelId`,ZT=`kmsKeyId`,QT=`keyMaterial`,$T=`keyName`,eE=`keyPairId`,tE=`keySet`,nE=`keyType`,rE=`kernel`,iE=`licenseConfigurationArn`,aE=`logicalGpuCount`,oE=`localGatewayId`,sE=`linkedGroupId`,cE=`lastLaunchedTime`,lE=`licenseSet`,uE=`launchTime`,dE=`lambda`,fE=`multiAttachEnabled`,pE=`mediaAcceleratorInfo`,mE=`macAddress`,hE=`maximumBandwidthInMbps`,gE=`mapCustomerOwnedIpOnLaunch`,_E=`maximumEbsAttachments`,vE=`maximumEbsCards`,yE=`maximumEfaInterfaces`,bE=`maximumEnaQueueCount`,xE=`maximumEnaQueueCountPerInterface`,SE=`maximumIops`,CE=`memoryInfo`,wE=`maximumNetworkCards`,TE=`maximumNetworkInterfaces`,EE=`metadataOptions`,DE=`maintenanceOptions`,OE=`mapPublicIpOnLaunch`,kE=`maxResults`,AE=`messageSet`,jE=`maximumSecondaryNetworkInterfaces`,ME=`modificationState`,NE=`maximumThroughputInMBps`,PE=`main`,FE=`manufacturer`,IE=`managed`,LE=`message`,RE=`monitoring`,zE=`mode`,BE=`name`,VE=`networkBorderGroup`,HE=`networkCards`,UE=`networkCardIndex`,WE=`noDevice`,GE=`neuronDevices`,KE=`nitroEnclavesSupport`,qE=`natGateway`,JE=`natGatewayAddressSet`,YE=`natGatewayId`,XE=`natGatewaySet`,ZE=`networkInterfaceId`,QE=`networkInterfaceOwnerId`,$E=`networkInterfaceSet`,eD=`networkInterface`,tD=`networkInfo`,nD=`neuronInfo`,rD=`networkPerformanceOptions`,iD=`networkPerformance`,aD=`noReboot`,oD=`nvmeSupport`,sD=`nitroTpmInfo`,cD=`nitroTpmSupport`,lD=`nextToken`,uD=`nestedVirtualization`,dD=`origin`,fD=`outpostArn`,pD=`ownerAlias`,mD=`ownerId`,hD=`optInStatus`,gD=`originalIops`,_D=`originalMultiAttachEnabled`,vD=`odbNetworkArn`,yD=`originalSize`,bD=`originalThroughput`,xD=`originalVolumeType`,SD=`operator`,CD=`output`,wD=`principal`,TD=`provisionedBandwidth`,ED=`peakBandwidthInGbps`,DD=`productCodes`,OD=`partitionCount`,kD=`productCode`,AD=`privateDnsNameOptions`,jD=`privateDnsNameOptionsOnLaunch`,MD=`privateDnsName`,ND=`publicDnsName`,PD=`platformDetails`,FD=`placementGroup`,ID=`placementGroupInfo`,LD=`placementGroupSet`,RD=`publicIp`,zD=`privateIpAddressesSet`,BD=`privateIpAddressSet`,VD=`privateIpAddress`,HD=`publicIpv4Pool`,UD=`processorInfo`,WD=`privateIp`,GD=`publicKey`,KD=`publicKeyMaterial`,qD=`prefixListId`,JD=`prefixListIds`,YD=`partitionNumber`,XD=`peeringStatus`,ZD=`phcSupport`,QD=`previousState`,$D=`provisionTime`,eO=`presignedUrl`,tO=`propagatingVgwSet`,nO=`parentZoneId`,rO=`parentZoneName`,iO=`platform`,aO=`placement`,oO=`primary`,sO=`progress`,cO=`provisioned`,lO=`return`,uO=`rootDeviceName`,dO=`rootDeviceType`,fO=`restoreExpiryTime`,pO=`regionEndpoint`,mO=`resourceExclusions`,hO=`referencedGroupId`,gO=`referencedGroupInfo`,_O=`reservationId`,vO=`ramdiskId`,yO=`regionInfo`,bO=`requesterId`,xO=`rebootMigrationSupport`,SO=`rebootMigration`,CO=`regionName`,wO=`revokedSecurityGroupRuleSet`,TO=`reservationSet`,EO=`routeSet`,DO=`routeTableAssociationId`,OO=`routeTableId`,kO=`routeTableSet`,AO=`resourceType`,jO=`requestTime`,MO=`routeTable`,NO=`ramdisk`,PO=`reason`,FO=`requested`,IO=`smithy.ts.sdk.synthetic.com.amazonaws.ec2`,LO=`supportedArchitectures`,RO=`subnetArn`,zO=`supportedBootModes`,BO=`sustainedClockSpeedInGhz`,VO=`sourceDestCheck`,HO=`spreadDomain`,UO=`supportedFeatures`,WO=`securityGroupArn`,GO=`securityGroupInfo`,KO=`securityGroupRuleArn`,qO=`securityGroupRuleId`,JO=`securityGroupRuleSet`,YO=`subGeographySet`,XO=`sizeInGB`,ZO=`sourceInstanceId`,QO=`secondaryInterfaceId`,$O=`sourceImageId`,ek=`sizeInMiB`,tk=`sourceImageRegion`,nk=`spotInstanceRequestId`,rk=`supportedInRegion`,ik=`secondaryInterfaceSet`,ak=`snapshotId`,ok=`subnetId`,sk=`spreadLevel`,ck=`serviceManaged`,lk=`statusMessage`,uk=`stateMessage`,dk=`secondaryNetworkId`,fk=`secondaryNetworkSupported`,pk=`sriovNetSupport`,mk=`secondaryPrivateIpAddressCount`,hk=`supportedRootDeviceTypes`,gk=`stateReason`,_k=`sourceSecurityGroupName`,vk=`sourceSecurityGroupOwnerId`,yk=`secondarySubnetId`,bk=`snapshotSet`,xk=`subnetSet`,Sk=`supportedStrategies`,Ck=`startTime`,wk=`sseType`,Tk=`storageTier`,Ek=`supportedUsageClasses`,Dk=`sourceVolumeId`,Ok=`supportedVirtualizationTypes`,kk=`supportedVersions`,Ak=`size`,jk=`state`,Mk=`status`,Nk=`strategy`,Pk=`subnet`,Fk=`tenancy`,Ik=`tcpEstablishedTimeout`,Lk=`totalFpgaMemoryInMiB`,Rk=`transitGatewayId`,zk=`totalGpuMemoryInMiB`,Bk=`totalInferenceMemoryInMiB`,Vk=`targetIops`,Hk=`targetMultiAttachEnabled`,Uk=`totalMediaMemoryInMiB`,Wk=`totalNeuronDeviceMemoryInMiB`,Gk=`toPort`,Kk=`threadsPerCore`,qk=`tagSet`,Jk=`totalSizeInGB`,Yk=`targetSize`,Xk=`tpmSupport`,Zk=`targetThroughput`,Qk=`transferType`,$k=`targetVolumeType`,eA=`throughput`,tA=`timestamp`,nA=`type`,rA=`userData`,iA=`userId`,aA=`unknownIpPermissionSet`,oA=`usageOperation`,sA=`usageOperationUpdateTime`,cA=`udpStreamTimeout`,lA=`udpTimeout`,uA=`value`,dA=`vCpuInfo`,fA=`validCores`,pA=`vpcEncryptionControlId`,mA=`vpcEndpointId`,hA=`vpcId`,gA=`volumeInitializationRate`,_A=`volumeId`,vA=`vpcLattice`,yA=`volumeModification`,bA=`virtualName`,xA=`volumeOwnerId`,SA=`vpcPeeringConnectionId`,CA=`virtualPrivateGateway`,wA=`vpcPeering`,TA=`volumeSet`,EA=`volumeSize`,DA=`vpcSet`,OA=`volumeType`,kA=`validThreadsPerCore`,AA=`virtualizationType`,jA=`version`,MA=`vpc`,NA=`workloadSet`,C=`xmlName`,PA=`zoneId`,FA=`zoneName`,IA=`zoneState`,LA=`zoneType`,w=`com.amazonaws.ec2`,RA=Lt.for(IO),zA=[-3,IO,`EC2ServiceException`,0,[],[]];RA.registerError(zA,Ts);var BA=[RA],VA=[0,w,hu,8,0],HA=[0,w,aie,8,0],UA=[0,w,Zae,8,0],WA=[0,w,py,8,0],GA=[3,w,Es,0,[Ys,Zs,tp,f_,Rie,z_,Ty,V_,s_,Kl,ql,Dl,Lv,Hv,v,R_],[[0,{[x]:`AllocationId`,[C]:bx}],[0,{[x]:`AssociationId`,[C]:wx}],[0,{[x]:`Domain`,[C]:_C}],[0,{[x]:`NetworkInterfaceId`,[C]:ZE}],[0,{[x]:`NetworkInterfaceOwnerId`,[C]:QE}],[0,{[x]:`PrivateIpAddress`,[C]:VD}],[()=>IL,{[x]:`TagSet`,[C]:qk}],[0,{[x]:`PublicIpv4Pool`,[C]:HD}],[0,{[x]:`NetworkBorderGroup`,[C]:VE}],[0,{[x]:`CustomerOwnedIp`,[C]:OS}],[0,{[x]:`CustomerOwnedIpv4Pool`,[C]:kS}],[0,{[x]:`CarrierIp`,[C]:bS}],[0,{[x]:`SubnetId`,[C]:ok}],[0,{[x]:`ServiceManaged`,[C]:ck}],[0,{[x]:`InstanceId`,[C]:iT}],[0,{[x]:`PublicIp`,[C]:RD}]]],KA=[3,w,Ds,0,[tp,Es,V_,s_,ql,Uy,jne,_],[0,0,0,0,0,[()=>LL,{[C]:Ky}],0,[2,{[x]:`DryRun`,[C]:b}]]],qA=[3,w,Os,0,[Ys,V_,s_,tp,Kl,ql,Dl,R_],[[0,{[x]:`AllocationId`,[C]:bx}],[0,{[x]:`PublicIpv4Pool`,[C]:HD}],[0,{[x]:`NetworkBorderGroup`,[C]:VE}],[0,{[x]:`Domain`,[C]:_C}],[0,{[x]:`CustomerOwnedIp`,[C]:OS}],[0,{[x]:`CustomerOwnedIpv4Pool`,[C]:kS}],[0,{[x]:`CarrierIp`,[C]:bS}],[0,{[x]:`PublicIp`,[C]:RD}]]],JA=[3,w,ks,0,[Ys,v,R_,_,f_,z_,dc],[0,0,0,[2,{[x]:`DryRun`,[C]:b}],[0,{[x]:`NetworkInterfaceId`,[C]:ZE}],[0,{[x]:`PrivateIpAddress`,[C]:VD}],[2,{[x]:`AllowReassociation`,[C]:Nx}]]],YA=[3,w,As,0,[Zs],[[0,{[x]:`AssociationId`,[C]:wx}]]],XA=[3,w,Ws,0,[$h,v],[[()=>TN,0],0],2],ZA=[3,w,Gs,0,[eg],[[()=>wN,{[x]:`IamInstanceProfileAssociation`,[C]:oT}]]],QA=[3,w,cc,0,[tv,Rm,V_,_,Lv],[[0,{[x]:`RouteTableId`,[C]:OO}],0,0,[2,{[x]:`DryRun`,[C]:b}],[0,{[x]:`SubnetId`,[C]:ok}]],1],$A=[3,w,lc,0,[Zs,hc],[[0,{[x]:`AssociationId`,[C]:wx}],[()=>fF,{[x]:`AssociationState`,[C]:Ix}]]],ej=[3,w,Hs,0,[Gh,Ob,_],[[0,{[x]:`InternetGatewayId`,[C]:nT}],[0,{[x]:`VpcId`,[C]:hA}],[2,{[x]:`DryRun`,[C]:b}]],2],tj=[3,w,Dc,0,[$f,v,Pb,ap,_],[0,0,0,1,[2,{[x]:`DryRun`,[C]:b}]],3],nj=[3,w,Is,0,[mb],[[2,{[x]:`Value`,[C]:uA}]]],rj=[3,w,Oc,0,[mb],[[0,{[x]:`Value`,[C]:uA}]]],ij=[3,w,_c,0,[Hm,Uy,_,ry,iy,xg,Sm,By,Pl,bg],[[0,{[x]:`GroupId`,[C]:vw}],[()=>LL,{[C]:Ky}],[2,{[x]:`DryRun`,[C]:b}],[0,{[x]:`SourceSecurityGroupName`,[C]:_k}],[0,{[x]:`SourceSecurityGroupOwnerId`,[C]:vk}],[0,{[x]:`IpProtocol`,[C]:CT}],[1,{[x]:`FromPort`,[C]:cw}],[1,{[x]:`ToPort`,[C]:Gk}],[0,{[x]:`CidrIp`,[C]:SS}],[()=>HI,{[x]:`IpPermissions`,[C]:wT}]],1],aj=[3,w,vc,0,[Y_,ov],[[2,{[x]:`Return`,[C]:lO}],[()=>OL,{[x]:`SecurityGroupRuleSet`,[C]:JO}]]],oj=[3,w,yc,0,[Pl,Sm,Hm,Km,bg,xg,ry,iy,By,Uy,_],[0,1,0,0,[()=>HI,0],0,0,0,1,[()=>LL,{[C]:Ky}],[2,{[x]:`DryRun`,[C]:b}]]],sj=[3,w,bc,0,[Y_,ov],[[2,{[x]:`Return`,[C]:lO}],[()=>OL,{[x]:`SecurityGroupRuleSet`,[C]:JO}]]],cj=[3,w,Ac,0,[C_,vie,$_,px,ux,Km,s_,gx,Pae,Nae,Gm,Zm,Koe,by],[[0,{[x]:`OptInStatus`,[C]:hD}],[()=>QF,{[x]:`MessageSet`,[C]:AE}],[0,{[x]:`RegionName`,[C]:CO}],[0,{[x]:`ZoneName`,[C]:FA}],[0,{[x]:`ZoneId`,[C]:PA}],[0,{[x]:`GroupName`,[C]:bw}],[0,{[x]:`NetworkBorderGroup`,[C]:VE}],[0,{[x]:`ZoneType`,[C]:LA}],[0,{[x]:`ParentZoneName`,[C]:rO}],[0,{[x]:`ParentZoneId`,[C]:nO}],[0,{[x]:`GroupLongName`,[C]:yw}],[()=>XF,{[x]:`GeographySet`,[C]:Cw}],[()=>$F,{[x]:`SubGeographySet`,[C]:YO}],[0,{[x]:`ZoneState`,[C]:IA}]]],lj=[3,w,jc,0,[Ac,Fc,Xs],[0,0,[()=>KF,{[C]:Ys}]]],uj=[3,w,Nc,0,[o_],[[0,{[x]:`Name`,[C]:BE}]]],dj=[3,w,Lc,0,[r_],[[0,{[x]:`Message`,[C]:LE}]]],fj=[3,w,zc,0,[o_],[[0,{[x]:`Name`,[C]:BE}]]],pj=[3,w,Qc,0,[mb],[[21,{[x]:`Value`,[C]:uA}]]],mj=[3,w,tl,0,[lm,l_,zd,Hb],[[()=>XM,{[x]:`Ebs`,[C]:tw}],[0,{[x]:`NoDevice`,[C]:WE}],[0,{[x]:`DeviceName`,[C]:$S}],[0,{[x]:`VirtualName`,[C]:bA}]]],hj=[3,w,ul,0,[Uh],[[0,{[x]:`InternetGatewayBlockMode`,[C]:tT}]]],gj=[3,w,iu,0,[$l,au],[0,()=>vj]],_j=[3,w,ru,0,[$l,au],[[0,{[x]:`CapacityReservationPreference`,[C]:jS}],[()=>yj,{[x]:`CapacityReservationTarget`,[C]:PS}]]],vj=[3,w,au,0,[Ql,tu],[0,0]],yj=[3,w,ou,0,[Ql,tu],[[0,{[x]:`CapacityReservationId`,[C]:AS}],[0,{[x]:`CapacityReservationResourceGroupArn`,[C]:MS}]]],bj=[3,w,ku,0,[Ey,fb,pb],[1,1,1]],xj=[3,w,Au,0,[Ey,fb,pb],[[1,{[x]:`TcpEstablishedTimeout`,[C]:Ik}],[1,{[x]:`UdpStreamTimeout`,[C]:cA}],[1,{[x]:`UdpTimeout`,[C]:lA}]]],Sj=[3,w,mu,0,[ty,ay,Qf,qd,mf,dm,Fg,kae,Uy,Cl,qu,_],[0,0,0,0,[0,{[x]:`DestinationRegion`,[C]:oC}],[2,{[x]:`Encrypted`,[C]:rw}],[0,{[x]:`KmsKeyId`,[C]:ZT}],[()=>VA,{[x]:`PresignedUrl`,[C]:eO}],[()=>LL,{[C]:Ky}],1,0,[2,{[x]:`DryRun`,[C]:b}]],2],Cj=[3,w,gu,0,[Ty,jv],[[()=>IL,{[x]:`TagSet`,[C]:qk}],[0,{[x]:`SnapshotId`,[C]:ak}]]],wj=[3,w,Gl,0,[xl,Vy,Cc,b_],[[1,{[x]:`CoreCount`,[C]:hS}],[1,{[x]:`ThreadsPerCore`,[C]:Kk}],[0,{[x]:`AmdSevSnp`,[C]:Rx}],[0,{[x]:`NestedVirtualization`,[C]:uD}]]],Tj=[3,w,Jl,0,[xl,Vy,Cc,b_],[1,1,0,0]],Ej=[3,w,Ml,0,[v,o_,Uy,Rv,_,Qf,zie,il],[[0,{[x]:`InstanceId`,[C]:iT}],[0,{[x]:`Name`,[C]:BE}],[()=>LL,{[C]:Ky}],0,[2,{[x]:`DryRun`,[C]:b}],[0,{[x]:`Description`,[C]:mC}],[2,{[x]:`NoReboot`,[C]:aD}],[()=>nI,{[x]:`BlockDeviceMapping`,[C]:nS}]],2],Dj=[3,w,Nl,0,[fg],[[0,{[x]:`ImageId`,[C]:lT}]]],Oj=[3,w,Al,0,[Uy,_],[[()=>LL,{[C]:Ky}],[2,{[x]:`DryRun`,[C]:b}]]],kj=[3,w,jl,0,[Bh],[[()=>sP,{[x]:`InternetGateway`,[C]:eT}]]],Aj=[3,w,Bl,0,[Ig,Rg,Uy,yre,_],[0,0,[()=>LL,{[C]:Ky}],0,[2,{[x]:`DryRun`,[C]:b}]],1],jj=[3,w,Ul,0,[rc,Ys,Eu,_,Lv,Ob,Mc,Uy,Mu,z_,Ioe,Zv,Qv],[0,0,[0,4],2,0,0,[()=>YF,{[C]:jc}],[()=>LL,{[C]:Ky}],0,0,[()=>KF,{[C]:Loe}],[()=>VI,{[C]:$v}],1]],Mj=[3,w,Wl,0,[Eu,u_],[[0,{[x]:`ClientToken`,[C]:IS}],[()=>OP,{[x]:`NatGateway`,[C]:qE}]]],Nj=[3,w,Xl,0,[k_,Uy,Bv,Vg,T_,_,Km,Sy],[1,[()=>LL,{[C]:Ky}],0,0,()=>RP,[2,{[x]:`DryRun`,[C]:b}],[0,{[x]:`GroupName`,[C]:bw}],[0,{[x]:`Strategy`,[C]:Nk}]]],Pj=[3,w,Zl,0,[I_],[[()=>VP,{[x]:`PlacementGroup`,[C]:FD}]]],Fj=[3,w,eu,0,[tv,tf,Db,Oy,Bg,Tl,Vl,w_,_,$u,Rm,sd,Vp,v,f_,Wb,d_],[[0,{[x]:`RouteTableId`,[C]:OO}],0,0,0,0,0,0,0,[2,{[x]:`DryRun`,[C]:b}],[0,{[x]:`DestinationCidrBlock`,[C]:GS}],[0,{[x]:`GatewayId`,[C]:gw}],[0,{[x]:`DestinationIpv6CidrBlock`,[C]:QS}],[0,{[x]:`EgressOnlyInternetGatewayId`,[C]:BC}],[0,{[x]:`InstanceId`,[C]:iT}],[0,{[x]:`NetworkInterfaceId`,[C]:ZE}],[0,{[x]:`VpcPeeringConnectionId`,[C]:SA}],[0,{[x]:`NatGatewayId`,[C]:YE}]],1],Ij=[3,w,nu,0,[Y_],[[2,{[x]:`Return`,[C]:lO}]]],Lj=[3,w,su,0,[Ob,Uy,Eu,_],[[0,{[x]:`VpcId`,[C]:hA}],[()=>LL,{[C]:Ky}],[0,4],[2,{[x]:`DryRun`,[C]:b}]],1],Rj=[3,w,cu,0,[nv,Eu],[[()=>uF,{[x]:`RouteTable`,[C]:MO}],[0,{[x]:`ClientToken`,[C]:IS}]]],zj=[3,w,fu,0,[Qf,Km,Ob,Uy,_],[[0,{[C]:Pm}],0,0,[()=>LL,{[C]:Ky}],[2,{[x]:`DryRun`,[C]:b}]],2],Bj=[3,w,pu,0,[Hm,Ty,iv],[[0,{[x]:`GroupId`,[C]:vw}],[()=>IL,{[x]:`TagSet`,[C]:qk}],[0,{[x]:`SecurityGroupArn`,[C]:WO}]]],Vj=[3,w,_u,0,[Pb,Qf,x_,Uy,qre,_],[0,0,0,[()=>LL,{[C]:Ky}],0,[2,{[x]:`DryRun`,[C]:b}]],1],Hj=[3,w,vu,0,[Ob,Uy,Ac,Fc,gl,kh,x_,gg,ig,vg,rg,_g,_],[0,[()=>LL,{[C]:Ky}],0,0,0,0,0,2,0,1,0,1,[2,{[x]:`DryRun`,[C]:b}]],1],Uj=[3,w,yu,0,[Cy],[[()=>wF,{[x]:`Subnet`,[C]:Pk}]]],Wj=[3,w,Pu,0,[Ac,Fc,dm,Mg,Fg,x_,_y,jv,Zb,Uy,Hg,eb,Eu,jb,T_,_],[0,0,[2,{[x]:`Encrypted`,[C]:rw}],1,0,0,1,0,0,[()=>LL,{[C]:Ky}],2,1,[0,4],1,()=>RP,[2,{[x]:`DryRun`,[C]:b}]]],Gj=[3,w,Fu,0,[gl,Cg,kh,ig,vg,rg,_g,jh,Sb,Uy,_,kg,ac],[0,0,0,0,1,0,1,0,()=>BF,[()=>LL,{[C]:Ky}],[2,{[x]:`DryRun`,[C]:b}],[0,{[x]:`InstanceTenancy`,[C]:GT}],[2,{[x]:`AmazonProvidedIpv6CidrBlock`,[C]:Ax}]]],Kj=[3,w,Iu,0,[ox],[[()=>IF,{[x]:`Vpc`,[C]:MA}]]],qj=[3,w,bu,0,[Sl],[0],1],Jj=[3,w,ed,0,[Nf,Rf,Lf],[[1,{[x]:`DefaultTcpEstablishedTimeout`,[C]:sC}],[1,{[x]:`DefaultUdpTimeout`,[C]:fC}],[1,{[x]:`DefaultUdpStreamTimeout`,[C]:dC}]]],Yj=[3,w,ld,0,[Gh,_],[[0,{[x]:`InternetGatewayId`,[C]:nT}],[2,{[x]:`DryRun`,[C]:b}]],1],Xj=[3,w,Nd,0,[Ig,Lg,_],[0,0,[2,{[x]:`DryRun`,[C]:b}]]],Zj=[3,w,Pd,0,[Y_,Lg],[[2,{[x]:`Return`,[C]:lO}],[0,{[x]:`KeyPairId`,[C]:eE}]]],Qj=[3,w,Hd,0,[d_,_],[[0,{[C]:d_}],2],1],$j=[3,w,Ud,0,[d_],[[0,{[x]:`NatGatewayId`,[C]:YE}]]],eM=[3,w,Zd,0,[Km,_],[[0,{[x]:`GroupName`,[C]:bw}],[2,{[x]:`DryRun`,[C]:b}]],1],tM=[3,w,rf,0,[tv,tf,_,$u,sd],[[0,{[x]:`RouteTableId`,[C]:OO}],0,[2,{[x]:`DryRun`,[C]:b}],[0,{[x]:`DestinationCidrBlock`,[C]:GS}],[0,{[x]:`DestinationIpv6CidrBlock`,[C]:QS}]],1],nM=[3,w,cf,0,[tv,_],[[0,{[x]:`RouteTableId`,[C]:OO}],[2,{[x]:`DryRun`,[C]:b}]],1],rM=[3,w,vf,0,[Hm,Km,_],[0,0,[2,{[x]:`DryRun`,[C]:b}]]],iM=[3,w,yf,0,[Y_,Hm],[[2,{[x]:`Return`,[C]:lO}],[0,{[x]:`GroupId`,[C]:vw}]]],aM=[3,w,Cf,0,[jv,_],[0,[2,{[x]:`DryRun`,[C]:b}]],1],oM=[3,w,wf,0,[Lv,_],[0,[2,{[x]:`DryRun`,[C]:b}]],1],sM=[3,w,Bf,0,[Pb,_],[0,[2,{[x]:`DryRun`,[C]:b}]],1],cM=[3,w,Vf,0,[Ob,_],[0,[2,{[x]:`DryRun`,[C]:b}]],1],lM=[3,w,Hu,0,[bae,_,Em,Xs],[[()=>mL,{[C]:R_}],[2,{[x]:`DryRun`,[C]:b}],[()=>fI,{[C]:Dm}],[()=>KF,{[C]:Ys}]]],uM=[3,w,Uu,0,[Uc],[[()=>GF,{[x]:`AddressesSet`,[C]:zx}]]],dM=[3,w,Ju,0,[hx,fx,js,_,Em],[[()=>QL,{[C]:px}],[()=>ZL,{[C]:ux}],2,[2,{[x]:`DryRun`,[C]:b}],[()=>fI,{[C]:Dm}]]],fM=[3,w,Yu,0,[Vc],[[()=>ZF,{[x]:`AvailabilityZoneInfo`,[C]:Kx}]]],pM=[3,w,_d,0,[Qs,Em,$g,y],[[()=>JF,{[C]:Zs}],[()=>fI,{[C]:Dm}],1,0]],mM=[3,w,vd,0,[ng,y],[[()=>vI,{[x]:`IamInstanceProfileAssociationSet`,[C]:sT}],[0,{[x]:`NextToken`,[C]:lD}]]],hM=[3,w,Sd,0,[cm,tne,eae,Nh,Lh,$g,y,_,Em],[[()=>dI,{[C]:ip}],[()=>yI,{[C]:fg}],[()=>oL,{[C]:E_}],2,2,1,0,[2,{[x]:`DryRun`,[C]:b}],[()=>fI,{[C]:Dm}]]],gM=[3,w,Cd,0,[y,mre],[[0,{[x]:`NextToken`,[C]:lD}],[()=>bI,{[x]:`ImagesSet`,[C]:PT}]]],_M=[3,w,wd,0,[pg,mg,_,Em,y,$g],[[()=>wI,{[C]:v}],2,[2,{[x]:`DryRun`,[C]:b}],[()=>fI,{[C]:Dm}],[0,{[x]:`NextToken`,[C]:lD}],[1,{[x]:`MaxResults`,[C]:kE}]]],vM=[3,w,Td,0,[y,Moe],[[0,{[x]:`NextToken`,[C]:lD}],[()=>vL,{[x]:`ReservationSet`,[C]:TO}]]],yM=[3,w,Ed,0,[_,Ag,Em,$g,y,pre],[2,[64,{[C]:Og}],[()=>fI,{[C]:Dm}],1,0,2]],bM=[3,w,Dd,0,[Ag,y],[[()=>LI,{[x]:`InstanceTypeSet`,[C]:HT}],[0,{[x]:`NextToken`,[C]:lD}]]],xM=[3,w,ud,0,[y,$g,_,qh,Em],[0,1,[2,{[x]:`DryRun`,[C]:b}],[()=>zI,{[x]:`InternetGatewayId`,[C]:nT}],[()=>fI,{[C]:Dm}]]],SM=[3,w,dd,0,[Yh,y],[[()=>BI,{[x]:`InternetGatewaySet`,[C]:rT}],[0,{[x]:`NextToken`,[C]:lD}]]],CM=[3,w,Fd,0,[Sre,Tre,Fne,_,Em],[[()=>qI,{[C]:Ig}],[()=>JI,{[C]:Lg}],2,[2,{[x]:`DryRun`,[C]:b}],[()=>fI,{[C]:Dm}]]],wM=[3,w,Id,0,[Cre],[[()=>YI,{[x]:`KeySet`,[C]:tE}]]],TM=[3,w,Wd,0,[_,Dm,$g,Fie,y],[2,[()=>fI,0],1,[()=>tL,{[C]:d_}],0]],EM=[3,w,Gd,0,[Lie,y],[[()=>nL,{[x]:`NatGatewaySet`,[C]:XE}],[0,{[x]:`NextToken`,[C]:lD}]]],DM=[3,w,Qd,0,[Um,_,Jm,Em],[[()=>sL,{[C]:Hm}],[2,{[x]:`DryRun`,[C]:b}],[64,{[x]:`GroupName`,[C]:bw}],[()=>fI,{[C]:Dm}]]],OM=[3,w,$d,0,[fae],[[()=>cL,{[x]:`PlacementGroupSet`,[C]:LD}]]],kM=[3,w,af,0,[coe,fc,_,Em],[[()=>_L,{[C]:$_}],2,[2,{[x]:`DryRun`,[C]:b}],[()=>fI,{[C]:Dm}]]],AM=[3,w,of,0,[koe],[[()=>gL,{[x]:`RegionInfo`,[C]:yO}]]],jM=[3,w,lf,0,[y,$g,_,Coe,Em],[0,1,[2,{[x]:`DryRun`,[C]:b}],[()=>CL,{[C]:tv}],[()=>fI,{[C]:Dm}]]],MM=[3,w,uf,0,[Doe,y],[[()=>wL,{[x]:`RouteTableSet`,[C]:kO}],[0,{[x]:`NextToken`,[C]:lD}]]],NM=[3,w,bf,0,[Um,Jm,y,$g,_,Em],[[()=>gI,{[C]:Hm}],[()=>_I,{[C]:Km}],0,1,[2,{[x]:`DryRun`,[C]:b}],[()=>fI,{[C]:Dm}]]],PM=[3,w,xf,0,[y,lv],[[0,{[x]:`NextToken`,[C]:lD}],[()=>EL,{[x]:`SecurityGroupInfo`,[C]:GO}]]],FM=[3,w,Tf,0,[$g,y,Gie,zae,Mv,_,Em],[1,0,[()=>oL,{[C]:E_}],[64,{[C]:Bae}],[()=>AL,{[C]:jv}],[2,{[x]:`DryRun`,[C]:b}],[()=>fI,{[C]:Dm}]]],IM=[3,w,Ef,0,[y,vy],[[0,{[x]:`NextToken`,[C]:lD}],[()=>jL,{[x]:`SnapshotSet`,[C]:bk}]]],LM=[3,w,Df,0,[Em,dv,y,$g,_],[[()=>fI,{[C]:Dm}],[()=>ML,{[C]:Lv}],0,1,[2,{[x]:`DryRun`,[C]:b}]]],RM=[3,w,Of,0,[y,wy],[[0,{[x]:`NextToken`,[C]:lD}],[()=>PL,{[x]:`SubnetSet`,[C]:xk}]]],zM=[3,w,Hf,0,[Fb,mg,_,Em,y,$g],[[()=>WL,{[C]:Pb}],2,[2,{[x]:`DryRun`,[C]:b}],[()=>fI,{[C]:Dm}],[0,{[x]:`NextToken`,[C]:lD}],[1,{[x]:`MaxResults`,[C]:kE}]]],BM=[3,w,Uf,0,[y,ix],[[0,{[x]:`NextToken`,[C]:lD}],[()=>GL,{[x]:`VolumeSet`,[C]:TA}]]],VM=[3,w,Wf,0,[Em,Ib,y,$g,_],[[()=>fI,{[C]:Dm}],[()=>qL,{[C]:Ob}],0,1,[2,{[x]:`DryRun`,[C]:b}]]],HM=[3,w,Gf,0,[y,sx],[[0,{[x]:`NextToken`,[C]:lD}],[()=>YL,{[x]:`VpcSet`,[C]:DA}]]],UM=[3,w,fd,0,[Gh,Ob,_],[[0,{[x]:`InternetGatewayId`,[C]:nT}],[0,{[x]:`VpcId`,[C]:hA}],[2,{[x]:`DryRun`,[C]:b}]],2],WM=[3,w,Kf,0,[Pb,$f,pm,v,_],[0,0,2,0,[2,{[x]:`DryRun`,[C]:b}]],1],GM=[3,w,Wu,0,[Zs,R_,_],[0,0,[2,{[x]:`DryRun`,[C]:b}]]],KM=[3,w,yd,0,[Zs],[0],1],qM=[3,w,bd,0,[eg],[[()=>wN,{[x]:`IamInstanceProfileAssociation`,[C]:oT}]]],JM=[3,w,df,0,[Zs,_],[[0,{[x]:`AssociationId`,[C]:wx}],[2,{[x]:`DryRun`,[C]:b}]],1],YM=[3,w,jd,0,[mv,Vu,nb],[[1,{[x]:`SizeInGB`,[C]:XO}],[1,{[x]:`Count`,[C]:US}],[0,{[x]:`Type`,[C]:nA}]]],XM=[3,w,np,0,[Yd,Mg,jv,Xb,Zb,Fg,eb,x_,Ac,dm,jb,Fc,ap],[[2,{[x]:`DeleteOnTermination`,[C]:rC}],[1,{[x]:`Iops`,[C]:qT}],[0,{[x]:`SnapshotId`,[C]:ak}],[1,{[x]:`VolumeSize`,[C]:EA}],[0,{[x]:`VolumeType`,[C]:OA}],[0,{[x]:`KmsKeyId`,[C]:ZT}],[1,{[x]:`Throughput`,[C]:eA}],[0,{[x]:`OutpostArn`,[C]:fD}],[0,{[x]:`AvailabilityZone`,[C]:Wx}],[2,{[x]:`Encrypted`,[C]:rw}],1,0,1]],ZM=[3,w,sp,0,[ap,el,fl,ol,Gg,t_,qg],[[1,{[x]:`EbsCardIndex`,[C]:vC}],[1,{[x]:`BaselineBandwidthInMbps`,[C]:tS}],[1,{[x]:`BaselineThroughputInMBps`,[C]:cS}],[1,{[x]:`BaselineIops`,[C]:rS}],[1,{[x]:`MaximumBandwidthInMbps`,[C]:hE}],[1,{[x]:`MaximumThroughputInMBps`,[C]:NE}],[1,{[x]:`MaximumIops`,[C]:SE}]]],QM=[3,w,Pp,0,[Up,am,Rp,v_,$re,tc,eie,cp],[[0,{[x]:`EbsOptimizedSupport`,[C]:VC}],[0,{[x]:`EncryptionSupport`,[C]:QC}],[()=>tN,{[x]:`EbsOptimizedInfo`,[C]:RC}],[0,{[x]:`NvmeSupport`,[C]:oD}],[1,{[x]:`MaximumEbsAttachments`,[C]:_E}],[0,{[x]:`AttachmentLimitType`,[C]:Dx}],[1,{[x]:`MaximumEbsCards`,[C]:vE}],[()=>oI,{[x]:`EbsCardSet`,[C]:yC}]]],$M=[3,w,jp,0,[Ec,Yd,xy,Pb,pc,Ub,T_,ap],[[4,{[x]:`AttachTime`,[C]:Ux}],[2,{[x]:`DeleteOnTermination`,[C]:rC}],[0,{[x]:`Status`,[C]:Mk}],[0,{[x]:`VolumeId`,[C]:_A}],[0,{[x]:`AssociatedResource`,[C]:Px}],[0,{[x]:`VolumeOwnerId`,[C]:xA}],[()=>zP,{[x]:`Operator`,[C]:SD}],[1,{[x]:`EbsCardIndex`,[C]:vC}]]],eN=[3,w,Mp,0,[Pb,Yd],[[0,{[x]:`VolumeId`,[C]:_A}],[2,{[x]:`DeleteOnTermination`,[C]:rC}]]],tN=[3,w,Rp,0,[el,fl,ol,Gg,t_,qg],[[1,{[x]:`BaselineBandwidthInMbps`,[C]:tS}],[1,{[x]:`BaselineThroughputInMBps`,[C]:cS}],[1,{[x]:`BaselineIops`,[C]:rS}],[1,{[x]:`MaximumBandwidthInMbps`,[C]:hE}],[1,{[x]:`MaximumThroughputInMBps`,[C]:NE}],[1,{[x]:`MaximumIops`,[C]:SE}]]],nN=[3,w,Fp,0,[tie],[[1,{[x]:`MaximumEfaInterfaces`,[C]:yE}]]],rN=[3,w,pp,0,[yp,mp,gp,_p],[[0,{[x]:`ElasticGpuId`,[C]:DC}],[0,{[x]:`ElasticGpuAssociationId`,[C]:CC}],[0,{[x]:`ElasticGpuAssociationState`,[C]:wC}],[0,{[x]:`ElasticGpuAssociationTime`,[C]:EC}]]],iN=[3,w,bp,0,[nb],[0],1],aN=[3,w,Sp,0,[nb,Vu],[0,1],1],oN=[3,w,Cp,0,[Op,wp,Ep,Dp],[[0,{[x]:`ElasticInferenceAcceleratorArn`,[C]:OC}],[0,{[x]:`ElasticInferenceAcceleratorAssociationId`,[C]:kC}],[0,{[x]:`ElasticInferenceAcceleratorAssociationState`,[C]:AC}],[4,{[x]:`ElasticInferenceAcceleratorAssociationTime`,[C]:MC}]]],sN=[3,w,$p,0,[Zp,nm],[2,()=>cN]],cN=[3,w,rm,0,[tm],[2]],lN=[3,w,Wp,0,[um],[[2,{[x]:`Enabled`,[C]:nw}]]],uN=[3,w,Hp,0,[um],[2]],dN=[3,w,Dm,0,[o_,nx],[0,[()=>VL,{[C]:mb}]]],fN=[3,w,hm,0,[o_,n_,Vu,Jg],[[0,{[x]:`Name`,[C]:BE}],[0,{[x]:`Manufacturer`,[C]:FE}],[1,{[x]:`Count`,[C]:US}],[()=>pN,{[x]:`MemoryInfo`,[C]:CE}]]],pN=[3,w,_m,0,[vv],[[1,{[x]:`SizeInMiB`,[C]:ek}]]],mN=[3,w,ym,0,[Om,Dy],[[()=>pI,{[x]:`Fpgas`,[C]:pw}],[1,{[x]:`TotalFpgaMemoryInMiB`,[C]:Lk}]]],hN=[3,w,Mm,0,[v,Wre,_],[0,2,[2,{[x]:`DryRun`,[C]:b}]],1],gN=[3,w,Nm,0,[v,tb,$ie],[[0,{[x]:`InstanceId`,[C]:iT}],[4,{[x]:`Timestamp`,[C]:tA}],[0,{[x]:`Output`,[C]:CD}]]],_N=[3,w,Fm,0,[o_,n_,Vu,Nre,Xm,lx,Jg],[[0,{[x]:`Name`,[C]:BE}],[0,{[x]:`Manufacturer`,[C]:FE}],[1,{[x]:`Count`,[C]:US}],[1,{[x]:`LogicalGpuCount`,[C]:aE}],[1,{[x]:`GpuPartitionSize`,[C]:Sw}],[()=>XL,{[x]:`WorkloadSet`,[C]:NA}],[()=>vN,{[x]:`MemoryInfo`,[C]:CE}]]],vN=[3,w,Lm,0,[vv],[[1,{[x]:`SizeInMiB`,[C]:ek}]]],yN=[3,w,Vm,0,[Qm,ky],[[()=>mI,{[x]:`Gpus`,[C]:Tw}],[1,{[x]:`TotalGpuMemoryInMiB`,[C]:zk}]]],bN=[3,w,Wm,0,[Hm,Km],[[0,{[x]:`GroupId`,[C]:vw}],[0,{[x]:`GroupName`,[C]:bw}]]],xN=[3,w,nh,0,[Bu],[[2,{[x]:`Configured`,[C]:HS}]]],SN=[3,w,rh,0,[Bu],[2]],CN=[3,w,$h,0,[Gc,fh],[[0,{[x]:`Arn`,[C]:Yx}],[0,{[x]:`Id`,[C]:Rw}]]],wN=[3,w,eg,0,[Zs,v,$h,by,tb],[[0,{[x]:`AssociationId`,[C]:wx}],[0,{[x]:`InstanceId`,[C]:iT}],[()=>CN,{[x]:`IamInstanceProfile`,[C]:aT}],[0,{[x]:`State`,[C]:jk}],[4,{[x]:`Timestamp`,[C]:tA}]]],TN=[3,w,sg,0,[Gc,o_],[[0,{[x]:`Arn`,[C]:Yx}],[0,{[x]:`Name`,[C]:BE}]]],EN=[3,w,hre,0,[F_,ub,il,Qf,om,dh,wne,o_,X_,Z_,qv,ny,Ty,ex,sl,Jy,Ff,sre,_v,nf,Fre,Sh,hv,bv,Tm,fg,ane,by,S_,wl,Iae,A_,Kc,fre,Pg,Q_,G_],[[0,{[x]:`PlatformDetails`,[C]:PD}],[0,{[x]:`UsageOperation`,[C]:oA}],[()=>tI,{[x]:`BlockDeviceMapping`,[C]:nS}],[0,{[x]:`Description`,[C]:mC}],[2,{[x]:`EnaSupport`,[C]:$C}],[0,{[x]:`Hypervisor`,[C]:Dw}],[0,{[x]:`ImageOwnerAlias`,[C]:mT}],[0,{[x]:`Name`,[C]:BE}],[0,{[x]:`RootDeviceName`,[C]:uO}],[0,{[x]:`RootDeviceType`,[C]:dO}],[0,{[x]:`SriovNetSupport`,[C]:pk}],[()=>xF,{[x]:`StateReason`,[C]:gk}],[()=>IL,{[x]:`TagSet`,[C]:qk}],[0,{[x]:`VirtualizationType`,[C]:AA}],[0,{[x]:`BootMode`,[C]:iS}],[0,{[x]:`TpmSupport`,[C]:Xk}],[0,{[x]:`DeprecationTime`,[C]:lC}],[0,{[x]:`ImdsSupport`,[C]:IT}],[0,{[x]:`SourceInstanceId`,[C]:ZO}],[0,{[x]:`DeregistrationProtection`,[C]:aC}],[0,{[x]:`LastLaunchedTime`,[C]:cE}],[2,{[x]:`ImageAllowed`,[C]:Kw}],[0,{[x]:`SourceImageId`,[C]:$O}],[0,{[x]:`SourceImageRegion`,[C]:tk}],[2,{[x]:`FreeTierEligible`,[C]:dw}],[0,{[x]:`ImageId`,[C]:lT}],[0,{[x]:`ImageLocation`,[C]:uT}],[0,{[x]:`ImageState`,[C]:FT}],[0,{[x]:`ImageOwnerId`,[C]:hT}],[0,{[x]:`CreationDate`,[C]:_S}],[2,{[x]:`IsPublic`,[C]:OT}],[()=>fL,{[x]:`ProductCodes`,[C]:DD}],[0,{[x]:`Architecture`,[C]:Xx}],[0,{[x]:`ImageType`,[C]:WT}],[0,{[x]:`KernelId`,[C]:XT}],[0,{[x]:`RamdiskId`,[C]:vO}],[0,{[x]:`Platform`,[C]:iO}]]],DN=[3,w,rne,0,[Ig,Sae,Uy,_],[[0,{[x]:`KeyName`,[C]:$T}],[21,{[x]:`PublicKeyMaterial`,[C]:KD}],[()=>LL,{[C]:Ky}],[2,{[x]:`DryRun`,[C]:b}]],2],ON=[3,w,ine,0,[Ng,Ig,Lg,Ty],[[0,{[x]:`KeyFingerprint`,[C]:YT}],[0,{[x]:`KeyName`,[C]:$T}],[0,{[x]:`KeyPairId`,[C]:eE}],[()=>IL,{[x]:`TagSet`,[C]:qk}]]],kN=[3,w,vh,0,[Hc,jy],[[()=>xI,{[x]:`Accelerators`,[C]:qx}],[1,{[x]:`TotalInferenceMemoryInMiB`,[C]:Bk}]]],AN=[3,w,Ph,0,[Vu,o_,n_,Jg],[[1,{[x]:`Count`,[C]:US}],[0,{[x]:`Name`,[C]:BE}],[0,{[x]:`Manufacturer`,[C]:FE}],[()=>jN,{[x]:`MemoryInfo`,[C]:CE}]]],jN=[3,w,Ih,0,[vv],[[1,{[x]:`SizeInMiB`,[C]:ek}]]],MN=[3,w,gre,0,[Kc,il,Eu,Lp,om,dh,$h,sne,vp,kp,p_,x_,X_,Z_,lv,rv,yv,qv,ny,Ty,ex,Gl,vl,Ql,iu,nh,Kre,Xg,Wp,sl,F_,ub,db,M_,wh,Jy,Zg,Ol,g_,T_,kv,v,fg,by,j_,P_,ly,Ig,ec,A_,Og,Ure,K_,Pg,Q_,G_,a_,Lv,Ob,z_,gae],[[0,{[x]:`Architecture`,[C]:Xx}],[()=>SI,{[x]:`BlockDeviceMapping`,[C]:nS}],[0,{[x]:`ClientToken`,[C]:IS}],[2,{[x]:`EbsOptimized`,[C]:LC}],[2,{[x]:`EnaSupport`,[C]:$C}],[0,{[x]:`Hypervisor`,[C]:Dw}],[()=>CN,{[x]:`IamInstanceProfile`,[C]:aT}],[0,{[x]:`InstanceLifecycle`,[C]:dT}],[()=>sI,{[x]:`ElasticGpuAssociationSet`,[C]:TC}],[()=>lI,{[x]:`ElasticInferenceAcceleratorAssociationSet`,[C]:jC}],[()=>kI,{[x]:`NetworkInterfaceSet`,[C]:$E}],[0,{[x]:`OutpostArn`,[C]:fD}],[0,{[x]:`RootDeviceName`,[C]:uO}],[0,{[x]:`RootDeviceType`,[C]:dO}],[()=>hI,{[x]:`GroupSet`,[C]:ww}],[2,{[x]:`SourceDestCheck`,[C]:VO}],[0,{[x]:`SpotInstanceRequestId`,[C]:nk}],[0,{[x]:`SriovNetSupport`,[C]:pk}],[()=>xF,{[x]:`StateReason`,[C]:gk}],[()=>IL,{[x]:`TagSet`,[C]:qk}],[0,{[x]:`VirtualizationType`,[C]:AA}],[()=>wj,{[x]:`CpuOptions`,[C]:DS}],[0,{[x]:`CapacityBlockId`,[C]:pS}],[0,{[x]:`CapacityReservationId`,[C]:AS}],[()=>_j,{[x]:`CapacityReservationSpecification`,[C]:NS}],[()=>xN,{[x]:`HibernationOptions`,[C]:jw}],[()=>XI,{[x]:`LicenseSet`,[C]:lE}],[()=>WN,{[x]:`MetadataOptions`,[C]:EE}],[()=>lN,{[x]:`EnclaveOptions`,[C]:HC}],[0,{[x]:`BootMode`,[C]:iS}],[0,{[x]:`PlatformDetails`,[C]:PD}],[0,{[x]:`UsageOperation`,[C]:oA}],[4,{[x]:`UsageOperationUpdateTime`,[C]:sA}],[()=>KP,{[x]:`PrivateDnsNameOptions`,[C]:AD}],[0,{[x]:`Ipv6Address`,[C]:Jw}],[0,{[x]:`TpmSupport`,[C]:Xk}],[()=>BN,{[x]:`MaintenanceOptions`,[C]:DE}],[0,{[x]:`CurrentInstanceBootMode`,[C]:xS}],[()=>YN,{[x]:`NetworkPerformanceOptions`,[C]:rD}],[()=>zP,{[x]:`Operator`,[C]:SD}],[()=>MI,{[x]:`SecondaryInterfaceSet`,[C]:ik}],[0,{[x]:`InstanceId`,[C]:iT}],[0,{[x]:`ImageId`,[C]:lT}],[()=>rP,{[x]:`InstanceState`,[C]:RT}],[0,{[x]:`PrivateDnsName`,[C]:MD}],[0,{[x]:`DnsName`,[C]:tC}],[0,{[x]:`Reason`,[C]:PO}],[0,{[x]:`KeyName`,[C]:$T}],[1,{[x]:`AmiLaunchIndex`,[C]:Ex}],[()=>fL,{[x]:`ProductCodes`,[C]:DD}],[0,{[x]:`InstanceType`,[C]:VT}],[4,{[x]:`LaunchTime`,[C]:uE}],[()=>BP,{[x]:`Placement`,[C]:aO}],[0,{[x]:`KernelId`,[C]:XT}],[0,{[x]:`RamdiskId`,[C]:vO}],[0,{[x]:`Platform`,[C]:iO}],[()=>DP,{[x]:`Monitoring`,[C]:RE}],[0,{[x]:`SubnetId`,[C]:ok}],[0,{[x]:`VpcId`,[C]:hA}],[0,{[x]:`PrivateIpAddress`,[C]:VD}],[0,{[x]:`IpAddress`,[C]:qw}]]],NN=[3,w,gh,0,[Zp,nm],[[2,{[x]:`EnaSrdEnabled`,[C]:KC}],[()=>PN,{[x]:`EnaSrdUdpSpecification`,[C]:XC}]]],PN=[3,w,_h,0,[tm],[[2,{[x]:`EnaSrdUdpEnabled`,[C]:YC}]]],FN=[3,w,Th,0,[zd,lm],[[0,{[x]:`DeviceName`,[C]:$S}],[()=>$M,{[x]:`Ebs`,[C]:tw}]]],IN=[3,w,Dh,0,[zd,lm,Hb,l_],[[0,{[x]:`DeviceName`,[C]:$S}],[()=>eN,{[x]:`Ebs`,[C]:tw}],[0,{[x]:`VirtualName`,[C]:bA}],[0,{[x]:`NoDevice`,[C]:WE}]]],LN=[3,w,cg,0,[Tg],[[0,{[x]:`Ipv4Prefix`,[C]:TT}]]],RN=[3,w,Xh,0,[wh,Pne],[[0,{[x]:`Ipv6Address`,[C]:Jw}],[2,{[x]:`IsPrimaryIpv6`,[C]:bT}]]],zN=[3,w,lg,0,[Sg],[[0,{[x]:`Ipv6Prefix`,[C]:DT}]]],BN=[3,w,une,0,[mc,ooe],[[0,{[x]:`AutoRecovery`,[C]:Fx}],[0,{[x]:`RebootMigration`,[C]:SO}]]],VN=[3,w,dne,0,[mc],[0]],HN=[3,w,fne,0,[fie,Yv],[0,()=>vF]],UN=[3,w,pne,0,[ch,ah,eh,ih,hg],[0,1,0,0,0]],WN=[3,w,mne,0,[by,ch,ah,eh,ih,hg],[[0,{[x]:`State`,[C]:jk}],[0,{[x]:`HttpTokens`,[C]:Iw}],[1,{[x]:`HttpPutResponseHopLimit`,[C]:Nw}],[0,{[x]:`HttpEndpoint`,[C]:kw}],[0,{[x]:`HttpProtocolIpv6`,[C]:Mw}],[0,{[x]:`InstanceMetadataTags`,[C]:fT}]]],GN=[3,w,gne,0,[Jc,Xc,Qf,km,ph,Wg,f_,S_,j_,z_,B_,rv,xy,Lv,Ob,jg,wg,yg,Du,T_],[[()=>KN,{[x]:`Association`,[C]:Zx}],[()=>qN,{[x]:`Attachment`,[C]:Qx}],[0,{[x]:`Description`,[C]:mC}],[()=>hI,{[x]:`GroupSet`,[C]:ww}],[()=>EI,{[x]:`Ipv6AddressesSet`,[C]:Gw}],[0,{[x]:`MacAddress`,[C]:mE}],[0,{[x]:`NetworkInterfaceId`,[C]:ZE}],[0,{[x]:`OwnerId`,[C]:mD}],[0,{[x]:`PrivateDnsName`,[C]:MD}],[0,{[x]:`PrivateIpAddress`,[C]:VD}],[()=>jI,{[x]:`PrivateIpAddressesSet`,[C]:zD}],[2,{[x]:`SourceDestCheck`,[C]:VO}],[0,{[x]:`Status`,[C]:Mk}],[0,{[x]:`SubnetId`,[C]:ok}],[0,{[x]:`VpcId`,[C]:hA}],[0,{[x]:`InterfaceType`,[C]:KT}],[()=>TI,{[x]:`Ipv4PrefixSet`,[C]:xT}],[()=>DI,{[x]:`Ipv6PrefixSet`,[C]:ST}],[()=>xj,{[x]:`ConnectionTrackingConfiguration`,[C]:LS}],[()=>zP,{[x]:`Operator`,[C]:SD}]]],KN=[3,w,_ne,0,[Dl,Kl,Tne,P_,R_],[[0,{[x]:`CarrierIp`,[C]:bS}],[0,{[x]:`CustomerOwnedIp`,[C]:OS}],[0,{[x]:`IpOwnerId`,[C]:_T}],[0,{[x]:`PublicDnsName`,[C]:ND}],[0,{[x]:`PublicIp`,[C]:RD}]]],qN=[3,w,vne,0,[Ec,$s,Yd,od,xy,c_,Qp,Kp],[[4,{[x]:`AttachTime`,[C]:Ux}],[0,{[x]:`AttachmentId`,[C]:Tx}],[2,{[x]:`DeleteOnTermination`,[C]:rC}],[1,{[x]:`DeviceIndex`,[C]:ZS}],[0,{[x]:`Status`,[C]:Mk}],[1,{[x]:`NetworkCardIndex`,[C]:UE}],[()=>NN,{[x]:`EnaSrdSpecification`,[C]:qC}],[1,{[x]:`EnaQueueCount`,[C]:UC}]]],JN=[3,w,bne,0,[ic,Yd,Qf,od,km,hh,ph,f_,z_,B_,Qv,Lv,Ls,jg,c_,wg,kne,yg,One,vae,Qp,Ou,Kp],[[2,{[x]:`AssociatePublicIpAddress`,[C]:kx}],[2,{[x]:`DeleteOnTermination`,[C]:rC}],[0,{[x]:`Description`,[C]:mC}],[1,{[x]:`DeviceIndex`,[C]:ZS}],[()=>TL,{[C]:av}],[1,{[x]:`Ipv6AddressCount`,[C]:Bw}],[()=>EI,{[x]:`Ipv6Addresses`,[C]:Gw}],[0,{[x]:`NetworkInterfaceId`,[C]:ZE}],[0,{[x]:`PrivateIpAddress`,[C]:VD}],[()=>dL,{[x]:`PrivateIpAddresses`,[C]:zD}],[1,{[x]:`SecondaryPrivateIpAddressCount`,[C]:mk}],[0,{[x]:`SubnetId`,[C]:ok}],2,0,1,[()=>WI,{[C]:Tg}],1,[()=>GI,{[C]:Sg}],1,2,()=>sN,()=>bj,1]],YN=[3,w,Sne,0,[pl],[[0,{[x]:`BandwidthWeighting`,[C]:lS}]]],XN=[3,w,Cne,0,[pl],[0]],ZN=[3,w,Mne,0,[Jc,q_,j_,z_],[[()=>KN,{[x]:`Association`,[C]:Zx}],[2,{[x]:`Primary`,[C]:oO}],[0,{[x]:`PrivateDnsName`,[C]:MD}],[0,{[x]:`PrivateIpAddress`,[C]:VD}]]],QN=[3,w,are,0,[Xc,Wg,gv,S_,B_,rv,xy,oy,Kv,jg],[[()=>$N,{[x]:`Attachment`,[C]:Qx}],[0,{[x]:`MacAddress`,[C]:mE}],[0,{[x]:`SecondaryInterfaceId`,[C]:QO}],[0,{[x]:`OwnerId`,[C]:mD}],[()=>NI,{[x]:`PrivateIpAddressSet`,[C]:BD}],[2,{[x]:`SourceDestCheck`,[C]:VO}],[0,{[x]:`Status`,[C]:Mk}],[0,{[x]:`SecondarySubnetId`,[C]:yk}],[0,{[x]:`SecondaryNetworkId`,[C]:dk}],[0,{[x]:`InterfaceType`,[C]:KT}]]],$N=[3,w,Zne,0,[Ec,$s,Yd,od,xy,c_],[[4,{[x]:`AttachTime`,[C]:Ux}],[0,{[x]:`AttachmentId`,[C]:Tx}],[2,{[x]:`DeleteOnTermination`,[C]:rC}],[1,{[x]:`DeviceIndex`,[C]:ZS}],[0,{[x]:`Status`,[C]:Mk}],[1,{[x]:`NetworkCardIndex`,[C]:UE}]]],eP=[3,w,$ne,0,[z_],[[0,{[x]:`PrivateIpAddress`,[C]:VD}]]],tP=[3,w,nre,0,[z_],[0],1],nP=[3,w,ire,0,[Yd,od,B_,pae,oy,jg,c_],[2,1,[()=>PI,{[C]:z_}],1,0,0,1]],rP=[3,w,cre,0,[zu,o_],[[1,{[x]:`Code`,[C]:VS}],[0,{[x]:`Name`,[C]:BE}]]],iP=[3,w,Yne,0,[v,Tu,Dae],[[0,{[x]:`InstanceId`,[C]:iT}],[()=>rP,{[x]:`CurrentState`,[C]:FS}],[()=>rP,{[x]:`PreviousState`,[C]:QD}]]],aP=[3,w,Eg,0,[Wy,ep,v_,am],[[1,{[x]:`TotalSizeInGB`,[C]:Jk}],[()=>aI,{[x]:`Disks`,[C]:gC}],[0,{[x]:`NvmeSupport`,[C]:oD}],[0,{[x]:`EncryptionSupport`,[C]:QC}]]],oP=[3,w,dre,0,[Og,El,Tm,fy,ey,hy,ll,dh,H_,bb,Jg,ore,Eg,Pp,m_,Vm,ym,L_,vh,sh,dl,ad,sc,Voe,Die,Bie,y_,Ug,h_,Eae,aoe,Tv],[[0,{[x]:`InstanceType`,[C]:VT}],[2,{[x]:`CurrentGeneration`,[C]:yS}],[2,{[x]:`FreeTierEligible`,[C]:dw}],[()=>zL,{[x]:`SupportedUsageClasses`,[C]:Ek}],[()=>bL,{[x]:`SupportedRootDeviceTypes`,[C]:hk}],[()=>HL,{[x]:`SupportedVirtualizationTypes`,[C]:Ok}],[2,{[x]:`BareMetal`,[C]:aS}],[0,{[x]:`Hypervisor`,[C]:Dw}],[()=>JP,{[x]:`ProcessorInfo`,[C]:UD}],[()=>MF,{[x]:`VCpuInfo`,[C]:dA}],[()=>SP,{[x]:`MemoryInfo`,[C]:CE}],[2,{[x]:`InstanceStorageSupported`,[C]:NT}],[()=>aP,{[x]:`InstanceStorageInfo`,[C]:MT}],[()=>QM,{[x]:`EbsInfo`,[C]:PC}],[()=>MP,{[x]:`NetworkInfo`,[C]:tD}],[()=>yN,{[x]:`GpuInfo`,[C]:_w}],[()=>mN,{[x]:`FpgaInfo`,[C]:ow}],[()=>HP,{[x]:`PlacementGroupInfo`,[C]:ID}],[()=>kN,{[x]:`InferenceAcceleratorInfo`,[C]:Vw}],[2,{[x]:`HibernationSupported`,[C]:Fw}],[2,{[x]:`BurstablePerformanceSupported`,[C]:sS}],[2,{[x]:`DedicatedHostsSupported`,[C]:XS}],[2,{[x]:`AutoRecoverySupported`,[C]:Mx}],[()=>rI,{[x]:`SupportedBootModes`,[C]:zO}],[0,{[x]:`NitroEnclavesSupport`,[C]:KE}],[0,{[x]:`NitroTpmSupport`,[C]:cD}],[()=>LP,{[x]:`NitroTpmInfo`,[C]:sD}],[()=>yP,{[x]:`MediaAcceleratorInfo`,[C]:pE}],[()=>IP,{[x]:`NeuronInfo`,[C]:nD}],[0,{[x]:`PhcSupport`,[C]:ZD}],[0,{[x]:`RebootMigrationSupport`,[C]:xO}],[2,{[x]:`SupportedInRegion`,[C]:rk}]]],sP=[3,w,Bh,0,[Zc,Gh,S_,Ty],[[()=>RI,{[x]:`AttachmentSet`,[C]:Vx}],[0,{[x]:`InternetGatewayId`,[C]:nT}],[0,{[x]:`OwnerId`,[C]:mD}],[()=>IL,{[x]:`TagSet`,[C]:qk}]]],cP=[3,w,Vh,0,[by,Ob],[[0,{[x]:`State`,[C]:jk}],[0,{[x]:`VpcId`,[C]:hA}]]],lP=[3,w,Vne,0,[xg,Sm,By,ob,Gne,qne,wae],[[0,{[x]:`IpProtocol`,[C]:CT}],[1,{[x]:`FromPort`,[C]:cw}],[1,{[x]:`ToPort`,[C]:Gk}],[()=>BL,{[x]:`Groups`,[C]:Ew}],[()=>UI,{[x]:`IpRanges`,[C]:AT}],[()=>KI,{[x]:`Ipv6Ranges`,[C]:jT}],[()=>uL,{[x]:`PrefixListIds`,[C]:JD}]]],uP=[3,w,Kne,0,[Qf,Pl],[[0,{[x]:`Description`,[C]:mC}],[0,{[x]:`CidrIp`,[C]:SS}]]],dP=[3,w,zne,0,[Tg],[0]],fP=[3,w,Bne,0,[Sg],[0]],pP=[3,w,Jne,0,[Qf,Fl],[[0,{[x]:`Description`,[C]:mC}],[0,{[x]:`CidrIpv6`,[C]:CS}]]],mP=[3,w,Ore,0,[Lg,Ty,Ig,Ng,bre],[[0,{[x]:`KeyPairId`,[C]:eE}],[()=>IL,{[x]:`TagSet`,[C]:qk}],[0,{[x]:`KeyName`,[C]:$T}],[0,{[x]:`KeyFingerprint`,[C]:YT}],[()=>WA,{[x]:`KeyMaterial`,[C]:QT}]]],hP=[3,w,Ere,0,[Lg,Rg,Ty,xae,Nu,Ig,Ng],[[0,{[x]:`KeyPairId`,[C]:eE}],[0,{[x]:`KeyType`,[C]:nE}],[()=>IL,{[x]:`TagSet`,[C]:qk}],[0,{[x]:`PublicKey`,[C]:GD}],[4,{[x]:`CreateTime`,[C]:BS}],[0,{[x]:`KeyName`,[C]:$T}],[0,{[x]:`KeyFingerprint`,[C]:YT}]]],gP=[3,w,Hre,0,[Bre,Vre,rx],[0,0,0]],_P=[3,w,jre,0,[zg],[[0,{[x]:`LicenseConfigurationArn`,[C]:iE}]]],vP=[3,w,Are,0,[zg],[0]],yP=[3,w,Ug,0,[Hc,Ry],[[()=>QI,{[x]:`Accelerators`,[C]:qx}],[1,{[x]:`TotalMediaMemoryInMiB`,[C]:Uk}]]],bP=[3,w,Xre,0,[Vu,o_,n_,Jg],[[1,{[x]:`Count`,[C]:US}],[0,{[x]:`Name`,[C]:BE}],[0,{[x]:`Manufacturer`,[C]:FE}],[()=>xP,{[x]:`MemoryInfo`,[C]:CE}]]],xP=[3,w,Qre,0,[vv],[[1,{[x]:`SizeInMiB`,[C]:ek}]]],SP=[3,w,Jg,0,[vv],[[1,{[x]:`SizeInMiB`,[C]:ek}]]],CP=[3,w,iie,0,[v,rv,Wp,Gu,_,Yc,mb,il,Ku,Og,kre,Ooe,ib,ug,km,Lp,qv,om],[[0,{[x]:`InstanceId`,[C]:iT}],[()=>nj,0],()=>uN,[()=>nj,0],[2,{[x]:`DryRun`,[C]:b}],[0,{[x]:`Attribute`,[C]:$x}],[()=>HA,{[x]:`Value`,[C]:uA}],[()=>CI,{[x]:`BlockDeviceMapping`,[C]:nS}],[()=>nj,{[x]:`DisableApiTermination`,[C]:WS}],[()=>rj,{[x]:`InstanceType`,[C]:VT}],[()=>rj,{[x]:`Kernel`,[C]:rE}],[()=>rj,{[x]:`Ramdisk`,[C]:NO}],[()=>pj,{[x]:`UserData`,[C]:rA}],[()=>rj,{[x]:`InstanceInitiatedShutdownBehavior`,[C]:cT}],[()=>gI,{[C]:Hm}],[()=>nj,{[x]:`EbsOptimized`,[C]:LC}],[()=>rj,{[x]:`SriovNetSupport`,[C]:pk}],[()=>nj,{[x]:`EnaSupport`,[C]:$C}]],1],wP=[3,w,lie,0,[Lv,Bs,Qg,Kg,ql,up,oae,Xp,Jp,Ip,Rd],[[0,{[x]:`SubnetId`,[C]:ok}],[()=>nj,0],[()=>nj,0],[()=>nj,0],0,[()=>nj,0],0,[()=>nj,0],[()=>nj,0],1,[()=>nj,0]],1],TP=[3,w,pie,0,[Pb,_,_y,Zb,Mg,eb,Hg],[0,2,1,0,1,1,2],1],EP=[3,w,mie,0,[Vb],[[()=>FF,{[x]:`VolumeModification`,[C]:yA}]]],DP=[3,w,a_,0,[by],[[0,{[x]:`State`,[C]:jk}]]],OP=[3,w,u_,0,[Nu,If,mm,xm,Oie,d_,O_,by,Lv,Ob,Ty,Mu,rc,Sc,oc,Fs,tv],[[4,{[x]:`CreateTime`,[C]:BS}],[4,{[x]:`DeleteTime`,[C]:uC}],[0,{[x]:`FailureCode`,[C]:iw}],[0,{[x]:`FailureMessage`,[C]:sw}],[()=>$I,{[x]:`NatGatewayAddressSet`,[C]:JE}],[0,{[x]:`NatGatewayId`,[C]:YE}],[()=>ZP,{[x]:`ProvisionedBandwidth`,[C]:TD}],[0,{[x]:`State`,[C]:jk}],[0,{[x]:`SubnetId`,[C]:ok}],[0,{[x]:`VpcId`,[C]:hA}],[()=>IL,{[x]:`TagSet`,[C]:qk}],[0,{[x]:`ConnectivityType`,[C]:zS}],[0,{[x]:`AvailabilityMode`,[C]:Ox}],[0,{[x]:`AutoScalingIps`,[C]:Lx}],[0,{[x]:`AutoProvisionZones`,[C]:jx}],[()=>eL,{[x]:`AttachedApplianceSet`,[C]:_x}],[0,{[x]:`RouteTableId`,[C]:OO}]]],kP=[3,w,Mie,0,[Ys,f_,yae,R_,Zs,Hne,xm,xy,Ac,Fc],[[0,{[x]:`AllocationId`,[C]:bx}],[0,{[x]:`NetworkInterfaceId`,[C]:ZE}],[0,{[x]:`PrivateIp`,[C]:WD}],[0,{[x]:`PublicIp`,[C]:RD}],[0,{[x]:`AssociationId`,[C]:wx}],[2,{[x]:`IsPrimary`,[C]:kT}],[0,{[x]:`FailureMessage`,[C]:sw}],[0,{[x]:`Status`,[C]:Mk}],[0,{[x]:`AvailabilityZone`,[C]:Wx}],[0,{[x]:`AvailabilityZoneId`,[C]:Gx}]]],AP=[3,w,kie,0,[nb,Ns,Db,wc,e_,mm,xm],[[0,{[x]:`Type`,[C]:nA}],[0,{[x]:`ApplianceArn`,[C]:vx}],[0,{[x]:`VpcEndpointId`,[C]:mA}],[0,{[x]:`AttachmentState`,[C]:Hx}],[0,{[x]:`ModificationState`,[C]:ME}],[0,{[x]:`FailureCode`,[C]:iw}],[0,{[x]:`FailureMessage`,[C]:sw}]]],jP=[3,w,xie,0,[c_,__,Yg,Rs,$c,tae,rd,nie,rie],[[1,{[x]:`NetworkCardIndex`,[C]:UE}],[0,{[x]:`NetworkPerformance`,[C]:iD}],[1,{[x]:`MaximumNetworkInterfaces`,[C]:TE}],[1,{[x]:`AdditionalFlexibleNetworkInterfaces`,[C]:yx}],[1,{[x]:`BaselineBandwidthInGbps`,[C]:eS}],[1,{[x]:`PeakBandwidthInGbps`,[C]:ED}],[1,{[x]:`DefaultEnaQueueCountPerInterface`,[C]:JS}],[1,{[x]:`MaximumEnaQueueCount`,[C]:bE}],[1,{[x]:`MaximumEnaQueueCountPerInterface`,[C]:xE}]]],MP=[3,w,m_,0,[__,Yg,sie,Bd,yie,yh,bh,lre,om,im,Fp,Np,em,hl,vm,Du,Jv,die,xh],[[0,{[x]:`NetworkPerformance`,[C]:iD}],[1,{[x]:`MaximumNetworkInterfaces`,[C]:TE}],[1,{[x]:`MaximumNetworkCards`,[C]:wE}],[1,{[x]:`DefaultNetworkCardIndex`,[C]:eC}],[()=>rL,{[x]:`NetworkCards`,[C]:HE}],[1,{[x]:`Ipv4AddressesPerInterface`,[C]:Hw}],[1,{[x]:`Ipv6AddressesPerInterface`,[C]:Uw}],[2,{[x]:`Ipv6Supported`,[C]:BT}],[0,{[x]:`EnaSupport`,[C]:$C}],[2,{[x]:`EfaSupported`,[C]:ZC}],[()=>nN,{[x]:`EfaInfo`,[C]:FC}],[2,{[x]:`EncryptionInTransitSupported`,[C]:NC}],[2,{[x]:`EnaSrdSupported`,[C]:JC}],[()=>eI,{[x]:`BandwidthWeightings`,[C]:uS}],[0,{[x]:`FlexibleEnaQueuesSupport`,[C]:aw}],[()=>Jj,{[x]:`ConnectionTrackingConfiguration`,[C]:LS}],[2,{[x]:`SecondaryNetworkSupported`,[C]:fk}],[1,{[x]:`MaximumSecondaryNetworkInterfaces`,[C]:jE}],[1,{[x]:`Ipv4AddressesPerSecondaryInterface`,[C]:Ww}]]],NP=[3,w,Sie,0,[Vu,rx],[[1,{[x]:`Count`,[C]:US}],[1,{[x]:`Version`,[C]:jA}]]],PP=[3,w,Cie,0,[Vu,o_,Ll,Jg],[[1,{[x]:`Count`,[C]:US}],[0,{[x]:`Name`,[C]:BE}],[()=>NP,{[x]:`CoreInfo`,[C]:TS}],[()=>FP,{[x]:`MemoryInfo`,[C]:CE}]]],FP=[3,w,Tie,0,[vv],[[1,{[x]:`SizeInMiB`,[C]:ek}]]],IP=[3,w,h_,0,[Eie,zy],[[()=>iL,{[x]:`NeuronDevices`,[C]:GE}],[1,{[x]:`TotalNeuronDeviceMemoryInMiB`,[C]:Wk}]]],LP=[3,w,y_,0,[gy],[[()=>aL,{[x]:`SupportedVersions`,[C]:kk}]]],RP=[3,w,qie,0,[D_],[0]],zP=[3,w,Jie,0,[_ie,D_,$m],[[2,{[x]:`Managed`,[C]:IE}],[0,{[x]:`Principal`,[C]:wD}],[2,{[x]:`HiddenByDefault`,[C]:Ow}]]],BP=[3,w,K_,0,[Fc,Wc,Km,Tae,th,$y,Woe,oh,Hm,Ac],[[0,{[x]:`AvailabilityZoneId`,[C]:Gx}],[0,{[x]:`Affinity`,[C]:Jx}],[0,{[x]:`GroupName`,[C]:bw}],[1,{[x]:`PartitionNumber`,[C]:YD}],[0,{[x]:`HostId`,[C]:Aw}],[0,{[x]:`Tenancy`,[C]:Fk}],[0,{[x]:`SpreadDomain`,[C]:HO}],[0,{[x]:`HostResourceGroupArn`,[C]:Pw}],[0,{[x]:`GroupId`,[C]:vw}],[0,{[x]:`AvailabilityZone`,[C]:Wx}]]],VP=[3,w,I_,0,[Km,by,Sy,k_,Hm,Ty,Am,Bv,Vg,T_],[[0,{[x]:`GroupName`,[C]:bw}],[0,{[x]:`State`,[C]:jk}],[0,{[x]:`Strategy`,[C]:Nk}],[1,{[x]:`PartitionCount`,[C]:OD}],[0,{[x]:`GroupId`,[C]:vw}],[()=>IL,{[x]:`TagSet`,[C]:qk}],[0,{[x]:`GroupArn`,[C]:mw}],[0,{[x]:`SpreadLevel`,[C]:sk}],[0,{[x]:`LinkedGroupId`,[C]:sE}],[()=>zP,{[x]:`Operator`,[C]:SD}]]],HP=[3,w,L_,0,[sy],[[()=>lL,{[x]:`SupportedStrategies`,[C]:Sk}]]],UP=[3,w,U_,0,[Qf,U_],[[0,{[x]:`Description`,[C]:mC}],[0,{[x]:`PrefixListId`,[C]:qD}]]],WP=[3,w,N_,0,[lh,Yp,qp],[[0,{[x]:`HostnameType`,[C]:Lw}],[2,{[x]:`EnableResourceNameDnsARecord`,[C]:GC}],[2,{[x]:`EnableResourceNameDnsAAAARecord`,[C]:WC}]]],GP=[3,w,sae,0,[lh,Yp,qp],[0,2,2]],KP=[3,w,cae,0,[lh,Yp,qp],[[0,{[x]:`HostnameType`,[C]:Lw}],[2,{[x]:`EnableResourceNameDnsARecord`,[C]:GC}],[2,{[x]:`EnableResourceNameDnsAAAARecord`,[C]:WC}]]],qP=[3,w,mae,0,[q_,z_],[[2,{[x]:`Primary`,[C]:oO}],[0,{[x]:`PrivateIpAddress`,[C]:VD}]]],JP=[3,w,H_,0,[Boe,Uoe,Goe,n_],[[()=>qF,{[x]:`SupportedArchitectures`,[C]:LO}],[1,{[x]:`SustainedClockSpeedInGhz`,[C]:BO}],[()=>FL,{[x]:`SupportedFeatures`,[C]:UO}],[0,{[x]:`Manufacturer`,[C]:FE}]]],YP=[3,w,aae,0,[nae,iae],[[0,{[x]:`ProductCode`,[C]:kD}],[0,{[x]:`Type`,[C]:nA}]]],XP=[3,w,Aae,0,[Rm],[[0,{[x]:`GatewayId`,[C]:gw}]]],ZP=[3,w,O_,0,[Oae,Fae,Eoe,joe,xy],[[4,{[x]:`ProvisionTime`,[C]:$D}],[0,{[x]:`Provisioned`,[C]:cO}],[4,{[x]:`RequestTime`,[C]:jO}],[0,{[x]:`Requested`,[C]:FO}],[0,{[x]:`Status`,[C]:Mk}]]],QP=[3,w,Yae,0,[pg,_],[[()=>wI,{[C]:v}],[2,{[x]:`DryRun`,[C]:b}]],1],$P=[3,w,loe,0,[Hm,W_,ab,Ob,Wb],[[0,{[x]:`GroupId`,[C]:vw}],[0,{[x]:`PeeringStatus`,[C]:XD}],[0,{[x]:`UserId`,[C]:iA}],[0,{[x]:`VpcId`,[C]:hA}],[0,{[x]:`VpcPeeringConnectionId`,[C]:SA}]]],eF=[3,w,Aoe,0,[C_,Zm,$_,fm],[[0,{[x]:`OptInStatus`,[C]:hD}],[()=>hL,{[x]:`GeographySet`,[C]:Cw}],[0,{[x]:`RegionName`,[C]:CO}],[0,{[x]:`RegionEndpoint`,[C]:pO}]]],tF=[3,w,Wae,0,[o_],[[0,{[x]:`Name`,[C]:BE}]]],nF=[3,w,Lae,0,[Ys,R_,s_,_],[0,0,0,[2,{[x]:`DryRun`,[C]:b}]]],rF=[3,w,Noe,0,[Qae,S_,eoe,km,_re],[[0,{[x]:`ReservationId`,[C]:_O}],[0,{[x]:`OwnerId`,[C]:mD}],[0,{[x]:`RequesterId`,[C]:bO}],[()=>hI,{[x]:`GroupSet`,[C]:ww}],[()=>OI,{[x]:`InstancesSet`,[C]:LT}]]],iF=[3,w,goe,0,[cv,Hm,zh,xg,Sm,By,Il,Fl,U_,Gae,Qf],[[0,{[x]:`SecurityGroupRuleId`,[C]:qO}],[0,{[x]:`GroupId`,[C]:vw}],[2,{[x]:`IsEgress`,[C]:$w}],[0,{[x]:`IpProtocol`,[C]:CT}],[1,{[x]:`FromPort`,[C]:cw}],[1,{[x]:`ToPort`,[C]:Gk}],[0,{[x]:`CidrIpv4`,[C]:wS}],[0,{[x]:`CidrIpv6`,[C]:CS}],[0,{[x]:`PrefixListId`,[C]:qD}],[0,{[x]:`ReferencedGroupId`,[C]:hO}],[0,{[x]:`Description`,[C]:mC}]]],aF=[3,w,doe,0,[Hm,sv,_,ry,iy,xg,Sm,By,Pl,bg],[[0,{[x]:`GroupId`,[C]:vw}],[()=>DL,{[C]:cv}],[2,{[x]:`DryRun`,[C]:b}],[0,{[x]:`SourceSecurityGroupName`,[C]:_k}],[0,{[x]:`SourceSecurityGroupOwnerId`,[C]:vk}],[0,{[x]:`IpProtocol`,[C]:CT}],[1,{[x]:`FromPort`,[C]:cw}],[1,{[x]:`ToPort`,[C]:Gk}],[0,{[x]:`CidrIp`,[C]:SS}],[()=>HI,{[x]:`IpPermissions`,[C]:wT}]],1],oF=[3,w,foe,0,[Y_,lb,ev],[[2,{[x]:`Return`,[C]:lO}],[()=>HI,{[x]:`UnknownIpPermissionSet`,[C]:aA}],[()=>yL,{[x]:`RevokedSecurityGroupRuleSet`,[C]:wO}]]],sF=[3,w,poe,0,[Pl,Sm,Hm,Km,bg,xg,ry,iy,By,sv,_],[0,1,0,0,[()=>HI,0],0,0,0,1,[()=>DL,{[C]:cv}],[2,{[x]:`DryRun`,[C]:b}]]],cF=[3,w,moe,0,[Y_,lb,ev],[[2,{[x]:`Return`,[C]:lO}],[()=>HI,{[x]:`UnknownIpPermissionSet`,[C]:aA}],[()=>yL,{[x]:`RevokedSecurityGroupRuleSet`,[C]:wO}]]],lF=[3,w,Poe,0,[$u,sd,tf,Vp,Rm,v,Ene,d_,Oy,Bg,Tl,f_,Hie,by,Wb,Vl,w_,Ch],[[0,{[x]:`DestinationCidrBlock`,[C]:GS}],[0,{[x]:`DestinationIpv6CidrBlock`,[C]:QS}],[0,{[x]:`DestinationPrefixListId`,[C]:iC}],[0,{[x]:`EgressOnlyInternetGatewayId`,[C]:BC}],[0,{[x]:`GatewayId`,[C]:gw}],[0,{[x]:`InstanceId`,[C]:iT}],[0,{[x]:`InstanceOwnerId`,[C]:gT}],[0,{[x]:`NatGatewayId`,[C]:YE}],[0,{[x]:`TransitGatewayId`,[C]:Rk}],[0,{[x]:`LocalGatewayId`,[C]:oE}],[0,{[x]:`CarrierGatewayId`,[C]:vS}],[0,{[x]:`NetworkInterfaceId`,[C]:ZE}],[0,{[x]:`Origin`,[C]:dD}],[0,{[x]:`State`,[C]:jk}],[0,{[x]:`VpcPeeringConnectionId`,[C]:SA}],[0,{[x]:`CoreNetworkArn`,[C]:ES}],[0,{[x]:`OdbNetworkArn`,[C]:vD}],[0,{[x]:`IpAddress`,[C]:qw}]]],uF=[3,w,nv,0,[qc,Mae,tv,Foe,Ty,Ob,S_],[[()=>SL,{[x]:`AssociationSet`,[C]:Bx}],[()=>pL,{[x]:`PropagatingVgwSet`,[C]:tO}],[0,{[x]:`RouteTableId`,[C]:OO}],[()=>xL,{[x]:`RouteSet`,[C]:EO}],[()=>IL,{[x]:`TagSet`,[C]:qk}],[0,{[x]:`VpcId`,[C]:hA}],[0,{[x]:`OwnerId`,[C]:mD}]]],dF=[3,w,voe,0,[gie,yoe,tv,Lv,Rm,V_,hc],[[2,{[x]:`Main`,[C]:PE}],[0,{[x]:`RouteTableAssociationId`,[C]:DO}],[0,{[x]:`RouteTableId`,[C]:OO}],[0,{[x]:`SubnetId`,[C]:ok}],[0,{[x]:`GatewayId`,[C]:gw}],[0,{[x]:`PublicIpv4Pool`,[C]:HD}],[()=>fF,{[x]:`AssociationState`,[C]:Ix}]]],fF=[3,w,xoe,0,[by,Wv],[[0,{[x]:`State`,[C]:jk}],[0,{[x]:`StatusMessage`,[C]:lk}]]],pF=[3,w,Jae,0,[um],[[2,{[x]:`Enabled`,[C]:nw}]],1],mF=[3,w,Xae,0,[Jre,Yre,il,fg,Og,hh,ph,Pg,Ig,a_,K_,Q_,Joe,lv,Lv,ib,bp,Ap,Uy,zre,hne,Su,Gl,iu,nh,Ire,Xg,Wp,M_,Zg,Gu,Gp,g_,T_,kv,_,Ku,ug,z_,Eu,Js,p_,$h,Lp],[1,1,[()=>nI,{[C]:tl}],0,0,1,[()=>EI,{[C]:wh}],0,0,[()=>pF,0],[()=>BP,0],0,[()=>TL,{[C]:av}],[()=>kL,{[C]:uv}],0,[()=>UA,0],[()=>cI,0],[()=>uI,{[C]:Sp}],[()=>LL,{[C]:Ky}],()=>gP,()=>HN,()=>qj,()=>Tj,()=>gj,()=>SN,[()=>ZI,{[C]:Rre}],()=>UN,()=>uN,()=>GP,()=>VN,2,2,()=>XN,()=>RP,[()=>FI,{[C]:Av}],[2,{[x]:`DryRun`,[C]:b}],[2,{[x]:`DisableApiTermination`,[C]:WS}],[0,{[x]:`InstanceInitiatedShutdownBehavior`,[C]:cT}],[0,{[x]:`PrivateIpAddress`,[C]:VD}],[0,{[x]:`ClientToken`,[UT]:1,[C]:IS}],[0,{[x]:`AdditionalInfo`,[C]:Cx}],[()=>AI,{[x]:`NetworkInterface`,[C]:eD}],[()=>TN,{[x]:`IamInstanceProfile`,[C]:aT}],[2,{[x]:`EbsOptimized`,[C]:LC}]],2],hF=[3,w,uv,0,[Hm,Ane,Ty,Ob,iv,S_,Km,Qf,bg],[[0,{[x]:`GroupId`,[C]:vw}],[()=>HI,{[x]:`IpPermissionsEgress`,[C]:yT}],[()=>IL,{[x]:`TagSet`,[C]:qk}],[0,{[x]:`VpcId`,[C]:hA}],[0,{[x]:`SecurityGroupArn`,[C]:WO}],[0,{[x]:`OwnerId`,[C]:mD}],[0,{[x]:`GroupName`,[C]:bw}],[0,{[x]:`GroupDescription`,[C]:hw}],[()=>HI,{[x]:`IpPermissions`,[C]:wT}]]],gF=[3,w,$oe,0,[cv,Hm,Ym,zh,xg,Sm,By,Il,Fl,U_,Kae,Qf,Ty,Xoe],[[0,{[x]:`SecurityGroupRuleId`,[C]:qO}],[0,{[x]:`GroupId`,[C]:vw}],[0,{[x]:`GroupOwnerId`,[C]:xw}],[2,{[x]:`IsEgress`,[C]:$w}],[0,{[x]:`IpProtocol`,[C]:CT}],[1,{[x]:`FromPort`,[C]:cw}],[1,{[x]:`ToPort`,[C]:Gk}],[0,{[x]:`CidrIpv4`,[C]:wS}],[0,{[x]:`CidrIpv6`,[C]:CS}],[0,{[x]:`PrefixListId`,[C]:qD}],[()=>$P,{[x]:`ReferencedGroupInfo`,[C]:gO}],[0,{[x]:`Description`,[C]:mC}],[()=>IL,{[x]:`TagSet`,[C]:qk}],[0,{[x]:`SecurityGroupRuleArn`,[C]:KO}]]],_F=[3,w,yy,0,[Uie,x_,Ty,dy,Hae,uy,Ac,Xy,Cl,ju,wm,jv,Pb,by,Gv,cy,J_,S_,Qf,Xb,dm,Fg,nd],[[0,{[x]:`OwnerAlias`,[C]:pD}],[0,{[x]:`OutpostArn`,[C]:fD}],[()=>IL,{[x]:`TagSet`,[C]:qk}],[0,{[x]:`StorageTier`,[C]:Tk}],[4,{[x]:`RestoreExpiryTime`,[C]:fO}],[0,{[x]:`SseType`,[C]:wk}],[0,{[x]:`AvailabilityZone`,[C]:Wx}],[0,{[x]:`TransferType`,[C]:Qk}],[1,{[x]:`CompletionDurationMinutes`,[C]:gS}],[4,{[x]:`CompletionTime`,[C]:RS}],[1,{[x]:`FullSnapshotSizeInBytes`,[C]:uw}],[0,{[x]:`SnapshotId`,[C]:ak}],[0,{[x]:`VolumeId`,[C]:_A}],[0,{[x]:`Status`,[C]:Mk}],[0,{[x]:`StatusMessage`,[C]:lk}],[4,{[x]:`StartTime`,[C]:Ck}],[0,{[x]:`Progress`,[C]:sO}],[0,{[x]:`OwnerId`,[C]:mD}],[0,{[x]:`Description`,[C]:mC}],[1,{[x]:`VolumeSize`,[C]:EA}],[2,{[x]:`Encrypted`,[C]:rw}],[0,{[x]:`KmsKeyId`,[C]:ZT}],[0,{[x]:`DataEncryptionKeyId`,[C]:qS}]]],vF=[3,w,Uv,0,[cie,Ov,al,tx,Qh],[0,0,1,4,0]],yF=[3,w,xv,0,[pg,Js,_],[[()=>wI,{[C]:v}],[0,{[x]:`AdditionalInfo`,[C]:Cx}],[2,{[x]:`DryRun`,[C]:b}]],1],bF=[3,w,Sv,0,[Nv],[[()=>II,{[x]:`InstancesSet`,[C]:LT}]]],xF=[3,w,ny,0,[zu,r_],[[0,{[x]:`Code`,[C]:VS}],[0,{[x]:`Message`,[C]:LE}]]],SF=[3,w,Cv,0,[pg,uh,Xv,_,pm],[[()=>wI,{[C]:v}],2,2,[2,{[x]:`DryRun`,[C]:b}],[2,{[x]:`Force`,[C]:fw}]],1],CF=[3,w,wv,0,[Fv],[[()=>II,{[x]:`InstancesSet`,[C]:LT}]]],wF=[3,w,Cy,0,[Fc,Ip,Kg,ql,S_,Bs,Ah,Ty,zoe,x_,up,gg,N_,ul,nb,Lv,by,Ob,gl,zs,Ac,id,Qg],[[0,{[x]:`AvailabilityZoneId`,[C]:Gx}],[1,{[x]:`EnableLniAtDeviceIndex`,[C]:IC}],[2,{[x]:`MapCustomerOwnedIpOnLaunch`,[C]:gE}],[0,{[x]:`CustomerOwnedIpv4Pool`,[C]:kS}],[0,{[x]:`OwnerId`,[C]:mD}],[2,{[x]:`AssignIpv6AddressOnCreation`,[C]:Sx}],[()=>NL,{[x]:`Ipv6CidrBlockAssociationSet`,[C]:Xw}],[()=>IL,{[x]:`TagSet`,[C]:qk}],[0,{[x]:`SubnetArn`,[C]:RO}],[0,{[x]:`OutpostArn`,[C]:fD}],[2,{[x]:`EnableDns64`,[C]:xC}],[2,{[x]:`Ipv6Native`,[C]:pT}],[()=>WP,{[x]:`PrivateDnsNameOptionsOnLaunch`,[C]:jD}],[()=>hj,{[x]:`BlockPublicAccessStates`,[C]:oS}],[0,{[x]:`Type`,[C]:nA}],[0,{[x]:`SubnetId`,[C]:ok}],[0,{[x]:`State`,[C]:jk}],[0,{[x]:`VpcId`,[C]:hA}],[0,{[x]:`CidrBlock`,[C]:dS}],[1,{[x]:`AvailableIpAddressCount`,[C]:xx}],[0,{[x]:`AvailabilityZone`,[C]:Wx}],[2,{[x]:`DefaultForAz`,[C]:YS}],[2,{[x]:`MapPublicIpOnLaunch`,[C]:OE}]]],TF=[3,w,Hoe,0,[by,Wv],[[0,{[x]:`State`,[C]:jk}],[0,{[x]:`StatusMessage`,[C]:lk}]]],EF=[3,w,fv,0,[Zs,kh,Mh,mh,Dg],[[0,{[x]:`AssociationId`,[C]:wx}],[0,{[x]:`Ipv6CidrBlock`,[C]:Yw}],[()=>TF,{[x]:`Ipv6CidrBlockState`,[C]:Zw}],[0,{[x]:`Ipv6AddressAttribute`,[C]:zw}],[0,{[x]:`IpSource`,[C]:zT}]]],DF=[3,w,Qy,0,[vre,mb],[[0,{[x]:`Key`,[C]:JT}],[0,{[x]:`Value`,[C]:uA}]]],OF=[3,w,Ky,0,[Toe,Ty],[[0,{[x]:`ResourceType`,[C]:AO}],[()=>IL,{[C]:Qy}]]],kF=[3,w,My,0,[pg,pm,Xv,_],[[()=>wI,{[C]:v}],2,2,[2,{[x]:`DryRun`,[C]:b}]],1],AF=[3,w,Ny,0,[Fy],[[()=>II,{[x]:`InstancesSet`,[C]:LT}]]],jF=[3,w,cb,0,[Qf,ab,Km,Hm,Ob,Wb,W_],[[0,{[x]:`Description`,[C]:mC}],[0,{[x]:`UserId`,[C]:iA}],[0,{[x]:`GroupName`,[C]:bw}],[0,{[x]:`GroupId`,[C]:vw}],[0,{[x]:`VpcId`,[C]:hA}],[0,{[x]:`VpcPeeringConnectionId`,[C]:SA}],[0,{[x]:`PeeringStatus`,[C]:XD}]]],MF=[3,w,bb,0,[zf,td,Pf,xb,$b],[[1,{[x]:`DefaultVCpus`,[C]:pC}],[1,{[x]:`DefaultCores`,[C]:KS}],[1,{[x]:`DefaultThreadsPerCore`,[C]:cC}],[()=>iI,{[x]:`ValidCores`,[C]:fA}],[()=>RL,{[x]:`ValidThreadsPerCore`,[C]:kA}]]],NF=[3,w,ax,0,[Fc,x_,my,Mg,Ty,Zb,Cm,Hg,eb,uy,T_,jb,Pb,_y,jv,Ac,by,Nu,Zc,dm,Fg],[[0,{[x]:`AvailabilityZoneId`,[C]:Gx}],[0,{[x]:`OutpostArn`,[C]:fD}],[0,{[x]:`SourceVolumeId`,[C]:Dk}],[1,{[x]:`Iops`,[C]:qT}],[()=>IL,{[x]:`TagSet`,[C]:qk}],[0,{[x]:`VolumeType`,[C]:OA}],[2,{[x]:`FastRestored`,[C]:lw}],[2,{[x]:`MultiAttachEnabled`,[C]:fE}],[1,{[x]:`Throughput`,[C]:eA}],[0,{[x]:`SseType`,[C]:wk}],[()=>zP,{[x]:`Operator`,[C]:SD}],[1,{[x]:`VolumeInitializationRate`,[C]:gA}],[0,{[x]:`VolumeId`,[C]:_A}],[1,{[x]:`Size`,[C]:Ak}],[0,{[x]:`SnapshotId`,[C]:ak}],[0,{[x]:`AvailabilityZone`,[C]:Wx}],[0,{[x]:`Status`,[C]:Mk}],[4,{[x]:`CreateTime`,[C]:BS}],[()=>UL,{[x]:`AttachmentSet`,[C]:Vx}],[2,{[x]:`Encrypted`,[C]:rw}],[0,{[x]:`KmsKeyId`,[C]:ZT}]]],PF=[3,w,gb,0,[Yd,pc,Dne,ap,Pb,v,$f,by,Ec],[[2,{[x]:`DeleteOnTermination`,[C]:rC}],[0,{[x]:`AssociatedResource`,[C]:Px}],[0,{[x]:`InstanceOwningService`,[C]:vT}],[1,{[x]:`EbsCardIndex`,[C]:vC}],[0,{[x]:`VolumeId`,[C]:_A}],[0,{[x]:`InstanceId`,[C]:iT}],[0,{[x]:`Device`,[C]:hC}],[0,{[x]:`Status`,[C]:Mk}],[4,{[x]:`AttachTime`,[C]:Ux}]]],FF=[3,w,Vb,0,[Pb,e_,Wv,qy,Py,Zy,Yy,Ly,Xie,Wie,Qie,Zie,Kie,J_,cy,sm,T_],[[0,{[x]:`VolumeId`,[C]:_A}],[0,{[x]:`ModificationState`,[C]:ME}],[0,{[x]:`StatusMessage`,[C]:lk}],[1,{[x]:`TargetSize`,[C]:Yk}],[1,{[x]:`TargetIops`,[C]:Vk}],[0,{[x]:`TargetVolumeType`,[C]:$k}],[1,{[x]:`TargetThroughput`,[C]:Zk}],[2,{[x]:`TargetMultiAttachEnabled`,[C]:Hk}],[1,{[x]:`OriginalSize`,[C]:yD}],[1,{[x]:`OriginalIops`,[C]:gD}],[0,{[x]:`OriginalVolumeType`,[C]:xD}],[1,{[x]:`OriginalThroughput`,[C]:bD}],[2,{[x]:`OriginalMultiAttachEnabled`,[C]:_D}],[1,{[x]:`Progress`,[C]:sO}],[4,{[x]:`StartTime`,[C]:Ck}],[4,{[x]:`EndTime`,[C]:ew}],[()=>zP,{[x]:`Operator`,[C]:SD}]]],IF=[3,w,ox,0,[S_,kg,Ah,_l,Rh,lp,Ty,ul,Ob,by,gl,Jd],[[0,{[x]:`OwnerId`,[C]:mD}],[0,{[x]:`InstanceTenancy`,[C]:GT}],[()=>JL,{[x]:`Ipv6CidrBlockAssociationSet`,[C]:Xw}],[()=>KL,{[x]:`CidrBlockAssociationSet`,[C]:fS}],[2,{[x]:`IsDefault`,[C]:Qw}],[()=>zF,{[x]:`EncryptionControl`,[C]:bC}],[()=>IL,{[x]:`TagSet`,[C]:qk}],[()=>hj,{[x]:`BlockPublicAccessStates`,[C]:oS}],[0,{[x]:`VpcId`,[C]:hA}],[0,{[x]:`State`,[C]:jk}],[0,{[x]:`CidrBlock`,[C]:dS}],[0,{[x]:`DhcpOptionsId`,[C]:nC}]]],LF=[3,w,_b,0,[Zs,gl,yl],[[0,{[x]:`AssociationId`,[C]:wx}],[0,{[x]:`CidrBlock`,[C]:dS}],[()=>RF,{[x]:`CidrBlockState`,[C]:mS}]]],RF=[3,w,yb,0,[by,Wv],[[0,{[x]:`State`,[C]:jk}],[0,{[x]:`StatusMessage`,[C]:lk}]]],zF=[3,w,Sb,0,[Ob,Eb,i_,by,Gv,Uae,Ty],[[0,{[x]:`VpcId`,[C]:hA}],[0,{[x]:`VpcEncryptionControlId`,[C]:pA}],[0,{[x]:`Mode`,[C]:zE}],[0,{[x]:`State`,[C]:jk}],[0,{[x]:`StateMessage`,[C]:uk}],[()=>HF,{[x]:`ResourceExclusions`,[C]:mO}],[()=>IL,{[x]:`TagSet`,[C]:qk}]]],BF=[3,w,Cb,0,[i_,Wh,Bp,Nie,qb,Gb,Mre,Rb,fp],[0,0,0,0,0,0,0,0,0],1],VF=[3,w,wb,0,[by,Gv],[[0,{[x]:`State`,[C]:jk}],[0,{[x]:`StateMessage`,[C]:uk}]]],HF=[3,w,Tb,0,[Bh,zp,u_,Kb,Jb,Gre,Lb,dp],[[()=>VF,{[x]:`InternetGateway`,[C]:eT}],[()=>VF,{[x]:`EgressOnlyInternetGateway`,[C]:zC}],[()=>VF,{[x]:`NatGateway`,[C]:qE}],[()=>VF,{[x]:`VirtualPrivateGateway`,[C]:CA}],[()=>VF,{[x]:`VpcPeering`,[C]:wA}],[()=>VF,{[x]:`Lambda`,[C]:dE}],[()=>VF,{[x]:`VpcLattice`,[C]:vA}],[()=>VF,{[x]:`ElasticFileSystem`,[C]:SC}]]],UF=[3,w,kb,0,[Zs,kh,Mh,s_,Cg,mh,Dg],[[0,{[x]:`AssociationId`,[C]:wx}],[0,{[x]:`Ipv6CidrBlock`,[C]:Yw}],[()=>RF,{[x]:`Ipv6CidrBlockState`,[C]:Zw}],[0,{[x]:`NetworkBorderGroup`,[C]:VE}],[0,{[x]:`Ipv6Pool`,[C]:ET}],[0,{[x]:`Ipv6AddressAttribute`,[C]:zw}],[0,{[x]:`IpSource`,[C]:zT}]]],WF=`unit`,GF=[1,w,nc,0,[()=>GA,{[C]:S}]],KF=[1,w,Ks,0,[0,{[C]:Ys}]],qF=[1,w,Tc,0,[0,{[C]:S}]],JF=[1,w,qs,0,[0,{[C]:Zs}]],YF=[1,w,Mc,0,[()=>lj,{[C]:jc}]],XF=[1,w,Pc,0,[()=>uj,{[C]:S}]],ZF=[1,w,Ic,0,[()=>cj,{[C]:S}]],QF=[1,w,Rc,0,[()=>dj,{[C]:S}]],$F=[1,w,Bc,0,[()=>fj,{[C]:S}]],eI=[1,w,ml,0,[0,{[C]:S}]],tI=[1,w,nl,0,[()=>mj,{[C]:S}]],nI=[1,w,rl,0,[()=>mj,{[C]:tl}]],rI=[1,w,cl,0,[0,{[C]:S}]],iI=[1,w,bl,0,[1,{[C]:S}]],aI=[1,w,xd,0,[()=>YM,{[C]:S}]],oI=[1,w,op,0,[()=>ZM,{[C]:S}]],sI=[1,w,hp,0,[()=>rN,{[C]:S}]],cI=[1,w,xp,0,[()=>iN,{[C]:S}]],lI=[1,w,Tp,0,[()=>oN,{[C]:S}]],uI=[1,w,Ap,0,[()=>aN,{[C]:S}]],dI=[1,w,rp,0,[0,{[C]:ip}]],fI=[1,w,bm,0,[()=>dN,{[C]:Dm}]],pI=[1,w,gm,0,[()=>fN,{[C]:S}]],mI=[1,w,Im,0,[()=>_N,{[C]:S}]],hI=[1,w,zm,0,[()=>bN,{[C]:S}]],gI=[1,w,Bm,0,[0,{[C]:vw}]],_I=[1,w,qm,0,[0,{[C]:Km}]],vI=[1,w,tg,0,[()=>wN,{[C]:S}]],yI=[1,w,dg,0,[0,{[C]:fg}]],bI=[1,w,one,0,[()=>EN,{[C]:S}]],xI=[1,w,Fh,0,[()=>AN,0]],SI=[1,w,Eh,0,[()=>FN,{[C]:S}]],CI=[1,w,Oh,0,[()=>IN,{[C]:S}]],wI=[1,w,ene,0,[0,{[C]:v}]],TI=[1,w,ag,0,[()=>LN,{[C]:S}]],EI=[1,w,Zh,0,[()=>RN,{[C]:S}]],DI=[1,w,og,0,[()=>zN,{[C]:S}]],OI=[1,w,cne,0,[()=>MN,{[C]:S}]],kI=[1,w,yne,0,[()=>GN,{[C]:S}]],AI=[1,w,xne,0,[()=>JN,{[C]:S}]],jI=[1,w,Nne,0,[()=>ZN,{[C]:S}]],MI=[1,w,Qne,0,[()=>QN,{[C]:S}]],NI=[1,w,ere,0,[()=>eP,{[C]:S}]],PI=[1,w,tre,0,[()=>tP,{[C]:S}]],FI=[1,w,rre,0,[()=>nP,{[C]:S}]],II=[1,w,Xne,0,[()=>iP,{[C]:S}]],LI=[1,w,ure,0,[()=>oP,{[C]:S}]],RI=[1,w,Hh,0,[()=>cP,{[C]:S}]],zI=[1,w,Kh,0,[0,{[C]:S}]],BI=[1,w,Jh,0,[()=>sP,{[C]:S}]],VI=[1,w,lne,0,[0,{[C]:S}]],HI=[1,w,Ine,0,[()=>lP,{[C]:S}]],UI=[1,w,Une,0,[()=>uP,{[C]:S}]],WI=[1,w,Lne,0,[()=>dP,{[C]:S}]],GI=[1,w,Rne,0,[()=>fP,{[C]:S}]],KI=[1,w,Wne,0,[()=>pP,{[C]:S}]],qI=[1,w,xre,0,[0,{[C]:Ig}]],JI=[1,w,wre,0,[0,{[C]:Lg}]],YI=[1,w,Dre,0,[()=>hP,{[C]:S}]],XI=[1,w,Pre,0,[()=>_P,{[C]:S}]],ZI=[1,w,Lre,0,[()=>vP,{[C]:S}]],QI=[1,w,Zre,0,[()=>bP,{[C]:S}]],$I=[1,w,jie,0,[()=>kP,{[C]:S}]],eL=[1,w,Aie,0,[()=>AP,{[C]:S}]],tL=[1,w,Pie,0,[0,{[C]:S}]],nL=[1,w,Iie,0,[()=>OP,{[C]:S}]],rL=[1,w,bie,0,[()=>jP,{[C]:S}]],iL=[1,w,wie,0,[()=>PP,{[C]:S}]],aL=[1,w,Vie,0,[0,{[C]:S}]],oL=[1,w,Yie,0,[0,{[C]:E_}]],sL=[1,w,lae,0,[0,{[C]:Hm}]],cL=[1,w,uae,0,[()=>VP,{[C]:S}]],lL=[1,w,dae,0,[0,{[C]:S}]],uL=[1,w,Cae,0,[()=>UP,{[C]:S}]],dL=[1,w,hae,0,[()=>qP,{[C]:S}]],fL=[1,w,rae,0,[()=>YP,{[C]:S}]],pL=[1,w,jae,0,[()=>XP,{[C]:S}]],mL=[1,w,_ae,0,[0,{[C]:R_}]],hL=[1,w,qae,0,[()=>tF,{[C]:S}]],gL=[1,w,noe,0,[()=>eF,{[C]:S}]],_L=[1,w,soe,0,[0,{[C]:$_}]],vL=[1,w,roe,0,[()=>rF,{[C]:S}]],yL=[1,w,_oe,0,[()=>iF,{[C]:S}]],bL=[1,w,Vae,0,[0,{[C]:S}]],xL=[1,w,ioe,0,[()=>lF,{[C]:S}]],SL=[1,w,boe,0,[()=>dF,{[C]:S}]],CL=[1,w,Soe,0,[0,{[C]:S}]],wL=[1,w,woe,0,[()=>uF,{[C]:S}]],TL=[1,w,qoe,0,[0,{[C]:av}]],EL=[1,w,Yoe,0,[()=>hF,{[C]:S}]],DL=[1,w,Zoe,0,[0,{[C]:S}]],OL=[1,w,Qoe,0,[()=>gF,{[C]:S}]],kL=[1,w,ese,0,[0,{[C]:uv}]],AL=[1,w,Ev,0,[0,{[C]:jv}]],jL=[1,w,zv,0,[()=>_F,{[C]:S}]],ML=[1,w,Dv,0,[0,{[C]:Lv}]],NL=[1,w,pv,0,[()=>EF,{[C]:S}]],PL=[1,w,Vv,0,[()=>wF,{[C]:S}]],FL=[1,w,Roe,0,[0,{[C]:S}]],IL=[1,w,Iy,0,[()=>DF,{[C]:S}]],LL=[1,w,Gy,0,[()=>OF,{[C]:S}]],RL=[1,w,Hy,0,[1,{[C]:S}]],zL=[1,w,rb,0,[0,{[C]:S}]],BL=[1,w,sb,0,[()=>jF,{[C]:S}]],VL=[1,w,Yb,0,[0,{[C]:S}]],HL=[1,w,Qb,0,[0,{[C]:S}]],UL=[1,w,hb,0,[()=>PF,{[C]:S}]],WL=[1,w,Mb,0,[0,{[C]:Pb}]],GL=[1,w,zb,0,[()=>NF,{[C]:S}]],KL=[1,w,vb,0,[()=>LF,{[C]:S}]],qL=[1,w,Nb,0,[0,{[C]:Ob}]],JL=[1,w,Ab,0,[()=>UF,{[C]:S}]],YL=[1,w,Bb,0,[()=>IF,{[C]:S}]],XL=[1,w,cx,0,[0,{[C]:S}]],ZL=[1,w,dx,0,[0,{[C]:ux}]],QL=[1,w,mx,0,[0,{[C]:px}]],$L=[9,w,Ms,0,()=>KA,()=>qA],eR=[9,w,Ps,0,()=>JA,()=>YA],tR=[9,w,Us,0,()=>XA,()=>ZA],nR=[9,w,uc,0,()=>QA,()=>$A],rR=[9,w,Vs,0,()=>ej,()=>WF],iR=[9,w,kc,0,()=>tj,()=>PF],aR=[9,w,gc,0,()=>ij,()=>aj],oR=[9,w,xc,0,()=>oj,()=>sj],sR=[9,w,xu,0,()=>Sj,()=>Cj],cR=[9,w,Rl,0,()=>Ej,()=>Dj],lR=[9,w,kl,0,()=>Oj,()=>kj],uR=[9,w,zl,0,()=>Aj,()=>mP],dR=[9,w,Hl,0,()=>jj,()=>Mj],fR=[9,w,Yl,0,()=>Nj,()=>Pj],pR=[9,w,uu,0,()=>Fj,()=>Ij],mR=[9,w,lu,0,()=>Lj,()=>Rj],hR=[9,w,du,0,()=>zj,()=>Bj],gR=[9,w,Cu,0,()=>Vj,()=>_F],_R=[9,w,wu,0,()=>Hj,()=>Uj],vR=[9,w,Lu,0,()=>Wj,()=>NF],yR=[9,w,Ru,0,()=>Gj,()=>Kj],bR=[9,w,cd,0,()=>Yj,()=>WF],xR=[9,w,Md,0,()=>Xj,()=>Zj],SR=[9,w,Vd,0,()=>Qj,()=>$j],CR=[9,w,Xd,0,()=>eM,()=>WF],wR=[9,w,hf,0,()=>tM,()=>WF],TR=[9,w,sf,0,()=>nM,()=>WF],ER=[9,w,_f,0,()=>rM,()=>iM],DR=[9,w,kf,0,()=>aM,()=>WF],OR=[9,w,Af,0,()=>oM,()=>WF],kR=[9,w,qf,0,()=>sM,()=>WF],AR=[9,w,Jf,0,()=>cM,()=>WF],jR=[9,w,Zu,0,()=>lM,()=>uM],MR=[9,w,Xu,0,()=>dM,()=>fM],NR=[9,w,gd,0,()=>pM,()=>mM],PR=[9,w,kd,0,()=>hM,()=>gM],FR=[9,w,Ad,0,()=>_M,()=>vM],IR=[9,w,Od,0,()=>yM,()=>bM],LR=[9,w,pd,0,()=>xM,()=>SM],RR=[9,w,Ld,0,()=>CM,()=>wM],zR=[9,w,Kd,0,()=>TM,()=>EM],BR=[9,w,ef,0,()=>DM,()=>OM],VR=[9,w,gf,0,()=>kM,()=>AM],HR=[9,w,ff,0,()=>jM,()=>MM],UR=[9,w,Sf,0,()=>NM,()=>PM],WR=[9,w,jf,0,()=>FM,()=>IM],GR=[9,w,Mf,0,()=>LM,()=>RM],KR=[9,w,Yf,0,()=>zM,()=>BM],qR=[9,w,Xf,0,()=>VM,()=>HM],JR=[9,w,md,0,()=>UM,()=>WF],YR=[9,w,Zf,0,()=>WM,()=>PF],XR=[9,w,Qu,0,()=>GM,()=>WF],ZR=[9,w,hd,0,()=>KM,()=>qM],QR=[9,w,pf,0,()=>JM,()=>WF],$R=[9,w,jm,0,()=>hN,()=>gN],ez=[9,w,nne,0,()=>DN,()=>ON],tz=[9,w,oie,0,()=>CP,()=>WF],nz=[9,w,uie,0,()=>wP,()=>WF],rz=[9,w,hie,0,()=>TP,()=>EP],iz=[9,w,$ae,0,()=>QP,()=>WF],az=[9,w,Rae,0,()=>nF,()=>WF],oz=[9,w,uoe,0,()=>aF,()=>oF],sz=[9,w,hoe,0,()=>sF,()=>cF],cz=[9,w,toe,0,()=>mF,()=>rF],lz=[9,w,Pv,0,()=>yF,()=>bF],uz=[9,w,Iv,0,()=>SF,()=>CF],dz=[9,w,Ay,0,()=>kF,()=>AF],fz=e=>({apiVersion:`2016-11-15`,base64Decoder:e?.base64Decoder??fe,base64Encoder:e?.base64Encoder??le,disableHostPrefix:e?.disableHostPrefix??!1,endpointProvider:e?.endpointProvider??ws,extensions:e?.extensions??[],httpAuthSchemeProvider:e?.httpAuthSchemeProvider??rs,httpAuthSchemes:e?.httpAuthSchemes??[{schemeId:`aws.auth#sigv4`,identityProvider:e=>e.getIdentityProvider(`aws.auth#sigv4`),signer:new Yi}],logger:e?.logger??new $t,protocol:e?.protocol??Ja,protocolSettings:e?.protocolSettings??{defaultNamespace:`com.amazonaws.ec2`,errorTypeRegistries:BA,xmlNamespace:`http://ec2.amazonaws.com/doc/2016-11-15`,version:`2016-11-15`,serviceTarget:`AmazonEC2`},serviceId:e?.serviceId??`EC2`,urlParser:e?.urlParser??nt,utf8Decoder:e?.utf8Decoder??ce,utf8Encoder:e?.utf8Encoder??me}),pz=e=>{let t=zi(e),n=()=>t().then(Wt),r=fz(e);return{...r,...e,runtime:`browser`,defaultsMode:t,bodyLengthChecker:e?.bodyLengthChecker??Ee,credentialDefaultProvider:e?.credentialDefaultProvider??(e=>()=>Promise.reject(Error(`Credential is missing`))),defaultUserAgentProvider:e?.defaultUserAgentProvider??Ei({serviceId:r.serviceId,clientVersion:ss.version}),maxAttempts:e?.maxAttempts??3,region:e?.region??yt(`Region is missing`),requestHandler:da.create(e?.requestHandler??n),retryMode:e?.retryMode??(async()=>(await n()).retryMode||Nr),sha256:e?.sha256??ee,streamCollector:e?.streamCollector??pa,useDualstackEndpoint:e?.useDualstackEndpoint??(()=>Promise.resolve(!1)),useFipsEndpoint:e?.useFipsEndpoint??(()=>Promise.resolve(!1))}},mz=e=>{let t=e.httpAuthSchemes,n=e.httpAuthSchemeProvider,r=e.credentials;return{setHttpAuthScheme(e){let n=t.findIndex(t=>t.schemeId===e.schemeId);n===-1?t.push(e):t.splice(n,1,e)},httpAuthSchemes(){return t},setHttpAuthSchemeProvider(e){n=e},httpAuthSchemeProvider(){return n},setCredentials(e){r=e},credentials(){return r}}},hz=e=>({httpAuthSchemes:e.httpAuthSchemes(),httpAuthSchemeProvider:e.httpAuthSchemeProvider(),credentials:e.credentials()}),gz=(e,t)=>{let n=Object.assign(Vi(e),Xt(e),Xn(e),mz(e));return t.forEach(e=>e.configure(n)),Object.assign(e,Hi(n),Zt(n),Zn(n),hz(n))},_z=class extends bt{config;constructor(...[e]){let t=pz(e||{});super(t),this.initConfig=t;let n=gz(is(Nn(zr(Li(Ir(li(as(t))))))),e?.extensions||[]);this.config=n,this.middlewareStack.use(Ot(this.config)),this.middlewareStack.use(Ti(this.config)),this.middlewareStack.use(Lr(this.config)),this.middlewareStack.use(tr(this.config)),this.middlewareStack.use(Hr(this.config)),this.middlewareStack.use(Gr(this.config)),this.middlewareStack.use(Kr(this.config)),this.middlewareStack.use(Zr(this.config,{httpAuthSchemeParametersProvider:ts,identityProviderConfigProvider:async e=>new ii({"aws.auth#sigv4":e.credentials})})),this.middlewareStack.use(ni(this.config))}destroy(){super.destroy()}},vz=e=>typeof e==`string`&&e.indexOf(`arn:`)===0&&e.split(`:`).length>=6,yz=async(e,t,n)=>({operation:Ze(t).operation,region:await re(e.region)()||(()=>{throw Error("expected `region` to be configured for `aws.auth#sigv4`")})()});function bz(e){return{schemeId:`aws.auth#sigv4`,signingProperties:{name:`eks`,region:e.region},propertiesExtractor:(e,t)=>({signingProperties:{config:e,context:t}})}}var xz=e=>{let t=[];switch(e.operation){default:t.push(bz(e))}return t},Sz=e=>{let t=Qi(e);return Object.assign(t,{authSchemePreference:re(e.authSchemePreference??[])})},Cz=e=>Object.assign(e,{useDualstackEndpoint:e.useDualstackEndpoint??!1,useFipsEndpoint:e.useFipsEndpoint??!1,defaultSigningName:`eks`}),wz={UseFIPS:{type:`builtInParams`,name:`useFipsEndpoint`},Endpoint:{type:`builtInParams`,name:`endpoint`},Region:{type:`builtInParams`,name:`region`},UseDualStack:{type:`builtInParams`,name:`useDualstackEndpoint`}},Tz={name:`@aws-sdk/client-eks`,description:`AWS SDK for JavaScript Eks Client for Node.js, Browser and React Native`,version:`3.1063.0`,scripts:{build:`concurrently 'yarn:build:types' 'yarn:build:es' && yarn build:cjs`,"build:cjs":`node ../../scripts/compilation/inline client-eks`,"build:es":`tsc -p tsconfig.es.json`,"build:include:deps":`yarn g:turbo run build -F="$npm_package_name"`,"build:types":`tsc -p tsconfig.types.json`,"build:types:downlevel":`downlevel-dts dist-types dist-types/ts3.4`,clean:`premove dist-cjs dist-es dist-types tsconfig.cjs.tsbuildinfo tsconfig.es.tsbuildinfo tsconfig.types.tsbuildinfo`,"extract:docs":`api-extractor run --local`,"generate:client":`node ../../scripts/generate-clients/single-service --solo eks`,"test:index":`tsc --noEmit ./test/index-types.ts && node ./test/index-objects.spec.mjs`},main:`./dist-cjs/index.js`,types:`./dist-types/index.d.ts`,module:`./dist-es/index.js`,sideEffects:!1,dependencies:{"@aws-crypto/sha256-browser":`5.2.0`,"@aws-crypto/sha256-js":`5.2.0`,"@aws-sdk/core":`^3.974.18`,"@aws-sdk/credential-provider-node":`^3.972.52`,"@aws-sdk/types":`^3.973.11`,"@smithy/core":`^3.24.6`,"@smithy/fetch-http-handler":`^5.4.6`,"@smithy/node-http-handler":`^4.7.6`,"@smithy/types":`^4.14.3`,tslib:`^2.6.2`},devDependencies:{"@tsconfig/node20":`20.1.8`,"@types/node":`^20.14.8`,concurrently:`7.0.0`,"downlevel-dts":`0.10.1`,premove:`4.0.0`,typescript:`~5.8.3`},engines:{node:`>=20.0.0`},typesVersions:{"<4.5":{"dist-types/*":[`dist-types/ts3.4/*`]}},files:[`dist-*/**`],author:{name:`AWS SDK for JavaScript Team`,url:`https://aws.amazon.com/sdk-for-javascript/`},license:`Apache-2.0`,browser:{"./dist-es/runtimeConfig":`./dist-es/runtimeConfig.browser`},"react-native":{"./dist-es/runtimeConfig":`./dist-es/runtimeConfig.native`},homepage:`https://github.com/aws/aws-sdk-js-v3/tree/main/clients/client-eks`,repository:{type:`git`,url:`https://github.com/aws/aws-sdk-js-v3.git`,directory:`clients/client-eks`}},Ez=`ref`,Dz=-1,Oz=!0,kz=`isSet`,Az=`PartitionResult`,jz=`booleanEquals`,Mz=`getAttr`,Nz=`stringEquals`,Pz={[Ez]:`Endpoint`},Fz={[Ez]:Az},Iz={fn:Mz,argv:[Fz,`name`]},Lz={},Rz=[{[Ez]:`Region`}],zz={conditions:[[kz,[Pz]],[kz,Rz],[`aws.partition`,Rz,Az],[jz,[{[Ez]:`UseFIPS`},Oz]],[jz,[{[Ez]:`UseDualStack`},Oz]],[jz,[{fn:Mz,argv:[Fz,`supportsDualStack`]},Oz]],[jz,[{fn:Mz,argv:[Fz,`supportsFIPS`]},Oz]],[Nz,[Iz,`aws`]],[Nz,[Iz,`aws-us-gov`]]],results:[[Dz],[Dz,`Invalid Configuration: FIPS and custom endpoint are not supported`],[Dz,`Invalid Configuration: Dualstack and custom endpoint are not supported`],[Pz,Lz],[`https://eks-fips.{Region}.{PartitionResult#dualStackDnsSuffix}`,Lz],[Dz,`FIPS and DualStack are enabled, but this partition does not support one or both`],[`https://fips.eks.{Region}.amazonaws.com`,Lz],[`https://eks.{Region}.amazonaws.com`,Lz],[`https://eks-fips.{Region}.{PartitionResult#dnsSuffix}`,Lz],[Dz,`FIPS is enabled but this partition does not support FIPS`],[`https://eks.{Region}.{PartitionResult#dualStackDnsSuffix}`,Lz],[Dz,`DualStack is enabled but this partition does not support DualStack`],[`https://eks.{Region}.{PartitionResult#dnsSuffix}`,Lz],[Dz,`Invalid Configuration: Missing Region`]]},Bz=2,Vz=new Int32Array([-1,1,-1,0,14,3,1,4,100000013,2,5,100000013,3,8,6,4,7,100000012,5,100000010,100000011,4,12,9,6,10,100000009,7,100000006,11,8,100000007,100000008,5,13,100000005,6,100000004,100000005,3,100000001,15,4,100000002,100000003]),Hz=on.from(Vz,Bz,zz.conditions,zz.results),Uz=new sn({size:50,params:[`Endpoint`,`Region`,`UseDualStack`,`UseFIPS`]}),Wz=(e,t={})=>Uz.get(e,()=>jn(Hz,{endpointParams:e,logger:t.logger}));dn.aws=ji;var Gz=class e extends Ht{constructor(t){super(t),Object.setPrototypeOf(this,e.prototype)}},Kz=class e extends Gz{name=`AccessDeniedException`;$fault=`client`;constructor(t){super({name:`AccessDeniedException`,$fault:`client`,...t}),Object.setPrototypeOf(this,e.prototype)}},qz=class e extends Gz{name=`InvalidParameterException`;$fault=`client`;clusterName;nodegroupName;fargateProfileName;addonName;subscriptionId;constructor(t){super({name:`InvalidParameterException`,$fault:`client`,...t}),Object.setPrototypeOf(this,e.prototype),this.clusterName=t.clusterName,this.nodegroupName=t.nodegroupName,this.fargateProfileName=t.fargateProfileName,this.addonName=t.addonName,this.subscriptionId=t.subscriptionId}},Jz=class e extends Gz{name=`InvalidRequestException`;$fault=`client`;clusterName;nodegroupName;addonName;subscriptionId;constructor(t){super({name:`InvalidRequestException`,$fault:`client`,...t}),Object.setPrototypeOf(this,e.prototype),this.clusterName=t.clusterName,this.nodegroupName=t.nodegroupName,this.addonName=t.addonName,this.subscriptionId=t.subscriptionId}},Yz=class e extends Gz{name=`ResourceNotFoundException`;$fault=`client`;clusterName;nodegroupName;fargateProfileName;addonName;subscriptionId;constructor(t){super({name:`ResourceNotFoundException`,$fault:`client`,...t}),Object.setPrototypeOf(this,e.prototype),this.clusterName=t.clusterName,this.nodegroupName=t.nodegroupName,this.fargateProfileName=t.fargateProfileName,this.addonName=t.addonName,this.subscriptionId=t.subscriptionId}},Xz=class e extends Gz{name=`ServerException`;$fault=`server`;clusterName;nodegroupName;addonName;subscriptionId;constructor(t){super({name:`ServerException`,$fault:`server`,...t}),Object.setPrototypeOf(this,e.prototype),this.clusterName=t.clusterName,this.nodegroupName=t.nodegroupName,this.addonName=t.addonName,this.subscriptionId=t.subscriptionId}},Zz=class e extends Gz{name=`ClientException`;$fault=`client`;clusterName;nodegroupName;addonName;subscriptionId;constructor(t){super({name:`ClientException`,$fault:`client`,...t}),Object.setPrototypeOf(this,e.prototype),this.clusterName=t.clusterName,this.nodegroupName=t.nodegroupName,this.addonName=t.addonName,this.subscriptionId=t.subscriptionId}},Qz=class e extends Gz{name=`ResourceInUseException`;$fault=`client`;clusterName;nodegroupName;addonName;constructor(t){super({name:`ResourceInUseException`,$fault:`client`,...t}),Object.setPrototypeOf(this,e.prototype),this.clusterName=t.clusterName,this.nodegroupName=t.nodegroupName,this.addonName=t.addonName}},$z=class e extends Gz{name=`ThrottlingException`;$fault=`client`;clusterName;constructor(t){super({name:`ThrottlingException`,$fault:`client`,...t}),Object.setPrototypeOf(this,e.prototype),this.clusterName=t.clusterName}},eB=class e extends Gz{name=`ResourceLimitExceededException`;$fault=`client`;clusterName;nodegroupName;subscriptionId;constructor(t){super({name:`ResourceLimitExceededException`,$fault:`client`,...t}),Object.setPrototypeOf(this,e.prototype),this.clusterName=t.clusterName,this.nodegroupName=t.nodegroupName,this.subscriptionId=t.subscriptionId}},tB=class e extends Gz{name=`ServiceUnavailableException`;$fault=`server`;constructor(t){super({name:`ServiceUnavailableException`,$fault:`server`,...t}),Object.setPrototypeOf(this,e.prototype)}},nB=class e extends Gz{name=`UnsupportedAvailabilityZoneException`;$fault=`client`;clusterName;nodegroupName;validZones;constructor(t){super({name:`UnsupportedAvailabilityZoneException`,$fault:`client`,...t}),Object.setPrototypeOf(this,e.prototype),this.clusterName=t.clusterName,this.nodegroupName=t.nodegroupName,this.validZones=t.validZones}},rB=class e extends Gz{name=`BadRequestException`;$fault=`client`;constructor(t){super({name:`BadRequestException`,$fault:`client`,...t}),Object.setPrototypeOf(this,e.prototype)}},iB=class e extends Gz{name=`NotFoundException`;$fault=`client`;constructor(t){super({name:`NotFoundException`,$fault:`client`,...t}),Object.setPrototypeOf(this,e.prototype)}},aB=class e extends Gz{name=`ResourcePropagationDelayException`;$fault=`client`;constructor(t){super({name:`ResourcePropagationDelayException`,$fault:`client`,...t}),Object.setPrototypeOf(this,e.prototype)}},oB=class e extends Gz{name=`InvalidStateException`;$fault=`client`;clusterName;constructor(t){super({name:`InvalidStateException`,$fault:`client`,...t}),Object.setPrototypeOf(this,e.prototype),this.clusterName=t.clusterName}},sB=`Addon`,cB=`AssociatedAccessPolicy`,lB=`AssociatedAccessPoliciesList`,uB=`AssociateAccessPolicyRequest`,dB=`AssociateAccessPolicyResponse`,fB=`AssociateAccessPolicy`,pB=`AccessConfigResponse`,mB=`AccessDeniedException`,hB=`AccessEntry`,gB=`AddonHealth`,_B=`AddonInfo`,vB=`AddonIssueList`,yB=`AddonIssue`,bB=`AddonNamespaceConfigRequest`,xB=`AddonNamespaceConfigResponse`,SB=`AccessPolicy`,CB=`AddonPodIdentityAssociations`,wB=`AddonPodIdentityAssociationsList`,TB=`AccessPoliciesList`,EB=`AccessScope`,DB=`AutoScalingGroup`,OB=`AutoScalingGroupList`,kB=`AddonVersionInfo`,AB=`AddonVersionInfoList`,jB=`Addons`,MB=`BadRequestException`,NB=`BlockStorage`,PB=`CreateAddon`,FB=`CreateAccessConfigRequest`,IB=`CreateAccessEntry`,LB=`CreateAccessEntryRequest`,RB=`CreateAccessEntryResponse`,zB=`CreateAddonRequest`,BB=`CreateAddonResponse`,VB=`ComputeConfigRequest`,HB=`ComputeConfigResponse`,UB=`ConnectorConfigResponse`,WB=`CreateClusterRequest`,GB=`CreateClusterResponse`,KB=`CreateCluster`,qB=`ClientException`,JB=`ClusterHealth`,YB=`ClusterIssueList`,XB=`ClusterIssue`,ZB=`CreateNodegroup`,QB=`CreateNodegroupRequest`,$B=`CreateNodegroupResponse`,eV=`ControlPlanePlacementRequest`,tV=`ControlPlanePlacementResponse`,nV=`ControlPlaneScalingConfig`,rV=`Certificate`,iV=`Cluster`,aV=`Compatibility`,oV=`Compatibilities`,sV=`DeleteAddon`,cV=`DeleteAccessEntry`,lV=`DeleteAccessEntryRequest`,uV=`DeleteAccessEntryResponse`,dV=`DescribeAccessEntryRequest`,fV=`DescribeAccessEntryResponse`,pV=`DescribeAccessEntry`,mV=`DisassociateAccessPolicy`,hV=`DisassociateAccessPolicyRequest`,gV=`DisassociateAccessPolicyResponse`,_V=`DeleteAddonRequest`,vV=`DeleteAddonResponse`,yV=`DescribeAddonRequest`,bV=`DescribeAddonResponse`,xV=`DescribeAddonVersions`,SV=`DescribeAddonVersionsRequest`,CV=`DescribeAddonVersionsResponse`,wV=`DescribeAddon`,TV=`DeleteClusterRequest`,EV=`DeleteClusterResponse`,DV=`DescribeClusterRequest`,OV=`DescribeClusterResponse`,kV=`DeleteCluster`,AV=`DescribeCluster`,jV=`DeleteNodegroup`,MV=`DeleteNodegroupRequest`,NV=`DeleteNodegroupResponse`,PV=`DescribeNodegroupRequest`,FV=`DescribeNodegroupResponse`,IV=`DescribeNodegroup`,LV=`EncryptionConfig`,RV=`EncryptionConfigList`,zV=`ErrorDetail`,BV=`ErrorDetails`,VV=`ElasticLoadBalancing`,HV=`Identity`,UV=`IssueList`,WV=`InvalidParameterException`,GV=`InvalidRequestException`,KV=`InvalidStateException`,qV=`Issue`,JV=`KubernetesNetworkConfigRequest`,YV=`KubernetesNetworkConfigResponse`,XV=`ListAddons`,ZV=`ListAssociatedAccessPolicies`,QV=`ListAssociatedAccessPoliciesRequest`,$V=`ListAssociatedAccessPoliciesResponse`,eH=`ListAccessEntries`,tH=`ListAccessEntriesRequest`,nH=`ListAccessEntriesResponse`,rH=`ListAccessPolicies`,iH=`ListAccessPoliciesRequest`,aH=`ListAccessPoliciesResponse`,oH=`ListAddonsRequest`,sH=`ListAddonsResponse`,cH=`ListClustersRequest`,lH=`ListClustersResponse`,uH=`ListClusters`,dH=`ListNodegroups`,fH=`ListNodegroupsRequest`,pH=`ListNodegroupsResponse`,mH=`LogSetup`,hH=`LogSetups`,gH=`LaunchTemplateSpecification`,_H=`Logging`,vH=`MarketplaceInformation`,yH=`Nodegroup`,bH=`NotFoundException`,xH=`NodegroupHealth`,SH=`NodegroupResources`,CH=`NodeRepairConfig`,wH=`NodeRepairConfigOverrides`,TH=`NodeRepairConfigOverridesList`,EH=`NodegroupScalingConfig`,DH=`NodegroupUpdateConfig`,OH=`OutpostConfigRequest`,kH=`OutpostConfigResponse`,AH=`OIDC`,jH=`Provider`,MH=`RemoteAccessConfig`,NH=`ResourceInUseException`,PH=`ResourceLimitExceededException`,FH=`RemoteNetworkConfigRequest`,IH=`RemoteNetworkConfigResponse`,LH=`ResourceNotFoundException`,RH=`RemoteNodeNetwork`,zH=`RemoteNodeNetworkList`,BH=`ResourcePropagationDelayException`,VH=`RemotePodNetwork`,HH=`RemotePodNetworkList`,UH=`StorageConfigRequest`,WH=`StorageConfigResponse`,GH=`ServerException`,KH=`ServiceUnavailableException`,qH=`Taint`,JH=`ThrottlingException`,YH=`Update`,XH=`UpdateAddon`,ZH=`UpdateAddonRequest`,QH=`UpdateAddonResponse`,$H=`UnsupportedAvailabilityZoneException`,eU=`UpdateLabelsPayload`,tU=`UpdateNodegroupConfig`,nU=`UpdateNodegroupConfigRequest`,rU=`UpdateNodegroupConfigResponse`,iU=`UpdateNodegroupVersion`,aU=`UpdateNodegroupVersionRequest`,oU=`UpdateNodegroupVersionResponse`,sU=`UpdateParam`,cU=`UpgradePolicyRequest`,lU=`UpgradePolicyResponse`,uU=`UpdateParams`,dU=`UpdateTaintsPayload`,fU=`VpcConfigRequest`,pU=`VpcConfigResponse`,mU=`WarmPoolConfig`,hU=`ZonalShiftConfigRequest`,gU=`ZonalShiftConfigResponse`,_U=`arn`,vU=`addonArn`,yU=`associatedAccessPolicy`,bU=`associatedAccessPolicies`,xU=`associatedAt`,SU=`accessConfig`,CU=`activationCode`,wU=`activationExpiry`,TU=`accessEntryArn`,EU=`accessEntry`,DU=`accessEntries`,OU=`activationId`,kU=`authenticationMode`,AU=`addonName`,jU=`addOrUpdateLabels`,MU=`addOrUpdateTaints`,NU=`accessPolicies`,PU=`associatedPolicyArn`,FU=`accessScope`,IU=`autoScalingGroups`,LU=`amiType`,RU=`addonVersion`,zU=`addonVersions`,BU=`addon`,VU=`addons`,HU=`architecture`,UU=`bootstrapClusterCreatorAdminPermissions`,WU=`blockStorage`,GU=`bootstrapSelfManagedAddons`,KU=`client`,qU=`createdAt`,JU=`certificateAuthority`,YU=`connectorConfig`,XU=`computeConfig`,ZU=`clusterLogging`,T=`clusterName`,QU=`controlPlaneInstanceType`,$U=`controlPlanePlacement`,eW=`controlPlaneScalingConfig`,tW=`clientRequestToken`,nW=`clusterSecurityGroupId`,rW=`computeTypes`,iW=`capacityType`,aW=`configurationValues`,oW=`clusterVersion`,sW=`cidrs`,cW=`cluster`,lW=`clusters`,uW=`code`,dW=`compatibilities`,fW=`data`,pW=`defaultNamespace`,mW=`deletionProtection`,hW=`diskSize`,gW=`desiredSize`,_W=`defaultVersion`,vW=`error`,yW=`encryptionConfig`,bW=`errorCode`,xW=`elasticLoadBalancing`,SW=`errorMessage`,CW=`endpointPublicAccess`,wW=`endpointPrivateAccess`,TW=`ec2SshKey`,EW=`effect`,DW=`enabled`,OW=`endpoint`,kW=`errors`,AW=`fargateProfileName`,jW=`force`,MW=`groupName`,NW=`health`,PW=`httpError`,E=`httpQuery`,D=`http`,FW=`issues`,IW=`ipFamily`,LW=`instanceTypes`,RW=`id`,zW=`identity`,BW=`include`,VW=`issuer`,HW=`key`,UW=`keyArn`,WW=`kubernetesGroups`,GW=`kubernetesNetworkConfig`,KW=`kubernetesVersion`,qW=`logging`,JW=`launchTemplate`,YW=`labels`,XW=`message`,ZW=`modifiedAt`,QW=`maxGroupPreparedCapacity`,$W=`marketplaceInformation`,eG=`maxParallelNodesRepairedCount`,tG=`maxParallelNodesRepairedPercentage`,nG=`maxResults`,rG=`minRepairWaitTimeMins`,iG=`minSize`,aG=`maxSize`,oG=`maxUnavailable`,sG=`maxUnhealthyNodeThresholdCount`,cG=`maxUnhealthyNodeThresholdPercentage`,lG=`maxUnavailablePercentage`,uG=`name`,dG=`nodegroupArn`,fG=`namespaceConfig`,pG=`nodeMonitoringCondition`,mG=`nodegroupName`,hG=`nodePools`,gG=`nodeRole`,_G=`nodeRoleArn`,vG=`nodeRepairConfig`,yG=`nodeRepairConfigOverrides`,O=`nextToken`,bG=`nodeUnhealthyReason`,xG=`namespaces`,SG=`namespace`,CG=`nodegroup`,wG=`nodegroups`,TG=`owner`,EG=`outpostArns`,DG=`outpostConfig`,OG=`oidc`,kG=`owners`,AG=`publisher`,jG=`principalArn`,MG=`publicAccessCidrs`,NG=`policyArn`,PG=`productId`,FG=`podIdentityAssociations`,IG=`poolState`,LG=`productUrl`,RG=`platformVersion`,zG=`platformVersions`,BG=`params`,VG=`provider`,HG=`preserve`,UG=`publishers`,WG=`roleArn`,GG=`remoteAccessSecurityGroup`,KG=`remoteAccess`,qG=`repairAction`,JG=`requiresConfiguration`,YG=`resolveConflicts`,XG=`resourceIds`,ZG=`requiresIamPermissions`,QG=`removeLabels`,$G=`remoteNetworkConfig`,eK=`remoteNodeNetworks`,tK=`reuseOnScaleIn`,nK=`remotePodNetworks`,rK=`removeTaints`,iK=`releaseVersion`,aK=`resourcesVpcConfig`,oK=`resources`,sK=`smithy.ts.sdk.synthetic.com.amazonaws.eks`,cK=`serviceAccount`,lK=`serviceAccountRoleArn`,uK=`storageConfig`,dK=`scalingConfig`,fK=`securityGroupIds`,pK=`subscriptionId`,mK=`serviceIpv4Cidr`,hK=`serviceIpv6Cidr`,gK=`subnetIds`,_K=`sourceSecurityGroups`,vK=`supportType`,yK=`server`,bK=`status`,xK=`subnets`,SK=`tags`,CK=`taintsList`,wK=`taints`,TK=`tier`,EK=`type`,DK=`types`,OK=`username`,kK=`updateConfig`,AK=`upgradePolicy`,jK=`updateStrategy`,MK=`update`,NK=`version`,PK=`vpcId`,FK=`validZones`,IK=`value`,LK=`warmPoolConfig`,RK=`zonalShiftConfig`,k=`com.amazonaws.eks`,zK=Lt.for(sK),BK=[-3,sK,`EKSServiceException`,0,[],[]];zK.registerError(BK,Gz);var VK=Lt.for(k),HK=[-3,k,mB,{[vW]:KU,[PW]:403},[XW],[0]];VK.registerError(HK,Kz);var UK=[-3,k,MB,{[vW]:KU,[PW]:400},[XW],[0]];VK.registerError(UK,rB);var WK=[-3,k,qB,{[vW]:KU,[PW]:400},[T,mG,AU,pK,XW],[0,0,0,0,0]];VK.registerError(WK,Zz);var GK=[-3,k,WV,{[vW]:KU,[PW]:400},[T,mG,AW,AU,pK,XW],[0,0,0,0,0,0]];VK.registerError(GK,qz);var KK=[-3,k,GV,{[vW]:KU,[PW]:400},[T,mG,AU,pK,XW],[0,0,0,0,0]];VK.registerError(KK,Jz);var qK=[-3,k,KV,{[vW]:KU,[PW]:400},[T,XW],[0,0]];VK.registerError(qK,oB);var JK=[-3,k,bH,{[vW]:KU,[PW]:404},[XW],[0]];VK.registerError(JK,iB);var YK=[-3,k,NH,{[vW]:KU,[PW]:409},[T,mG,AU,XW],[0,0,0,0]];VK.registerError(YK,Qz);var XK=[-3,k,PH,{[vW]:KU,[PW]:400},[T,mG,pK,XW],[0,0,0,0]];VK.registerError(XK,eB);var ZK=[-3,k,LH,{[vW]:KU,[PW]:404},[T,mG,AW,AU,pK,XW],[0,0,0,0,0,0]];VK.registerError(ZK,Yz);var QK=[-3,k,BH,{[vW]:KU,[PW]:428},[XW],[0]];VK.registerError(QK,aB);var $K=[-3,k,GH,{[vW]:yK,[PW]:500},[T,mG,AU,pK,XW],[0,0,0,0,0]];VK.registerError($K,Xz);var eq=[-3,k,KH,{[vW]:yK,[PW]:503},[XW],[0]];VK.registerError(eq,tB);var tq=[-3,k,JH,{[vW]:KU,[PW]:429},[T,XW],[0,0]];VK.registerError(tq,$z);var nq=[-3,k,$H,{[vW]:KU,[PW]:400},[XW,T,mG,FK],[0,0,0,64]];VK.registerError(nq,nB);var rq=[zK,VK],iq=[3,k,pB,0,[UU,kU],[2,0]],aq=[3,k,hB,0,[T,jG,WW,TU,qU,ZW,SK,OK,EK],[0,0,64,0,4,4,128,0,0]],oq=[3,k,SB,0,[uG,_U],[0,0]],sq=[3,k,EB,0,[EK,xG],[0,64]],cq=[3,k,sB,0,[AU,T,bK,RU,NW,vU,qU,ZW,lK,SK,AG,TG,$W,aW,FG,fG],[0,0,0,0,()=>lq,0,4,4,0,128,0,0,()=>kJ,0,64,()=>pq]],lq=[3,k,gB,0,[FW],[()=>pY]],uq=[3,k,_B,0,[AU,EK,zU,AG,TG,$W,pW],[0,0,()=>gY,0,0,()=>kJ,0]],dq=[3,k,yB,0,[uW,XW,XG],[0,0,64]],fq=[3,k,bB,0,[SG],[0]],pq=[3,k,xB,0,[SG],[0]],mq=[3,k,CB,0,[cK,WG],[0,0],2],hq=[3,k,kB,0,[RU,HU,rW,dW,JG,ZG],[0,64,64,()=>bY,2,2]],gq=[3,k,uB,0,[T,jG,NG,FU],[[0,1],[0,1],0,()=>sq],4],_q=[3,k,dB,0,[T,jG,yU],[0,0,()=>vq]],vq=[3,k,cB,0,[NG,FU,xU,ZW],[0,()=>sq,4,4]],yq=[3,k,DB,0,[uG],[0]],bq=[3,k,NB,0,[DW],[2]],xq=[3,k,rV,0,[fW],[0]],Sq=[3,k,iV,0,[uG,_U,qU,NK,OW,WG,aK,GW,qW,zW,bK,JU,tW,RG,SK,yW,YU,RW,NW,DG,SU,AK,RK,$G,XU,uK,mW,eW],[0,0,4,0,0,0,()=>cY,()=>pJ,()=>DJ,()=>uJ,0,()=>xq,0,0,128,()=>xY,()=>Oq,0,()=>Cq,()=>zJ,()=>iq,()=>oY,()=>dY,()=>UJ,()=>Dq,()=>qJ,2,()=>jq]],Cq=[3,k,JB,0,[FW],[()=>yY]],wq=[3,k,XB,0,[uW,XW,XG],[0,0,64]],Tq=[3,k,aV,0,[oW,zG,_W],[0,64,2]],Eq=[3,k,VB,0,[DW,hG,_G],[2,64,0]],Dq=[3,k,HB,0,[DW,hG,_G],[2,64,0]],Oq=[3,k,UB,0,[OU,CU,wU,VG,WG],[0,0,4,0,0]],kq=[3,k,eV,0,[MW],[0]],Aq=[3,k,tV,0,[MW],[0]],jq=[3,k,nV,0,[TK],[0]],Mq=[3,k,FB,0,[UU,kU],[2,0]],Nq=[3,k,LB,0,[T,jG,WW,SK,tW,OK,EK],[[0,1],0,64,128,[0,4],0,0],2],Pq=[3,k,RB,0,[EU],[()=>aq]],Fq=[3,k,zB,0,[T,AU,RU,lK,YG,tW,SK,aW,FG,fG],[[0,1],0,0,0,0,[0,4],128,0,()=>mY,()=>fq],2],Iq=[3,k,BB,0,[BU],[()=>cq]],Lq=[3,k,WB,0,[uG,WG,aK,NK,GW,qW,tW,SK,yW,DG,SU,GU,AK,RK,$G,XU,uK,mW,eW],[0,0,()=>sY,0,()=>fJ,()=>DJ,[0,4],128,()=>xY,()=>RJ,()=>Mq,2,()=>aY,()=>uY,()=>HJ,()=>Eq,()=>KJ,2,()=>jq],3],Rq=[3,k,GB,0,[cW],[()=>Sq]],zq=[3,k,QB,0,[T,mG,xK,gG,dK,hW,LW,LU,KG,YW,wK,SK,tW,JW,kK,vG,iW,NK,iK,LK],[[0,1],0,64,0,()=>NJ,1,64,0,()=>VJ,128,()=>OY,128,[0,4],()=>mJ,()=>PJ,()=>FJ,0,0,0,()=>lY],4],Bq=[3,k,$B,0,[CG],[()=>AJ]],Vq=[3,k,lV,0,[T,jG],[[0,1],[0,1]],2],Hq=[3,k,uV,0,[],[]],Uq=[3,k,_V,0,[T,AU,HG],[[0,1],[0,1],[2,{[E]:HG}]],2],Wq=[3,k,vV,0,[BU],[()=>cq]],Gq=[3,k,TV,0,[uG],[[0,1]],1],Kq=[3,k,EV,0,[cW],[()=>Sq]],qq=[3,k,MV,0,[T,mG],[[0,1],[0,1]],2],Jq=[3,k,NV,0,[CG],[()=>AJ]],Yq=[3,k,dV,0,[T,jG],[[0,1],[0,1]],2],Xq=[3,k,fV,0,[EU],[()=>aq]],Zq=[3,k,yV,0,[T,AU],[[0,1],[0,1]],2],Qq=[3,k,bV,0,[BU],[()=>cq]],$q=[3,k,SV,0,[KW,nG,O,AU,DK,UG,kG],[[0,{[E]:KW}],[1,{[E]:nG}],[0,{[E]:O}],[0,{[E]:AU}],[64,{[E]:DK}],[64,{[E]:UG}],[64,{[E]:kG}]]],eJ=[3,k,CV,0,[VU,O],[()=>hY,0]],tJ=[3,k,DV,0,[uG],[[0,1]],1],nJ=[3,k,OV,0,[cW],[()=>Sq]],rJ=[3,k,PV,0,[T,mG],[[0,1],[0,1]],2],iJ=[3,k,FV,0,[CG],[()=>AJ]],aJ=[3,k,hV,0,[T,jG,NG],[[0,1],[0,1],[0,1]],3],oJ=[3,k,gV,0,[],[]],sJ=[3,k,VV,0,[DW],[2]],cJ=[3,k,LV,0,[oK,VG],[64,()=>BJ]],lJ=[3,k,zV,0,[bW,SW,XG],[0,0,64]],uJ=[3,k,HV,0,[OG],[()=>LJ]],dJ=[3,k,qV,0,[uW,XW,XG],[0,0,64]],fJ=[3,k,JV,0,[mK,IW,xW],[0,0,()=>sJ]],pJ=[3,k,YV,0,[mK,hK,IW,xW],[0,0,0,()=>sJ]],mJ=[3,k,gH,0,[uG,NK,RW],[0,0,0]],hJ=[3,k,tH,0,[T,PU,nG,O],[[0,1],[0,{[E]:PU}],[1,{[E]:nG}],[0,{[E]:O}]],1],gJ=[3,k,nH,0,[DU,O],[64,0]],_J=[3,k,iH,0,[nG,O],[[1,{[E]:nG}],[0,{[E]:O}]]],vJ=[3,k,aH,0,[NU,O],[()=>fY,0]],yJ=[3,k,oH,0,[T,nG,O],[[0,1],[1,{[E]:nG}],[0,{[E]:O}]],1],bJ=[3,k,sH,0,[VU,O],[64,0]],xJ=[3,k,QV,0,[T,jG,nG,O],[[0,1],[0,1],[1,{[E]:nG}],[0,{[E]:O}]],2],SJ=[3,k,$V,0,[T,jG,O,bU],[0,0,0,()=>_Y]],CJ=[3,k,cH,0,[nG,O,BW],[[1,{[E]:nG}],[0,{[E]:O}],[64,{[E]:BW}]]],wJ=[3,k,lH,0,[lW,O],[64,0]],TJ=[3,k,fH,0,[T,nG,O],[[0,1],[1,{[E]:nG}],[0,{[E]:O}]],1],EJ=[3,k,pH,0,[wG,O],[64,0]],DJ=[3,k,_H,0,[ZU],[()=>wY]],OJ=[3,k,mH,0,[DK,DW],[64,2]],kJ=[3,k,vH,0,[PG,LG],[0,0]],AJ=[3,k,yH,0,[mG,dG,T,NK,iK,qU,ZW,bK,iW,dK,LW,xK,KG,LU,gG,YW,wK,oK,hW,NW,kK,vG,JW,SK,LK],[0,0,0,0,0,4,4,0,0,()=>NJ,64,64,()=>VJ,0,0,128,()=>OY,()=>MJ,1,()=>jJ,()=>PJ,()=>FJ,()=>mJ,128,()=>lY]],jJ=[3,k,xH,0,[FW],[()=>CY]],MJ=[3,k,SH,0,[IU,GG],[()=>vY,0]],NJ=[3,k,EH,0,[iG,aG,gW],[1,1,1]],PJ=[3,k,DH,0,[oG,lG,jK],[1,1,0]],FJ=[3,k,CH,0,[DW,sG,cG,eG,tG,yG],[2,1,1,1,1,()=>TY]],IJ=[3,k,wH,0,[pG,bG,rG,qG],[0,0,1,0]],LJ=[3,k,AH,0,[VW],[0]],RJ=[3,k,OH,0,[EG,QU,$U],[64,0,()=>kq],2],zJ=[3,k,kH,0,[EG,QU,$U],[64,0,()=>Aq],2],BJ=[3,k,jH,0,[UW],[0]],VJ=[3,k,MH,0,[TW,_K],[0,64]],HJ=[3,k,FH,0,[eK,nK],[()=>EY,()=>DY]],UJ=[3,k,IH,0,[eK,nK],[()=>EY,()=>DY]],WJ=[3,k,RH,0,[sW],[64]],GJ=[3,k,VH,0,[sW],[64]],KJ=[3,k,UH,0,[WU],[()=>bq]],qJ=[3,k,WH,0,[WU],[()=>bq]],JJ=[3,k,qH,0,[HW,IK,EW],[0,0,0]],YJ=[3,k,YH,0,[RW,bK,EK,BG,qU,kW],[0,0,0,()=>kY,4,()=>SY]],XJ=[3,k,ZH,0,[T,AU,RU,lK,YG,tW,aW,FG],[[0,1],[0,1],0,0,0,[0,4],0,()=>mY],2],ZJ=[3,k,QH,0,[MK],[()=>YJ]],QJ=[3,k,eU,0,[jU,QG],[128,64]],$J=[3,k,nU,0,[T,mG,YW,wK,dK,kK,vG,LK,tW],[[0,1],[0,1],()=>QJ,()=>iY,()=>NJ,()=>PJ,()=>FJ,()=>lY,[0,4]],2],eY=[3,k,rU,0,[MK],[()=>YJ]],tY=[3,k,aU,0,[T,mG,NK,iK,JW,jW,tW],[[0,1],[0,1],0,0,()=>mJ,2,[0,4]],2],nY=[3,k,oU,0,[MK],[()=>YJ]],rY=[3,k,sU,0,[EK,IK],[0,0]],iY=[3,k,dU,0,[MU,rK],[()=>OY,()=>OY]],aY=[3,k,cU,0,[vK],[0]],oY=[3,k,lU,0,[vK],[0]],sY=[3,k,fU,0,[gK,fK,CW,wW,MG],[64,64,2,2,64]],cY=[3,k,pU,0,[gK,fK,nW,PK,CW,wW,MG],[64,64,0,0,2,2,64]],lY=[3,k,mU,0,[DW,iG,QW,IG,tK],[2,1,1,0,2]],uY=[3,k,hU,0,[DW],[2]],dY=[3,k,gU,0,[DW],[2]],fY=[1,k,TB,0,()=>oq],pY=[1,k,vB,0,()=>dq],mY=[1,k,wB,0,()=>mq],hY=[1,k,jB,0,()=>uq],gY=[1,k,AB,0,()=>hq],_Y=[1,k,lB,0,()=>vq],vY=[1,k,OB,0,()=>yq],yY=[1,k,YB,0,()=>wq],bY=[1,k,oV,0,()=>Tq],xY=[1,k,RV,0,()=>cJ],SY=[1,k,BV,0,()=>lJ],CY=[1,k,UV,0,()=>dJ],wY=[1,k,hH,0,()=>OJ],TY=[1,k,TH,0,()=>IJ],EY=[1,k,zH,0,()=>WJ],DY=[1,k,HH,0,()=>GJ],OY=[1,k,CK,0,()=>JJ],kY=[1,k,uU,0,()=>rY],AY=[9,k,fB,{[D]:[`POST`,`/clusters/{clusterName}/access-entries/{principalArn}/access-policies`,200]},()=>gq,()=>_q],jY=[9,k,IB,{[D]:[`POST`,`/clusters/{clusterName}/access-entries`,200]},()=>Nq,()=>Pq],MY=[9,k,PB,{[D]:[`POST`,`/clusters/{clusterName}/addons`,200]},()=>Fq,()=>Iq],NY=[9,k,KB,{[D]:[`POST`,`/clusters`,200]},()=>Lq,()=>Rq],PY=[9,k,ZB,{[D]:[`POST`,`/clusters/{clusterName}/node-groups`,200]},()=>zq,()=>Bq],FY=[9,k,cV,{[D]:[`DELETE`,`/clusters/{clusterName}/access-entries/{principalArn}`,200]},()=>Vq,()=>Hq],IY=[9,k,sV,{[D]:[`DELETE`,`/clusters/{clusterName}/addons/{addonName}`,200]},()=>Uq,()=>Wq],LY=[9,k,kV,{[D]:[`DELETE`,`/clusters/{name}`,200]},()=>Gq,()=>Kq],RY=[9,k,jV,{[D]:[`DELETE`,`/clusters/{clusterName}/node-groups/{nodegroupName}`,200]},()=>qq,()=>Jq],zY=[9,k,pV,{[D]:[`GET`,`/clusters/{clusterName}/access-entries/{principalArn}`,200]},()=>Yq,()=>Xq],BY=[9,k,wV,{[D]:[`GET`,`/clusters/{clusterName}/addons/{addonName}`,200]},()=>Zq,()=>Qq],VY=[9,k,xV,{[D]:[`GET`,`/addons/supported-versions`,200]},()=>$q,()=>eJ],HY=[9,k,AV,{[D]:[`GET`,`/clusters/{name}`,200]},()=>tJ,()=>nJ],UY=[9,k,IV,{[D]:[`GET`,`/clusters/{clusterName}/node-groups/{nodegroupName}`,200]},()=>rJ,()=>iJ],WY=[9,k,mV,{[D]:[`DELETE`,`/clusters/{clusterName}/access-entries/{principalArn}/access-policies/{policyArn}`,200]},()=>aJ,()=>oJ],GY=[9,k,eH,{[D]:[`GET`,`/clusters/{clusterName}/access-entries`,200]},()=>hJ,()=>gJ],KY=[9,k,rH,{[D]:[`GET`,`/access-policies`,200]},()=>_J,()=>vJ],qY=[9,k,XV,{[D]:[`GET`,`/clusters/{clusterName}/addons`,200]},()=>yJ,()=>bJ],JY=[9,k,ZV,{[D]:[`GET`,`/clusters/{clusterName}/access-entries/{principalArn}/access-policies`,200]},()=>xJ,()=>SJ],YY=[9,k,uH,{[D]:[`GET`,`/clusters`,200]},()=>CJ,()=>wJ],XY=[9,k,dH,{[D]:[`GET`,`/clusters/{clusterName}/node-groups`,200]},()=>TJ,()=>EJ],ZY=[9,k,XH,{[D]:[`POST`,`/clusters/{clusterName}/addons/{addonName}/update`,200]},()=>XJ,()=>ZJ],QY=[9,k,tU,{[D]:[`POST`,`/clusters/{clusterName}/node-groups/{nodegroupName}/update-config`,200]},()=>$J,()=>eY],$Y=[9,k,iU,{[D]:[`POST`,`/clusters/{clusterName}/node-groups/{nodegroupName}/update-version`,200]},()=>tY,()=>nY],eX=e=>({apiVersion:`2017-11-01`,base64Decoder:e?.base64Decoder??fe,base64Encoder:e?.base64Encoder??le,disableHostPrefix:e?.disableHostPrefix??!1,endpointProvider:e?.endpointProvider??Wz,extensions:e?.extensions??[],httpAuthSchemeProvider:e?.httpAuthSchemeProvider??xz,httpAuthSchemes:e?.httpAuthSchemes??[{schemeId:`aws.auth#sigv4`,identityProvider:e=>e.getIdentityProvider(`aws.auth#sigv4`),signer:new Yi}],logger:e?.logger??new $t,protocol:e?.protocol??Pa,protocolSettings:e?.protocolSettings??{defaultNamespace:`com.amazonaws.eks`,errorTypeRegistries:rq,version:`2017-11-01`,serviceTarget:`AWSWesleyFrontend`},serviceId:e?.serviceId??`EKS`,urlParser:e?.urlParser??nt,utf8Decoder:e?.utf8Decoder??ce,utf8Encoder:e?.utf8Encoder??me}),tX=e=>{let t=zi(e),n=()=>t().then(Wt),r=eX(e);return{...r,...e,runtime:`browser`,defaultsMode:t,bodyLengthChecker:e?.bodyLengthChecker??Ee,credentialDefaultProvider:e?.credentialDefaultProvider??(e=>()=>Promise.reject(Error(`Credential is missing`))),defaultUserAgentProvider:e?.defaultUserAgentProvider??Ei({serviceId:r.serviceId,clientVersion:Tz.version}),maxAttempts:e?.maxAttempts??3,region:e?.region??yt(`Region is missing`),requestHandler:da.create(e?.requestHandler??n),retryMode:e?.retryMode??(async()=>(await n()).retryMode||Nr),sha256:e?.sha256??ee,streamCollector:e?.streamCollector??pa,useDualstackEndpoint:e?.useDualstackEndpoint??(()=>Promise.resolve(!1)),useFipsEndpoint:e?.useFipsEndpoint??(()=>Promise.resolve(!1))}},nX=e=>{let t=e.httpAuthSchemes,n=e.httpAuthSchemeProvider,r=e.credentials;return{setHttpAuthScheme(e){let n=t.findIndex(t=>t.schemeId===e.schemeId);n===-1?t.push(e):t.splice(n,1,e)},httpAuthSchemes(){return t},setHttpAuthSchemeProvider(e){n=e},httpAuthSchemeProvider(){return n},setCredentials(e){r=e},credentials(){return r}}},rX=e=>({httpAuthSchemes:e.httpAuthSchemes(),httpAuthSchemeProvider:e.httpAuthSchemeProvider(),credentials:e.credentials()}),iX=(e,t)=>{let n=Object.assign(Vi(e),Xt(e),Xn(e),nX(e));return t.forEach(e=>e.configure(n)),Object.assign(e,Hi(n),Zt(n),Zn(n),rX(n))},aX=class extends bt{config;constructor(...[e]){let t=tX(e||{});super(t),this.initConfig=t;let n=iX(Sz(Nn(zr(Li(Ir(li(Cz(t))))))),e?.extensions||[]);this.config=n,this.middlewareStack.use(Ot(this.config)),this.middlewareStack.use(Ti(this.config)),this.middlewareStack.use(Lr(this.config)),this.middlewareStack.use(tr(this.config)),this.middlewareStack.use(Hr(this.config)),this.middlewareStack.use(Gr(this.config)),this.middlewareStack.use(Kr(this.config)),this.middlewareStack.use(Zr(this.config,{httpAuthSchemeParametersProvider:yz,identityProviderConfigProvider:async e=>new ii({"aws.auth#sigv4":e.credentials})})),this.middlewareStack.use(ni(this.config))}destroy(){super.destroy()}},oX=async(e,t,n)=>({operation:Ze(t).operation,region:await re(e.region)()||(()=>{throw Error("expected `region` to be configured for `aws.auth#sigv4`")})()});function sX(e){return{schemeId:`aws.auth#sigv4`,signingProperties:{name:`elasticloadbalancing`,region:e.region},propertiesExtractor:(e,t)=>({signingProperties:{config:e,context:t}})}}var cX=e=>{let t=[];switch(e.operation){default:t.push(sX(e))}return t},lX=e=>{let t=Qi(e);return Object.assign(t,{authSchemePreference:re(e.authSchemePreference??[])})},uX=e=>Object.assign(e,{useDualstackEndpoint:e.useDualstackEndpoint??!1,useFipsEndpoint:e.useFipsEndpoint??!1,defaultSigningName:`elasticloadbalancing`}),dX={UseFIPS:{type:`builtInParams`,name:`useFipsEndpoint`},Endpoint:{type:`builtInParams`,name:`endpoint`},Region:{type:`builtInParams`,name:`region`},UseDualStack:{type:`builtInParams`,name:`useDualstackEndpoint`}},fX={name:`@aws-sdk/client-elastic-load-balancing-v2`,description:`AWS SDK for JavaScript Elastic Load Balancing V2 Client for Node.js, Browser and React Native`,version:`3.1063.0`,scripts:{build:`concurrently 'yarn:build:types' 'yarn:build:es' && yarn build:cjs`,"build:cjs":`node ../../scripts/compilation/inline client-elastic-load-balancing-v2`,"build:es":`tsc -p tsconfig.es.json`,"build:include:deps":`yarn g:turbo run build -F="$npm_package_name"`,"build:types":`tsc -p tsconfig.types.json`,"build:types:downlevel":`downlevel-dts dist-types dist-types/ts3.4`,clean:`premove dist-cjs dist-es dist-types tsconfig.cjs.tsbuildinfo tsconfig.es.tsbuildinfo tsconfig.types.tsbuildinfo`,"extract:docs":`api-extractor run --local`,"generate:client":`node ../../scripts/generate-clients/single-service --solo elastic-load-balancing-v2`,"test:e2e":`yarn g:vitest run -c vitest.config.e2e.mts --mode development`,"test:e2e:watch":`yarn g:vitest watch -c vitest.config.e2e.mts`,"test:index":`tsc --noEmit ./test/index-types.ts && node ./test/index-objects.spec.mjs`},main:`./dist-cjs/index.js`,types:`./dist-types/index.d.ts`,module:`./dist-es/index.js`,sideEffects:!1,dependencies:{"@aws-crypto/sha256-browser":`5.2.0`,"@aws-crypto/sha256-js":`5.2.0`,"@aws-sdk/core":`^3.974.18`,"@aws-sdk/credential-provider-node":`^3.972.52`,"@aws-sdk/types":`^3.973.11`,"@smithy/core":`^3.24.6`,"@smithy/fetch-http-handler":`^5.4.6`,"@smithy/node-http-handler":`^4.7.6`,"@smithy/types":`^4.14.3`,tslib:`^2.6.2`},devDependencies:{"@tsconfig/node20":`20.1.8`,"@types/node":`^20.14.8`,concurrently:`7.0.0`,"downlevel-dts":`0.10.1`,premove:`4.0.0`,typescript:`~5.8.3`},engines:{node:`>=20.0.0`},typesVersions:{"<4.5":{"dist-types/*":[`dist-types/ts3.4/*`]}},files:[`dist-*/**`],author:{name:`AWS SDK for JavaScript Team`,url:`https://aws.amazon.com/sdk-for-javascript/`},license:`Apache-2.0`,browser:{"./dist-es/runtimeConfig":`./dist-es/runtimeConfig.browser`},"react-native":{"./dist-es/runtimeConfig":`./dist-es/runtimeConfig.native`},homepage:`https://github.com/aws/aws-sdk-js-v3/tree/main/clients/client-elastic-load-balancing-v2`,repository:{type:`git`,url:`https://github.com/aws/aws-sdk-js-v3.git`,directory:`clients/client-elastic-load-balancing-v2`}},pX=`ref`,mX=-1,hX=!0,gX=`isSet`,_X=`PartitionResult`,vX=`booleanEquals`,yX=`getAttr`,bX={[pX]:`Endpoint`},xX={[pX]:_X},SX={},CX=[{[pX]:`Region`}],wX={conditions:[[gX,[bX]],[gX,CX],[`aws.partition`,CX,_X],[vX,[{[pX]:`UseFIPS`},hX]],[vX,[{[pX]:`UseDualStack`},hX]],[vX,[{fn:yX,argv:[xX,`supportsDualStack`]},hX]],[vX,[{fn:yX,argv:[xX,`supportsFIPS`]},hX]],[`stringEquals`,[{fn:yX,argv:[xX,`name`]},`aws-us-gov`]]],results:[[mX],[mX,`Invalid Configuration: FIPS and custom endpoint are not supported`],[mX,`Invalid Configuration: Dualstack and custom endpoint are not supported`],[bX,SX],[`https://elasticloadbalancing-fips.{Region}.{PartitionResult#dualStackDnsSuffix}`,SX],[mX,`FIPS and DualStack are enabled, but this partition does not support one or both`],[`https://elasticloadbalancing.{Region}.amazonaws.com`,SX],[`https://elasticloadbalancing-fips.{Region}.{PartitionResult#dnsSuffix}`,SX],[mX,`FIPS is enabled but this partition does not support FIPS`],[`https://elasticloadbalancing.{Region}.{PartitionResult#dualStackDnsSuffix}`,SX],[mX,`DualStack is enabled but this partition does not support DualStack`],[`https://elasticloadbalancing.{Region}.{PartitionResult#dnsSuffix}`,SX],[mX,`Invalid Configuration: Missing Region`]]},TX=2,EX=new Int32Array([-1,1,-1,0,13,3,1,4,100000012,2,5,100000012,3,8,6,4,7,100000011,5,100000009,100000010,4,11,9,6,10,100000008,7,100000006,100000007,5,12,100000005,6,100000004,100000005,3,100000001,14,4,100000002,100000003]),DX=on.from(EX,TX,wX.conditions,wX.results),OX=new sn({size:50,params:[`Endpoint`,`Region`,`UseDualStack`,`UseFIPS`]}),kX=(e,t={})=>OX.get(e,()=>jn(DX,{endpointParams:e,logger:t.logger}));dn.aws=ji;var A=class e extends Ht{constructor(t){super(t),Object.setPrototypeOf(this,e.prototype)}},AX=class e extends A{name=`CertificateNotFoundException`;$fault=`client`;Message;constructor(t){super({name:`CertificateNotFoundException`,$fault:`client`,...t}),Object.setPrototypeOf(this,e.prototype),this.Message=t.Message}},jX=class e extends A{name=`ListenerNotFoundException`;$fault=`client`;Message;constructor(t){super({name:`ListenerNotFoundException`,$fault:`client`,...t}),Object.setPrototypeOf(this,e.prototype),this.Message=t.Message}},MX=class e extends A{name=`TooManyCertificatesException`;$fault=`client`;Message;constructor(t){super({name:`TooManyCertificatesException`,$fault:`client`,...t}),Object.setPrototypeOf(this,e.prototype),this.Message=t.Message}},NX=class e extends A{name=`DuplicateTagKeysException`;$fault=`client`;Message;constructor(t){super({name:`DuplicateTagKeysException`,$fault:`client`,...t}),Object.setPrototypeOf(this,e.prototype),this.Message=t.Message}},PX=class e extends A{name=`LoadBalancerNotFoundException`;$fault=`client`;Message;constructor(t){super({name:`LoadBalancerNotFoundException`,$fault:`client`,...t}),Object.setPrototypeOf(this,e.prototype),this.Message=t.Message}},FX=class e extends A{name=`RuleNotFoundException`;$fault=`client`;Message;constructor(t){super({name:`RuleNotFoundException`,$fault:`client`,...t}),Object.setPrototypeOf(this,e.prototype),this.Message=t.Message}},IX=class e extends A{name=`TargetGroupNotFoundException`;$fault=`client`;Message;constructor(t){super({name:`TargetGroupNotFoundException`,$fault:`client`,...t}),Object.setPrototypeOf(this,e.prototype),this.Message=t.Message}},LX=class e extends A{name=`TooManyTagsException`;$fault=`client`;Message;constructor(t){super({name:`TooManyTagsException`,$fault:`client`,...t}),Object.setPrototypeOf(this,e.prototype),this.Message=t.Message}},RX=class e extends A{name=`TrustStoreNotFoundException`;$fault=`client`;Message;constructor(t){super({name:`TrustStoreNotFoundException`,$fault:`client`,...t}),Object.setPrototypeOf(this,e.prototype),this.Message=t.Message}},zX=class e extends A{name=`InvalidRevocationContentException`;$fault=`client`;Message;constructor(t){super({name:`InvalidRevocationContentException`,$fault:`client`,...t}),Object.setPrototypeOf(this,e.prototype),this.Message=t.Message}},BX=class e extends A{name=`RevocationContentNotFoundException`;$fault=`client`;Message;constructor(t){super({name:`RevocationContentNotFoundException`,$fault:`client`,...t}),Object.setPrototypeOf(this,e.prototype),this.Message=t.Message}},VX=class e extends A{name=`TooManyTrustStoreRevocationEntriesException`;$fault=`client`;Message;constructor(t){super({name:`TooManyTrustStoreRevocationEntriesException`,$fault:`client`,...t}),Object.setPrototypeOf(this,e.prototype),this.Message=t.Message}},HX=class e extends A{name=`AllocationIdNotFoundException`;$fault=`client`;Message;constructor(t){super({name:`AllocationIdNotFoundException`,$fault:`client`,...t}),Object.setPrototypeOf(this,e.prototype),this.Message=t.Message}},UX=class e extends A{name=`ALPNPolicyNotSupportedException`;$fault=`client`;Message;constructor(t){super({name:`ALPNPolicyNotSupportedException`,$fault:`client`,...t}),Object.setPrototypeOf(this,e.prototype),this.Message=t.Message}},WX=class e extends A{name=`AvailabilityZoneNotSupportedException`;$fault=`client`;Message;constructor(t){super({name:`AvailabilityZoneNotSupportedException`,$fault:`client`,...t}),Object.setPrototypeOf(this,e.prototype),this.Message=t.Message}},GX=class e extends A{name=`CaCertificatesBundleNotFoundException`;$fault=`client`;Message;constructor(t){super({name:`CaCertificatesBundleNotFoundException`,$fault:`client`,...t}),Object.setPrototypeOf(this,e.prototype),this.Message=t.Message}},KX=class e extends A{name=`CapacityDecreaseRequestsLimitExceededException`;$fault=`client`;Message;constructor(t){super({name:`CapacityDecreaseRequestsLimitExceededException`,$fault:`client`,...t}),Object.setPrototypeOf(this,e.prototype),this.Message=t.Message}},qX=class e extends A{name=`CapacityReservationPendingException`;$fault=`client`;Message;constructor(t){super({name:`CapacityReservationPendingException`,$fault:`client`,...t}),Object.setPrototypeOf(this,e.prototype),this.Message=t.Message}},JX=class e extends A{name=`CapacityUnitsLimitExceededException`;$fault=`client`;Message;constructor(t){super({name:`CapacityUnitsLimitExceededException`,$fault:`client`,...t}),Object.setPrototypeOf(this,e.prototype),this.Message=t.Message}},YX=class e extends A{name=`DuplicateListenerException`;$fault=`client`;Message;constructor(t){super({name:`DuplicateListenerException`,$fault:`client`,...t}),Object.setPrototypeOf(this,e.prototype),this.Message=t.Message}},XX=class e extends A{name=`IncompatibleProtocolsException`;$fault=`client`;Message;constructor(t){super({name:`IncompatibleProtocolsException`,$fault:`client`,...t}),Object.setPrototypeOf(this,e.prototype),this.Message=t.Message}},ZX=class e extends A{name=`InvalidConfigurationRequestException`;$fault=`client`;Message;constructor(t){super({name:`InvalidConfigurationRequestException`,$fault:`client`,...t}),Object.setPrototypeOf(this,e.prototype),this.Message=t.Message}},QX=class e extends A{name=`InvalidLoadBalancerActionException`;$fault=`client`;Message;constructor(t){super({name:`InvalidLoadBalancerActionException`,$fault:`client`,...t}),Object.setPrototypeOf(this,e.prototype),this.Message=t.Message}},$X=class e extends A{name=`SSLPolicyNotFoundException`;$fault=`client`;Message;constructor(t){super({name:`SSLPolicyNotFoundException`,$fault:`client`,...t}),Object.setPrototypeOf(this,e.prototype),this.Message=t.Message}},eZ=class e extends A{name=`TargetGroupAssociationLimitException`;$fault=`client`;Message;constructor(t){super({name:`TargetGroupAssociationLimitException`,$fault:`client`,...t}),Object.setPrototypeOf(this,e.prototype),this.Message=t.Message}},tZ=class e extends A{name=`TooManyActionsException`;$fault=`client`;Message;constructor(t){super({name:`TooManyActionsException`,$fault:`client`,...t}),Object.setPrototypeOf(this,e.prototype),this.Message=t.Message}},nZ=class e extends A{name=`TooManyListenersException`;$fault=`client`;Message;constructor(t){super({name:`TooManyListenersException`,$fault:`client`,...t}),Object.setPrototypeOf(this,e.prototype),this.Message=t.Message}},rZ=class e extends A{name=`TooManyRegistrationsForTargetIdException`;$fault=`client`;Message;constructor(t){super({name:`TooManyRegistrationsForTargetIdException`,$fault:`client`,...t}),Object.setPrototypeOf(this,e.prototype),this.Message=t.Message}},iZ=class e extends A{name=`TooManyTargetsException`;$fault=`client`;Message;constructor(t){super({name:`TooManyTargetsException`,$fault:`client`,...t}),Object.setPrototypeOf(this,e.prototype),this.Message=t.Message}},aZ=class e extends A{name=`TooManyUniqueTargetGroupsPerLoadBalancerException`;$fault=`client`;Message;constructor(t){super({name:`TooManyUniqueTargetGroupsPerLoadBalancerException`,$fault:`client`,...t}),Object.setPrototypeOf(this,e.prototype),this.Message=t.Message}},oZ=class e extends A{name=`TrustStoreNotReadyException`;$fault=`client`;Message;constructor(t){super({name:`TrustStoreNotReadyException`,$fault:`client`,...t}),Object.setPrototypeOf(this,e.prototype),this.Message=t.Message}},sZ=class e extends A{name=`UnsupportedProtocolException`;$fault=`client`;Message;constructor(t){super({name:`UnsupportedProtocolException`,$fault:`client`,...t}),Object.setPrototypeOf(this,e.prototype),this.Message=t.Message}},cZ=class e extends A{name=`DuplicateLoadBalancerNameException`;$fault=`client`;Message;constructor(t){super({name:`DuplicateLoadBalancerNameException`,$fault:`client`,...t}),Object.setPrototypeOf(this,e.prototype),this.Message=t.Message}},lZ=class e extends A{name=`InvalidSchemeException`;$fault=`client`;Message;constructor(t){super({name:`InvalidSchemeException`,$fault:`client`,...t}),Object.setPrototypeOf(this,e.prototype),this.Message=t.Message}},uZ=class e extends A{name=`InvalidSecurityGroupException`;$fault=`client`;Message;constructor(t){super({name:`InvalidSecurityGroupException`,$fault:`client`,...t}),Object.setPrototypeOf(this,e.prototype),this.Message=t.Message}},dZ=class e extends A{name=`InvalidSubnetException`;$fault=`client`;Message;constructor(t){super({name:`InvalidSubnetException`,$fault:`client`,...t}),Object.setPrototypeOf(this,e.prototype),this.Message=t.Message}},fZ=class e extends A{name=`OperationNotPermittedException`;$fault=`client`;Message;constructor(t){super({name:`OperationNotPermittedException`,$fault:`client`,...t}),Object.setPrototypeOf(this,e.prototype),this.Message=t.Message}},pZ=class e extends A{name=`ResourceInUseException`;$fault=`client`;Message;constructor(t){super({name:`ResourceInUseException`,$fault:`client`,...t}),Object.setPrototypeOf(this,e.prototype),this.Message=t.Message}},mZ=class e extends A{name=`SubnetNotFoundException`;$fault=`client`;Message;constructor(t){super({name:`SubnetNotFoundException`,$fault:`client`,...t}),Object.setPrototypeOf(this,e.prototype),this.Message=t.Message}},hZ=class e extends A{name=`TooManyLoadBalancersException`;$fault=`client`;Message;constructor(t){super({name:`TooManyLoadBalancersException`,$fault:`client`,...t}),Object.setPrototypeOf(this,e.prototype),this.Message=t.Message}},gZ=class e extends A{name=`PriorityInUseException`;$fault=`client`;Message;constructor(t){super({name:`PriorityInUseException`,$fault:`client`,...t}),Object.setPrototypeOf(this,e.prototype),this.Message=t.Message}},_Z=class e extends A{name=`TooManyRulesException`;$fault=`client`;Message;constructor(t){super({name:`TooManyRulesException`,$fault:`client`,...t}),Object.setPrototypeOf(this,e.prototype),this.Message=t.Message}},vZ=class e extends A{name=`TooManyTargetGroupsException`;$fault=`client`;Message;constructor(t){super({name:`TooManyTargetGroupsException`,$fault:`client`,...t}),Object.setPrototypeOf(this,e.prototype),this.Message=t.Message}},yZ=class e extends A{name=`DuplicateTargetGroupNameException`;$fault=`client`;Message;constructor(t){super({name:`DuplicateTargetGroupNameException`,$fault:`client`,...t}),Object.setPrototypeOf(this,e.prototype),this.Message=t.Message}},bZ=class e extends A{name=`DuplicateTrustStoreNameException`;$fault=`client`;Message;constructor(t){super({name:`DuplicateTrustStoreNameException`,$fault:`client`,...t}),Object.setPrototypeOf(this,e.prototype),this.Message=t.Message}},xZ=class e extends A{name=`InvalidCaCertificatesBundleException`;$fault=`client`;Message;constructor(t){super({name:`InvalidCaCertificatesBundleException`,$fault:`client`,...t}),Object.setPrototypeOf(this,e.prototype),this.Message=t.Message}},SZ=class e extends A{name=`TooManyTrustStoresException`;$fault=`client`;Message;constructor(t){super({name:`TooManyTrustStoresException`,$fault:`client`,...t}),Object.setPrototypeOf(this,e.prototype),this.Message=t.Message}},CZ=class e extends A{name=`DeleteAssociationSameAccountException`;$fault=`client`;Message;constructor(t){super({name:`DeleteAssociationSameAccountException`,$fault:`client`,...t}),Object.setPrototypeOf(this,e.prototype),this.Message=t.Message}},wZ=class e extends A{name=`TrustStoreAssociationNotFoundException`;$fault=`client`;Message;constructor(t){super({name:`TrustStoreAssociationNotFoundException`,$fault:`client`,...t}),Object.setPrototypeOf(this,e.prototype),this.Message=t.Message}},TZ=class e extends A{name=`TrustStoreInUseException`;$fault=`client`;Message;constructor(t){super({name:`TrustStoreInUseException`,$fault:`client`,...t}),Object.setPrototypeOf(this,e.prototype),this.Message=t.Message}},EZ=class e extends A{name=`InvalidTargetException`;$fault=`client`;Message;constructor(t){super({name:`InvalidTargetException`,$fault:`client`,...t}),Object.setPrototypeOf(this,e.prototype),this.Message=t.Message}},DZ=class e extends A{name=`HealthUnavailableException`;$fault=`server`;Message;constructor(t){super({name:`HealthUnavailableException`,$fault:`server`,...t}),Object.setPrototypeOf(this,e.prototype),this.Message=t.Message}},OZ=class e extends A{name=`RevocationIdNotFoundException`;$fault=`client`;Message;constructor(t){super({name:`RevocationIdNotFoundException`,$fault:`client`,...t}),Object.setPrototypeOf(this,e.prototype),this.Message=t.Message}},kZ=class e extends A{name=`ResourceNotFoundException`;$fault=`client`;Message;constructor(t){super({name:`ResourceNotFoundException`,$fault:`client`,...t}),Object.setPrototypeOf(this,e.prototype),this.Message=t.Message}},AZ=class e extends A{name=`InsufficientCapacityException`;$fault=`server`;Message;constructor(t){super({name:`InsufficientCapacityException`,$fault:`server`,...t}),Object.setPrototypeOf(this,e.prototype),this.Message=t.Message}},jZ=class e extends A{name=`PriorRequestNotCompleteException`;$fault=`client`;Message;constructor(t){super({name:`PriorRequestNotCompleteException`,$fault:`client`,...t}),Object.setPrototypeOf(this,e.prototype),this.Message=t.Message}},MZ=`Action`,NZ=`AdditionalClaims`,PZ=`AuthenticateCognitoActionConfig`,FZ=`AuthenticateCognitoConfig`,IZ=`AnomalyDetection`,LZ=`AuthorizationEndpoint`,RZ=`AllocationId`,zZ=`AllocationIdNotFoundException`,BZ=`ALPNPolicyNotSupportedException`,VZ=`AdministrativeOverride`,HZ=`AuthenticateOidcActionConfig`,UZ=`AuthenticateOidcConfig`,WZ=`AlpnPolicy`,GZ=`AuthenticationRequestExtraParams`,KZ=`AddTags`,qZ=`AddTagsInput`,JZ=`AddTagsOutput`,YZ=`AdvertiseTrustStoreCaNames`,XZ=`AvailabilityZone`,ZZ=`AvailabilityZoneNotSupportedException`,QZ=`AvailabilityZones`,$Z=`Actions`,eQ=`Attributes`,tQ=`Certificates`,nQ=`CertificateArn`,rQ=`CaCertificatesBundleNotFoundException`,iQ=`CapacityDecreaseRequestsLimitExceededException`,aQ=`CanonicalHostedZoneId`,oQ=`ClientId`,sQ=`CertificateList`,cQ=`CreateLoadBalancer`,lQ=`CreateLoadBalancerInput`,uQ=`CreateLoadBalancerOutput`,dQ=`CreateListenerInput`,fQ=`CreateListenerOutput`,pQ=`CreateListener`,mQ=`CertificateNotFoundException`,hQ=`CustomerOwnedIpv4Pool`,gQ=`CreateRule`,_Q=`CreateRuleInput`,vQ=`CreateRuleOutput`,yQ=`CapacityReservationPendingException`,bQ=`ClientSecret`,xQ=`ContentType`,SQ=`CreateTargetGroup`,CQ=`CreateTargetGroupInput`,wQ=`CreateTargetGroupOutput`,TQ=`CreatedTime`,EQ=`CapacityUnitsLimitExceededException`,DQ=`Certificate`,OQ=`Cipher`,kQ=`Ciphers`,AQ=`Code`,jQ=`Conditions`,MQ=`Description`,NQ=`DefaultActions`,PQ=`DeleteAssociationSameAccountException`,FQ=`DeleteListener`,IQ=`DeleteLoadBalancer`,LQ=`DescribeLoadBalancerAttributes`,RQ=`DescribeLoadBalancerAttributesInput`,zQ=`DescribeLoadBalancerAttributesOutput`,BQ=`DeleteLoadBalancerInput`,VQ=`DescribeLoadBalancersInput`,HQ=`DuplicateLoadBalancerNameException`,UQ=`DeleteLoadBalancerOutput`,WQ=`DescribeLoadBalancersOutput`,GQ=`DescribeLoadBalancers`,KQ=`DuplicateListenerException`,qQ=`DeleteListenerInput`,JQ=`DescribeListenersInput`,YQ=`DeleteListenerOutput`,XQ=`DescribeListenersOutput`,ZQ=`DescribeListeners`,QQ=`DNSName`,$Q=`DeleteRule`,e$=`DeleteRuleInput`,t$=`DescribeRulesInput`,n$=`DeleteRuleOutput`,r$=`DescribeRulesOutput`,i$=`DescribeRules`,a$=`DurationSeconds`,o$=`DescribeSSLPolicies`,s$=`DescribeSSLPoliciesInput`,c$=`DescribeSSLPoliciesOutput`,l$=`DeregisterTargets`,u$=`DeleteTargetGroup`,d$=`DescribeTargetGroupAttributes`,f$=`DescribeTargetGroupAttributesInput`,p$=`DescribeTargetGroupAttributesOutput`,m$=`DeleteTargetGroupInput`,h$=`DescribeTargetGroupsInput`,g$=`DuplicateTargetGroupNameException`,_$=`DeleteTargetGroupOutput`,v$=`DescribeTargetGroupsOutput`,y$=`DescribeTargetGroups`,b$=`DescribeTargetHealth`,x$=`DescribeTargetHealthInput`,tse=`DescribeTargetHealthOutput`,S$=`DeregisterTargetsInput`,C$=`DescribeTagsInput`,w$=`DuplicateTagKeysException`,T$=`DeregisterTargetsOutput`,E$=`DescribeTagsOutput`,D$=`DuplicateTrustStoreNameException`,O$=`DescribeTags`,k$=`Enabled`,A$=`EnablePrefixForIpv6SourceNat`,j$=`EnforceSecurityGroupInboundRulesOnPrivateLinkTraffic`,M$=`Format`,N$=`ForwardActionConfig`,P$=`ForwardConfig`,F$=`FixedResponseActionConfig`,I$=`FixedResponseConfig`,L$=`Field`,R$=`GrpcCode`,z$=`Host`,B$=`HttpCode`,V$=`HealthCheckEnabled`,H$=`HealthCheckIntervalSeconds`,U$=`HealthCheckProtocol`,W$=`HealthCheckPort`,G$=`HealthCheckPath`,K$=`HealthCheckTimeoutSeconds`,q$=`HostHeaderConfig`,J$=`HostHeaderConditionConfig`,Y$=`HttpHeaderConditionConfig`,X$=`HttpHeaderConfig`,Z$=`HttpHeaderName`,Q$=`HostHeaderRewriteConfig`,$$=`HttpRequestMethodConfig`,e1=`HttpRequestMethodConditionConfig`,t1=`HealthyThresholdCount`,n1=`HealthUnavailableException`,r1=`Issuer`,i1=`IpAddress`,a1=`IpAddressType`,o1=`InvalidCaCertificatesBundleException`,s1=`IgnoreClientCertificateExpiry`,c1=`InsufficientCapacityException`,l1=`InvalidConfigurationRequestException`,u1=`IsDefault`,d1=`Ipv4IpamPoolId`,f1=`InvalidLoadBalancerActionException`,p1=`IpamPools`,m1=`IPv6Address`,h1=`IncompatibleProtocolsException`,g1=`InvalidRevocationContentException`,_1=`InvalidSchemeException`,v1=`InvalidSubnetException`,y1=`InvalidSecurityGroupException`,b1=`InvalidTargetException`,x1=`Id`,S1=`Include`,C1=`JwksEndpoint`,w1=`JwtValidationActionAdditionalClaim`,T1=`JwtValidationActionAdditionalClaims`,E1=`JwtValidationActionConfig`,D1=`JwtValidationConfig`,O1=`Key`,k1=`Listeners`,A1=`ListenerArn`,j1=`ListenerArns`,M1=`LoadBalancers`,N1=`LoadBalancerAddresses`,P1=`LoadBalancerArn`,F1=`LoadBalancerArns`,I1=`LoadBalancerAddress`,L1=`LoadBalancerAttribute`,R1=`LoadBalancerAttributes`,z1=`LoadBalancerName`,B1=`LoadBalancerNotFoundException`,V1=`LoadBalancerState`,H1=`LoadBalancerType`,U1=`LoadBalancer`,W1=`ListenerNotFoundException`,G1=`Listener`,j=`Message`,K1=`MutualAuthentication`,q1=`MutualAuthenticationAttributes`,J1=`MessageBody`,Y1=`MitigationInEffect`,X1=`ModifyListener`,Z1=`ModifyLoadBalancerAttributes`,Q1=`ModifyLoadBalancerAttributesInput`,$1=`ModifyLoadBalancerAttributesOutput`,e0=`ModifyListenerInput`,t0=`ModifyListenerOutput`,n0=`ModifyRule`,r0=`ModifyRuleInput`,i0=`ModifyRuleOutput`,a0=`ModifyTargetGroupAttributes`,o0=`ModifyTargetGroupAttributesInput`,s0=`ModifyTargetGroupAttributesOutput`,c0=`Matcher`,l0=`Marker`,u0=`Mode`,d0=`Name`,f0=`NextMarker`,p0=`Names`,m0=`Order`,h0=`OutpostId`,g0=`OperationNotPermittedException`,_0=`OnUnauthenticatedRequest`,v0=`Priority`,y0=`PrivateIPv4Address`,b0=`PriorityInUseException`,x0=`PathPatternConfig`,S0=`PathPatternConditionConfig`,C0=`PriorRequestNotCompleteException`,w0=`PageSize`,T0=`ProtocolVersion`,E0=`Path`,D0=`Port`,O0=`Protocol`,k0=`Query`,A0=`QueryStringConfig`,j0=`QueryStringConditionConfig`,M0=`QuicServerId`,N0=`QueryStringKeyValuePair`,P0=`QueryStringKeyValuePairList`,F0=`Reason`,I0=`ResourceArns`,L0=`RedirectActionConfig`,R0=`ResourceArn`,z0=`RuleArn`,B0=`RuleArns`,V0=`RedirectConfig`,H0=`RewriteConfigList`,U0=`RuleConditionList`,W0=`RevocationContentNotFoundException`,G0=`RewriteConfig`,K0=`RuleCondition`,q0=`RevocationIdNotFoundException`,J0=`ResourceInUseException`,Y0=`ResourceNotFoundException`,X0=`RuleNotFoundException`,Z0=`RegisterTargetsInput`,Q0=`RemoveTagsInput`,$0=`RuleTransformList`,e2=`RegisterTargetsOutput`,t2=`RemoveTagsOutput`,n2=`ResetTransforms`,r2=`RegisterTargets`,i2=`RemoveTags`,a2=`RuleTransform`,o2=`RegexValues`,s2=`Result`,c2=`Regex`,l2=`Replace`,u2=`Rewrites`,d2=`Rules`,f2=`Rule`,p2=`State`,m2=`StatusCode`,h2=`SessionCookieName`,g2=`SecurityGroups`,_2=`SecurityGroupIds`,v2=`SubnetId`,y2=`SourceIpConfig`,b2=`SourceIpConditionConfig`,x2=`SupportedLoadBalancerTypes`,S2=`SubnetMappings`,C2=`SubnetMapping`,w2=`SubnetNotFoundException`,T2=`SourceNatIpv6Prefixes`,E2=`SourceNatIpv6Prefix`,D2=`SslPolicy`,O2=`SslPolicies`,k2=`SslProtocols`,A2=`SetSubnets`,j2=`SetSecurityGroups`,M2=`SetSecurityGroupsInput`,N2=`SetSecurityGroupsOutput`,P2=`SetSubnetsInput`,F2=`SSLPolicyNotFoundException`,I2=`SetSubnetsOutput`,L2=`SessionTimeout`,R2=`Scope`,z2=`Scheme`,B2=`Subnets`,V2=`Type`,H2=`TargetControlPort`,U2=`TagDescriptions`,W2=`TagDescription`,G2=`TargetDescription`,K2=`TargetDescriptions`,q2=`TokenEndpoint`,J2=`TargetGroups`,Y2=`TargetGroupArn`,X2=`TargetGroupAssociationLimitException`,Z2=`TargetGroupArns`,Q2=`TargetGroupAttribute`,$2=`TargetGroupAttributes`,e4=`TargetGroupList`,t4=`TargetGroupName`,n4=`TargetGroupNotFoundException`,r4=`TargetGroupStickinessConfig`,i4=`TargetGroupTuple`,a4=`TargetGroup`,o4=`TargetHealth`,s4=`TargetHealthDescriptions`,c4=`TargetHealthDescription`,l4=`TagKeys`,u4=`TagList`,d4=`TooManyActionsException`,f4=`TooManyCertificatesException`,p4=`TooManyLoadBalancersException`,m4=`TooManyListenersException`,h4=`TooManyRulesException`,g4=`TooManyRegistrationsForTargetIdException`,_4=`TooManyTagsException`,v4=`TooManyTargetsException`,y4=`TooManyTargetGroupsException`,b4=`TooManyTrustStoresException`,nse=`TooManyTrustStoreRevocationEntriesException`,rse=`TooManyUniqueTargetGroupsPerLoadBalancerException`,ise=`TrustStoreArn`,ase=`TrustStoreAssociationNotFoundException`,ose=`TrustStoreAssociationStatus`,sse=`TrustStoreInUseException`,cse=`TrustStoreNotFoundException`,lse=`TrustStoreNotReadyException`,x4=`TargetType`,S4=`Tags`,use=`Tag`,C4=`Targets`,dse=`Target`,w4=`Transforms`,fse=`UseExistingClientSecret`,pse=`UserInfoEndpoint`,mse=`UserPoolArn`,hse=`UserPoolClientId`,gse=`UserPoolDomain`,_se=`UnsupportedProtocolException`,T4=`UrlRewriteConfig`,E4=`UnhealthyThresholdCount`,D4=`Values`,O4=`VpcId`,k4=`Value`,vse=`Weight`,yse=`ZoneName`,M=`awsQueryError`,N=`client`,P=`error`,F=`httpError`,A4=`smithy.ts.sdk.synthetic.com.amazonaws.elasticloadbalancingv2`,j4=`server`,I=`com.amazonaws.elasticloadbalancingv2`,M4=Lt.for(A4),bse=[-3,A4,`ElasticLoadBalancingV2ServiceException`,0,[],[]];M4.registerError(bse,A);var L=Lt.for(I),xse=[-3,I,zZ,{[M]:[`AllocationIdNotFound`,400],[P]:N,[F]:400},[j],[0]];L.registerError(xse,HX);var Sse=[-3,I,BZ,{[M]:[`ALPNPolicyNotFound`,400],[P]:N,[F]:400},[j],[0]];L.registerError(Sse,UX);var Cse=[-3,I,ZZ,{[M]:[`AvailabilityZoneNotSupported`,400],[P]:N,[F]:400},[j],[0]];L.registerError(Cse,WX);var wse=[-3,I,rQ,{[M]:[`CaCertificatesBundleNotFound`,400],[P]:N,[F]:400},[j],[0]];L.registerError(wse,GX);var Tse=[-3,I,iQ,{[M]:[`CapacityDecreaseRequestLimitExceeded`,400],[P]:N,[F]:400},[j],[0]];L.registerError(Tse,KX);var Ese=[-3,I,yQ,{[M]:[`CapacityReservationPending`,400],[P]:N,[F]:400},[j],[0]];L.registerError(Ese,qX);var Dse=[-3,I,EQ,{[M]:[`CapacityUnitsLimitExceeded`,400],[P]:N,[F]:400},[j],[0]];L.registerError(Dse,JX);var Ose=[-3,I,mQ,{[M]:[`CertificateNotFound`,400],[P]:N,[F]:400},[j],[0]];L.registerError(Ose,AX);var kse=[-3,I,PQ,{[M]:[`DeleteAssociationSameAccount`,400],[P]:N,[F]:400},[j],[0]];L.registerError(kse,CZ);var Ase=[-3,I,KQ,{[M]:[`DuplicateListener`,400],[P]:N,[F]:400},[j],[0]];L.registerError(Ase,YX);var jse=[-3,I,HQ,{[M]:[`DuplicateLoadBalancerName`,400],[P]:N,[F]:400},[j],[0]];L.registerError(jse,cZ);var Mse=[-3,I,w$,{[M]:[`DuplicateTagKeys`,400],[P]:N,[F]:400},[j],[0]];L.registerError(Mse,NX);var Nse=[-3,I,g$,{[M]:[`DuplicateTargetGroupName`,400],[P]:N,[F]:400},[j],[0]];L.registerError(Nse,yZ);var Pse=[-3,I,D$,{[M]:[`DuplicateTrustStoreName`,400],[P]:N,[F]:400},[j],[0]];L.registerError(Pse,bZ);var Fse=[-3,I,n1,{[M]:[`HealthUnavailable`,500],[P]:j4,[F]:500},[j],[0]];L.registerError(Fse,DZ);var Ise=[-3,I,h1,{[M]:[`IncompatibleProtocols`,400],[P]:N,[F]:400},[j],[0]];L.registerError(Ise,XX);var Lse=[-3,I,c1,{[M]:[`InsufficientCapacity`,500],[P]:j4,[F]:500},[j],[0]];L.registerError(Lse,AZ);var Rse=[-3,I,o1,{[M]:[`InvalidCaCertificatesBundle`,400],[P]:N,[F]:400},[j],[0]];L.registerError(Rse,xZ);var zse=[-3,I,l1,{[M]:[`InvalidConfigurationRequest`,400],[P]:N,[F]:400},[j],[0]];L.registerError(zse,ZX);var Bse=[-3,I,f1,{[M]:[`InvalidLoadBalancerAction`,400],[P]:N,[F]:400},[j],[0]];L.registerError(Bse,QX);var Vse=[-3,I,g1,{[M]:[`InvalidRevocationContent`,400],[P]:N,[F]:400},[j],[0]];L.registerError(Vse,zX);var Hse=[-3,I,_1,{[M]:[`InvalidScheme`,400],[P]:N,[F]:400},[j],[0]];L.registerError(Hse,lZ);var Use=[-3,I,y1,{[M]:[`InvalidSecurityGroup`,400],[P]:N,[F]:400},[j],[0]];L.registerError(Use,uZ);var Wse=[-3,I,v1,{[M]:[`InvalidSubnet`,400],[P]:N,[F]:400},[j],[0]];L.registerError(Wse,dZ);var Gse=[-3,I,b1,{[M]:[`InvalidTarget`,400],[P]:N,[F]:400},[j],[0]];L.registerError(Gse,EZ);var Kse=[-3,I,W1,{[M]:[`ListenerNotFound`,400],[P]:N,[F]:400},[j],[0]];L.registerError(Kse,jX);var qse=[-3,I,B1,{[M]:[`LoadBalancerNotFound`,400],[P]:N,[F]:400},[j],[0]];L.registerError(qse,PX);var Jse=[-3,I,g0,{[M]:[`OperationNotPermitted`,400],[P]:N,[F]:400},[j],[0]];L.registerError(Jse,fZ);var Yse=[-3,I,b0,{[M]:[`PriorityInUse`,400],[P]:N,[F]:400},[j],[0]];L.registerError(Yse,gZ);var Xse=[-3,I,C0,{[M]:[`PriorRequestNotComplete`,429],[P]:N,[F]:429},[j],[0]];L.registerError(Xse,jZ);var Zse=[-3,I,J0,{[M]:[`ResourceInUse`,400],[P]:N,[F]:400},[j],[0]];L.registerError(Zse,pZ);var Qse=[-3,I,Y0,{[M]:[`ResourceNotFound`,400],[P]:N,[F]:400},[j],[0]];L.registerError(Qse,kZ);var $se=[-3,I,W0,{[M]:[`RevocationContentNotFound`,400],[P]:N,[F]:400},[j],[0]];L.registerError($se,BX);var ece=[-3,I,q0,{[M]:[`RevocationIdNotFound`,400],[P]:N,[F]:400},[j],[0]];L.registerError(ece,OZ);var tce=[-3,I,X0,{[M]:[`RuleNotFound`,400],[P]:N,[F]:400},[j],[0]];L.registerError(tce,FX);var nce=[-3,I,F2,{[M]:[`SSLPolicyNotFound`,400],[P]:N,[F]:400},[j],[0]];L.registerError(nce,$X);var rce=[-3,I,w2,{[M]:[`SubnetNotFound`,400],[P]:N,[F]:400},[j],[0]];L.registerError(rce,mZ);var ice=[-3,I,X2,{[M]:[`TargetGroupAssociationLimit`,400],[P]:N,[F]:400},[j],[0]];L.registerError(ice,eZ);var ace=[-3,I,n4,{[M]:[`TargetGroupNotFound`,400],[P]:N,[F]:400},[j],[0]];L.registerError(ace,IX);var oce=[-3,I,d4,{[M]:[`TooManyActions`,400],[P]:N,[F]:400},[j],[0]];L.registerError(oce,tZ);var sce=[-3,I,f4,{[M]:[`TooManyCertificates`,400],[P]:N,[F]:400},[j],[0]];L.registerError(sce,MX);var cce=[-3,I,m4,{[M]:[`TooManyListeners`,400],[P]:N,[F]:400},[j],[0]];L.registerError(cce,nZ);var lce=[-3,I,p4,{[M]:[`TooManyLoadBalancers`,400],[P]:N,[F]:400},[j],[0]];L.registerError(lce,hZ);var uce=[-3,I,g4,{[M]:[`TooManyRegistrationsForTargetId`,400],[P]:N,[F]:400},[j],[0]];L.registerError(uce,rZ);var dce=[-3,I,h4,{[M]:[`TooManyRules`,400],[P]:N,[F]:400},[j],[0]];L.registerError(dce,_Z);var fce=[-3,I,_4,{[M]:[`TooManyTags`,400],[P]:N,[F]:400},[j],[0]];L.registerError(fce,LX);var pce=[-3,I,y4,{[M]:[`TooManyTargetGroups`,400],[P]:N,[F]:400},[j],[0]];L.registerError(pce,vZ);var mce=[-3,I,v4,{[M]:[`TooManyTargets`,400],[P]:N,[F]:400},[j],[0]];L.registerError(mce,iZ);var hce=[-3,I,nse,{[M]:[`TooManyTrustStoreRevocationEntries`,400],[P]:N,[F]:400},[j],[0]];L.registerError(hce,VX);var gce=[-3,I,b4,{[M]:[`TooManyTrustStores`,400],[P]:N,[F]:400},[j],[0]];L.registerError(gce,SZ);var _ce=[-3,I,rse,{[M]:[`TooManyUniqueTargetGroupsPerLoadBalancer`,400],[P]:N,[F]:400},[j],[0]];L.registerError(_ce,aZ);var vce=[-3,I,ase,{[M]:[`AssociationNotFound`,400],[P]:N,[F]:400},[j],[0]];L.registerError(vce,wZ);var yce=[-3,I,sse,{[M]:[`TrustStoreInUse`,400],[P]:N,[F]:400},[j],[0]];L.registerError(yce,TZ);var bce=[-3,I,cse,{[M]:[`TrustStoreNotFound`,400],[P]:N,[F]:400},[j],[0]];L.registerError(bce,RX);var xce=[-3,I,lse,{[M]:[`TrustStoreNotReady`,400],[P]:N,[F]:400},[j],[0]];L.registerError(xce,oZ);var Sce=[-3,I,_se,{[M]:[`UnsupportedProtocol`,400],[P]:N,[F]:400},[j],[0]];L.registerError(Sce,sZ);var Cce=[M4,L],wce=[3,I,MZ,0,[V2,Y2,UZ,FZ,m0,V0,I$,P$,D1],[0,0,()=>Ace,()=>kce,1,()=>Ble,()=>gle,()=>_le,()=>Cle],1],Tce=[3,I,qZ,0,[I0,S4],[64,()=>J4],2],Ece=[3,I,JZ,0,[],[]],Dce=[3,I,VZ,0,[p2,F0,MQ],[0,0,0]],Oce=[3,I,IZ,0,[s2,Y1],[0,0]],kce=[3,I,PZ,0,[mse,hse,gse,h2,R2,L2,GZ,_0],[0,0,0,0,0,1,128,0],3],Ace=[3,I,HZ,0,[r1,LZ,q2,pse,oQ,bQ,h2,R2,L2,GZ,_0,fse],[0,0,0,0,0,0,0,0,1,128,0,2],5],jce=[3,I,XZ,0,[yse,v2,h0,N1,T2],[0,0,0,()=>pue,64]],Mce=[3,I,DQ,0,[nQ,u1],[0,2]],Nce=[3,I,OQ,0,[d0,v0],[0,1]],Pce=[3,I,dQ,0,[P1,NQ,O0,D0,D2,tQ,WZ,S4,K1],[0,()=>L4,0,1,0,()=>z4,64,()=>J4,()=>F4],2],Fce=[3,I,fQ,0,[k1],[()=>B4]],Ice=[3,I,lQ,0,[d0,B2,S2,g2,z2,S4,V2,a1,hQ,A$,p1],[0,64,()=>q4,64,0,()=>J4,0,0,0,0,()=>N4],1],Lce=[3,I,uQ,0,[M1],[()=>H4]],Rce=[3,I,_Q,0,[A1,jQ,v0,$Z,S4,w4],[0,()=>W4,1,()=>L4,()=>J4,()=>K4],4],zce=[3,I,vQ,0,[d2],[()=>G4]],Bce=[3,I,CQ,0,[d0,O0,T0,D0,O4,U$,W$,V$,G$,H$,K$,t1,E4,c0,x4,S4,a1,H2],[0,0,0,1,0,0,0,2,0,1,1,1,1,()=>P4,0,()=>J4,0,1],1],Vce=[3,I,wQ,0,[J2],[()=>Z4]],Hce=[3,I,qQ,0,[A1],[0],1],Uce=[3,I,YQ,0,[],[]],Wce=[3,I,BQ,0,[P1],[0],1],Gce=[3,I,UQ,0,[],[]],Kce=[3,I,e$,0,[z0],[0],1],qce=[3,I,n$,0,[],[]],Jce=[3,I,m$,0,[Y2],[0],1],Yce=[3,I,_$,0,[],[]],Xce=[3,I,S$,0,[Y2,C4],[0,()=>Y4],2],Zce=[3,I,T$,0,[],[]],Qce=[3,I,JQ,0,[P1,j1,l0,w0],[0,64,0,1]],$ce=[3,I,XQ,0,[k1,f0],[()=>B4,0]],ele=[3,I,RQ,0,[P1],[0],1],tle=[3,I,zQ,0,[eQ],[()=>V4]],nle=[3,I,VQ,0,[F1,p0,l0,w0],[64,64,0,1]],rle=[3,I,WQ,0,[M1,f0],[()=>H4,0]],ile=[3,I,t$,0,[A1,B0,l0,w0],[0,64,0,1]],ale=[3,I,r$,0,[d2,f0],[()=>G4,0]],ole=[3,I,s$,0,[p0,l0,w0,H1],[64,0,1,0]],sle=[3,I,c$,0,[O2,f0],[()=>hue,0]],cle=[3,I,C$,0,[I0],[64],1],lle=[3,I,E$,0,[U2],[()=>gue]],ule=[3,I,f$,0,[Y2],[0],1],dle=[3,I,p$,0,[eQ],[()=>X4]],fle=[3,I,h$,0,[P1,Z2,p0,l0,w0],[0,64,64,0,1]],ple=[3,I,v$,0,[J2,f0],[()=>Z4,0]],mle=[3,I,x$,0,[Y2,C4,S1],[0,()=>Y4,64],1],hle=[3,I,tse,0,[s4],[()=>vue]],gle=[3,I,F$,0,[m2,J1,xQ],[0,0,0],1],_le=[3,I,N$,0,[J2,r4],[()=>_ue,()=>oue]],vle=[3,I,J$,0,[D4,o2],[64,64]],yle=[3,I,Q$,0,[u2],[()=>U4]],ble=[3,I,Y$,0,[Z$,D4,o2],[0,64,64]],xle=[3,I,e1,0,[D4],[64]],N4=[3,I,p1,0,[d1],[0]],Sle=[3,I,w1,0,[M$,d0,D4],[0,0,64],3],Cle=[3,I,E1,0,[C1,r1,NZ],[0,0,()=>fue],2],wle=[3,I,G1,0,[A1,P1,D0,O0,tQ,D2,NQ,WZ,K1],[0,0,1,0,()=>z4,0,()=>L4,64,()=>F4]],Tle=[3,I,U1,0,[P1,QQ,aQ,TQ,z1,z2,O4,p2,V2,QZ,g2,a1,hQ,j$,A$,p1],[0,0,0,4,0,0,0,()=>Ole,0,()=>R4,64,0,0,0,0,()=>N4]],Ele=[3,I,I1,0,[i1,RZ,y0,m1],[0,0,0,0]],Dle=[3,I,L1,0,[O1,k4],[0,0]],Ole=[3,I,V1,0,[AQ,F0],[0,0]],P4=[3,I,c0,0,[B$,R$],[0,0]],kle=[3,I,e0,0,[A1,D0,O0,D2,tQ,NQ,WZ,K1],[0,1,0,0,()=>z4,()=>L4,64,()=>F4],1],Ale=[3,I,t0,0,[k1],[()=>B4]],jle=[3,I,Q1,0,[P1,eQ],[0,()=>V4],2],Mle=[3,I,$1,0,[eQ],[()=>V4]],Nle=[3,I,r0,0,[z0,jQ,$Z,w4,n2],[0,()=>W4,()=>L4,()=>K4,2],1],Ple=[3,I,i0,0,[d2],[()=>G4]],Fle=[3,I,o0,0,[Y2,eQ],[0,()=>X4],2],Ile=[3,I,s0,0,[eQ],[()=>X4]],F4=[3,I,q1,0,[u0,ise,s1,ose,YZ],[0,0,2,0,0]],Lle=[3,I,S0,0,[D4,o2],[64,64]],Rle=[3,I,j0,0,[D4],[()=>mue]],zle=[3,I,N0,0,[O1,k4],[0,0]],Ble=[3,I,L0,0,[m2,O0,D0,z$,E0,k0],[0,0,0,0,0,0],1],Vle=[3,I,Z0,0,[Y2,C4],[0,()=>Y4],2],Hle=[3,I,e2,0,[],[]],Ule=[3,I,Q0,0,[I0,l4],[64,64],2],Wle=[3,I,t2,0,[],[]],Gle=[3,I,G0,0,[c2,l2],[0,0],2],Kle=[3,I,f2,0,[z0,v0,jQ,$Z,u1,w4],[0,0,()=>W4,()=>L4,2,()=>K4]],qle=[3,I,K0,0,[L$,D4,q$,x0,X$,A0,$$,y2,o2],[0,64,()=>vle,()=>Lle,()=>ble,()=>Rle,()=>xle,()=>$le,64]],Jle=[3,I,a2,0,[V2,Q$,T4],[0,()=>yle,()=>uue],1],Yle=[3,I,M2,0,[P1,g2,j$],[0,64,0],2],Xle=[3,I,N2,0,[_2,j$],[64,0]],Zle=[3,I,P2,0,[P1,B2,S2,a1,A$],[0,64,()=>q4,0,0],1],Qle=[3,I,I2,0,[QZ,a1,A$],[()=>R4,0,0]],$le=[3,I,b2,0,[D4],[64]],eue=[3,I,D2,0,[k2,kQ,d0,x2],[64,()=>due,0,64]],tue=[3,I,C2,0,[v2,RZ,y0,m1,E2],[0,0,0,0,0]],nue=[3,I,use,0,[O1,k4],[0,0],1],rue=[3,I,W2,0,[R0,S4],[0,()=>J4]],I4=[3,I,G2,0,[x1,D0,XZ,M0],[0,1,0,0],1],iue=[3,I,a4,0,[Y2,t4,O0,D0,O4,U$,W$,V$,H$,K$,t1,E4,G$,c0,F1,x4,T0,a1,H2],[0,0,0,1,0,0,0,2,1,1,1,1,0,()=>P4,64,0,0,0,1]],aue=[3,I,Q2,0,[O1,k4],[0,0]],oue=[3,I,r4,0,[k$,a$],[2,1]],sue=[3,I,i4,0,[Y2,vse],[0,1]],cue=[3,I,o4,0,[p2,F0,MQ],[0,0,0]],lue=[3,I,c4,0,[dse,W$,o4,IZ,VZ],[()=>I4,0,()=>cue,()=>Oce,()=>Dce]],uue=[3,I,T4,0,[u2],[()=>U4]],L4=[1,I,$Z,0,()=>wce],R4=[1,I,QZ,0,()=>jce],z4=[1,I,sQ,0,()=>Mce],due=[1,I,kQ,0,()=>Nce],fue=[1,I,T1,0,()=>Sle],B4=[1,I,k1,0,()=>wle],pue=[1,I,N1,0,()=>Ele],V4=[1,I,R1,0,()=>Dle],H4=[1,I,M1,0,()=>Tle],mue=[1,I,P0,0,()=>zle],U4=[1,I,H0,0,()=>Gle],W4=[1,I,U0,0,()=>qle],G4=[1,I,d2,0,()=>Kle],K4=[1,I,$0,0,()=>Jle],hue=[1,I,O2,0,()=>eue],q4=[1,I,S2,0,()=>tue],gue=[1,I,U2,0,()=>rue],J4=[1,I,u4,0,()=>nue],Y4=[1,I,K2,0,()=>I4],X4=[1,I,$2,0,()=>aue],_ue=[1,I,e4,0,()=>sue],Z4=[1,I,J2,0,()=>iue],vue=[1,I,s4,0,()=>lue],yue=[9,I,KZ,0,()=>Tce,()=>Ece],bue=[9,I,pQ,0,()=>Pce,()=>Fce],xue=[9,I,cQ,0,()=>Ice,()=>Lce],Sue=[9,I,gQ,0,()=>Rce,()=>zce],Cue=[9,I,SQ,0,()=>Bce,()=>Vce],wue=[9,I,FQ,0,()=>Hce,()=>Uce],Tue=[9,I,IQ,0,()=>Wce,()=>Gce],Eue=[9,I,$Q,0,()=>Kce,()=>qce],Due=[9,I,u$,0,()=>Jce,()=>Yce],Oue=[9,I,l$,0,()=>Xce,()=>Zce],kue=[9,I,ZQ,0,()=>Qce,()=>$ce],Aue=[9,I,LQ,0,()=>ele,()=>tle],jue=[9,I,GQ,0,()=>nle,()=>rle],Mue=[9,I,i$,0,()=>ile,()=>ale],Nue=[9,I,o$,0,()=>ole,()=>sle],Pue=[9,I,O$,0,()=>cle,()=>lle],Fue=[9,I,d$,0,()=>ule,()=>dle],Iue=[9,I,y$,0,()=>fle,()=>ple],Lue=[9,I,b$,0,()=>mle,()=>hle],Rue=[9,I,X1,0,()=>kle,()=>Ale],zue=[9,I,Z1,0,()=>jle,()=>Mle],Bue=[9,I,n0,0,()=>Nle,()=>Ple],Vue=[9,I,a0,0,()=>Fle,()=>Ile],Hue=[9,I,r2,0,()=>Vle,()=>Hle],Uue=[9,I,i2,0,()=>Ule,()=>Wle],Wue=[9,I,j2,0,()=>Yle,()=>Xle],Gue=[9,I,A2,0,()=>Zle,()=>Qle],Kue=e=>({apiVersion:`2015-12-01`,base64Decoder:e?.base64Decoder??fe,base64Encoder:e?.base64Encoder??le,disableHostPrefix:e?.disableHostPrefix??!1,endpointProvider:e?.endpointProvider??kX,extensions:e?.extensions??[],httpAuthSchemeProvider:e?.httpAuthSchemeProvider??cX,httpAuthSchemes:e?.httpAuthSchemes??[{schemeId:`aws.auth#sigv4`,identityProvider:e=>e.getIdentityProvider(`aws.auth#sigv4`),signer:new Yi}],logger:e?.logger??new $t,protocol:e?.protocol??qa,protocolSettings:e?.protocolSettings??{defaultNamespace:`com.amazonaws.elasticloadbalancingv2`,errorTypeRegistries:Cce,xmlNamespace:`http://elasticloadbalancing.amazonaws.com/doc/2015-12-01/`,version:`2015-12-01`,serviceTarget:`ElasticLoadBalancing_v10`},serviceId:e?.serviceId??`Elastic Load Balancing v2`,urlParser:e?.urlParser??nt,utf8Decoder:e?.utf8Decoder??ce,utf8Encoder:e?.utf8Encoder??me}),que=e=>{let t=zi(e),n=()=>t().then(Wt),r=Kue(e);return{...r,...e,runtime:`browser`,defaultsMode:t,bodyLengthChecker:e?.bodyLengthChecker??Ee,credentialDefaultProvider:e?.credentialDefaultProvider??(e=>()=>Promise.reject(Error(`Credential is missing`))),defaultUserAgentProvider:e?.defaultUserAgentProvider??Ei({serviceId:r.serviceId,clientVersion:fX.version}),maxAttempts:e?.maxAttempts??3,region:e?.region??yt(`Region is missing`),requestHandler:da.create(e?.requestHandler??n),retryMode:e?.retryMode??(async()=>(await n()).retryMode||Nr),sha256:e?.sha256??ee,streamCollector:e?.streamCollector??pa,useDualstackEndpoint:e?.useDualstackEndpoint??(()=>Promise.resolve(!1)),useFipsEndpoint:e?.useFipsEndpoint??(()=>Promise.resolve(!1))}},Jue=e=>{let t=e.httpAuthSchemes,n=e.httpAuthSchemeProvider,r=e.credentials;return{setHttpAuthScheme(e){let n=t.findIndex(t=>t.schemeId===e.schemeId);n===-1?t.push(e):t.splice(n,1,e)},httpAuthSchemes(){return t},setHttpAuthSchemeProvider(e){n=e},httpAuthSchemeProvider(){return n},setCredentials(e){r=e},credentials(){return r}}},Yue=e=>({httpAuthSchemes:e.httpAuthSchemes(),httpAuthSchemeProvider:e.httpAuthSchemeProvider(),credentials:e.credentials()}),Xue=(e,t)=>{let n=Object.assign(Vi(e),Xt(e),Xn(e),Jue(e));return t.forEach(e=>e.configure(n)),Object.assign(e,Hi(n),Zt(n),Zn(n),Yue(n))},Zue=class extends bt{config;constructor(...[e]){let t=que(e||{});super(t),this.initConfig=t;let n=Xue(lX(Nn(zr(Li(Ir(li(uX(t))))))),e?.extensions||[]);this.config=n,this.middlewareStack.use(Ot(this.config)),this.middlewareStack.use(Ti(this.config)),this.middlewareStack.use(Lr(this.config)),this.middlewareStack.use(tr(this.config)),this.middlewareStack.use(Hr(this.config)),this.middlewareStack.use(Gr(this.config)),this.middlewareStack.use(Kr(this.config)),this.middlewareStack.use(Zr(this.config,{httpAuthSchemeParametersProvider:oX,identityProviderConfigProvider:async e=>new ii({"aws.auth#sigv4":e.credentials})})),this.middlewareStack.use(ni(this.config))}destroy(){super.destroy()}},Que=async(e,t,n)=>({operation:Ze(t).operation,region:await re(e.region)()||(()=>{throw Error("expected `region` to be configured for `aws.auth#sigv4`")})()});function $ue(e){return{schemeId:`aws.auth#sigv4`,signingProperties:{name:`iam`,region:e.region},propertiesExtractor:(e,t)=>({signingProperties:{config:e,context:t}})}}var ede=e=>{let t=[];switch(e.operation){default:t.push($ue(e))}return t},tde=e=>{let t=Qi(e);return Object.assign(t,{authSchemePreference:re(e.authSchemePreference??[])})},nde=e=>Object.assign(e,{useDualstackEndpoint:e.useDualstackEndpoint??!1,useFipsEndpoint:e.useFipsEndpoint??!1,defaultSigningName:`iam`}),rde={UseFIPS:{type:`builtInParams`,name:`useFipsEndpoint`},Endpoint:{type:`builtInParams`,name:`endpoint`},Region:{type:`builtInParams`,name:`region`},UseDualStack:{type:`builtInParams`,name:`useDualstackEndpoint`}},ide={name:`@aws-sdk/client-iam`,description:`AWS SDK for JavaScript Iam Client for Node.js, Browser and React Native`,version:`3.1063.0`,scripts:{build:`concurrently 'yarn:build:types' 'yarn:build:es' && yarn build:cjs`,"build:cjs":`node ../../scripts/compilation/inline client-iam`,"build:es":`tsc -p tsconfig.es.json`,"build:include:deps":`yarn g:turbo run build -F="$npm_package_name"`,"build:types":`tsc -p tsconfig.types.json`,"build:types:downlevel":`downlevel-dts dist-types dist-types/ts3.4`,clean:`premove dist-cjs dist-es dist-types tsconfig.cjs.tsbuildinfo tsconfig.es.tsbuildinfo tsconfig.types.tsbuildinfo`,"extract:docs":`api-extractor run --local`,"generate:client":`node ../../scripts/generate-clients/single-service --solo iam`,test:`yarn g:vitest run --passWithNoTests`,"test:e2e":`yarn g:vitest run -c vitest.config.e2e.mts --mode development`,"test:e2e:watch":`yarn g:vitest watch -c vitest.config.e2e.mts`,"test:index":`tsc --noEmit ./test/index-types.ts && node ./test/index-objects.spec.mjs`,"test:integration":`yarn g:vitest run --passWithNoTests -c vitest.config.integ.mts`,"test:integration:watch":`yarn g:vitest run --passWithNoTests -c vitest.config.integ.mts`,"test:watch":`yarn g:vitest watch --passWithNoTests`},main:`./dist-cjs/index.js`,types:`./dist-types/index.d.ts`,module:`./dist-es/index.js`,sideEffects:!1,dependencies:{"@aws-crypto/sha256-browser":`5.2.0`,"@aws-crypto/sha256-js":`5.2.0`,"@aws-sdk/core":`^3.974.18`,"@aws-sdk/credential-provider-node":`^3.972.52`,"@aws-sdk/types":`^3.973.11`,"@smithy/core":`^3.24.6`,"@smithy/fetch-http-handler":`^5.4.6`,"@smithy/node-http-handler":`^4.7.6`,"@smithy/types":`^4.14.3`,tslib:`^2.6.2`},devDependencies:{"@smithy/snapshot-testing":`^2.1.7`,"@tsconfig/node20":`20.1.8`,"@types/node":`^20.14.8`,concurrently:`7.0.0`,"downlevel-dts":`0.10.1`,premove:`4.0.0`,typescript:`~5.8.3`,vitest:`^4.0.17`},engines:{node:`>=20.0.0`},typesVersions:{"<4.5":{"dist-types/*":[`dist-types/ts3.4/*`]}},files:[`dist-*/**`],author:{name:`AWS SDK for JavaScript Team`,url:`https://aws.amazon.com/sdk-for-javascript/`},license:`Apache-2.0`,browser:{"./dist-es/runtimeConfig":`./dist-es/runtimeConfig.browser`},"react-native":{"./dist-es/runtimeConfig":`./dist-es/runtimeConfig.native`},homepage:`https://github.com/aws/aws-sdk-js-v3/tree/main/clients/client-iam`,repository:{type:`git`,url:`https://github.com/aws/aws-sdk-js-v3.git`,directory:`clients/client-iam`}},Q4=`ref`,$4=`authSchemes`,e3=`name`,t3=`signingRegion`,n3=-1,r3=!0,i3=`isSet`,a3=`PartitionResult`,o3=`booleanEquals`,s3=`stringEquals`,c3=`getAttr`,l3=`sigv4`,u3={[Q4]:`Endpoint`},d3={fn:c3,argv:[{[Q4]:a3},`name`]},f3={[Q4]:a3},p3={[$4]:[{[e3]:l3,[t3]:`us-east-1`}]},m3={[$4]:[{[e3]:l3,[t3]:`cn-north-1`}]},h3={[$4]:[{[e3]:l3,[t3]:`us-gov-west-1`}]},g3={[$4]:[{[e3]:l3,[t3]:`us-iso-east-1`}]},_3={[$4]:[{[e3]:l3,[t3]:`us-isob-east-1`}]},v3={[$4]:[{[e3]:l3,[t3]:`{PartitionResult#implicitGlobalRegion}`}]},y3=[{[Q4]:`Region`}],b3={conditions:[[i3,[u3]],[i3,y3],[`aws.partition`,y3,a3],[o3,[{[Q4]:`UseFIPS`},r3]],[o3,[{[Q4]:`UseDualStack`},r3]],[s3,[d3,`aws`]],[s3,[d3,`aws-cn`]],[s3,[d3,`aws-us-gov`]],[o3,[{fn:c3,argv:[f3,`supportsDualStack`]},r3]],[s3,[d3,`aws-iso`]],[s3,[d3,`aws-iso-b`]],[o3,[{fn:c3,argv:[f3,`supportsFIPS`]},r3]],[s3,[d3,`aws-iso-e`]],[s3,[d3,`aws-iso-f`]],[s3,[d3,`aws-eusc`]]],results:[[n3],[n3,`Invalid Configuration: FIPS and custom endpoint are not supported`],[n3,`Invalid Configuration: Dualstack and custom endpoint are not supported`],[u3,{}],[`https://iam.global.api.aws`,p3],[`https://iam-fips.global.api.aws`,p3],[`https://iam.global.api.amazonwebservices.com.cn`,m3],[`https://iam.cn-north-1.amazonaws.com.cn`,m3],[`https://iam.us-gov.api.aws`,h3],[`https://iam.us-gov.amazonaws.com`,h3],[`https://iam.us-iso-east-1.c2s.ic.gov`,g3],[`https://iam-fips.us-iso-east-1.c2s.ic.gov`,g3],[`https://iam.us-isob-east-1.sc2s.sgov.gov`,_3],[`https://iam-fips.us-isob-east-1.sc2s.sgov.gov`,_3],[`https://iam.eu-isoe-west-1.cloud.adc-e.uk`,{[$4]:[{[e3]:l3,[t3]:`eu-isoe-west-1`}]}],[`https://iam.us-isof-south-1.csp.hci.ic.gov`,{[$4]:[{[e3]:l3,[t3]:`us-isof-south-1`}]}],[`https://iam.eusc-de-east-1.amazonaws.eu`,{[$4]:[{[e3]:l3,[t3]:`eusc-de-east-1`}]}],[`https://iam-fips.{PartitionResult#dualStackDnsSuffix}`,v3],[n3,`FIPS and DualStack are enabled, but this partition does not support one or both`],[`https://iam-fips.{PartitionResult#dnsSuffix}`,v3],[n3,`FIPS is enabled but this partition does not support FIPS`],[`https://iam.{PartitionResult#dualStackDnsSuffix}`,v3],[n3,`DualStack is enabled but this partition does not support DualStack`],[`https://iam.{PartitionResult#dnsSuffix}`,v3],[n3,`Invalid Configuration: Missing Region`]]},ade=2,ode=new Int32Array([-1,1,-1,0,27,3,1,4,100000024,2,5,100000024,3,18,6,4,14,7,6,100000007,8,7,100000009,9,9,100000010,10,10,100000012,11,12,100000014,12,13,100000015,13,14,100000016,100000023,5,100000004,15,6,100000006,16,7,100000008,17,8,100000021,100000022,4,23,19,7,100000009,20,9,100000011,21,10,100000013,22,11,100000019,100000020,5,100000005,24,7,100000008,25,8,26,100000018,11,100000017,100000018,3,100000001,28,4,100000002,100000003]),sde=on.from(ode,ade,b3.conditions,b3.results),cde=new sn({size:50,params:[`Endpoint`,`Region`,`UseDualStack`,`UseFIPS`]}),lde=(e,t={})=>cde.get(e,()=>jn(sde,{endpointParams:e,logger:t.logger}));dn.aws=ji;var R=class e extends Ht{constructor(t){super(t),Object.setPrototypeOf(this,e.prototype)}},ude=class e extends R{name=`ConcurrentModificationException`;$fault=`client`;constructor(t){super({name:`ConcurrentModificationException`,$fault:`client`,...t}),Object.setPrototypeOf(this,e.prototype)}},dde=class e extends R{name=`NoSuchEntityException`;$fault=`client`;constructor(t){super({name:`NoSuchEntityException`,$fault:`client`,...t}),Object.setPrototypeOf(this,e.prototype)}},fde=class e extends R{name=`ServiceFailureException`;$fault=`server`;constructor(t){super({name:`ServiceFailureException`,$fault:`server`,...t}),Object.setPrototypeOf(this,e.prototype)}},pde=class e extends R{name=`AccountNotManagementOrDelegatedAdministratorException`;$fault=`client`;Message;constructor(t){super({name:`AccountNotManagementOrDelegatedAdministratorException`,$fault:`client`,...t}),Object.setPrototypeOf(this,e.prototype),this.Message=t.Message}},mde=class e extends R{name=`InvalidInputException`;$fault=`client`;constructor(t){super({name:`InvalidInputException`,$fault:`client`,...t}),Object.setPrototypeOf(this,e.prototype)}},hde=class e extends R{name=`LimitExceededException`;$fault=`client`;constructor(t){super({name:`LimitExceededException`,$fault:`client`,...t}),Object.setPrototypeOf(this,e.prototype)}},gde=class e extends R{name=`EntityAlreadyExistsException`;$fault=`client`;constructor(t){super({name:`EntityAlreadyExistsException`,$fault:`client`,...t}),Object.setPrototypeOf(this,e.prototype)}},_de=class e extends R{name=`UnmodifiableEntityException`;$fault=`client`;constructor(t){super({name:`UnmodifiableEntityException`,$fault:`client`,...t}),Object.setPrototypeOf(this,e.prototype)}},vde=class e extends R{name=`PolicyNotAttachableException`;$fault=`client`;constructor(t){super({name:`PolicyNotAttachableException`,$fault:`client`,...t}),Object.setPrototypeOf(this,e.prototype)}},yde=class e extends R{name=`EntityTemporarilyUnmodifiableException`;$fault=`client`;constructor(t){super({name:`EntityTemporarilyUnmodifiableException`,$fault:`client`,...t}),Object.setPrototypeOf(this,e.prototype)}},bde=class e extends R{name=`InvalidUserTypeException`;$fault=`client`;constructor(t){super({name:`InvalidUserTypeException`,$fault:`client`,...t}),Object.setPrototypeOf(this,e.prototype)}},xde=class e extends R{name=`PasswordPolicyViolationException`;$fault=`client`;constructor(t){super({name:`PasswordPolicyViolationException`,$fault:`client`,...t}),Object.setPrototypeOf(this,e.prototype)}},Sde=class e extends R{name=`OpenIdIdpCommunicationErrorException`;$fault=`client`;constructor(t){super({name:`OpenIdIdpCommunicationErrorException`,$fault:`client`,...t}),Object.setPrototypeOf(this,e.prototype)}},Cde=class e extends R{name=`MalformedPolicyDocumentException`;$fault=`client`;constructor(t){super({name:`MalformedPolicyDocumentException`,$fault:`client`,...t}),Object.setPrototypeOf(this,e.prototype)}},wde=class e extends R{name=`ServiceNotSupportedException`;$fault=`client`;constructor(t){super({name:`ServiceNotSupportedException`,$fault:`client`,...t}),Object.setPrototypeOf(this,e.prototype)}},Tde=class e extends R{name=`DeleteConflictException`;$fault=`client`;constructor(t){super({name:`DeleteConflictException`,$fault:`client`,...t}),Object.setPrototypeOf(this,e.prototype)}},Ede=class e extends R{name=`OrganizationNotFoundException`;$fault=`client`;Message;constructor(t){super({name:`OrganizationNotFoundException`,$fault:`client`,...t}),Object.setPrototypeOf(this,e.prototype),this.Message=t.Message}},Dde=class e extends R{name=`OrganizationNotInAllFeaturesModeException`;$fault=`client`;Message;constructor(t){super({name:`OrganizationNotInAllFeaturesModeException`,$fault:`client`,...t}),Object.setPrototypeOf(this,e.prototype),this.Message=t.Message}},Ode=class e extends R{name=`ServiceAccessNotEnabledException`;$fault=`client`;Message;constructor(t){super({name:`ServiceAccessNotEnabledException`,$fault:`client`,...t}),Object.setPrototypeOf(this,e.prototype),this.Message=t.Message}},kde=class e extends R{name=`FeatureDisabledException`;$fault=`client`;constructor(t){super({name:`FeatureDisabledException`,$fault:`client`,...t}),Object.setPrototypeOf(this,e.prototype)}},Ade=class e extends R{name=`InvalidAuthenticationCodeException`;$fault=`client`;constructor(t){super({name:`InvalidAuthenticationCodeException`,$fault:`client`,...t}),Object.setPrototypeOf(this,e.prototype)}},jde=class e extends R{name=`CallerIsNotManagementAccountException`;$fault=`client`;Message;constructor(t){super({name:`CallerIsNotManagementAccountException`,$fault:`client`,...t}),Object.setPrototypeOf(this,e.prototype),this.Message=t.Message}},Mde=class e extends R{name=`FeatureEnabledException`;$fault=`client`;constructor(t){super({name:`FeatureEnabledException`,$fault:`client`,...t}),Object.setPrototypeOf(this,e.prototype)}},Nde=class e extends R{name=`ReportGenerationLimitExceededException`;$fault=`client`;constructor(t){super({name:`ReportGenerationLimitExceededException`,$fault:`client`,...t}),Object.setPrototypeOf(this,e.prototype)}},Pde=class e extends R{name=`CredentialReportExpiredException`;$fault=`client`;constructor(t){super({name:`CredentialReportExpiredException`,$fault:`client`,...t}),Object.setPrototypeOf(this,e.prototype)}},Fde=class e extends R{name=`CredentialReportNotPresentException`;$fault=`client`;constructor(t){super({name:`CredentialReportNotPresentException`,$fault:`client`,...t}),Object.setPrototypeOf(this,e.prototype)}},Ide=class e extends R{name=`CredentialReportNotReadyException`;$fault=`client`;constructor(t){super({name:`CredentialReportNotReadyException`,$fault:`client`,...t}),Object.setPrototypeOf(this,e.prototype)}},Lde=class e extends R{name=`UnrecognizedPublicKeyEncodingException`;$fault=`client`;constructor(t){super({name:`UnrecognizedPublicKeyEncodingException`,$fault:`client`,...t}),Object.setPrototypeOf(this,e.prototype)}},Rde=class e extends R{name=`PolicyEvaluationException`;$fault=`server`;constructor(t){super({name:`PolicyEvaluationException`,$fault:`server`,...t}),Object.setPrototypeOf(this,e.prototype)}},zde=class e extends R{name=`KeyPairMismatchException`;$fault=`client`;constructor(t){super({name:`KeyPairMismatchException`,$fault:`client`,...t}),Object.setPrototypeOf(this,e.prototype)}},Bde=class e extends R{name=`MalformedCertificateException`;$fault=`client`;constructor(t){super({name:`MalformedCertificateException`,$fault:`client`,...t}),Object.setPrototypeOf(this,e.prototype)}},Vde=class e extends R{name=`DuplicateCertificateException`;$fault=`client`;constructor(t){super({name:`DuplicateCertificateException`,$fault:`client`,...t}),Object.setPrototypeOf(this,e.prototype)}},Hde=class e extends R{name=`InvalidCertificateException`;$fault=`client`;constructor(t){super({name:`InvalidCertificateException`,$fault:`client`,...t}),Object.setPrototypeOf(this,e.prototype)}},Ude=class e extends R{name=`DuplicateSSHPublicKeyException`;$fault=`client`;constructor(t){super({name:`DuplicateSSHPublicKeyException`,$fault:`client`,...t}),Object.setPrototypeOf(this,e.prototype)}},Wde=class e extends R{name=`InvalidPublicKeyException`;$fault=`client`;constructor(t){super({name:`InvalidPublicKeyException`,$fault:`client`,...t}),Object.setPrototypeOf(this,e.prototype)}},x3=`Arn`,Gde=`AttachmentCount`,S3=`AccessKey`,C3=`AccessKeyId`,w3=`AccessKeyMetadata`,Kde=`AccountNotManagementOrDelegatedAdministratorException`,qde=`AttachedPolicy`,Jde=`AttachedPermissionsBoundary`,T3=`AttachedPolicies`,Yde=`AttachRolePolicy`,E3=`AssumeRolePolicyDocument`,Xde=`AttachRolePolicyRequest`,Zde=`AddRoleToInstanceProfile`,Qde=`AddRoleToInstanceProfileRequest`,$de=`AttachUserPolicy`,efe=`AttachUserPolicyRequest`,tfe=`CreateAccessKey`,nfe=`CreateAccessKeyRequest`,rfe=`CreateAccessKeyResponse`,D3=`CreateDate`,ife=`CallerIsNotManagementAccountException`,afe=`CreateInstanceProfile`,ofe=`CreateInstanceProfileRequest`,sfe=`CreateInstanceProfileResponse`,cfe=`ConcurrentModificationException`,lfe=`CreatePolicyRequest`,ufe=`CreatePolicyResponse`,dfe=`CreatePolicy`,ffe=`CreateRole`,pfe=`CredentialReportExpiredException`,mfe=`CredentialReportNotPresentException`,hfe=`CredentialReportNotReadyException`,gfe=`CreateRoleRequest`,_fe=`CreateRoleResponse`,vfe=`CreateUser`,yfe=`CreateUserRequest`,bfe=`CreateUserResponse`,O3=`Description`,xfe=`DeleteAccessKey`,Sfe=`DeleteAccessKeyRequest`,Cfe=`DeleteConflictException`,wfe=`DuplicateCertificateException`,Tfe=`DeleteInstanceProfile`,Efe=`DeleteInstanceProfileRequest`,Dfe=`DeletePolicyRequest`,Ofe=`DeletePolicy`,kfe=`DetachRolePolicyRequest`,Afe=`DetachRolePolicy`,jfe=`DeleteRoleRequest`,Mfe=`DeleteRole`,Nfe=`DuplicateSSHPublicKeyException`,Pfe=`DeleteUser`,Ffe=`DetachUserPolicyRequest`,Ife=`DetachUserPolicy`,Lfe=`DeleteUserRequest`,Rfe=`DefaultVersionId`,zfe=`Document`,Bfe=`EntityAlreadyExistsException`,Vfe=`EntityTemporarilyUnmodifiableException`,Hfe=`FeatureDisabledException`,Ufe=`FeatureEnabledException`,Wfe=`GetInstanceProfile`,Gfe=`GetInstanceProfileRequest`,Kfe=`GetInstanceProfileResponse`,qfe=`GetPolicy`,Jfe=`GetPolicyRequest`,Yfe=`GetPolicyResponse`,Xfe=`GetPolicyVersion`,Zfe=`GetPolicyVersionRequest`,Qfe=`GetPolicyVersionResponse`,$fe=`GetRole`,epe=`GetRoleRequest`,tpe=`GetRoleResponse`,npe=`GetUser`,rpe=`GetUserRequest`,ipe=`GetUserResponse`,ape=`IsAttachable`,ope=`InvalidAuthenticationCodeException`,spe=`InvalidCertificateException`,cpe=`IsDefaultVersion`,lpe=`InvalidInputException`,k3=`InstanceProfile`,upe=`InstanceProfileId`,dpe=`InvalidPublicKeyException`,A3=`InstanceProfileName`,j3=`InstanceProfiles`,M3=`IsTruncated`,fpe=`InvalidUserTypeException`,ppe=`Key`,mpe=`KeyPairMismatchException`,hpe=`ListAccessKeys`,gpe=`ListAccessKeysRequest`,_pe=`ListAccessKeysResponse`,vpe=`ListAttachedRolePolicies`,ype=`ListAttachedRolePoliciesRequest`,bpe=`ListAttachedRolePoliciesResponse`,xpe=`ListAttachedUserPolicies`,Spe=`ListAttachedUserPoliciesRequest`,Cpe=`ListAttachedUserPoliciesResponse`,wpe=`LimitExceededException`,Tpe=`ListInstanceProfiles`,Epe=`ListInstanceProfilesForRole`,Dpe=`ListInstanceProfilesForRoleRequest`,Ope=`ListInstanceProfilesForRoleResponse`,kpe=`ListInstanceProfilesRequest`,Ape=`ListInstanceProfilesResponse`,jpe=`ListPoliciesRequest`,Mpe=`ListPoliciesResponse`,Npe=`ListPolicies`,Ppe=`ListRoles`,Fpe=`ListRolesRequest`,Ipe=`ListRolesResponse`,Lpe=`ListUsers`,Rpe=`LastUsedDate`,zpe=`ListUsersRequest`,Bpe=`ListUsersResponse`,N3=`Message`,Vpe=`MalformedCertificateException`,P3=`MaxItems`,Hpe=`MalformedPolicyDocumentException`,F3=`MaxSessionDuration`,I3=`Marker`,Upe=`NoSuchEntityException`,Wpe=`OnlyAttached`,Gpe=`OpenIdIdpCommunicationErrorException`,Kpe=`OrganizationNotFoundException`,qpe=`OrganizationNotInAllFeaturesModeException`,L3=`PolicyArn`,R3=`PermissionsBoundary`,Jpe=`PermissionsBoundaryArn`,Ype=`PermissionsBoundaryType`,Xpe=`PermissionsBoundaryUsageCount`,Zpe=`PolicyDocument`,Qpe=`PolicyEvaluationException`,$pe=`PolicyId`,eme=`PasswordLastUsed`,z3=`PolicyName`,tme=`PolicyNotAttachableException`,nme=`PasswordPolicyViolationException`,B3=`PathPrefix`,rme=`PolicyUsageFilter`,V3=`PolicyVersion`,H3=`Path`,U3=`Policy`,ime=`Policies`,ame=`Region`,ome=`ReportGenerationLimitExceededException`,sme=`RoleId`,W3=`RoleLastUsed`,G3=`RoleName`,cme=`RemoveRoleFromInstanceProfile`,lme=`RemoveRoleFromInstanceProfileRequest`,K3=`Role`,q3=`Roles`,J3=`Status`,ume=`SecretAccessKey`,dme=`ServiceAccessNotEnabledException`,fme=`ServiceFailureException`,pme=`ServiceNotSupportedException`,mme=`Scope`,Y3=`Tags`,hme=`Tag`,gme=`UpdateAccessKey`,_me=`UpdateAccessKeyRequest`,vme=`UpdateDate`,yme=`UnmodifiableEntityException`,bme=`UserId`,X3=`UserName`,xme=`UnrecognizedPublicKeyEncodingException`,Z3=`User`,Sme=`Users`,Q3=`VersionId`,Cme=`Value`,wme=`accessKeyMetadataListType`,Tme=`accessKeySecretType`,Eme=`attachedPoliciesListType`,z=`awsQueryError`,B=`client`,V=`error`,H=`httpError`,Dme=`instanceProfileListType`,U=`message`,Ome=`policyListType`,kme=`roleListType`,$3=`smithy.ts.sdk.synthetic.com.amazonaws.iam`,e6=`server`,Ame=`tagListType`,jme=`userListType`,W=`com.amazonaws.iam`,t6=Lt.for($3),Mme=[-3,$3,`IAMServiceException`,0,[],[]];t6.registerError(Mme,R);var G=Lt.for(W),Nme=[-3,W,Kde,{[V]:B,[H]:400},[N3],[0]];G.registerError(Nme,pde);var Pme=[-3,W,ife,{[V]:B,[H]:400},[N3],[0]];G.registerError(Pme,jde);var Fme=[-3,W,cfe,{[z]:[`ConcurrentModification`,409],[V]:B,[H]:409},[U],[0]];G.registerError(Fme,ude);var Ime=[-3,W,pfe,{[z]:[`ReportExpired`,410],[V]:B,[H]:410},[U],[0]];G.registerError(Ime,Pde);var Lme=[-3,W,mfe,{[z]:[`ReportNotPresent`,410],[V]:B,[H]:410},[U],[0]];G.registerError(Lme,Fde);var Rme=[-3,W,hfe,{[z]:[`ReportInProgress`,404],[V]:B,[H]:404},[U],[0]];G.registerError(Rme,Ide);var zme=[-3,W,Cfe,{[z]:[`DeleteConflict`,409],[V]:B,[H]:409},[U],[0]];G.registerError(zme,Tde);var Bme=[-3,W,wfe,{[z]:[`DuplicateCertificate`,409],[V]:B,[H]:409},[U],[0]];G.registerError(Bme,Vde);var Vme=[-3,W,Nfe,{[z]:[`DuplicateSSHPublicKey`,400],[V]:B,[H]:400},[U],[0]];G.registerError(Vme,Ude);var Hme=[-3,W,Bfe,{[z]:[`EntityAlreadyExists`,409],[V]:B,[H]:409},[U],[0]];G.registerError(Hme,gde);var Ume=[-3,W,Vfe,{[z]:[`EntityTemporarilyUnmodifiable`,409],[V]:B,[H]:409},[U],[0]];G.registerError(Ume,yde);var Wme=[-3,W,Hfe,{[z]:[`FeatureDisabled`,404],[V]:B,[H]:404},[U],[0]];G.registerError(Wme,kde);var Gme=[-3,W,Ufe,{[z]:[`FeatureEnabled`,409],[V]:B,[H]:409},[U],[0]];G.registerError(Gme,Mde);var Kme=[-3,W,ope,{[z]:[`InvalidAuthenticationCode`,403],[V]:B,[H]:403},[U],[0]];G.registerError(Kme,Ade);var qme=[-3,W,spe,{[z]:[`InvalidCertificate`,400],[V]:B,[H]:400},[U],[0]];G.registerError(qme,Hde);var Jme=[-3,W,lpe,{[z]:[`InvalidInput`,400],[V]:B,[H]:400},[U],[0]];G.registerError(Jme,mde);var Yme=[-3,W,dpe,{[z]:[`InvalidPublicKey`,400],[V]:B,[H]:400},[U],[0]];G.registerError(Yme,Wde);var Xme=[-3,W,fpe,{[z]:[`InvalidUserType`,400],[V]:B,[H]:400},[U],[0]];G.registerError(Xme,bde);var Zme=[-3,W,mpe,{[z]:[`KeyPairMismatch`,400],[V]:B,[H]:400},[U],[0]];G.registerError(Zme,zde);var Qme=[-3,W,wpe,{[z]:[`LimitExceeded`,409],[V]:B,[H]:409},[U],[0]];G.registerError(Qme,hde);var $me=[-3,W,Vpe,{[z]:[`MalformedCertificate`,400],[V]:B,[H]:400},[U],[0]];G.registerError($me,Bde);var ehe=[-3,W,Hpe,{[z]:[`MalformedPolicyDocument`,400],[V]:B,[H]:400},[U],[0]];G.registerError(ehe,Cde);var the=[-3,W,Upe,{[z]:[`NoSuchEntity`,404],[V]:B,[H]:404},[U],[0]];G.registerError(the,dde);var nhe=[-3,W,Gpe,{[z]:[`OpenIdIdpCommunicationError`,400],[V]:B,[H]:400},[U],[0]];G.registerError(nhe,Sde);var rhe=[-3,W,Kpe,{[V]:B,[H]:400},[N3],[0]];G.registerError(rhe,Ede);var ihe=[-3,W,qpe,{[V]:B,[H]:400},[N3],[0]];G.registerError(ihe,Dde);var ahe=[-3,W,nme,{[z]:[`PasswordPolicyViolation`,400],[V]:B,[H]:400},[U],[0]];G.registerError(ahe,xde);var ohe=[-3,W,Qpe,{[z]:[`PolicyEvaluation`,500],[V]:e6,[H]:500},[U],[0]];G.registerError(ohe,Rde);var she=[-3,W,tme,{[z]:[`PolicyNotAttachable`,400],[V]:B,[H]:400},[U],[0]];G.registerError(she,vde);var che=[-3,W,ome,{[z]:[`ReportGenerationLimitExceeded`,409],[V]:B,[H]:409},[U],[0]];G.registerError(che,Nde);var lhe=[-3,W,dme,{[V]:B,[H]:400},[N3],[0]];G.registerError(lhe,Ode);var uhe=[-3,W,fme,{[z]:[`ServiceFailure`,500],[V]:e6,[H]:500},[U],[0]];G.registerError(uhe,fde);var dhe=[-3,W,pme,{[z]:[`NotSupportedService`,404],[V]:B,[H]:404},[U],[0]];G.registerError(dhe,wde);var fhe=[-3,W,yme,{[z]:[`UnmodifiableEntity`,400],[V]:B,[H]:400},[U],[0]];G.registerError(fhe,_de);var phe=[-3,W,xme,{[z]:[`UnrecognizedPublicKeyEncoding`,400],[V]:B,[H]:400},[U],[0]];G.registerError(phe,Lde);var mhe=[t6,G],hhe=[0,W,Tme,8,0],ghe=[3,W,S3,0,[X3,C3,J3,ume,D3],[0,0,0,[()=>hhe,0],4],4],_he=[3,W,w3,0,[X3,C3,J3,D3],[0,0,0,4]],vhe=[3,W,Qde,0,[A3,G3],[0,0],2],n6=[3,W,Jde,0,[Ype,Jpe],[0,0]],yhe=[3,W,qde,0,[z3,L3],[0,0]],bhe=[3,W,Xde,0,[G3,L3],[0,0],2],xhe=[3,W,efe,0,[X3,L3],[0,0],2],She=[3,W,nfe,0,[X3],[0]],Che=[3,W,rfe,0,[S3],[[()=>ghe,0]],1],whe=[3,W,ofe,0,[A3,H3,Y3],[0,0,()=>d6],1],The=[3,W,sfe,0,[k3],[()=>r6],1],Ehe=[3,W,lfe,0,[z3,Zpe,H3,O3,Y3],[0,0,0,0,()=>d6],2],Dhe=[3,W,ufe,0,[U3],[()=>i6]],Ohe=[3,W,gfe,0,[G3,E3,H3,O3,F3,R3,Y3],[0,0,0,0,1,0,()=>d6],2],khe=[3,W,_fe,0,[K3],[()=>a6],1],Ahe=[3,W,yfe,0,[X3,H3,R3,Y3],[0,0,0,()=>d6],1],jhe=[3,W,bfe,0,[Z3],[()=>o6]],Mhe=[3,W,Sfe,0,[C3,X3],[0,0],1],Nhe=[3,W,Efe,0,[A3],[0],1],Phe=[3,W,Dfe,0,[L3],[0],1],Fhe=[3,W,jfe,0,[G3],[0],1],Ihe=[3,W,Lfe,0,[X3],[0],1],Lhe=[3,W,kfe,0,[G3,L3],[0,0],2],Rhe=[3,W,Ffe,0,[X3,L3],[0,0],2],zhe=[3,W,Gfe,0,[A3],[0],1],Bhe=[3,W,Kfe,0,[k3],[()=>r6],1],Vhe=[3,W,Jfe,0,[L3],[0],1],Hhe=[3,W,Yfe,0,[U3],[()=>i6]],Uhe=[3,W,Zfe,0,[L3,Q3],[0,0],2],Whe=[3,W,Qfe,0,[V3],[()=>dge]],Ghe=[3,W,epe,0,[G3],[0],1],Khe=[3,W,tpe,0,[K3],[()=>a6],1],qhe=[3,W,rpe,0,[X3],[0]],Jhe=[3,W,ipe,0,[Z3],[()=>o6],1],r6=[3,W,k3,0,[H3,A3,upe,x3,D3,q3,Y3],[0,0,0,0,4,()=>u6,()=>d6],6],Yhe=[3,W,gpe,0,[X3,I3,P3],[0,0,1]],Xhe=[3,W,_pe,0,[w3,M3,I3],[()=>gge,2,0],1],Zhe=[3,W,ype,0,[G3,B3,I3,P3],[0,0,0,1],1],Qhe=[3,W,bpe,0,[T3,M3,I3],[()=>c6,2,0]],$he=[3,W,Spe,0,[X3,B3,I3,P3],[0,0,0,1],1],ege=[3,W,Cpe,0,[T3,M3,I3],[()=>c6,2,0]],tge=[3,W,Dpe,0,[G3,I3,P3],[0,0,1],1],nge=[3,W,Ope,0,[j3,M3,I3],[()=>l6,2,0],1],rge=[3,W,kpe,0,[B3,I3,P3],[0,0,1]],ige=[3,W,Ape,0,[j3,M3,I3],[()=>l6,2,0],1],age=[3,W,jpe,0,[mme,Wpe,B3,rme,I3,P3],[0,2,0,0,0,1]],oge=[3,W,Mpe,0,[ime,M3,I3],[()=>_ge,2,0]],sge=[3,W,Fpe,0,[B3,I3,P3],[0,0,1]],cge=[3,W,Ipe,0,[q3,M3,I3],[()=>u6,2,0],1],lge=[3,W,zpe,0,[B3,I3,P3],[0,0,1]],uge=[3,W,Bpe,0,[Sme,M3,I3],[()=>vge,2,0],1],i6=[3,W,U3,0,[z3,$pe,x3,H3,Rfe,Gde,Xpe,ape,O3,D3,vme,Y3],[0,0,0,0,0,1,1,2,0,4,4,()=>d6]],dge=[3,W,V3,0,[zfe,Q3,cpe,D3],[0,0,2,4]],fge=[3,W,lme,0,[A3,G3],[0,0],2],a6=[3,W,K3,0,[H3,G3,sme,x3,D3,E3,O3,F3,R3,Y3,W3],[0,0,0,0,4,0,0,1,()=>n6,()=>d6,()=>pge],5],pge=[3,W,W3,0,[Rpe,ame],[4,0]],mge=[3,W,hme,0,[ppe,Cme],[0,0],2],hge=[3,W,_me,0,[C3,J3,X3],[0,0,0],2],o6=[3,W,Z3,0,[H3,X3,bme,x3,D3,eme,R3,Y3],[0,0,0,0,4,4,()=>n6,()=>d6],5],s6=`unit`,gge=[1,W,wme,0,()=>_he],c6=[1,W,Eme,0,()=>yhe],l6=[1,W,Dme,0,()=>r6],_ge=[1,W,Ome,0,()=>i6],u6=[1,W,kme,0,()=>a6],d6=[1,W,Ame,0,()=>mge],vge=[1,W,jme,0,()=>o6],yge=[9,W,Zde,0,()=>vhe,()=>s6],bge=[9,W,Yde,0,()=>bhe,()=>s6],xge=[9,W,$de,0,()=>xhe,()=>s6],Sge=[9,W,tfe,0,()=>She,()=>Che],Cge=[9,W,afe,0,()=>whe,()=>The],wge=[9,W,dfe,0,()=>Ehe,()=>Dhe],Tge=[9,W,ffe,0,()=>Ohe,()=>khe],Ege=[9,W,vfe,0,()=>Ahe,()=>jhe],Dge=[9,W,xfe,0,()=>Mhe,()=>s6],Oge=[9,W,Tfe,0,()=>Nhe,()=>s6],kge=[9,W,Ofe,0,()=>Phe,()=>s6],Age=[9,W,Mfe,0,()=>Fhe,()=>s6],jge=[9,W,Pfe,0,()=>Ihe,()=>s6],Mge=[9,W,Afe,0,()=>Lhe,()=>s6],Nge=[9,W,Ife,0,()=>Rhe,()=>s6],Pge=[9,W,Wfe,0,()=>zhe,()=>Bhe],Fge=[9,W,qfe,0,()=>Vhe,()=>Hhe],Ige=[9,W,Xfe,0,()=>Uhe,()=>Whe],Lge=[9,W,$fe,0,()=>Ghe,()=>Khe],Rge=[9,W,npe,0,()=>qhe,()=>Jhe],zge=[9,W,hpe,0,()=>Yhe,()=>Xhe],Bge=[9,W,vpe,0,()=>Zhe,()=>Qhe],Vge=[9,W,xpe,0,()=>$he,()=>ege],Hge=[9,W,Tpe,0,()=>rge,()=>ige],Uge=[9,W,Epe,0,()=>tge,()=>nge],Wge=[9,W,Npe,0,()=>age,()=>oge],Gge=[9,W,Ppe,0,()=>sge,()=>cge],Kge=[9,W,Lpe,0,()=>lge,()=>uge],qge=[9,W,cme,0,()=>fge,()=>s6],Jge=[9,W,gme,0,()=>hge,()=>s6],Yge=e=>({apiVersion:`2010-05-08`,base64Decoder:e?.base64Decoder??fe,base64Encoder:e?.base64Encoder??le,disableHostPrefix:e?.disableHostPrefix??!1,endpointProvider:e?.endpointProvider??lde,extensions:e?.extensions??[],httpAuthSchemeProvider:e?.httpAuthSchemeProvider??ede,httpAuthSchemes:e?.httpAuthSchemes??[{schemeId:`aws.auth#sigv4`,identityProvider:e=>e.getIdentityProvider(`aws.auth#sigv4`),signer:new Yi}],logger:e?.logger??new $t,protocol:e?.protocol??qa,protocolSettings:e?.protocolSettings??{defaultNamespace:`com.amazonaws.iam`,errorTypeRegistries:mhe,xmlNamespace:`https://iam.amazonaws.com/doc/2010-05-08/`,version:`2010-05-08`,serviceTarget:`AWSIdentityManagementV20100508`},serviceId:e?.serviceId??`IAM`,urlParser:e?.urlParser??nt,utf8Decoder:e?.utf8Decoder??ce,utf8Encoder:e?.utf8Encoder??me}),Xge=e=>{let t=zi(e),n=()=>t().then(Wt),r=Yge(e);return{...r,...e,runtime:`browser`,defaultsMode:t,bodyLengthChecker:e?.bodyLengthChecker??Ee,credentialDefaultProvider:e?.credentialDefaultProvider??(e=>()=>Promise.reject(Error(`Credential is missing`))),defaultUserAgentProvider:e?.defaultUserAgentProvider??Ei({serviceId:r.serviceId,clientVersion:ide.version}),maxAttempts:e?.maxAttempts??3,region:e?.region??yt(`Region is missing`),requestHandler:da.create(e?.requestHandler??n),retryMode:e?.retryMode??(async()=>(await n()).retryMode||Nr),sha256:e?.sha256??ee,streamCollector:e?.streamCollector??pa,useDualstackEndpoint:e?.useDualstackEndpoint??(()=>Promise.resolve(!1)),useFipsEndpoint:e?.useFipsEndpoint??(()=>Promise.resolve(!1))}},Zge=e=>{let t=e.httpAuthSchemes,n=e.httpAuthSchemeProvider,r=e.credentials;return{setHttpAuthScheme(e){let n=t.findIndex(t=>t.schemeId===e.schemeId);n===-1?t.push(e):t.splice(n,1,e)},httpAuthSchemes(){return t},setHttpAuthSchemeProvider(e){n=e},httpAuthSchemeProvider(){return n},setCredentials(e){r=e},credentials(){return r}}},Qge=e=>({httpAuthSchemes:e.httpAuthSchemes(),httpAuthSchemeProvider:e.httpAuthSchemeProvider(),credentials:e.credentials()}),$ge=(e,t)=>{let n=Object.assign(Vi(e),Xt(e),Xn(e),Zge(e));return t.forEach(e=>e.configure(n)),Object.assign(e,Hi(n),Zt(n),Zn(n),Qge(n))},e_e=class extends bt{config;constructor(...[e]){let t=Xge(e||{});super(t),this.initConfig=t;let n=$ge(tde(Nn(zr(Li(Ir(li(nde(t))))))),e?.extensions||[]);this.config=n,this.middlewareStack.use(Ot(this.config)),this.middlewareStack.use(Ti(this.config)),this.middlewareStack.use(Lr(this.config)),this.middlewareStack.use(tr(this.config)),this.middlewareStack.use(Hr(this.config)),this.middlewareStack.use(Gr(this.config)),this.middlewareStack.use(Kr(this.config)),this.middlewareStack.use(Zr(this.config,{httpAuthSchemeParametersProvider:Que,identityProviderConfigProvider:async e=>new ii({"aws.auth#sigv4":e.credentials})})),this.middlewareStack.use(ni(this.config))}destroy(){super.destroy()}},f6={WHEN_SUPPORTED:`WHEN_SUPPORTED`,WHEN_REQUIRED:`WHEN_REQUIRED`},t_e=f6.WHEN_SUPPORTED,n_e={WHEN_SUPPORTED:`WHEN_SUPPORTED`,WHEN_REQUIRED:`WHEN_REQUIRED`},r_e=f6.WHEN_SUPPORTED,p6;(function(e){e.MD5=`MD5`,e.CRC32=`CRC32`,e.CRC32C=`CRC32C`,e.CRC64NVME=`CRC64NVME`,e.SHA1=`SHA1`,e.SHA256=`SHA256`})(p6||={});var i_e;(function(e){e.HEADER=`header`,e.TRAILER=`trailer`})(i_e||={});var a_e=p6.CRC32,o_e=e=>{let{requestChecksumCalculation:t,responseChecksumValidation:n,requestStreamBufferSize:r}=e;return Object.assign(e,{requestChecksumCalculation:re(t??t_e),responseChecksumValidation:re(n??r_e),requestStreamBufferSize:Number(r??0),checksumAlgorithms:e.checksumAlgorithms??{}})},s_e=e=>(t,n)=>async r=>{let i=await e.region(),a=e.region,o=()=>{};n.__s3RegionRedirect&&(Object.defineProperty(e,"region",{writable:!1,value:async()=>n.__s3RegionRedirect}),o=()=>Object.defineProperty(e,"region",{writable:!0,value:a}));try{let a=await t(r);if(n.__s3RegionRedirect&&(o(),i!==await e.region()))throw Error(`Region was not restored following S3 region redirect.`);return a}catch(e){throw o(),e}},c_e={tags:[`REGION_REDIRECT`,`S3`],name:`regionRedirectEndpointMiddleware`,override:!0,relation:`before`,toMiddleware:`endpointV2Middleware`};function l_e(e){return(t,n)=>async r=>{try{return await t(r)}catch(i){if(e.followRegionRedirects){let a=i?.$metadata?.httpStatusCode,o=n.commandName===`HeadBucketCommand`,s=i?.$response?.headers?.[`x-amz-bucket-region`];if(s&&(a===301||a===400&&(i?.name===`IllegalLocationConstraintException`||o))){try{let t=s;n.logger?.debug(`Redirecting from ${await e.region()} to ${t}`),n.__s3RegionRedirect=t}catch(e){throw Error(`Region redirect failed: `+e)}return t(r)}}throw i}}}var u_e={step:`initialize`,tags:[`REGION_REDIRECT`,`S3`],name:`regionRedirectMiddleware`,override:!0},d_e=e=>({applyToStack:t=>{t.add(l_e(e),u_e),t.addRelativeTo(s_e(e),c_e)}}),f_e=class e{data;lastPurgeTime=Date.now();static EXPIRED_CREDENTIAL_PURGE_INTERVAL_MS=3e4;constructor(e={}){this.data=e}get(e){let t=this.data[e];if(t)return t}set(e,t){return this.data[e]=t,t}delete(e){delete this.data[e]}async purgeExpired(){let t=Date.now();if(!(this.lastPurgeTime+e.EXPIRED_CREDENTIAL_PURGE_INTERVAL_MS>t))for(let e in this.data){let n=this.data[e];if(!n.isRefreshing){let r=await n.identity;r.expiration&&r.expiration.getTime()(t.expiration?.getTime()??0){i.set(r,new m6(Promise.resolve(e)))})),t)):i.set(r,new m6(this.getIdentity(r))).identity}async getIdentity(e){await this.cache.purgeExpired().catch(e=>{console.warn(`Error while clearing expired entries in S3ExpressIdentityCache:
+`+e)});let t=await this.createSessionFn(e);if(!t.Credentials?.AccessKeyId||!t.Credentials?.SecretAccessKey)throw Error(`s3#createSession response credential missing AccessKeyId or SecretAccessKey.`);return{accessKeyId:t.Credentials.AccessKeyId,secretAccessKey:t.Credentials.SecretAccessKey,sessionToken:t.Credentials.SessionToken,expiration:t.Credentials.Expiration?new Date(t.Credentials.Expiration):void 0}}},m_e=(e,{session:t})=>{let[n,r]=t,{forcePathStyle:i,useAccelerateEndpoint:a,disableMultiregionAccessPoints:o,followRegionRedirects:s,s3ExpressIdentityProvider:c,bucketEndpoint:l,expectContinueHeader:u}=e;return Object.assign(e,{forcePathStyle:i??!1,useAccelerateEndpoint:a??!1,disableMultiregionAccessPoints:o??!1,followRegionRedirects:s??!1,s3ExpressIdentityProvider:c??new p_e(async e=>n().send(new r({Bucket:e}))),bucketEndpoint:l??!1,expectContinueHeader:u??2097152})},h6={CrtSignerV4:null},g6=`X-Amz-S3session-Token`,_6=g6.toLowerCase(),h_e=class extends ae{async signWithCredentials(e,t,n){let r=v6(t);e.headers[_6]=t.sessionToken;let i=this;return y6(i,r),i.signRequest(e,n??{})}async presignWithCredentials(e,t,n){let r=v6(t);return delete e.headers[_6],e.headers[g6]=t.sessionToken,e.query=e.query??{},e.query[g6]=t.sessionToken,y6(this,r),this.presign(e,n)}};function v6(e){return{accessKeyId:e.accessKeyId,secretAccessKey:e.secretAccessKey,expiration:e.expiration}}function y6(e,t){let n=e.credentialProvider;e.credentialProvider=()=>(e.credentialProvider=n,Promise.resolve(t))}var b6=class{sigv4aSigner;sigv4Signer;signerOptions;static sigv4aDependency(){return typeof h6.CrtSignerV4==`function`?`crt`:typeof rr.SignatureV4a==`function`?`js`:`none`}constructor(e){this.sigv4Signer=new h_e(e),this.signerOptions=e}async sign(e,t={}){return t.signingRegion===`*`?this.getSigv4aSigner().sign(e,t):this.sigv4Signer.sign(e,t)}async signWithCredentials(e,t,n={}){if(n.signingRegion===`*`){let r=this.getSigv4aSigner(),i=h6.CrtSignerV4;if(i&&r instanceof i)return r.signWithCredentials(e,t,n);throw Error(`signWithCredentials with signingRegion '*' is only supported when using the CRT dependency @aws-sdk/signature-v4-crt. Please check whether you have installed the "@aws-sdk/signature-v4-crt" package explicitly. You must also register the package by calling [require("@aws-sdk/signature-v4-crt");] or an ESM equivalent such as [import "@aws-sdk/signature-v4-crt";]. For more information please go to https://github.com/aws/aws-sdk-js-v3#functionality-requiring-aws-common-runtime-crt`)}return this.sigv4Signer.signWithCredentials(e,t,n)}async presign(e,t={}){if(t.signingRegion===`*`){let n=this.getSigv4aSigner(),r=h6.CrtSignerV4;if(r&&n instanceof r)return n.presign(e,t);throw Error(`presign with signingRegion '*' is only supported when using the CRT dependency @aws-sdk/signature-v4-crt. Please check whether you have installed the "@aws-sdk/signature-v4-crt" package explicitly. You must also register the package by calling [require("@aws-sdk/signature-v4-crt");] or an ESM equivalent such as [import "@aws-sdk/signature-v4-crt";]. For more information please go to https://github.com/aws/aws-sdk-js-v3#functionality-requiring-aws-common-runtime-crt`)}return this.sigv4Signer.presign(e,t)}async presignWithCredentials(e,t,n={}){if(n.signingRegion===`*`)throw Error(`Method presignWithCredentials is not supported for [signingRegion=*].`);return this.sigv4Signer.presignWithCredentials(e,t,n)}getSigv4aSigner(){if(!this.sigv4aSigner){let e=h6.CrtSignerV4,t=rr.SignatureV4a;if(this.signerOptions.runtime===`node`){if(!e&&!t)throw Error(`Neither CRT nor JS SigV4a implementation is available. Please load either @aws-sdk/signature-v4-crt or @aws-sdk/signature-v4a. For more information please go to https://github.com/aws/aws-sdk-js-v3#functionality-requiring-aws-common-runtime-crt`);if(e&&typeof e==`function`)this.sigv4aSigner=new e({...this.signerOptions,signingAlgorithm:1});else if(t&&typeof t==`function`)this.sigv4aSigner=new t({...this.signerOptions});else throw Error(`Available SigV4a implementation is not a valid constructor. Please ensure you've properly imported @aws-sdk/signature-v4-crt or @aws-sdk/signature-v4a.For more information please go to https://github.com/aws/aws-sdk-js-v3#functionality-requiring-aws-common-runtime-crt`)}else{if(!t||typeof t!=`function`)throw Error(`JS SigV4a implementation is not available or not a valid constructor. Please check whether you have installed the @aws-sdk/signature-v4a package explicitly. The CRT implementation is not available for browsers. You must also register the package by calling [require('@aws-sdk/signature-v4a');] or an ESM equivalent such as [import '@aws-sdk/signature-v4a';]. For more information please go to https://github.com/aws/aws-sdk-js-v3#using-javascript-non-crt-implementation-of-sigv4a`);this.sigv4aSigner=new t({...this.signerOptions})}}return this.sigv4aSigner}},g_e=`sigv4-s3express`,__e=`x-amz-s3session-token`,v_e=e=>(t,n)=>async r=>{if(n.endpointV2){let t=n.endpointV2,i=t.properties?.authSchemes?.[0]?.name===g_e;if((t.properties?.backend===`S3Express`||t.properties?.bucketType===`Directory`)&&(Rr(n,`S3_EXPRESS_BUCKET`,`J`),n.isS3ExpressBucket=!0),i){let t=r.input.Bucket;if(t){let i=await e.s3ExpressIdentityProvider.getS3ExpressIdentity(await e.credentials(),{Bucket:t});n.s3ExpressIdentity=i,m.isInstance(r.request)&&i.sessionToken&&(r.request.headers[__e]=i.sessionToken)}}}return t(r)},y_e={name:`s3ExpressMiddleware`,step:`build`,tags:[`S3`,`S3_EXPRESS`],override:!0},b_e=e=>({applyToStack:t=>{t.add(v_e(e),y_e)}}),x_e=async(e,t,n,r)=>{let i=await r.signWithCredentials(n,e,{});if(i.headers[`X-Amz-Security-Token`]||i.headers[`x-amz-security-token`])throw Error(`X-Amz-Security-Token must not be set for s3-express requests.`);return i},S_e=e=>e=>{throw e},C_e=(e,t)=>{},w_e=e=>(t,n)=>async r=>{if(!m.isInstance(r.request))return t(r);let i=Ze(n).selectedHttpAuthScheme;if(!i)throw Error(`No HttpAuthScheme was selected: unable to sign request`);let{httpAuthOption:{signingProperties:a={}},identity:o,signer:s}=i,c;c=n.s3ExpressIdentity?await x_e(n.s3ExpressIdentity,a,r.request,await e.signer()):await s.sign(r.request,o,a);let l=await t({...r,request:c}).catch((s.errorHandler||S_e)(a));return(s.successHandler||C_e)(l.response,a),l},T_e=e=>({applyToStack:t=>{t.addRelativeTo(w_e(e),ti)}});function E_e(e){return new ReadableStream({start(t){t.enqueue(e),t.close()}})}var D_e={CopyObjectCommand:!0,UploadPartCopyCommand:!0,CompleteMultipartUploadCommand:!0},O_e=e=>(t,n)=>async r=>{let i=await t(r),{response:a}=i;if(!Qe.isInstance(a))return i;let{statusCode:o,body:s}=a;if(o<200||o>=300)return i;let c=await k_e(s,e);if(a.body=E_e(c),c.length===0&&D_e[n.commandName]){let e=Error(`S3 aborted request`);throw e.$metadata={httpStatusCode:503},e.name=`InternalError`,e}let l=e.utf8Encoder(c.subarray(c.length-16));return l&&l.endsWith(``)&&(a.statusCode=503),i},k_e=(e=new Uint8Array,t)=>e instanceof Uint8Array?Promise.resolve(e):t.streamCollector(e)||Promise.resolve(new Uint8Array),A_e={relation:`after`,toMiddleware:`deserializerMiddleware`,tags:[`THROW_200_EXCEPTIONS`,`S3`],name:`throw200ExceptionsMiddleware`,override:!0},x6=e=>({applyToStack:t=>{t.addRelativeTo(O_e(e),A_e)}});function j_e(e){return(t,n)=>async r=>{if(e.bucketEndpoint){let e=n.endpointV2;if(e){let t=r.input.Bucket;if(typeof t==`string`)try{let r=new URL(t);n.endpointV2={...e,url:r}}catch(e){let r=`@aws-sdk/middleware-sdk-s3: bucketEndpoint=true was set but Bucket=${t} could not be parsed as URL.`;throw n.logger?.constructor?.name===`NoOpLogger`?console.warn(r):n.logger?.warn?.(r),e}}}return t(r)}}var M_e={name:`bucketEndpointMiddleware`,override:!0,relation:`after`,toMiddleware:`endpointV2Middleware`};function N_e({bucketEndpoint:e}){return t=>async n=>{let{input:{Bucket:r}}=n;if(!e&&typeof r==`string`&&!vz(r)&&r.indexOf(`/`)>=0){let e=Error(`Bucket name shouldn't contain '/', received '${r}'`);throw e.name=`InvalidBucketName`,e}return t({...n})}}var P_e={step:`initialize`,tags:[`VALIDATE_BUCKET_NAME`],name:`validateBucketNameMiddleware`,override:!0},F_e=e=>({applyToStack:t=>{t.add(N_e(e),P_e),t.addRelativeTo(j_e(e),M_e)}}),I_e=class extends Qa{async serializeRequest(e,t,n){let r=await super.serializeRequest(e,t,n),i=h.of(e.input),a=i.getSchema(),o=0,s=a[6]??0;if(t&&typeof t==`object`)for(let[e,n]of i.structIterator()){if(++o>s)break;if(e===`Bucket`){if(!t.Bucket&&n.getMergedTraits().httpLabel)throw Error(`No value provided for input HTTP label: Bucket.`);break}}return r}};function L_e(e){return t=>async n=>{let{request:r}=n;if(e.expectContinueHeader!==!1&&m.isInstance(r)&&r.body&&e.runtime===`node`&&e.requestHandler?.constructor?.name!==`FetchHttpHandler`){let t=!0;if(typeof e.expectContinueHeader==`number`)try{t=(Number(r.headers?.[`content-length`])??e.bodyLengthChecker?.(r.body)??1/0)>=e.expectContinueHeader}catch{}else t=!!e.expectContinueHeader;t&&(r.headers.Expect=`100-continue`)}return t({...n,request:r})}}var R_e={step:`build`,tags:[`SET_EXPECT_HEADER`,`EXPECT_HEADER`],name:`addExpectContinueMiddleware`,override:!0},z_e=e=>({applyToStack:t=>{t.add(L_e(e),R_e)}}),K=`ref`,q=`argv`,S6=`backend`,C6=`authSchemes`,w6=`disableDoubleEncoding`,T6=`signingName`,E6=`signingRegion`,D6=`signingRegionSet`,J=-1,Y=!0,O6=!1,k6=`isSet`,A6=`booleanEquals`,X=`stringEquals`,j6=`coalesce`,M6=`substring`,N6=``,P6=`aws.partition`,F6=`partitionResult`,I6=`accessPointSuffix`,L6=`regionPrefix`,R6=e=>`outpostId_ssa_`+e+N6,z6=`hardwareType`,B6=`ite`,V6=`isValidHostLabel`,H6=`sigv4`,U6=`aws.isVirtualHostableS3Bucket`,W6=`url`,G6=`getAttr`,K6=`bucketArn`,q6=`--`,J6=`arnType`,Y6=`accesspoint`,X6=e=>`accessPointName_ssa_`+e+N6,Z6=`s3-object-lambda`,Q6=`s3-outposts`,$6=`bucketPartition`,e8=`us-east-1`,t8=`outpostType`,n8=`name`,r8=`s3`,i8=`{url#scheme}://{Bucket}.{url#authority}{url#path}`,a8=`{url#scheme}://{url#authority}{url#path}`,o8=`{url#scheme}://{url#authority}{url#normalizedPath}{Bucket}`,s8=`https://{Bucket}.s3-accelerate.{partitionResult#dnsSuffix}`,c8=`https://{Bucket}.s3.{partitionResult#dnsSuffix}`,l8=e=>`{url#scheme}://{accessPointName_ssa_`+e+`}-{bucketArn#accountId}.{url#authority}{url#path}`,u8=e=>"Invalid ARN: The access point name may only contain a-z, A-Z, 0-9 and `-`. Found: `{accessPointName_ssa_"+e+"}`",d8=`sigv4a`,f8=`{url#scheme}://{url#authority}{url#normalizedPath}{uri_encoded_bucket}`,p8=`https://s3.{partitionResult#dnsSuffix}/{uri_encoded_bucket}`,m8=`https://s3.{partitionResult#dnsSuffix}`,h8={[K]:`UseFIPS`},g8={[K]:`UseDualStack`},_8={[K]:`Bucket`},v8={fn:G6,[q]:[{[K]:F6},n8]},y8={[K]:W6},b8={[K]:`Region`},x8={[K]:K6},S8={[K]:J6},C8={[K]:`accessPointName_ssa_1`},w8={fn:G6,[q]:[x8,`region`]},T8={[K]:z6},E8={fn:G6,[q]:[x8,`service`]},D8={fn:G6,[q]:[x8,`accountId`]},O8={[S6]:`S3Express`,[C6]:[{[w6]:!0,[n8]:`{_s3e_auth}`,[T6]:`s3express`,[E6]:`{Region}`}]},k8={[S6]:`S3Express`,[C6]:[{[w6]:!0,[n8]:H6,[T6]:`s3express`,[E6]:`{Region}`}]},A8={[C6]:[{[w6]:!0,[n8]:d8,[T6]:Q6,[D6]:[`*`]},{[w6]:!0,[n8]:H6,[T6]:Q6,[E6]:`{Region}`}]},j8={[C6]:[{[w6]:!0,[n8]:H6,[T6]:r8,[E6]:e8}]},M8={[C6]:[{[w6]:!0,[n8]:H6,[T6]:r8,[E6]:`{Region}`}]},N8={[C6]:[{[w6]:!0,[n8]:H6,[T6]:Z6,[E6]:`{bucketArn#region}`}]},P8={[C6]:[{[w6]:!0,[n8]:H6,[T6]:r8,[E6]:`{bucketArn#region}`}]},F8={[C6]:[{[w6]:!0,[n8]:d8,[T6]:Q6,[D6]:[`*`]},{[w6]:!0,[n8]:H6,[T6]:Q6,[E6]:`{bucketArn#region}`}]},I8={[C6]:[{[w6]:!0,[n8]:H6,[T6]:Z6,[E6]:`{Region}`}]},L8=[b8],R8=[{[K]:`Endpoint`}],z8=[_8],B8=[_8,0,7,!0],V8=[x8,`resourceId[1]`],B_e=[`*`],H8={conditions:[[k6,L8],[A6,[{[K]:`Accelerate`},Y]],[A6,[h8,Y]],[A6,[g8,Y]],[k6,R8],[k6,z8],[X,[{fn:j6,[q]:[{fn:M6,[q]:[_8,0,6,Y]},N6]},`--x-s3`]],[X,[{fn:j6,[q]:[{fn:M6,[q]:B8},N6]},`--xa-s3`]],[P6,L8,F6],[M6,B8,I6],[X,[{[K]:I6},`--op-s3`]],[M6,[_8,8,12,Y],L6],[M6,[_8,32,49,Y],R6(2)],[M6,[_8,49,50,Y],z6],[A6,[{[K]:`ForcePathStyle`},Y]],[X,[v8,`aws-cn`]],[B6,[g8,`.dualstack`,N6],`_s3e_ds`],[V6,[{[K]:R6(2)},O6]],[B6,[h8,`-fips`,N6],`_s3e_fips`],[B6,[{fn:j6,[q]:[{[K]:`DisableS3ExpressSessionAuth`},O6]},H6,`sigv4-s3express`],`_s3e_auth`],[U6,[_8,O6]],[`parseURL`,R8,W6],[A6,[{fn:j6,[q]:[{[K]:`UseS3ExpressControlEndpoint`},O6]},Y]],[U6,[_8,Y]],[X,[{fn:G6,[q]:[y8,`scheme`]},`http`]],[V6,[b8,O6]],[`aws.parseArn`,z8,K6],[G6,[{fn:`split`,[q]:[_8,q6,0]},`[-2]`],`s3expressAvailabilityZoneId`],[X,[{fn:j6,[q]:[{fn:M6,[q]:[_8,0,4,O6]},N6]},`arn:`]],[X,[{fn:j6,[q]:[{fn:M6,[q]:[_8,16,18,Y]},N6]},q6]],[A6,[{fn:G6,[q]:[y8,`isIp`]},Y]],[X,[{fn:j6,[q]:[{fn:M6,[q]:[_8,21,23,Y]},N6]},q6]],[X,[{fn:j6,[q]:[{fn:M6,[q]:[_8,27,29,Y]},N6]},q6]],[X,[{[K]:L6},`beta`]],[`uriEncode`,z8,`uri_encoded_bucket`],[V6,[b8,Y]],[A6,[{fn:j6,[q]:[{[K]:`UseObjectLambdaEndpoint`},O6]},Y]],[G6,[x8,`resourceId[0]`],J6],[X,[S8,N6]],[X,[S8,Y6]],[G6,V8,X6(1)],[X,[C8,N6]],[X,[w8,N6]],[X,[{fn:j6,[q]:[{fn:M6,[q]:[_8,14,16,Y]},N6]},q6]],[X,[T8,`e`]],[X,[T8,`o`]],[X,[b8,`aws-global`]],[X,[{fn:j6,[q]:[{fn:M6,[q]:[_8,19,21,Y]},N6]},q6]],[X,[E8,Z6]],[A6,[{fn:j6,[q]:[{[K]:`DisableAccessPoints`},O6]},Y]],[X,[E8,Q6]],[P6,[w8],$6],[V6,[C8,Y]],[X,[{fn:j6,[q]:[{fn:M6,[q]:[_8,26,28,Y]},N6]},q6]],[X,[{fn:j6,[q]:[{fn:M6,[q]:[_8,15,17,Y]},N6]},q6]],[G6,[x8,`resourceId[4]`]],[X,[{fn:j6,[q]:[{fn:M6,[q]:[_8,20,22,Y]},N6]},q6]],[A6,[{[K]:`UseGlobalEndpoint`},Y]],[X,[b8,e8]],[G6,V8,R6(1)],[A6,[{fn:j6,[q]:[{[K]:`UseArnRegion`},Y]},Y]],[V6,[{[K]:R6(1)},O6]],[G6,[x8,`resourceId[2]`],t8],[X,[b8,w8]],[X,[{fn:G6,[q]:[{[K]:$6},n8]},v8]],[A6,[{[K]:`DisableMultiRegionAccessPoints`},Y]],[V6,[w8,Y]],[X,[{fn:G6,[q]:[x8,`partition`]},v8]],[X,[D8,N6]],[X,[E8,r8]],[V6,[D8,O6]],[G6,[x8,`resourceId[3]`],X6(2)],[V6,[C8,O6]],[X,[{[K]:t8},Y6]],[V6,[{[K]:X6(2)},O6]]],results:[[J],[J,`Accelerate cannot be used with FIPS`],[J,`Cannot set dual-stack in combination with a custom endpoint.`],[J,`A custom endpoint cannot be combined with FIPS`],[J,`A custom endpoint cannot be combined with S3 Accelerate`],[J,`Partition does not support FIPS`],[J,`S3Express does not support S3 Accelerate.`],[`{url#scheme}://{url#authority}/{uri_encoded_bucket}{url#path}`,O8],[i8,O8],[J,`S3Express bucket name is not a valid virtual hostable name.`],[`https://s3express-control{_s3e_fips}{_s3e_ds}.{Region}.{partitionResult#dnsSuffix}/{uri_encoded_bucket}`,k8],[`https://{Bucket}.s3express{_s3e_fips}-{s3expressAvailabilityZoneId}{_s3e_ds}.{Region}.{partitionResult#dnsSuffix}`,O8],[J,`Unrecognized S3Express bucket name format.`],[a8,O8],[`https://s3express-control{_s3e_fips}{_s3e_ds}.{Region}.{partitionResult#dnsSuffix}`,k8],[J,`Expected a endpoint to be specified but no endpoint was found`],[`https://{Bucket}.ec2.{url#authority}`,A8],[`https://{Bucket}.ec2.s3-outposts.{Region}.{partitionResult#dnsSuffix}`,A8],[`https://{Bucket}.op-{outpostId_ssa_2}.{url#authority}`,A8],[`https://{Bucket}.op-{outpostId_ssa_2}.s3-outposts.{Region}.{partitionResult#dnsSuffix}`,A8],[J,`Unrecognized hardware type: "Expected hardware type o or e but got {hardwareType}"`],[J,`Invalid Outposts Bucket alias - it must be a valid bucket name.`],[J,"Invalid ARN: The outpost Id must only contain a-z, A-Z, 0-9 and `-`."],[J,"Custom endpoint `{Endpoint}` was not a valid URI"],[J,`S3 Accelerate cannot be used in this region`],[`https://{Bucket}.s3-fips.dualstack.us-east-1.{partitionResult#dnsSuffix}`,j8],[`https://{Bucket}.s3-fips.dualstack.{Region}.{partitionResult#dnsSuffix}`,M8],[`https://{Bucket}.s3-fips.us-east-1.{partitionResult#dnsSuffix}`,j8],[`https://{Bucket}.s3-fips.{Region}.{partitionResult#dnsSuffix}`,M8],[`https://{Bucket}.s3-accelerate.dualstack.us-east-1.{partitionResult#dnsSuffix}`,j8],[`https://{Bucket}.s3-accelerate.dualstack.{partitionResult#dnsSuffix}`,M8],[`https://{Bucket}.s3.dualstack.us-east-1.{partitionResult#dnsSuffix}`,j8],[`https://{Bucket}.s3.dualstack.{Region}.{partitionResult#dnsSuffix}`,M8],[o8,j8],[i8,j8],[o8,M8],[i8,M8],[s8,j8],[s8,M8],[c8,j8],[c8,M8],[`https://{Bucket}.s3.{Region}.{partitionResult#dnsSuffix}`,M8],[J,`Invalid region: region was not a valid DNS name.`],[J,`S3 Object Lambda does not support Dual-stack`],[J,`S3 Object Lambda does not support S3 Accelerate`],[J,`Access points are not supported for this operation`],[J,"Invalid configuration: region from ARN `{bucketArn#region}` does not match client region `{Region}` and UseArnRegion is `false`"],[J,`Invalid ARN: Missing account id`],[l8(1),N8],[`https://{accessPointName_ssa_1}-{bucketArn#accountId}.s3-object-lambda-fips.{bucketArn#region}.{bucketPartition#dnsSuffix}`,N8],[`https://{accessPointName_ssa_1}-{bucketArn#accountId}.s3-object-lambda.{bucketArn#region}.{bucketPartition#dnsSuffix}`,N8],[J,u8(1)],[J,"Invalid ARN: The account id may only contain a-z, A-Z, 0-9 and `-`. Found: `{bucketArn#accountId}`"],[J,"Invalid region in ARN: `{bucketArn#region}` (invalid DNS name)"],[J,"Client was configured for partition `{partitionResult#name}` but ARN (`{Bucket}`) has `{bucketPartition#name}`"],[J,"Invalid ARN: The ARN may only contain a single resource component after `accesspoint`."],[J,`Invalid ARN: bucket ARN is missing a region`],[J,"Invalid ARN: Expected a resource of the format `accesspoint:` but no name was provided"],[J,"Invalid ARN: Object Lambda ARNs only support `accesspoint` arn types, but found: `{arnType}`"],[J,`Access Points do not support S3 Accelerate`],[`https://{accessPointName_ssa_1}-{bucketArn#accountId}.s3-accesspoint-fips.dualstack.{bucketArn#region}.{bucketPartition#dnsSuffix}`,P8],[`https://{accessPointName_ssa_1}-{bucketArn#accountId}.s3-accesspoint-fips.{bucketArn#region}.{bucketPartition#dnsSuffix}`,P8],[`https://{accessPointName_ssa_1}-{bucketArn#accountId}.s3-accesspoint.dualstack.{bucketArn#region}.{bucketPartition#dnsSuffix}`,P8],[l8(1),P8],[`https://{accessPointName_ssa_1}-{bucketArn#accountId}.s3-accesspoint.{bucketArn#region}.{bucketPartition#dnsSuffix}`,P8],[J,`Invalid ARN: The ARN was not for the S3 service, found: {bucketArn#service}`],[J,`S3 MRAP does not support dual-stack`],[J,`S3 MRAP does not support FIPS`],[J,`S3 MRAP does not support S3 Accelerate`],[J,`Invalid configuration: Multi-Region Access Point ARNs are disabled.`],[`https://{accessPointName_ssa_1}.accesspoint.s3-global.{partitionResult#dnsSuffix}`,{[C6]:[{[w6]:Y,name:d8,[T6]:r8,[D6]:B_e}]}],[J,"Client was configured for partition `{partitionResult#name}` but bucket referred to partition `{bucketArn#partition}`"],[J,`Invalid Access Point Name`],[J,`S3 Outposts does not support Dual-stack`],[J,`S3 Outposts does not support FIPS`],[J,`S3 Outposts does not support S3 Accelerate`],[J,`Invalid Arn: Outpost Access Point ARN contains sub resources`],[`https://{accessPointName_ssa_2}-{bucketArn#accountId}.{outpostId_ssa_1}.{url#authority}`,F8],[`https://{accessPointName_ssa_2}-{bucketArn#accountId}.{outpostId_ssa_1}.s3-outposts.{bucketArn#region}.{bucketPartition#dnsSuffix}`,F8],[J,u8(2)],[J,"Expected an outpost type `accesspoint`, found {outpostType}"],[J,`Invalid ARN: expected an access point name`],[J,`Invalid ARN: Expected a 4-component resource`],[J,"Invalid ARN: The outpost Id may only contain a-z, A-Z, 0-9 and `-`. Found: `{outpostId_ssa_1}`"],[J,`Invalid ARN: The Outpost Id was not set`],[J,`Invalid ARN: Unrecognized format: {Bucket} (type: {arnType})`],[J,`Invalid ARN: No ARN type specified`],[J,"Invalid ARN: `{Bucket}` was not a valid ARN"],[J,`Path-style addressing cannot be used with ARN buckets`],[`https://s3-fips.dualstack.us-east-1.{partitionResult#dnsSuffix}/{uri_encoded_bucket}`,j8],[`https://s3-fips.dualstack.{Region}.{partitionResult#dnsSuffix}/{uri_encoded_bucket}`,M8],[`https://s3-fips.us-east-1.{partitionResult#dnsSuffix}/{uri_encoded_bucket}`,j8],[`https://s3-fips.{Region}.{partitionResult#dnsSuffix}/{uri_encoded_bucket}`,M8],[`https://s3.dualstack.us-east-1.{partitionResult#dnsSuffix}/{uri_encoded_bucket}`,j8],[`https://s3.dualstack.{Region}.{partitionResult#dnsSuffix}/{uri_encoded_bucket}`,M8],[f8,j8],[f8,M8],[p8,j8],[p8,M8],[`https://s3.{Region}.{partitionResult#dnsSuffix}/{uri_encoded_bucket}`,M8],[J,`Path-style addressing cannot be used with S3 Accelerate`],[a8,I8],[`https://s3-object-lambda-fips.{Region}.{partitionResult#dnsSuffix}`,I8],[`https://s3-object-lambda.{Region}.{partitionResult#dnsSuffix}`,I8],[`https://s3-fips.dualstack.us-east-1.{partitionResult#dnsSuffix}`,j8],[`https://s3-fips.dualstack.{Region}.{partitionResult#dnsSuffix}`,M8],[`https://s3-fips.us-east-1.{partitionResult#dnsSuffix}`,j8],[`https://s3-fips.{Region}.{partitionResult#dnsSuffix}`,M8],[`https://s3.dualstack.us-east-1.{partitionResult#dnsSuffix}`,j8],[`https://s3.dualstack.{Region}.{partitionResult#dnsSuffix}`,M8],[a8,j8],[a8,M8],[m8,j8],[m8,M8],[`https://s3.{Region}.{partitionResult#dnsSuffix}`,M8],[J,`A region must be set when sending requests to S3.`]]},V_e=2,H_e=new Int32Array([-1,1,-1,0,3,100000115,1,424,4,2,272,5,3,233,6,4,85,7,5,15,8,8,9,100000115,16,10,13,18,11,13,19,12,13,22,100000014,13,35,14,100000042,36,100000103,435,6,271,16,7,270,17,8,19,18,14,501,106,9,20,24,10,21,24,11,22,24,12,23,24,13,547,24,14,77,25,20,73,26,26,27,78,37,28,100000086,38,100000086,29,39,47,30,48,100000058,31,50,32,100000085,51,33,136,55,100000076,34,59,35,100000084,60,39,36,61,37,100000083,62,38,146,63,41,100000046,61,40,100000083,62,41,150,64,42,100000054,66,43,100000053,70,44,100000052,71,45,100000081,73,46,100000080,74,100000078,100000079,40,48,100000057,41,100000057,49,42,185,50,48,62,51,49,100000045,52,51,53,526,60,56,54,62,100000055,55,63,57,100000046,62,100000055,57,64,58,100000054,66,59,100000053,69,60,100000065,70,61,100000052,72,100000064,100000051,49,100000045,63,51,64,526,60,67,65,62,100000055,66,63,68,100000046,62,100000055,68,64,69,100000054,66,70,100000053,68,100000047,71,70,72,100000052,72,100000050,100000051,25,74,100000042,46,100000039,75,57,76,100000041,58,100000040,100000041,26,100000088,78,28,100000087,79,34,82,80,35,81,545,36,100000103,100000115,46,100000097,83,57,84,100000099,58,100000098,100000099,5,101,86,8,87,100000115,16,88,89,18,91,89,19,90,92,21,97,95,19,93,92,21,98,95,21,97,94,22,100000014,95,35,96,100000042,36,100000103,100000042,22,100000013,98,35,99,100000042,36,100000101,100,46,100000110,100000111,6,214,102,7,208,103,8,119,104,14,118,105,21,106,100000023,26,107,502,37,108,100000086,38,100000086,109,39,112,110,48,100000058,111,50,136,100000085,40,113,100000057,41,100000057,114,42,115,500,48,100000056,116,52,117,100000072,65,100000069,100000072,21,501,100000023,9,120,124,10,121,124,11,122,124,12,123,124,13,202,124,14,195,125,20,190,126,21,127,100000023,23,128,129,24,189,129,26,130,197,37,131,100000086,38,100000086,132,39,159,133,48,100000058,134,50,135,100000085,51,141,136,55,100000076,137,59,138,100000084,60,100000083,139,61,140,100000083,63,100000083,100000046,55,100000076,142,59,143,100000084,60,148,144,61,145,100000083,62,147,146,63,150,100000046,63,153,100000046,61,149,100000083,62,153,150,64,151,100000054,66,152,100000053,70,100000082,100000052,64,154,100000054,66,155,100000053,70,156,100000052,71,157,100000081,73,158,100000080,74,100000077,100000079,40,160,100000057,41,100000057,161,42,185,162,48,174,163,49,100000045,164,51,165,526,60,168,166,62,100000055,167,63,169,100000046,62,100000055,169,64,170,100000054,66,171,100000053,69,172,100000065,70,173,100000052,72,100000063,100000051,49,100000045,175,51,176,526,60,179,177,62,100000055,178,63,180,100000046,62,100000055,180,64,181,100000054,66,182,100000053,68,100000047,183,70,184,100000052,72,100000048,100000051,48,100000056,186,52,187,100000072,65,100000069,188,67,100000070,100000071,25,100000036,100000042,21,191,100000023,25,192,100000042,30,194,193,46,100000034,100000036,46,100000033,100000035,21,196,100000023,26,100000088,197,28,100000087,198,34,201,199,35,200,545,36,100000101,100000115,46,100000095,100000096,17,203,100000022,20,204,100000021,21,205,550,33,206,550,44,100000016,207,45,100000018,100000020,8,209,215,16,210,220,18,211,220,19,212,224,20,213,227,21,231,401,8,218,215,19,216,100000009,20,217,227,21,231,100000009,16,219,220,18,223,220,19,221,224,20,222,227,21,231,100000012,19,226,224,20,225,100000009,21,100000009,100000012,20,230,227,21,228,100000009,30,229,100000009,34,100000007,100000009,21,231,415,30,232,100000008,34,100000007,100000008,4,100000002,234,5,235,480,6,271,236,7,270,237,8,238,491,9,239,243,10,240,243,11,241,243,12,242,243,13,547,243,14,266,244,20,264,245,26,246,267,37,247,100000086,38,100000086,248,39,249,518,40,250,100000057,41,100000057,251,42,538,252,48,100000043,253,49,100000045,254,51,255,526,60,258,256,62,100000055,257,63,259,100000046,62,100000055,259,64,260,100000054,66,261,100000053,69,262,100000065,70,263,100000052,72,100000062,100000051,25,265,100000042,46,100000031,100000032,26,100000088,267,28,100000087,268,34,269,544,46,100000093,100000094,8,397,100000009,8,407,100000009,3,346,273,4,100000003,274,5,284,275,8,276,100000115,15,100000005,277,16,278,281,18,279,281,19,280,281,22,100000014,281,35,282,100000042,36,100000102,283,46,100000106,100000107,6,405,285,7,395,286,8,295,287,14,501,288,26,289,502,37,290,100000086,38,100000086,291,39,292,307,40,293,100000057,41,100000057,294,42,335,500,9,296,300,10,297,300,11,298,300,12,299,300,13,394,300,14,339,301,15,100000005,302,20,337,303,26,304,341,37,305,100000086,38,100000086,306,39,309,307,48,100000058,308,50,100000074,100000085,40,310,100000057,41,100000057,311,42,335,312,48,324,313,49,100000045,314,51,315,526,60,318,316,62,100000055,317,63,319,100000046,62,100000055,319,64,320,100000054,66,321,100000053,69,322,100000065,70,323,100000052,72,100000061,100000051,49,100000045,325,51,326,526,60,329,327,62,100000055,328,63,330,100000046,62,100000055,330,64,331,100000054,66,332,100000053,68,100000047,333,70,334,100000052,72,100000049,100000051,48,100000056,336,52,100000067,100000072,25,338,100000042,46,100000027,100000028,15,100000005,340,26,100000088,341,28,100000087,342,34,345,343,35,344,545,36,100000102,100000115,46,100000091,100000092,4,100000002,347,5,357,348,8,349,100000115,15,100000005,350,16,351,354,18,352,354,19,353,354,22,100000014,354,35,355,100000042,36,100000043,356,46,100000104,100000105,6,405,358,7,395,359,8,360,491,9,361,365,10,362,365,11,363,365,12,364,365,13,394,365,14,389,366,15,100000005,367,20,387,368,26,369,391,37,370,100000086,38,100000086,371,39,372,518,40,373,100000057,41,100000057,374,42,538,375,48,100000043,376,49,100000045,377,51,378,526,60,381,379,62,100000055,380,63,382,100000046,62,100000055,382,64,383,100000054,66,384,100000053,69,385,100000065,70,386,100000052,72,100000060,100000051,25,388,100000042,46,100000025,100000026,15,100000005,390,26,100000088,391,28,100000087,392,34,393,544,46,100000089,100000090,15,100000005,547,8,396,100000009,15,100000005,397,16,398,410,18,399,410,19,400,410,20,401,100000009,27,402,100000012,29,100000011,403,31,100000011,404,32,100000011,422,8,406,100000009,15,100000005,407,16,408,410,18,409,410,19,411,410,20,100000012,100000009,20,414,412,22,413,100000009,34,100000010,100000009,22,416,415,27,419,100000012,27,418,417,34,100000010,100000012,34,100000010,419,43,100000011,420,47,100000011,421,53,100000011,422,54,100000011,423,56,100000011,100000012,2,100000001,425,3,478,426,4,100000004,427,5,438,428,8,429,100000115,16,430,433,18,431,433,19,432,433,22,100000014,433,35,434,100000042,36,100000044,435,46,100000112,436,57,437,100000114,58,100000113,100000114,6,100000006,439,7,100000006,440,8,450,441,14,501,442,26,443,502,37,444,100000086,38,100000086,445,39,446,465,40,447,100000057,41,100000057,448,42,471,449,48,100000044,500,9,451,455,10,452,455,11,453,455,12,454,455,13,547,455,14,473,456,15,460,457,20,458,461,25,459,100000042,46,100000037,100000038,20,540,461,26,462,474,37,463,100000086,38,100000086,464,39,467,465,48,100000058,466,50,100000075,100000085,40,468,100000057,41,100000057,469,42,471,470,48,100000044,524,48,100000044,472,52,100000068,100000072,26,100000088,474,28,100000087,475,34,100000100,476,35,477,545,36,100000044,100000115,4,100000002,479,5,488,480,8,481,100000115,16,482,485,18,483,485,19,484,485,22,100000014,485,35,486,100000042,36,100000043,487,46,100000108,100000109,6,100000006,489,7,100000006,490,8,503,491,14,501,492,26,493,502,37,494,100000086,38,100000086,495,39,496,518,40,497,100000057,41,100000057,498,42,538,499,48,100000043,500,49,100000045,526,26,100000088,502,28,100000087,100000115,9,504,508,10,505,508,11,506,508,12,507,508,13,547,508,14,541,509,15,513,510,20,511,514,25,512,100000042,46,100000029,100000030,20,540,514,26,515,542,37,516,100000086,38,100000086,517,39,520,518,48,100000058,519,50,100000073,100000085,40,521,100000057,41,100000057,522,42,538,523,48,100000043,524,49,100000045,525,51,529,526,60,100000055,527,62,100000055,528,63,100000055,100000046,60,532,530,62,100000055,531,63,533,100000046,62,100000055,533,64,534,100000054,66,535,100000053,69,536,100000065,70,537,100000052,72,100000059,100000051,48,100000043,539,52,100000066,100000072,25,100000024,100000042,26,100000088,542,28,100000087,543,34,100000100,544,35,546,545,36,100000042,100000115,36,100000043,100000115,17,548,100000022,20,549,100000021,33,552,550,44,100000017,551,45,100000019,100000020,44,100000015,553,45,100000015,100000020]),U_e=on.from(H_e,V_e,H8.conditions,H8.results),W_e=new sn({size:50,params:[`Accelerate`,`Bucket`,`DisableAccessPoints`,`DisableMultiRegionAccessPoints`,`DisableS3ExpressSessionAuth`,`Endpoint`,`ForcePathStyle`,`Region`,`UseArnRegion`,`UseDualStack`,`UseFIPS`,`UseGlobalEndpoint`,`UseObjectLambdaEndpoint`,`UseS3ExpressControlEndpoint`]}),U8=(e,t={})=>W_e.get(e,()=>jn(U_e,{endpointParams:e,logger:t.logger}));dn.aws=ji;var G_e=(e=>async(t,n,r)=>{if(!r)throw Error("Could not find `input` for `defaultEndpointRuleSetHttpAuthSchemeParametersProvider`");let i=await e(t,n,r),a=Ze(n)?.commandInstance?.constructor?.getEndpointParameterInstructions;if(!a)throw Error(`getEndpointParameterInstructions() is not defined on '${n.commandName}'`);let o=await pt(r,{getEndpointParameterInstructions:a},t);return Object.assign(i,o)})(async(e,t,n)=>({operation:Ze(t).operation,region:await re(e.region)()||(()=>{throw Error("expected `region` to be configured for `aws.auth#sigv4`")})()}));function W8(e){return{schemeId:`aws.auth#sigv4`,signingProperties:{name:`s3`,region:e.region},propertiesExtractor:(e,t)=>({signingProperties:{config:e,context:t}})}}function G8(e){return{schemeId:`aws.auth#sigv4a`,signingProperties:{name:`s3`,region:e.region},propertiesExtractor:(e,t)=>({signingProperties:{config:e,context:t}})}}var K_e=((e,t,n)=>r=>{let i=e(r).properties?.authSchemes;if(!i)return t(r);let a=[];for(let e of i){let{name:t,properties:o={},...s}=e,c=t.toLowerCase();t!==c&&console.warn(`HttpAuthScheme has been normalized with lowercasing: '${t}' to '${c}'`);let l;if(c===`sigv4a`){l=`aws.auth#sigv4a`;let e=i.find(e=>{let t=e.name.toLowerCase();return t!==`sigv4a`&&t.startsWith(`sigv4`)});if(b6.sigv4aDependency()===`none`&&e)continue}else if(c.startsWith(`sigv4`))l=`aws.auth#sigv4`;else throw Error(`Unknown HttpAuthScheme found in '@smithy.rules#endpointRuleSet': '${c}'`);let u=n[l];if(!u)throw Error(`Could not find HttpAuthOption create function for '${l}'`);let d=u(r);d.schemeId=l,d.signingProperties={...d.signingProperties||{},...s,...o},a.push(d)}return a})(U8,e=>{let t=[];switch(e.operation){default:t.push(W8(e)),t.push(G8(e))}return t},{"aws.auth#sigv4":W8,"aws.auth#sigv4a":G8}),q_e=e=>{let t=Zi(Qi(e));return Object.assign(t,{authSchemePreference:re(e.authSchemePreference??[])})},J_e=e=>Object.assign(e,{useFipsEndpoint:e.useFipsEndpoint??!1,useDualstackEndpoint:e.useDualstackEndpoint??!1,forcePathStyle:e.forcePathStyle??!1,useAccelerateEndpoint:e.useAccelerateEndpoint??!1,useGlobalEndpoint:e.useGlobalEndpoint??!1,disableMultiregionAccessPoints:e.disableMultiregionAccessPoints??!1,defaultSigningName:`s3`,clientContextParams:e.clientContextParams??{}}),K8={ForcePathStyle:{type:`clientContextParams`,name:`forcePathStyle`},UseArnRegion:{type:`clientContextParams`,name:`useArnRegion`},DisableMultiRegionAccessPoints:{type:`clientContextParams`,name:`disableMultiregionAccessPoints`},Accelerate:{type:`clientContextParams`,name:`useAccelerateEndpoint`},DisableS3ExpressSessionAuth:{type:`clientContextParams`,name:`disableS3ExpressSessionAuth`},UseGlobalEndpoint:{type:`builtInParams`,name:`useGlobalEndpoint`},UseFIPS:{type:`builtInParams`,name:`useFipsEndpoint`},Endpoint:{type:`builtInParams`,name:`endpoint`},Region:{type:`builtInParams`,name:`region`},UseDualStack:{type:`builtInParams`,name:`useDualstackEndpoint`}},q8=class e extends Ht{constructor(t){super(t),Object.setPrototypeOf(this,e.prototype)}},Y_e=class e extends q8{name=`NoSuchUpload`;$fault=`client`;constructor(t){super({name:`NoSuchUpload`,$fault:`client`,...t}),Object.setPrototypeOf(this,e.prototype)}},X_e=class e extends q8{name=`AccessDenied`;$fault=`client`;constructor(t){super({name:`AccessDenied`,$fault:`client`,...t}),Object.setPrototypeOf(this,e.prototype)}},Z_e=class e extends q8{name=`ObjectNotInActiveTierError`;$fault=`client`;constructor(t){super({name:`ObjectNotInActiveTierError`,$fault:`client`,...t}),Object.setPrototypeOf(this,e.prototype)}},Q_e=class e extends q8{name=`BucketAlreadyExists`;$fault=`client`;constructor(t){super({name:`BucketAlreadyExists`,$fault:`client`,...t}),Object.setPrototypeOf(this,e.prototype)}},$_e=class e extends q8{name=`BucketAlreadyOwnedByYou`;$fault=`client`;constructor(t){super({name:`BucketAlreadyOwnedByYou`,$fault:`client`,...t}),Object.setPrototypeOf(this,e.prototype)}},eve=class e extends q8{name=`NoSuchBucket`;$fault=`client`;constructor(t){super({name:`NoSuchBucket`,$fault:`client`,...t}),Object.setPrototypeOf(this,e.prototype)}},tve=class e extends q8{name=`InvalidObjectState`;$fault=`client`;StorageClass;AccessTier;constructor(t){super({name:`InvalidObjectState`,$fault:`client`,...t}),Object.setPrototypeOf(this,e.prototype),this.StorageClass=t.StorageClass,this.AccessTier=t.AccessTier}},nve=class e extends q8{name=`NoSuchKey`;$fault=`client`;constructor(t){super({name:`NoSuchKey`,$fault:`client`,...t}),Object.setPrototypeOf(this,e.prototype)}},rve=class e extends q8{name=`NotFound`;$fault=`client`;constructor(t){super({name:`NotFound`,$fault:`client`,...t}),Object.setPrototypeOf(this,e.prototype)}},ive=class e extends q8{name=`EncryptionTypeMismatch`;$fault=`client`;constructor(t){super({name:`EncryptionTypeMismatch`,$fault:`client`,...t}),Object.setPrototypeOf(this,e.prototype)}},ave=class e extends q8{name=`InvalidRequest`;$fault=`client`;constructor(t){super({name:`InvalidRequest`,$fault:`client`,...t}),Object.setPrototypeOf(this,e.prototype)}},ove=class e extends q8{name=`InvalidWriteOffset`;$fault=`client`;constructor(t){super({name:`InvalidWriteOffset`,$fault:`client`,...t}),Object.setPrototypeOf(this,e.prototype)}},sve=class e extends q8{name=`TooManyParts`;$fault=`client`;constructor(t){super({name:`TooManyParts`,$fault:`client`,...t}),Object.setPrototypeOf(this,e.prototype)}},cve=class e extends q8{name=`IdempotencyParameterMismatch`;$fault=`client`;constructor(t){super({name:`IdempotencyParameterMismatch`,$fault:`client`,...t}),Object.setPrototypeOf(this,e.prototype)}},lve=class e extends q8{name=`ObjectAlreadyInActiveTierError`;$fault=`client`;constructor(t){super({name:`ObjectAlreadyInActiveTierError`,$fault:`client`,...t}),Object.setPrototypeOf(this,e.prototype)}},J8=`ACL`,uve=`AccessDenied`,dve=`AbortDate`,Y8=`AccessKeyId`,fve=`AbortMultipartUpload`,pve=`AbortMultipartUploadOutput`,mve=`AbortMultipartUploadRequest`,hve=`AcceptRanges`,gve=`AbortRuleId`,_ve=`AccessTier`,X8=`Bucket`,Z8=`BucketArn`,vve=`BucketAlreadyExists`,yve=`BucketAlreadyOwnedByYou`,bve=`BypassGovernanceRetention`,xve=`BucketInfo`,Q8=`BucketKeyEnabled`,Sve=`BucketNamespace`,$8=`BucketRegion`,e5=`Body`,t5=`Buckets`,n5=`ChecksumAlgorithm`,Cve=`CreateBucket`,r5=`CreateBucketConfiguration`,wve=`CreateBucketOutput`,Tve=`CreateBucketRequest`,i5=`CacheControl`,a5=`ChecksumCRC32`,o5=`ChecksumCRC32C`,s5=`ChecksumCRC64NVME`,c5=`Cache-Control`,Eve=`CreationDate`,l5=`Content-Disposition`,u5=`ContentDisposition`,d5=`Content-Encoding`,f5=`ContentEncoding`,p5=`ContentLanguage`,m5=`Content-Language`,h5=`Content-Length`,g5=`ContentLength`,_5=`Content-MD5`,v5=`ChecksumMD5`,y5=`ContentMD5`,Dve=`CompletedMultipartUpload`,Ove=`CompleteMultipartUploadOutput`,kve=`CreateMultipartUploadOutput`,Ave=`CompleteMultipartUploadResult`,jve=`CompleteMultipartUploadRequest`,Mve=`CreateMultipartUploadRequest`,b5=`CompleteMultipartUpload`,Nve=`CreateMultipartUpload`,Pve=`ChecksumMode`,Fve=`CommonPrefix`,Ive=`CommonPrefixList`,Lve=`CompletedPartList`,Rve=`CompletedPart`,zve=`CommonPrefixes`,Bve=`ContentRange`,Vve=`Content-Range`,x5=`ChecksumSHA1`,S5=`ChecksumSHA256`,C5=`ChecksumSHA512`,Hve=`CreateSessionOutput`,Uve=`CreateSessionResult`,Wve=`CreateSessionRequest`,Gve=`CreateSession`,w5=`ChecksumType`,T5=`Content-Type`,E5=`ContentType`,D5=`ContinuationToken`,O5=`ChecksumXXHASH64`,k5=`ChecksumXXHASH3`,A5=`ChecksumXXHASH128`,Kve=`Contents`,j5=`Credentials`,M5=`DeleteMarker`,qve=`DisplayName`,Jve=`DeleteObjectOutput`,Yve=`DeleteObjectRequest`,Xve=`DeleteObject`,Zve=`DataRedundancy`,N5=`Delimiter`,P5=`Expiration`,F5=`ExpectedBucketOwner`,I5=`ExpiresString`,Qve=`EncryptionTypeMismatch`,L5=`ETag`,R5=`EncodingType`,z5=`Expires`,$ve=`FetchOwner`,B5=`GrantFullControl`,eye=`GetObject`,tye=`GetObjectOutput`,nye=`GetObjectRequest`,V5=`GrantRead`,H5=`GrantReadACP`,rye=`GrantWrite`,U5=`GrantWriteACP`,iye=`ID`,W5=`IfMatch`,aye=`IfMatchInitiatedTime`,oye=`IfMatchLastModifiedTime`,sye=`IfMatchSize`,cye=`If-Modified-Since`,lye=`IfModifiedSince`,uye=`InitiateMultipartUploadResult`,G5=`If-Match`,K5=`IfNoneMatch`,q5=`If-None-Match`,dye=`InvalidObjectState`,fye=`IdempotencyParameterMismatch`,pye=`InvalidRequest`,mye=`IsRestoreInProgress`,hye=`IsTruncated`,gye=`IfUnmodifiedSince`,_ye=`If-Unmodified-Since`,vye=`InvalidWriteOffset`,J5=`Key`,yye=`KeyCount`,Y5=`Location`,bye=`ListAllMyBucketsResult`,xye=`ListBuckets`,Sye=`ListBucketsOutput`,Cye=`ListBucketsRequest`,wye=`ListBucketResult`,Tye=`LocationConstraint`,Eye=`LocationInfo`,X5=`LastModified`,Dye=`Last-Modified`,Oye=`ListObjectsV2`,kye=`ListObjectsV2Output`,Aye=`ListObjectsV2Request`,Z5=`Metadata`,jye=`MaxBuckets`,Mye=`MFA`,Q5=`MaxKeys`,Nye=`MissingMeta`,Pye=`MpuObjectSize`,Fye=`MultipartUpload`,$5=`Name`,Iye=`NextContinuationToken`,Lye=`NotFound`,Rye=`NoSuchBucket`,zye=`NoSuchKey`,Bye=`NoSuchUpload`,e7=`Owner`,Vye=`ObjectAlreadyInActiveTierError`,Hye=`ObjectLockEnabledForBucket`,t7=`ObjectLockLegalHoldStatus`,n7=`ObjectLockMode`,r7=`ObjectLockRetainUntilDate`,Uye=`ObjectList`,Wye=`ObjectNotInActiveTierError`,Gye=`ObjectOwnership`,Kye=`OptionalObjectAttributes`,qye=`Object`,i7=`Prefix`,Jye=`PartsCount`,a7=`PartNumber`,Yye=`PutObject`,Xye=`PutObjectOutput`,Zye=`PutObjectRequest`,Qye=`PutObjectTagging`,$ye=`PutObjectTaggingOutput`,ebe=`PutObjectTaggingRequest`,o7=`Parts`,tbe=`Part`,s7=`RequestCharged`,nbe=`ResponseCacheControl`,rbe=`ResponseContentDisposition`,ibe=`ResponseContentEncoding`,abe=`ResponseContentLanguage`,obe=`ResponseContentType`,sbe=`ResponseExpires`,cbe=`RestoreExpiryDate`,c7=`RequestPayer`,lbe=`ReplicationStatus`,l7=`RestoreStatus`,u7=`Range`,ube=`Restore`,d7=`StartAfter`,f7=`SecretAccessKey`,dbe=`StreamingBlob`,p7=`StorageClass`,fbe=`SessionCredentialValue`,pbe=`SessionCredentials`,mbe=`SessionMode`,m7=`ServerSideEncryption`,h7=`SSECustomerAlgorithm`,g7=`SSECustomerKey`,_7=`SSECustomerKeyMD5`,v7=`SSEKMSEncryptionContext`,y7=`SSEKMSKeyId`,b7=`SessionToken`,x7=`Size`,hbe=`Tags`,gbe=`TagCount`,_be=`TooManyParts`,S7=`TagSet`,C7=`Tag`,w7=`Tagging`,T7=`Type`,E7=`UploadId`,vbe=`UploadPart`,ybe=`UploadPartOutput`,bbe=`UploadPartRequest`,xbe=`Value`,D7=`VersionId`,Sbe=`WriteOffsetBytes`,O7=`WebsiteRedirectLocation`,Cbe=`accept-ranges`,wbe=`bucket-region`,k7=`client`,A7=`continuation-token`,Tbe=`delimiter`,j7=`error`,Ebe=`encoding-type`,Dbe=`fetch-owner`,M7=`http`,N7=`httpChecksum`,P7=`httpError`,Z=`httpHeader`,F7=`httpPayload`,I7=`httpPrefixHeaders`,Q=`httpQuery`,Obe=`max-buckets`,kbe=`max-keys`,L7=`prefix`,R7=`partNumber`,Abe=`response-cache-control`,jbe=`response-content-disposition`,Mbe=`response-content-encoding`,Nbe=`response-content-language`,Pbe=`response-content-type`,Fbe=`response-expires`,z7=`smithy.ts.sdk.synthetic.com.amazonaws.s3`,Ibe=`start-after`,Lbe=`streaming`,B7=`uploadId`,V7=`versionId`,H7=`xmlFlattened`,U7=`xmlName`,W7=`x-amz-acl`,Rbe=`x-amz-abort-date`,zbe=`x-amz-abort-rule-id`,Bbe=`x-amz-bucket-arn`,Vbe=`x-amz-bypass-governance-retention`,Hbe=`x-amz-bucket-namespace`,Ube=`x-amz-bucket-object-lock-enabled`,G7=`x-amz-checksum-algorithm`,K7=`x-amz-checksum-crc32`,q7=`x-amz-checksum-crc32c`,J7=`x-amz-checksum-crc64nvme`,Y7=`x-amz-checksum-md5`,Wbe=`x-amz-checksum-mode`,X7=`x-amz-checksum-sha1`,Z7=`x-amz-checksum-sha256`,Q7=`x-amz-checksum-sha512`,Gbe=`x-amz-create-session-mode`,$7=`x-amz-checksum-type`,e9=`x-amz-checksum-xxhash64`,t9=`x-amz-checksum-xxhash3`,n9=`x-amz-checksum-xxhash128`,r9=`x-amz-delete-marker`,i9=`x-amz-expiration`,a9=`x-amz-expected-bucket-owner`,o9=`x-amz-grant-full-control`,s9=`x-amz-grant-read`,c9=`x-amz-grant-read-acp`,Kbe=`x-amz-grant-write`,l9=`x-amz-grant-write-acp`,qbe=`x-amz-if-match-initiated-time`,Jbe=`x-amz-if-match-last-modified-time`,Ybe=`x-amz-if-match-size`,u9=`x-amz-meta-`,Xbe=`x-amz-mfa`,Zbe=`x-amz-missing-meta`,Qbe=`x-amz-mp-object-size`,$be=`x-amz-mp-parts-count`,d9=`x-amz-object-lock-legal-hold`,f9=`x-amz-object-lock-mode`,p9=`x-amz-object-lock-retain-until-date`,exe=`x-amz-object-ownership`,txe=`x-amz-optional-object-attributes`,nxe=`x-amz-object-size`,rxe=`x-amz-restore`,m9=`x-amz-request-charged`,h9=`x-amz-request-payer`,ixe=`x-amz-replication-status`,g9=`x-amz-storage-class`,_9=`x-amz-sdk-checksum-algorithm`,v9=`x-amz-server-side-encryption`,y9=`x-amz-server-side-encryption-aws-kms-key-id`,b9=`x-amz-server-side-encryption-bucket-key-enabled`,x9=`x-amz-server-side-encryption-context`,S9=`x-amz-server-side-encryption-customer-algorithm`,C9=`x-amz-server-side-encryption-customer-key`,w9=`x-amz-server-side-encryption-customer-key-MD5`,T9=`x-amz-tagging`,axe=`x-amz-tagging-count`,E9=`x-amz-version-id`,oxe=`x-amz-write-offset-bytes`,D9=`x-amz-website-redirect-location`,$=`com.amazonaws.s3`,O9=Lt.for(z7),sxe=[-3,z7,`S3ServiceException`,0,[],[]];O9.registerError(sxe,q8);var k9=Lt.for($),cxe=[-3,$,uve,{[j7]:k7,[P7]:403},[],[]];k9.registerError(cxe,X_e);var lxe=[-3,$,vve,{[j7]:k7,[P7]:409},[],[]];k9.registerError(lxe,Q_e);var uxe=[-3,$,yve,{[j7]:k7,[P7]:409},[],[]];k9.registerError(uxe,$_e);var dxe=[-3,$,Qve,{[j7]:k7,[P7]:400},[],[]];k9.registerError(dxe,ive);var fxe=[-3,$,fye,{[j7]:k7,[P7]:400},[],[]];k9.registerError(fxe,cve);var pxe=[-3,$,dye,{[j7]:k7,[P7]:403},[p7,_ve],[0,0]];k9.registerError(pxe,tve);var mxe=[-3,$,pye,{[j7]:k7,[P7]:400},[],[]];k9.registerError(mxe,ave);var hxe=[-3,$,vye,{[j7]:k7,[P7]:400},[],[]];k9.registerError(hxe,ove);var gxe=[-3,$,Rye,{[j7]:k7,[P7]:404},[],[]];k9.registerError(gxe,eve);var _xe=[-3,$,zye,{[j7]:k7,[P7]:404},[],[]];k9.registerError(_xe,nve);var vxe=[-3,$,Bye,{[j7]:k7,[P7]:404},[],[]];k9.registerError(vxe,Y_e);var yxe=[-3,$,Lye,{[j7]:k7},[],[]];k9.registerError(yxe,rve);var bxe=[-3,$,Vye,{[j7]:k7,[P7]:403},[],[]];k9.registerError(bxe,lve);var xxe=[-3,$,Wye,{[j7]:k7,[P7]:403},[],[]];k9.registerError(xxe,Z_e);var Sxe=[-3,$,_be,{[j7]:k7,[P7]:400},[],[]];k9.registerError(Sxe,sve);var Cxe=[O9,k9],A9=[0,$,fbe,8,0],j9=[0,$,g7,8,0],M9=[0,$,v7,8,0],N9=[0,$,y7,8,0],P9=[0,$,dbe,{[Lbe]:1},42],wxe=[3,$,pve,0,[s7],[[0,{[Z]:m9}]]],Txe=[3,$,mve,0,[X8,J5,E7,c7,F5,aye],[[0,1],[0,1],[0,{[Q]:B7}],[0,{[Z]:h9}],[0,{[Z]:a9}],[6,{[Z]:qbe}]],3],Exe=[3,$,X8,0,[$5,Eve,$8,Z8],[0,4,0,0]],Dxe=[3,$,xve,0,[Zve,T7],[0,0]],Oxe=[3,$,Fve,0,[i7],[0]],kxe=[3,$,Dve,0,[o7],[[()=>cSe,{[H7]:1,[U7]:tbe}]]],Axe=[3,$,Rve,0,[L5,a5,o5,s5,x5,S5,C5,v5,O5,k5,A5,a7],[0,0,0,0,0,0,0,0,0,0,0,1]],jxe=[3,$,Ove,{[U7]:Ave},[Y5,X8,J5,P5,L5,a5,o5,s5,x5,S5,C5,v5,O5,k5,A5,w5,m7,D7,y7,Q8,s7],[0,0,0,[0,{[Z]:i9}],0,0,0,0,0,0,0,0,0,0,0,0,[0,{[Z]:v9}],[0,{[Z]:E9}],[()=>N9,{[Z]:y9}],[2,{[Z]:b9}],[0,{[Z]:m9}]]],Mxe=[3,$,jve,0,[X8,J5,E7,Fye,a5,o5,s5,x5,S5,C5,v5,O5,k5,A5,w5,Pye,c7,F5,W5,K5,h7,g7,_7],[[0,1],[0,1],[0,{[Q]:B7}],[()=>kxe,{[F7]:1,[U7]:b5}],[0,{[Z]:K7}],[0,{[Z]:q7}],[0,{[Z]:J7}],[0,{[Z]:X7}],[0,{[Z]:Z7}],[0,{[Z]:Q7}],[0,{[Z]:Y7}],[0,{[Z]:e9}],[0,{[Z]:t9}],[0,{[Z]:n9}],[0,{[Z]:$7}],[1,{[Z]:Qbe}],[0,{[Z]:h9}],[0,{[Z]:a9}],[0,{[Z]:G5}],[0,{[Z]:q5}],[0,{[Z]:S9}],[()=>j9,{[Z]:C9}],[0,{[Z]:w9}]],3],Nxe=[3,$,r5,0,[Tye,Y5,X8,hbe],[0,()=>Jxe,()=>Dxe,[()=>I9,0]]],Pxe=[3,$,wve,0,[Y5,Z8],[[0,{[Z]:Y5}],[0,{[Z]:Bbe}]]],Fxe=[3,$,Tve,0,[X8,J8,r5,B5,V5,H5,rye,U5,Hye,Gye,Sve],[[0,1],[0,{[Z]:W7}],[()=>Nxe,{[F7]:1,[U7]:r5}],[0,{[Z]:o9}],[0,{[Z]:s9}],[0,{[Z]:c9}],[0,{[Z]:Kbe}],[0,{[Z]:l9}],[2,{[Z]:Ube}],[0,{[Z]:exe}],[0,{[Z]:Hbe}]],1],Ixe=[3,$,kve,{[U7]:uye},[dve,gve,X8,J5,E7,m7,h7,_7,y7,v7,Q8,s7,n5,w5],[[4,{[Z]:Rbe}],[0,{[Z]:zbe}],[0,{[U7]:X8}],0,0,[0,{[Z]:v9}],[0,{[Z]:S9}],[0,{[Z]:w9}],[()=>N9,{[Z]:y9}],[()=>M9,{[Z]:x9}],[2,{[Z]:b9}],[0,{[Z]:m9}],[0,{[Z]:G7}],[0,{[Z]:$7}]]],Lxe=[3,$,Mve,0,[X8,J5,J8,i5,u5,f5,p5,E5,z5,B5,V5,H5,U5,Z5,m7,p7,O7,h7,g7,_7,y7,v7,Q8,c7,w7,n7,r7,t7,F5,n5,w5],[[0,1],[0,1],[0,{[Z]:W7}],[0,{[Z]:c5}],[0,{[Z]:l5}],[0,{[Z]:d5}],[0,{[Z]:m5}],[0,{[Z]:T5}],[4,{[Z]:z5}],[0,{[Z]:o9}],[0,{[Z]:s9}],[0,{[Z]:c9}],[0,{[Z]:l9}],[128,{[I7]:u9}],[0,{[Z]:v9}],[0,{[Z]:g9}],[0,{[Z]:D9}],[0,{[Z]:S9}],[()=>j9,{[Z]:C9}],[0,{[Z]:w9}],[()=>N9,{[Z]:y9}],[()=>M9,{[Z]:x9}],[2,{[Z]:b9}],[0,{[Z]:h9}],[0,{[Z]:T9}],[0,{[Z]:f9}],[5,{[Z]:p9}],[0,{[Z]:d9}],[0,{[Z]:a9}],[0,{[Z]:G7}],[0,{[Z]:$7}]],2],Rxe=[3,$,Hve,{[U7]:Uve},[j5,m7,y7,v7,Q8],[[()=>tSe,{[U7]:j5}],[0,{[Z]:v9}],[()=>N9,{[Z]:y9}],[()=>M9,{[Z]:x9}],[2,{[Z]:b9}]],1],zxe=[3,$,Wve,0,[X8,mbe,m7,y7,v7,Q8],[[0,1],[0,{[Z]:Gbe}],[0,{[Z]:v9}],[()=>N9,{[Z]:y9}],[()=>M9,{[Z]:x9}],[2,{[Z]:b9}]],1],Bxe=[3,$,Jve,0,[M5,D7,s7],[[2,{[Z]:r9}],[0,{[Z]:E9}],[0,{[Z]:m9}]]],Vxe=[3,$,Yve,0,[X8,J5,Mye,D7,c7,bve,F5,W5,oye,sye],[[0,1],[0,1],[0,{[Z]:Xbe}],[0,{[Q]:V7}],[0,{[Z]:h9}],[2,{[Z]:Vbe}],[0,{[Z]:a9}],[0,{[Z]:G5}],[6,{[Z]:Jbe}],[1,{[Z]:Ybe}]],2],Hxe=[3,$,tye,0,[e5,M5,hve,P5,ube,X5,g5,L5,a5,o5,s5,x5,S5,C5,v5,O5,k5,A5,w5,Nye,D7,i5,u5,f5,p5,Bve,E5,z5,I5,O7,m7,Z5,h7,_7,y7,Q8,p7,s7,lbe,Jye,gbe,n7,r7,t7],[[()=>P9,16],[2,{[Z]:r9}],[0,{[Z]:Cbe}],[0,{[Z]:i9}],[0,{[Z]:rxe}],[4,{[Z]:Dye}],[1,{[Z]:h5}],[0,{[Z]:L5}],[0,{[Z]:K7}],[0,{[Z]:q7}],[0,{[Z]:J7}],[0,{[Z]:X7}],[0,{[Z]:Z7}],[0,{[Z]:Q7}],[0,{[Z]:Y7}],[0,{[Z]:e9}],[0,{[Z]:t9}],[0,{[Z]:n9}],[0,{[Z]:$7}],[1,{[Z]:Zbe}],[0,{[Z]:E9}],[0,{[Z]:c5}],[0,{[Z]:l5}],[0,{[Z]:d5}],[0,{[Z]:m5}],[0,{[Z]:Vve}],[0,{[Z]:T5}],[4,{[Z]:z5}],[0,{[Z]:I5}],[0,{[Z]:D9}],[0,{[Z]:v9}],[128,{[I7]:u9}],[0,{[Z]:S9}],[0,{[Z]:w9}],[()=>N9,{[Z]:y9}],[2,{[Z]:b9}],[0,{[Z]:g9}],[0,{[Z]:m9}],[0,{[Z]:ixe}],[1,{[Z]:$be}],[1,{[Z]:axe}],[0,{[Z]:f9}],[5,{[Z]:p9}],[0,{[Z]:d9}]]],Uxe=[3,$,nye,0,[X8,J5,W5,lye,K5,gye,u7,nbe,rbe,ibe,abe,obe,sbe,D7,h7,g7,_7,c7,a7,F5,Pve],[[0,1],[0,1],[0,{[Z]:G5}],[4,{[Z]:cye}],[0,{[Z]:q5}],[4,{[Z]:_ye}],[0,{[Z]:u7}],[0,{[Q]:Abe}],[0,{[Q]:jbe}],[0,{[Q]:Mbe}],[0,{[Q]:Nbe}],[0,{[Q]:Pbe}],[6,{[Q]:Fbe}],[0,{[Q]:V7}],[0,{[Z]:S9}],[()=>j9,{[Z]:C9}],[0,{[Z]:w9}],[0,{[Z]:h9}],[1,{[Q]:R7}],[0,{[Z]:a9}],[0,{[Z]:Wbe}]],2],Wxe=[3,$,Sye,{[U7]:bye},[t5,e7,D5,i7],[[()=>oSe,0],()=>F9,0,0]],Gxe=[3,$,Cye,0,[jye,D5,i7,$8],[[1,{[Q]:Obe}],[0,{[Q]:A7}],[0,{[Q]:L7}],[0,{[Q]:wbe}]]],Kxe=[3,$,kye,{[U7]:wye},[hye,Kve,$5,i7,N5,Q5,zve,R5,yye,D5,Iye,d7,s7],[2,[()=>lSe,{[H7]:1}],0,0,0,1,[()=>sSe,{[H7]:1}],0,1,0,0,0,[0,{[Z]:m9}]]],qxe=[3,$,Aye,0,[X8,N5,R5,Q5,i7,D5,$ve,d7,c7,F5,Kye],[[0,1],[0,{[Q]:Tbe}],[0,{[Q]:Ebe}],[1,{[Q]:kbe}],[0,{[Q]:L7}],[0,{[Q]:A7}],[2,{[Q]:Dbe}],[0,{[Q]:Ibe}],[0,{[Z]:h9}],[0,{[Z]:a9}],[64,{[Z]:txe}]],1],Jxe=[3,$,Eye,0,[T7,$5],[0,0]],Yxe=[3,$,qye,0,[J5,X5,L5,n5,w5,x7,p7,e7,l7],[0,4,0,[64,{[H7]:1}],0,1,0,()=>F9,()=>eSe]],F9=[3,$,e7,0,[qve,iye],[0,0]],Xxe=[3,$,Xye,0,[P5,L5,a5,o5,s5,x5,S5,C5,v5,O5,k5,A5,w5,m7,D7,h7,_7,y7,v7,Q8,x7,s7],[[0,{[Z]:i9}],[0,{[Z]:L5}],[0,{[Z]:K7}],[0,{[Z]:q7}],[0,{[Z]:J7}],[0,{[Z]:X7}],[0,{[Z]:Z7}],[0,{[Z]:Q7}],[0,{[Z]:Y7}],[0,{[Z]:e9}],[0,{[Z]:t9}],[0,{[Z]:n9}],[0,{[Z]:$7}],[0,{[Z]:v9}],[0,{[Z]:E9}],[0,{[Z]:S9}],[0,{[Z]:w9}],[()=>N9,{[Z]:y9}],[()=>M9,{[Z]:x9}],[2,{[Z]:b9}],[1,{[Z]:nxe}],[0,{[Z]:m9}]]],Zxe=[3,$,Zye,0,[X8,J5,J8,e5,i5,u5,f5,p5,g5,y5,E5,n5,a5,o5,s5,x5,S5,C5,v5,O5,k5,A5,z5,W5,K5,B5,V5,H5,U5,Sbe,Z5,m7,p7,O7,h7,g7,_7,y7,v7,Q8,c7,w7,n7,r7,t7,F5],[[0,1],[0,1],[0,{[Z]:W7}],[()=>P9,16],[0,{[Z]:c5}],[0,{[Z]:l5}],[0,{[Z]:d5}],[0,{[Z]:m5}],[1,{[Z]:h5}],[0,{[Z]:_5}],[0,{[Z]:T5}],[0,{[Z]:_9}],[0,{[Z]:K7}],[0,{[Z]:q7}],[0,{[Z]:J7}],[0,{[Z]:X7}],[0,{[Z]:Z7}],[0,{[Z]:Q7}],[0,{[Z]:Y7}],[0,{[Z]:e9}],[0,{[Z]:t9}],[0,{[Z]:n9}],[4,{[Z]:z5}],[0,{[Z]:G5}],[0,{[Z]:q5}],[0,{[Z]:o9}],[0,{[Z]:s9}],[0,{[Z]:c9}],[0,{[Z]:l9}],[1,{[Z]:oxe}],[128,{[I7]:u9}],[0,{[Z]:v9}],[0,{[Z]:g9}],[0,{[Z]:D9}],[0,{[Z]:S9}],[()=>j9,{[Z]:C9}],[0,{[Z]:w9}],[()=>N9,{[Z]:y9}],[()=>M9,{[Z]:x9}],[2,{[Z]:b9}],[0,{[Z]:h9}],[0,{[Z]:T9}],[0,{[Z]:f9}],[5,{[Z]:p9}],[0,{[Z]:d9}],[0,{[Z]:a9}]],2],Qxe=[3,$,$ye,0,[D7],[[0,{[Z]:E9}]]],$xe=[3,$,ebe,0,[X8,J5,w7,D7,y5,n5,F5,c7],[[0,1],[0,1],[()=>rSe,{[F7]:1,[U7]:w7}],[0,{[Q]:V7}],[0,{[Z]:_5}],[0,{[Z]:_9}],[0,{[Z]:a9}],[0,{[Z]:h9}]],3],eSe=[3,$,l7,0,[mye,cbe],[2,4]],tSe=[3,$,pbe,0,[Y8,f7,b7,P5],[[0,{[U7]:Y8}],[()=>A9,{[U7]:f7}],[()=>A9,{[U7]:b7}],[4,{[U7]:P5}]],4],nSe=[3,$,C7,0,[J5,xbe],[0,0],2],rSe=[3,$,w7,0,[S7],[[()=>I9,0]],1],iSe=[3,$,ybe,0,[m7,L5,a5,o5,s5,x5,S5,C5,v5,O5,k5,A5,h7,_7,y7,Q8,s7],[[0,{[Z]:v9}],[0,{[Z]:L5}],[0,{[Z]:K7}],[0,{[Z]:q7}],[0,{[Z]:J7}],[0,{[Z]:X7}],[0,{[Z]:Z7}],[0,{[Z]:Q7}],[0,{[Z]:Y7}],[0,{[Z]:e9}],[0,{[Z]:t9}],[0,{[Z]:n9}],[0,{[Z]:S9}],[0,{[Z]:w9}],[()=>N9,{[Z]:y9}],[2,{[Z]:b9}],[0,{[Z]:m9}]]],aSe=[3,$,bbe,0,[X8,J5,a7,E7,e5,g5,y5,n5,a5,o5,s5,x5,S5,C5,v5,O5,k5,A5,h7,g7,_7,c7,F5],[[0,1],[0,1],[1,{[Q]:R7}],[0,{[Q]:B7}],[()=>P9,16],[1,{[Z]:h5}],[0,{[Z]:_5}],[0,{[Z]:_9}],[0,{[Z]:K7}],[0,{[Z]:q7}],[0,{[Z]:J7}],[0,{[Z]:X7}],[0,{[Z]:Z7}],[0,{[Z]:Q7}],[0,{[Z]:Y7}],[0,{[Z]:e9}],[0,{[Z]:t9}],[0,{[Z]:n9}],[0,{[Z]:S9}],[()=>j9,{[Z]:C9}],[0,{[Z]:w9}],[0,{[Z]:h9}],[0,{[Z]:a9}]],4],oSe=[1,$,t5,0,[()=>Exe,{[U7]:X8}]],sSe=[1,$,Ive,0,()=>Oxe],cSe=[1,$,Lve,0,()=>Axe],lSe=[1,$,Uye,0,[()=>Yxe,0]],I9=[1,$,S7,0,[()=>nSe,{[U7]:C7}]],uSe=[9,$,fve,{[M7]:[`DELETE`,`/{Key+}?x-id=AbortMultipartUpload`,204]},()=>Txe,()=>wxe],dSe=[9,$,b5,{[M7]:[`POST`,`/{Key+}`,200]},()=>Mxe,()=>jxe],fSe=[9,$,Cve,{[M7]:[`PUT`,`/`,200]},()=>Fxe,()=>Pxe],pSe=[9,$,Nve,{[M7]:[`POST`,`/{Key+}?uploads`,200]},()=>Lxe,()=>Ixe],mSe=[9,$,Gve,{[M7]:[`GET`,`/?session`,200]},()=>zxe,()=>Rxe],hSe=[9,$,Xve,{[M7]:[`DELETE`,`/{Key+}?x-id=DeleteObject`,204]},()=>Vxe,()=>Bxe],gSe=[9,$,eye,{[N7]:`-`,[M7]:[`GET`,`/{Key+}?x-id=GetObject`,200]},()=>Uxe,()=>Hxe],_Se=[9,$,xye,{[M7]:[`GET`,`/?x-id=ListBuckets`,200]},()=>Gxe,()=>Wxe],vSe=[9,$,Oye,{[M7]:[`GET`,`/?list-type=2`,200]},()=>qxe,()=>Kxe],ySe=[9,$,Yye,{[N7]:`-`,[M7]:[`PUT`,`/{Key+}?x-id=PutObject`,200]},()=>Zxe,()=>Xxe],bSe=[9,$,Qye,{[N7]:`-`,[M7]:[`PUT`,`/{Key+}?tagging`,200]},()=>$xe,()=>Qxe],xSe=[9,$,vbe,{[N7]:`-`,[M7]:[`PUT`,`/{Key+}?x-id=UploadPart`,200]},()=>aSe,()=>iSe],SSe=class extends Bt.classBuilder().ep({...K8,DisableS3ExpressSessionAuth:{type:`staticContextParams`,value:!0},Bucket:{type:`contextParams`,name:`Bucket`}}).m(function(e,t,n,r){return[Pn(n,e.getEndpointParameterInstructions()),x6(n)]}).s(`AmazonS3`,`CreateSession`,{}).n(`S3Client`,`CreateSessionCommand`).sc(mSe).build(){},CSe={name:`@aws-sdk/client-s3`,description:`AWS SDK for JavaScript S3 Client for Node.js, Browser and React Native`,version:`3.1063.0`,scripts:{build:`concurrently 'yarn:build:types' 'yarn:build:es' && yarn build:cjs`,"build:cjs":`node ../../scripts/compilation/inline client-s3`,"build:es":`tsc -p tsconfig.es.json`,"build:include:deps":`yarn g:turbo run build -F="$npm_package_name"`,"build:types":`tsc -p tsconfig.types.json`,"build:types:downlevel":`downlevel-dts dist-types dist-types/ts3.4`,clean:`premove dist-cjs dist-es dist-types tsconfig.cjs.tsbuildinfo tsconfig.es.tsbuildinfo tsconfig.types.tsbuildinfo`,"extract:docs":`api-extractor run --local`,"generate:client":`node ../../scripts/generate-clients/single-service --solo s3`,test:`yarn g:vitest run`,"test:browser":`node ./test/browser-build/esbuild && yarn g:vitest run -c vitest.config.browser.mts`,"test:browser:watch":`node ./test/browser-build/esbuild && yarn g:vitest watch -c vitest.config.browser.mts`,"test:e2e":`yarn g:vitest run -c vitest.config.e2e.mts && yarn test:browser`,"test:e2e:watch":`yarn g:vitest watch -c vitest.config.e2e.mts`,"test:index":`tsc --noEmit ./test/index-types.ts && node ./test/index-objects.spec.mjs`,"test:integration":`yarn g:vitest run -c vitest.config.integ.mts`,"test:integration:watch":`yarn g:vitest watch -c vitest.config.integ.mts`,"test:watch":`yarn g:vitest watch`},main:`./dist-cjs/index.js`,types:`./dist-types/index.d.ts`,module:`./dist-es/index.js`,sideEffects:!1,dependencies:{"@aws-crypto/sha1-browser":`5.2.0`,"@aws-crypto/sha256-browser":`5.2.0`,"@aws-crypto/sha256-js":`5.2.0`,"@aws-sdk/core":`^3.974.18`,"@aws-sdk/credential-provider-node":`^3.972.52`,"@aws-sdk/middleware-flexible-checksums":`^3.974.27`,"@aws-sdk/middleware-sdk-s3":`^3.972.48`,"@aws-sdk/signature-v4-multi-region":`^3.996.32`,"@aws-sdk/types":`^3.973.11`,"@smithy/core":`^3.24.6`,"@smithy/fetch-http-handler":`^5.4.6`,"@smithy/node-http-handler":`^4.7.6`,"@smithy/types":`^4.14.3`,tslib:`^2.6.2`},devDependencies:{"@aws-sdk/signature-v4-crt":`3.1063.0`,"@smithy/snapshot-testing":`^2.1.7`,"@tsconfig/node20":`20.1.8`,"@types/node":`^20.14.8`,concurrently:`7.0.0`,"downlevel-dts":`0.10.1`,premove:`4.0.0`,typescript:`~5.8.3`,vitest:`^4.0.17`},engines:{node:`>=20.0.0`},typesVersions:{"<4.5":{"dist-types/*":[`dist-types/ts3.4/*`]}},files:[`dist-*/**`],author:{name:`AWS SDK for JavaScript Team`,url:`https://aws.amazon.com/sdk-for-javascript/`},license:`Apache-2.0`,browser:{"./dist-es/runtimeConfig":`./dist-es/runtimeConfig.browser`},"react-native":{"./dist-es/runtimeConfig":`./dist-es/runtimeConfig.native`},homepage:`https://github.com/aws/aws-sdk-js-v3/tree/main/clients/client-s3`,repository:{type:`git`,url:`https://github.com/aws/aws-sdk-js-v3.git`,directory:`clients/client-s3`}};function L9(e){return typeof e==`string`?e.length===0:e.byteLength===0}var R9={name:`SHA-1`},z9={name:`HMAC`,hash:R9},wSe=new Uint8Array([218,57,163,238,94,107,75,13,50,85,191,239,149,96,24,144,175,216,7,9]),TSe=function(){function e(e){this.toHash=new Uint8Array,e!==void 0&&(this.key=new Promise(function(t,n){ie().crypto.subtle.importKey(`raw`,B9(e),z9,!1,[`sign`]).then(t,n)}),this.key.catch(function(){}))}return e.prototype.update=function(e){if(!L9(e)){var t=B9(e),n=new Uint8Array(this.toHash.byteLength+t.byteLength);n.set(this.toHash,0),n.set(t,this.toHash.byteLength),this.toHash=n}},e.prototype.digest=function(){var e=this;return this.key?this.key.then(function(t){return ie().crypto.subtle.sign(z9,t,e.toHash).then(function(e){return new Uint8Array(e)})}):L9(this.toHash)?Promise.resolve(wSe):Promise.resolve().then(function(){return ie().crypto.subtle.digest(R9,e.toHash)}).then(function(e){return Promise.resolve(new Uint8Array(e))})},e.prototype.reset=function(){this.toHash=new Uint8Array},e}();function B9(e){return typeof e==`string`?de(e):ArrayBuffer.isView(e)?new Uint8Array(e.buffer,e.byteOffset,e.byteLength/Uint8Array.BYTES_PER_ELEMENT):new Uint8Array(e)}var ESe=function(){function e(e){if(ne(ie()))this.hash=new TSe(e);else throw Error(`SHA1 not supported`)}return e.prototype.update=function(e,t){this.hash.update(ue(e))},e.prototype.digest=function(){return this.hash.digest()},e.prototype.reset=function(){this.hash.reset()},e}();async function DSe(e,t,n=1024*1024){let r=e.size,i=0;for(;i{n.update(e)}),n.digest()},kSe=[1732584193,4023233417,2562383102,271733878],ASe=class{state;buffer;bufferLength;bytesHashed;finished;constructor(){this.reset()}update(e){if(jSe(e))return;if(this.finished)throw Error(`Attempted to update an already finished hash.`);let t=MSe(e),n=0,{byteLength:r}=t;for(this.bytesHashed+=r;r>0;)this.buffer.setUint8(this.bufferLength++,t[n++]),r--,this.bufferLength===64&&(this.hashBuffer(),this.bufferLength=0)}async digest(){if(!this.finished){let{buffer:e,bufferLength:t,bytesHashed:n}=this,r=n*8;if(e.setUint8(this.bufferLength++,128),t%64>=56){for(let t=this.bufferLength;t<64;t++)e.setUint8(t,0);this.hashBuffer(),this.bufferLength=0}for(let t=this.bufferLength;t<56;t++)e.setUint8(t,0);e.setUint32(56,r>>>0,!0),e.setUint32(60,Math.floor(r/4294967296),!0),this.hashBuffer(),this.finished=!0}let e=new DataView(new ArrayBuffer(16));for(let t=0;t<4;t++)e.setUint32(t*4,this.state[t],!0);return new Uint8Array(e.buffer,e.byteOffset,e.byteLength)}hashBuffer(){let{buffer:e,state:t}=this,n=t[0],r=t[1],i=t[2],a=t[3];n=H9(n,r,i,a,e.getUint32(0,!0),7,3614090360),a=H9(a,n,r,i,e.getUint32(4,!0),12,3905402710),i=H9(i,a,n,r,e.getUint32(8,!0),17,606105819),r=H9(r,i,a,n,e.getUint32(12,!0),22,3250441966),n=H9(n,r,i,a,e.getUint32(16,!0),7,4118548399),a=H9(a,n,r,i,e.getUint32(20,!0),12,1200080426),i=H9(i,a,n,r,e.getUint32(24,!0),17,2821735955),r=H9(r,i,a,n,e.getUint32(28,!0),22,4249261313),n=H9(n,r,i,a,e.getUint32(32,!0),7,1770035416),a=H9(a,n,r,i,e.getUint32(36,!0),12,2336552879),i=H9(i,a,n,r,e.getUint32(40,!0),17,4294925233),r=H9(r,i,a,n,e.getUint32(44,!0),22,2304563134),n=H9(n,r,i,a,e.getUint32(48,!0),7,1804603682),a=H9(a,n,r,i,e.getUint32(52,!0),12,4254626195),i=H9(i,a,n,r,e.getUint32(56,!0),17,2792965006),r=H9(r,i,a,n,e.getUint32(60,!0),22,1236535329),n=U9(n,r,i,a,e.getUint32(4,!0),5,4129170786),a=U9(a,n,r,i,e.getUint32(24,!0),9,3225465664),i=U9(i,a,n,r,e.getUint32(44,!0),14,643717713),r=U9(r,i,a,n,e.getUint32(0,!0),20,3921069994),n=U9(n,r,i,a,e.getUint32(20,!0),5,3593408605),a=U9(a,n,r,i,e.getUint32(40,!0),9,38016083),i=U9(i,a,n,r,e.getUint32(60,!0),14,3634488961),r=U9(r,i,a,n,e.getUint32(16,!0),20,3889429448),n=U9(n,r,i,a,e.getUint32(36,!0),5,568446438),a=U9(a,n,r,i,e.getUint32(56,!0),9,3275163606),i=U9(i,a,n,r,e.getUint32(12,!0),14,4107603335),r=U9(r,i,a,n,e.getUint32(32,!0),20,1163531501),n=U9(n,r,i,a,e.getUint32(52,!0),5,2850285829),a=U9(a,n,r,i,e.getUint32(8,!0),9,4243563512),i=U9(i,a,n,r,e.getUint32(28,!0),14,1735328473),r=U9(r,i,a,n,e.getUint32(48,!0),20,2368359562),n=W9(n,r,i,a,e.getUint32(20,!0),4,4294588738),a=W9(a,n,r,i,e.getUint32(32,!0),11,2272392833),i=W9(i,a,n,r,e.getUint32(44,!0),16,1839030562),r=W9(r,i,a,n,e.getUint32(56,!0),23,4259657740),n=W9(n,r,i,a,e.getUint32(4,!0),4,2763975236),a=W9(a,n,r,i,e.getUint32(16,!0),11,1272893353),i=W9(i,a,n,r,e.getUint32(28,!0),16,4139469664),r=W9(r,i,a,n,e.getUint32(40,!0),23,3200236656),n=W9(n,r,i,a,e.getUint32(52,!0),4,681279174),a=W9(a,n,r,i,e.getUint32(0,!0),11,3936430074),i=W9(i,a,n,r,e.getUint32(12,!0),16,3572445317),r=W9(r,i,a,n,e.getUint32(24,!0),23,76029189),n=W9(n,r,i,a,e.getUint32(36,!0),4,3654602809),a=W9(a,n,r,i,e.getUint32(48,!0),11,3873151461),i=W9(i,a,n,r,e.getUint32(60,!0),16,530742520),r=W9(r,i,a,n,e.getUint32(8,!0),23,3299628645),n=G9(n,r,i,a,e.getUint32(0,!0),6,4096336452),a=G9(a,n,r,i,e.getUint32(28,!0),10,1126891415),i=G9(i,a,n,r,e.getUint32(56,!0),15,2878612391),r=G9(r,i,a,n,e.getUint32(20,!0),21,4237533241),n=G9(n,r,i,a,e.getUint32(48,!0),6,1700485571),a=G9(a,n,r,i,e.getUint32(12,!0),10,2399980690),i=G9(i,a,n,r,e.getUint32(40,!0),15,4293915773),r=G9(r,i,a,n,e.getUint32(4,!0),21,2240044497),n=G9(n,r,i,a,e.getUint32(32,!0),6,1873313359),a=G9(a,n,r,i,e.getUint32(60,!0),10,4264355552),i=G9(i,a,n,r,e.getUint32(24,!0),15,2734768916),r=G9(r,i,a,n,e.getUint32(52,!0),21,1309151649),n=G9(n,r,i,a,e.getUint32(16,!0),6,4149444226),a=G9(a,n,r,i,e.getUint32(44,!0),10,3174756917),i=G9(i,a,n,r,e.getUint32(8,!0),15,718787259),r=G9(r,i,a,n,e.getUint32(36,!0),21,3951481745),t[0]=n+t[0]&4294967295,t[1]=r+t[1]&4294967295,t[2]=i+t[2]&4294967295,t[3]=a+t[3]&4294967295}reset(){this.state=Uint32Array.from(kSe),this.buffer=new DataView(new ArrayBuffer(64)),this.bufferLength=0,this.bytesHashed=0,this.finished=!1}};function V9(e,t,n,r,i,a){return t=(t+e&4294967295)+(r+a&4294967295)&4294967295,(t<>>32-i)+n&4294967295}function H9(e,t,n,r,i,a,o){return V9(t&n|~t&r,e,t,i,a,o)}function U9(e,t,n,r,i,a,o){return V9(t&r|n&~r,e,t,i,a,o)}function W9(e,t,n,r,i,a,o){return V9(t^n^r,e,t,i,a,o)}function G9(e,t,n,r,i,a,o){return V9(n^(t|~r),e,t,i,a,o)}function jSe(e){return typeof e==`string`?e.length===0:e.byteLength===0}function MSe(e){return typeof e==`string`?ce(e):ArrayBuffer.isView(e)?new Uint8Array(e.buffer,e.byteOffset,e.byteLength/Uint8Array.BYTES_PER_ELEMENT):new Uint8Array(e)}var NSe=e=>({apiVersion:`2006-03-01`,base64Decoder:e?.base64Decoder??fe,base64Encoder:e?.base64Encoder??le,disableHostPrefix:e?.disableHostPrefix??!1,endpointProvider:e?.endpointProvider??U8,extensions:e?.extensions??[],getAwsChunkedEncodingStream:e?.getAwsChunkedEncodingStream??ge,httpAuthSchemeProvider:e?.httpAuthSchemeProvider??K_e,httpAuthSchemes:e?.httpAuthSchemes??[{schemeId:`aws.auth#sigv4`,identityProvider:e=>e.getIdentityProvider(`aws.auth#sigv4`),signer:new Yi},{schemeId:`aws.auth#sigv4a`,identityProvider:e=>e.getIdentityProvider(`aws.auth#sigv4a`),signer:new Xi}],logger:e?.logger??new $t,protocol:e?.protocol??I_e,protocolSettings:e?.protocolSettings??{defaultNamespace:`com.amazonaws.s3`,errorTypeRegistries:Cxe,xmlNamespace:`http://s3.amazonaws.com/doc/2006-03-01/`,version:`2006-03-01`,serviceTarget:`AmazonS3`},sdkStreamMixin:e?.sdkStreamMixin??be,serviceId:e?.serviceId??`S3`,signerConstructor:e?.signerConstructor??b6,signingEscapePath:e?.signingEscapePath??!1,urlParser:e?.urlParser??nt,useArnRegion:e?.useArnRegion??void 0,utf8Decoder:e?.utf8Decoder??ce,utf8Encoder:e?.utf8Encoder??me}),PSe=e=>{let t=zi(e),n=()=>t().then(Wt),r=NSe(e);return{...r,...e,runtime:`browser`,defaultsMode:t,bodyLengthChecker:e?.bodyLengthChecker??Ee,credentialDefaultProvider:e?.credentialDefaultProvider??(e=>()=>Promise.reject(Error(`Credential is missing`))),defaultUserAgentProvider:e?.defaultUserAgentProvider??Ei({serviceId:r.serviceId,clientVersion:CSe.version}),eventStreamSerdeProvider:e?.eventStreamSerdeProvider??je,maxAttempts:e?.maxAttempts??3,md5:e?.md5??ASe,region:e?.region??yt(`Region is missing`),requestHandler:da.create(e?.requestHandler??n),retryMode:e?.retryMode??(async()=>(await n()).retryMode||Nr),sha1:e?.sha1??ESe,sha256:e?.sha256??ee,streamCollector:e?.streamCollector??pa,streamHasher:e?.streamHasher??OSe,useDualstackEndpoint:e?.useDualstackEndpoint??(()=>Promise.resolve(!1)),useFipsEndpoint:e?.useFipsEndpoint??(()=>Promise.resolve(!1))}},FSe=e=>{let t=e.httpAuthSchemes,n=e.httpAuthSchemeProvider,r=e.credentials;return{setHttpAuthScheme(e){let n=t.findIndex(t=>t.schemeId===e.schemeId);n===-1?t.push(e):t.splice(n,1,e)},httpAuthSchemes(){return t},setHttpAuthSchemeProvider(e){n=e},httpAuthSchemeProvider(){return n},setCredentials(e){r=e},credentials(){return r}}},ISe=e=>({httpAuthSchemes:e.httpAuthSchemes(),httpAuthSchemeProvider:e.httpAuthSchemeProvider(),credentials:e.credentials()}),LSe=(e,t)=>{let n=Object.assign(Vi(e),Xt(e),Xn(e),FSe(e));return t.forEach(e=>e.configure(n)),Object.assign(e,Hi(n),Zt(n),Zn(n),ISe(n))},RSe=class extends bt{config;constructor(...[e]){let t=PSe(e||{});super(t),this.initConfig=t;let n=LSe(m_e(q_e(eee(Nn(zr(Li(Ir(o_e(li(J_e(t))))))))),{session:[()=>this,SSe]}),e?.extensions||[]);this.config=n,this.middlewareStack.use(Ot(this.config)),this.middlewareStack.use(Ti(this.config)),this.middlewareStack.use(Lr(this.config)),this.middlewareStack.use(tr(this.config)),this.middlewareStack.use(Hr(this.config)),this.middlewareStack.use(Gr(this.config)),this.middlewareStack.use(Kr(this.config)),this.middlewareStack.use(Zr(this.config,{httpAuthSchemeParametersProvider:G_e,identityProviderConfigProvider:async e=>new ii({"aws.auth#sigv4":e.credentials,"aws.auth#sigv4a":e.credentials})})),this.middlewareStack.use(ni(this.config)),this.middlewareStack.use(F_e(this.config)),this.middlewareStack.use(z_e(this.config)),this.middlewareStack.use(d_e(this.config)),this.middlewareStack.use(b_e(this.config)),this.middlewareStack.use(T_e(this.config))}destroy(){super.destroy()}},K9=`ap-southeast-2`,q9=`${window.location.protocol}//localhost:9999`,zSe=`${window.location.protocol}//localhost:8443`,J9=null,Y9=null,X9=null,Z9=null,Q9=null,$9=null;function BSe(){if(!J9){let e=oe();if(!e)throw Error(`AWS credentials not configured`);J9=new _z({endpoint:q9,region:K9,credentials:{accessKeyId:e.accessKeyId,secretAccessKey:e.secretAccessKey}}),J9.middlewareStack.add(e=>async t=>(m.isInstance(t.request)&&(t.request.hostname=window.location.hostname,t.request.port=Number(window.location.port)||443,t.request.path=`/proxy/awsgw${t.request.path}`),await e(t)),{step:`finalizeRequest`,name:`proxyRewrite`,override:!0})}return J9}function VSe(){if(!Y9){let e=oe();if(!e)throw Error(`AWS credentials not configured`);Y9=new aX({endpoint:q9,region:K9,credentials:{accessKeyId:e.accessKeyId,secretAccessKey:e.secretAccessKey}}),Y9.middlewareStack.add(e=>async t=>(m.isInstance(t.request)&&(t.request.hostname=window.location.hostname,t.request.port=Number(window.location.port)||443,t.request.path=`/proxy/awsgw${t.request.path}`),await e(t)),{step:`finalizeRequest`,name:`proxyRewrite`,override:!0})}return Y9}function HSe(){if(!X9){let e=oe();if(!e)throw Error(`AWS credentials not configured`);X9=new Zue({endpoint:q9,region:K9,credentials:{accessKeyId:e.accessKeyId,secretAccessKey:e.secretAccessKey}}),X9.middlewareStack.add(e=>async t=>(m.isInstance(t.request)&&(t.request.hostname=window.location.hostname,t.request.port=Number(window.location.port)||443,t.request.path=`/proxy/awsgw${t.request.path}`),await e(t)),{step:`finalizeRequest`,name:`proxyRewrite`,override:!0})}return X9}function USe(){if(!Z9){let e=oe();if(!e)throw Error(`AWS credentials not configured`);Z9=new es({endpoint:q9,region:K9,credentials:{accessKeyId:e.accessKeyId,secretAccessKey:e.secretAccessKey}}),Z9.middlewareStack.add(e=>async t=>(m.isInstance(t.request)&&(t.request.hostname=window.location.hostname,t.request.port=Number(window.location.port)||443,t.request.path=`/proxy/awsgw${t.request.path}`),await e(t)),{step:`finalizeRequest`,name:`proxyRewrite`,override:!0})}return Z9}function WSe(){if(!Q9){let e=oe();if(!e)throw Error(`AWS credentials not configured`);Q9=new e_e({endpoint:q9,region:K9,credentials:{accessKeyId:e.accessKeyId,secretAccessKey:e.secretAccessKey}}),Q9.middlewareStack.add(e=>async t=>(m.isInstance(t.request)&&(t.request.hostname=window.location.hostname,t.request.port=Number(window.location.port)||443,t.request.path=`/proxy/awsgw${t.request.path}`),await e(t)),{step:`finalizeRequest`,name:`proxyRewrite`,override:!0})}return Q9}function GSe(){if(!$9){let e=oe();if(!e)throw Error(`AWS credentials not configured`);$9=new RSe({endpoint:zSe,region:K9,credentials:{accessKeyId:e.accessKeyId,secretAccessKey:e.secretAccessKey},forcePathStyle:!0}),$9.middlewareStack.add(e=>async t=>(m.isInstance(t.request)&&t.request.path.endsWith(`/`)&&t.request.path!==`/`&&(t.request.path=t.request.path.slice(0,-1)),await e(t)),{step:`build`,name:`removeTrailingSlash`}),$9.middlewareStack.add(e=>async t=>(m.isInstance(t.request)&&(t.request.hostname=window.location.hostname,t.request.port=Number(window.location.port)||443,t.request.path=`/proxy/s3${t.request.path}`),await e(t)),{step:`finalizeRequest`,name:`proxyRewrite`,override:!0})}return $9}function KSe(){J9=null,Y9=null,X9=null,Z9=null,Q9=null,$9=null}export{qge as $,ZR as $n,$Y as $t,wge as A,OR as An,AY as At,Pge as B,RR as Bn,BY as Bt,f6 as C,xR as Cn,Pn as Cr,Bue as Ct,xge as D,TR as Dn,Qe as Dr,Wue as Dt,bge as E,wR as En,rt as Er,Uue as Et,kge as F,NR as Fn,FY as Ft,zge as G,UR as Gn,GY as Gt,Ige as H,BR as Hn,HY as Ht,Age as I,PR as In,IY as It,Hge as J,KR as Jn,JY as Jt,Bge as K,WR as Kn,KY as Kt,jge as L,IR as Ln,LY as Lt,Ege as M,AR as Mn,MY as Mt,Dge as N,jR as Nn,NY as Nt,Sge as O,ER as On,qe as Or,Gue as Ot,Oge as P,MR as Pn,PY as Pt,Kge as Q,XR as Qn,QY as Qt,Mge as R,FR as Rn,RY as Rt,a_e as S,bR as Sn,Mn as Sr,zue as St,yge as T,CR as Tn,Bt as Tr,Hue as Tt,Lge as U,VR as Un,UY as Ut,Fge as V,zR as Vn,VY as Vt,Rge as W,HR as Wn,WY as Wt,Wge as X,JR as Xn,XY as Xt,Uge as Y,qR as Yn,YY as Yt,Gge as Z,YR as Zn,ZY as Zt,bSe as _,hR as _n,oa as _r,Pue as _t,HSe as a,rR as an,rz as ar,Sue as at,x6 as b,vR as bn,Vn as br,Lue as bt,uSe as c,oR as cn,oz as cr,Tue as ct,pSe as d,lR as dn,lz as dr,Oue as dt,wz as en,QR as er,Jge as et,hSe as f,uR as fn,uz as fr,kue as ft,ySe as g,mR as gn,Jo as gr,Nue as gt,vSe as h,pR as hn,qo as hr,Mue as ht,VSe as i,nR as in,nz as ir,xue as it,Tge as j,kR as jn,jY as jt,Cge as k,DR as kn,Pe as kr,dX as kt,dSe as l,sR as ln,sz as lr,Eue as lt,_Se as m,fR as mn,os as mr,jue as mt,USe as n,eR as nn,ez as nr,yue as nt,WSe as o,iR as on,iz as or,Cue as ot,gSe as p,dR as pn,dz as pr,Aue as pt,Vge as q,GR as qn,qY as qt,BSe as r,tR as rn,tz as rr,bue as rt,GSe as s,aR as sn,az as sr,wue as st,KSe as t,$L as tn,$R as tr,rde as tt,fSe as u,cR as un,cz as ur,Due as ut,xSe as v,gR as vn,Rr as vr,Fue as vt,n_e as w,SR as wn,$t as wr,Vue as wt,p6 as x,yR as xn,In as xr,Rue as xt,K8 as y,_R as yn,nr as yr,Iue as yt,Nge as z,LR as zn,zY as zt};
\ No newline at end of file
diff --git a/spinifex/services/spinifexui/frontend/dist/assets/back-link.js b/spinifex/services/spinifexui/frontend/dist/assets/back-link.js
index 3c6671eae..829a71053 100644
--- a/spinifex/services/spinifexui/frontend/dist/assets/back-link.js
+++ b/spinifex/services/spinifexui/frontend/dist/assets/back-link.js
@@ -1 +1 @@
-import{Fo as e,Ho as t,Lo as n,wo as r}from"./index.js";var i=r(`arrow-left`,[[`path`,{d:`m12 19-7-7 7-7`,key:`1l729n`}],[`path`,{d:`M19 12H5`,key:`x3x0zl`}]]),a=e(),o=t();function s(e){let t=(0,a.c)(7),r,s;t[0]===e?(r=t[1],s=t[2]):({children:r,...s}=e,t[0]=e,t[1]=r,t[2]=s);let c;t[3]===Symbol.for(`react.memo_cache_sentinel`)?(c=(0,o.jsx)(i,{className:`size-4`}),t[3]=c):c=t[3];let l;return t[4]!==r||t[5]!==s?(l=(0,o.jsxs)(n,{className:`mb-6 inline-flex items-center gap-2 text-sm`,...s,children:[c,r]}),t[4]=r,t[5]=s,t[6]=l):l=t[6],l}export{s as t};
\ No newline at end of file
+import{n as e,t}from"./compiler-runtime.js";import{Or as n}from"./awsClient.js";import{Nr as r}from"./index.js";var i=r(`arrow-left`,[[`path`,{d:`m12 19-7-7 7-7`,key:`1l729n`}],[`path`,{d:`M19 12H5`,key:`x3x0zl`}]]),a=t(),o=e();function s(e){let t=(0,a.c)(7),r,s;t[0]===e?(r=t[1],s=t[2]):({children:r,...s}=e,t[0]=e,t[1]=r,t[2]=s);let c;t[3]===Symbol.for(`react.memo_cache_sentinel`)?(c=(0,o.jsx)(i,{className:`size-4`}),t[3]=c):c=t[3];let l;return t[4]!==r||t[5]!==s?(l=(0,o.jsxs)(n,{className:`mb-6 inline-flex items-center gap-2 text-sm`,...s,children:[c,r]}),t[4]=r,t[5]=s,t[6]=l):l=t[6],l}export{s as t};
\ No newline at end of file
diff --git a/spinifex/services/spinifexui/frontend/dist/assets/badge.js b/spinifex/services/spinifexui/frontend/dist/assets/badge.js
index b38ffe7c7..b34159e56 100644
--- a/spinifex/services/spinifexui/frontend/dist/assets/badge.js
+++ b/spinifex/services/spinifexui/frontend/dist/assets/badge.js
@@ -1 +1 @@
-import{$a as e,Eo as t,Fo as n,Po as r,To as i}from"./index.js";var a=n(),o=i(`group/badge inline-flex h-5 w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-full border border-transparent px-2 py-0.5 text-[0.625rem] font-medium whitespace-nowrap transition-all transition-colors focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&>svg]:pointer-events-none [&>svg]:size-2.5!`,{variants:{variant:{default:`bg-primary text-primary-foreground [a]:hover:bg-primary/80`,secondary:`bg-secondary text-secondary-foreground [a]:hover:bg-secondary/80`,destructive:`bg-destructive/10 text-destructive focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:focus-visible:ring-destructive/40 [a]:hover:bg-destructive/20`,outline:`border-border bg-input/20 text-foreground dark:bg-input/30 [a]:hover:bg-muted [a]:hover:text-muted-foreground`,ghost:`hover:bg-muted hover:text-muted-foreground dark:hover:bg-muted/50`,link:`text-primary underline-offset-4 hover:underline`}},defaultVariants:{variant:`default`}});function s(n){let i=(0,a.c)(13),s,c,l,u,d;if(i[0]!==n){let{className:a,variant:f,render:p,...m}=n;s=p,d=f===void 0?`default`:f,u=t,c=`span`,l=r({className:e(o({className:a,variant:d}))},m),i[0]=n,i[1]=s,i[2]=c,i[3]=l,i[4]=u,i[5]=d}else s=i[1],c=i[2],l=i[3],u=i[4],d=i[5];let f;i[6]===d?f=i[7]:(f={slot:`badge`,variant:d},i[6]=d,i[7]=f);let p;return i[8]!==s||i[9]!==c||i[10]!==l||i[11]!==f?(p={defaultTagName:c,props:l,render:s,state:f},i[8]=s,i[9]=c,i[10]=l,i[11]=f,i[12]=p):p=i[12],u(p)}export{s as t};
\ No newline at end of file
+import{t as e}from"./compiler-runtime.js";import{f as t,t as n}from"./dist.js";import{w as r}from"./SignatureV4.js";import{Pr as i}from"./index.js";var a=e(),o=n(`group/badge inline-flex h-5 w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-full border border-transparent px-2 py-0.5 text-[0.625rem] font-medium whitespace-nowrap transition-all transition-colors focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&>svg]:pointer-events-none [&>svg]:size-2.5!`,{variants:{variant:{default:`bg-primary text-primary-foreground [a]:hover:bg-primary/80`,secondary:`bg-secondary text-secondary-foreground [a]:hover:bg-secondary/80`,destructive:`bg-destructive/10 text-destructive focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:focus-visible:ring-destructive/40 [a]:hover:bg-destructive/20`,outline:`border-border bg-input/20 text-foreground dark:bg-input/30 [a]:hover:bg-muted [a]:hover:text-muted-foreground`,ghost:`hover:bg-muted hover:text-muted-foreground dark:hover:bg-muted/50`,link:`text-primary underline-offset-4 hover:underline`}},defaultVariants:{variant:`default`}});function s(e){let n=(0,a.c)(13),s,c,l,u,d;if(n[0]!==e){let{className:a,variant:f,render:p,...m}=e;s=p,d=f===void 0?`default`:f,u=i,c=`span`,l=t({className:r(o({className:a,variant:d}))},m),n[0]=e,n[1]=s,n[2]=c,n[3]=l,n[4]=u,n[5]=d}else s=n[1],c=n[2],l=n[3],u=n[4],d=n[5];let f;n[6]===d?f=n[7]:(f={slot:`badge`,variant:d},n[6]=d,n[7]=f);let p;return n[8]!==s||n[9]!==c||n[10]!==l||n[11]!==f?(p={defaultTagName:c,props:l,render:s,state:f},n[8]=s,n[9]=c,n[10]=l,n[11]=f,n[12]=p):p=n[12],u(p)}export{s as t};
\ No newline at end of file
diff --git a/spinifex/services/spinifexui/frontend/dist/assets/button.js b/spinifex/services/spinifexui/frontend/dist/assets/button.js
new file mode 100644
index 000000000..d3baa494c
--- /dev/null
+++ b/spinifex/services/spinifexui/frontend/dist/assets/button.js
@@ -0,0 +1 @@
+import{n as e,o as t,r as n,t as r}from"./compiler-runtime.js";import{d as i,f as a,l as o,n as s,t as c,u as l}from"./dist.js";import{w as u}from"./SignatureV4.js";var d=t(n());function f(){return typeof window<`u`}function p(e){return g(e)?(e.nodeName||``).toLowerCase():`#document`}function m(e){var t;return(e==null||(t=e.ownerDocument)==null?void 0:t.defaultView)||window}function h(e){return((g(e)?e.ownerDocument:e.document)||window.document)?.documentElement}function g(e){return f()?e instanceof Node||e instanceof m(e).Node:!1}function _(e){return f()?e instanceof Element||e instanceof m(e).Element:!1}function v(e){return f()?e instanceof HTMLElement||e instanceof m(e).HTMLElement:!1}function y(e){return!f()||typeof ShadowRoot>`u`?!1:e instanceof ShadowRoot||e instanceof m(e).ShadowRoot}function b(e){let{overflow:t,overflowX:n,overflowY:r,display:i}=j(e);return/auto|scroll|overlay|hidden|clip/.test(t+r+n)&&i!==`inline`&&i!==`contents`}function x(e){return/^(table|td|th)$/.test(p(e))}function S(e){try{if(e.matches(`:popover-open`))return!0}catch{}try{return e.matches(`:modal`)}catch{return!1}}var C=/transform|translate|scale|rotate|perspective|filter/,w=/paint|layout|strict|content/,T=e=>!!e&&e!==`none`,E;function D(e){let t=_(e)?j(e):e;return T(t.transform)||T(t.translate)||T(t.scale)||T(t.rotate)||T(t.perspective)||!k()&&(T(t.backdropFilter)||T(t.filter))||C.test(t.willChange||``)||w.test(t.contain||``)}function O(e){let t=N(e);for(;v(t)&&!A(t);){if(D(t))return t;if(S(t))return null;t=N(t)}return null}function k(){return E??=typeof CSS<`u`&&CSS.supports&&CSS.supports(`-webkit-backdrop-filter`,`none`),E}function A(e){return/^(html|body|#document)$/.test(p(e))}function j(e){return m(e).getComputedStyle(e)}function M(e){return _(e)?{scrollLeft:e.scrollLeft,scrollTop:e.scrollTop}:{scrollLeft:e.scrollX,scrollTop:e.scrollY}}function N(e){if(p(e)===`html`)return e;let t=e.assignedSlot||e.parentNode||y(e)&&e.host||h(e);return y(t)?t.host:t}function P(e){let t=N(e);return A(t)?e.ownerDocument?e.ownerDocument.body:e.body:v(t)&&b(t)?t:P(t)}function F(e,t,n){t===void 0&&(t=[]),n===void 0&&(n=!0);let r=P(e),i=r===e.ownerDocument?.body,a=m(r);if(i){let e=I(a);return t.concat(a,a.visualViewport||[],b(r)?r:[],e&&n?F(e):[])}else return t.concat(r,F(r,[],n))}function I(e){return e.parent&&Object.getPrototypeOf(e.parent)?e.frameElement:null}var L={...d},R=L.useInsertionEffect,z=R&&R!==L.useLayoutEffect?R:e=>e();function B(e){let t=o(V).current;return t.next=e,z(t.effect),t.trampoline}function V(){let e={next:void 0,callback:H,trampoline:(...t)=>e.callback?.(...t),effect:()=>{e.callback=e.next}};return e}function H(){}var U=typeof document<`u`?d.useLayoutEffect:()=>{},W=d.createContext(void 0);function G(e=!1){let t=d.useContext(W);if(t===void 0&&!e)throw Error(l(16));return t}function K(e){let{focusableWhenDisabled:t,disabled:n,composite:r=!1,tabIndex:i=0,isNativeButton:a}=e,o=r&&t!==!1,s=r&&t===!1;return{props:d.useMemo(()=>{let e={onKeyDown(e){n&&t&&e.key!==`Tab`&&e.preventDefault()}};return r||(e.tabIndex=i,!a&&n&&(e.tabIndex=t?i:-1)),(a&&(t||o)||!a&&n)&&(e[`aria-disabled`]=n),a&&(!t||s)&&(e.disabled=n),e},[r,n,t,o,s,a,i])}}function q(e={}){let{disabled:t=!1,focusableWhenDisabled:n,tabIndex:r=0,native:o=!0,composite:s}=e,c=d.useRef(null),l=G(!0),u=s??l!==void 0,{props:f}=K({focusableWhenDisabled:n,disabled:t,composite:u,tabIndex:r,isNativeButton:o}),p=d.useCallback(()=>{let e=c.current;J(e)&&u&&t&&f.disabled===void 0&&e.disabled&&(e.disabled=!1)},[t,f.disabled,u]);return U(p,[p]),{getButtonProps:d.useCallback((e={})=>{let{onClick:n,onMouseDown:r,onKeyUp:s,onKeyDown:c,onPointerDown:l,...d}=e;return a({onClick(e){if(t){e.preventDefault();return}n?.(e)},onMouseDown(e){t||r?.(e)},onKeyDown(e){if(t||(i(e),c?.(e),e.baseUIHandlerPrevented))return;let r=e.target===e.currentTarget,a=e.currentTarget,s=J(a),l=!o&&Y(a),d=r&&(o?s:!l),f=e.key===`Enter`,p=e.key===` `,m=a.getAttribute(`role`),h=m?.startsWith(`menuitem`)||m===`option`||m===`gridcell`;if(r&&u&&p){if(e.defaultPrevented&&h)return;e.preventDefault(),l||o&&s?(a.click(),e.preventBaseUIHandler()):d&&(n?.(e),e.preventBaseUIHandler());return}d&&(!o&&(p||f)&&e.preventDefault(),!o&&f&&n?.(e))},onKeyUp(e){if(!t){if(i(e),s?.(e),e.target===e.currentTarget&&o&&u&&J(e.currentTarget)&&e.key===` `){e.preventDefault();return}e.baseUIHandlerPrevented||e.target===e.currentTarget&&!o&&!u&&e.key===` `&&n?.(e)}},onPointerDown(e){if(t){e.preventDefault();return}l?.(e)}},o?{type:`button`}:{role:`button`},f,d)},[t,f,u,o]),buttonRef:B(e=>{c.current=e,p()})}}function J(e){return v(e)&&e.tagName===`BUTTON`}function Y(e){return!!(e?.tagName===`A`&&e?.href)}var X=d.forwardRef(function(e,t){let{render:n,className:r,disabled:i=!1,focusableWhenDisabled:a=!1,nativeButton:o=!0,style:c,...l}=e,{getButtonProps:u,buttonRef:d}=q({disabled:i,focusableWhenDisabled:a,native:o});return s(`button`,e,{state:{disabled:i},ref:[t,d],props:[l,u]})}),Z=r(),Q=e(),$=c(`group/button inline-flex shrink-0 items-center justify-center rounded-md border border-transparent bg-clip-padding text-xs/relaxed font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-[2px] focus-visible:ring-ring/30 disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-[2px] aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4`,{variants:{variant:{default:`bg-primary text-primary-foreground hover:bg-primary/80`,outline:`border-border hover:bg-input/50 hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:bg-input/30`,secondary:`bg-secondary text-secondary-foreground hover:bg-secondary/80 aria-expanded:bg-secondary aria-expanded:text-secondary-foreground`,ghost:`hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:hover:bg-muted/50`,destructive:`bg-destructive/10 text-destructive hover:bg-destructive/20 focus-visible:border-destructive/40 focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:hover:bg-destructive/30 dark:focus-visible:ring-destructive/40`,link:`text-primary underline-offset-4 hover:underline`},size:{default:`h-7 gap-1 px-2 text-xs/relaxed has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3.5`,xs:`h-5 gap-1 rounded-sm px-2 text-[0.625rem] has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-2.5`,sm:`h-6 gap-1 px-2 text-xs/relaxed has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3`,lg:`h-8 gap-1 px-2.5 text-xs/relaxed has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2 [&_svg:not([class*='size-'])]:size-4`,icon:`size-7 [&_svg:not([class*='size-'])]:size-3.5`,"icon-xs":`size-5 rounded-sm [&_svg:not([class*='size-'])]:size-2.5`,"icon-sm":`size-6 [&_svg:not([class*='size-'])]:size-3`,"icon-lg":`size-8 [&_svg:not([class*='size-'])]:size-4`}},defaultVariants:{variant:`default`,size:`default`}});function ee(e){let t=(0,Z.c)(12),n,r,i,a;t[0]===e?(n=t[1],r=t[2],i=t[3],a=t[4]):({className:n,variant:i,size:a,...r}=e,t[0]=e,t[1]=n,t[2]=r,t[3]=i,t[4]=a);let o=i===void 0?`default`:i,s=a===void 0?`default`:a,c;t[5]!==n||t[6]!==s||t[7]!==o?(c=u($({variant:o,size:s,className:n})),t[5]=n,t[6]=s,t[7]=o,t[8]=c):c=t[8];let l;return t[9]!==r||t[10]!==c?(l=(0,Q.jsx)(X,{className:c,"data-slot":`button`,...r}),t[9]=r,t[10]=c,t[11]=l):l=t[11],l}export{b as C,k as D,S as E,g as S,x as T,m as _,G as a,v as b,L as c,h as d,I as f,N as g,F as h,W as i,j as l,M as m,$ as n,U as o,p,q as r,B as s,ee as t,O as u,D as v,y as w,A as x,_ as y};
\ No newline at end of file
diff --git a/spinifex/services/spinifexui/frontend/dist/assets/card.js b/spinifex/services/spinifexui/frontend/dist/assets/card.js
index aaa70f591..550f7630d 100644
--- a/spinifex/services/spinifexui/frontend/dist/assets/card.js
+++ b/spinifex/services/spinifexui/frontend/dist/assets/card.js
@@ -1 +1 @@
-import{$a as e,Fo as t,Ho as n}from"./index.js";var r=t(),i=n();function a(t){let n=(0,r.c)(10),a,o,s;n[0]===t?(a=n[1],o=n[2],s=n[3]):({className:a,size:s,...o}=t,n[0]=t,n[1]=a,n[2]=o,n[3]=s);let c=s===void 0?`default`:s,l;n[4]===a?l=n[5]:(l=e(`group/card flex flex-col gap-4 overflow-hidden rounded-lg bg-card py-4 text-xs/relaxed text-card-foreground ring-1 ring-foreground/10 has-[>img:first-child]:pt-0 data-[size=sm]:gap-3 data-[size=sm]:py-3 *:[img:first-child]:rounded-t-lg *:[img:last-child]:rounded-b-lg`,a),n[4]=a,n[5]=l);let u;return n[6]!==o||n[7]!==c||n[8]!==l?(u=(0,i.jsx)(`div`,{className:l,"data-size":c,"data-slot":`card`,...o}),n[6]=o,n[7]=c,n[8]=l,n[9]=u):u=n[9],u}function o(t){let n=(0,r.c)(8),a,o;n[0]===t?(a=n[1],o=n[2]):({className:a,...o}=t,n[0]=t,n[1]=a,n[2]=o);let s;n[3]===a?s=n[4]:(s=e(`group/card-header @container/card-header grid auto-rows-min items-start gap-1 rounded-t-lg px-4 group-data-[size=sm]/card:px-3 has-data-[slot=card-action]:grid-cols-[1fr_auto] has-data-[slot=card-description]:grid-rows-[auto_auto] [.border-b]:pb-4 group-data-[size=sm]/card:[.border-b]:pb-3`,a),n[3]=a,n[4]=s);let c;return n[5]!==o||n[6]!==s?(c=(0,i.jsx)(`div`,{className:s,"data-slot":`card-header`,...o}),n[5]=o,n[6]=s,n[7]=c):c=n[7],c}function s(t){let n=(0,r.c)(8),a,o;n[0]===t?(a=n[1],o=n[2]):({className:a,...o}=t,n[0]=t,n[1]=a,n[2]=o);let s;n[3]===a?s=n[4]:(s=e(`text-sm font-medium`,a),n[3]=a,n[4]=s);let c;return n[5]!==o||n[6]!==s?(c=(0,i.jsx)(`div`,{className:s,"data-slot":`card-title`,...o}),n[5]=o,n[6]=s,n[7]=c):c=n[7],c}function c(t){let n=(0,r.c)(8),a,o;n[0]===t?(a=n[1],o=n[2]):({className:a,...o}=t,n[0]=t,n[1]=a,n[2]=o);let s;n[3]===a?s=n[4]:(s=e(`col-start-2 row-span-2 row-start-1 self-start justify-self-end`,a),n[3]=a,n[4]=s);let c;return n[5]!==o||n[6]!==s?(c=(0,i.jsx)(`div`,{className:s,"data-slot":`card-action`,...o}),n[5]=o,n[6]=s,n[7]=c):c=n[7],c}function l(t){let n=(0,r.c)(8),a,o;n[0]===t?(a=n[1],o=n[2]):({className:a,...o}=t,n[0]=t,n[1]=a,n[2]=o);let s;n[3]===a?s=n[4]:(s=e(`px-4 group-data-[size=sm]/card:px-3`,a),n[3]=a,n[4]=s);let c;return n[5]!==o||n[6]!==s?(c=(0,i.jsx)(`div`,{className:s,"data-slot":`card-content`,...o}),n[5]=o,n[6]=s,n[7]=c):c=n[7],c}function u(t){let n=(0,r.c)(8),a,o;n[0]===t?(a=n[1],o=n[2]):({className:a,...o}=t,n[0]=t,n[1]=a,n[2]=o);let s;n[3]===a?s=n[4]:(s=e(`flex items-center rounded-b-lg px-4 group-data-[size=sm]/card:px-3 [.border-t]:pt-4 group-data-[size=sm]/card:[.border-t]:pt-3`,a),n[3]=a,n[4]=s);let c;return n[5]!==o||n[6]!==s?(c=(0,i.jsx)(`div`,{className:s,"data-slot":`card-footer`,...o}),n[5]=o,n[6]=s,n[7]=c):c=n[7],c}export{o as a,u as i,c as n,s as o,l as r,a as t};
\ No newline at end of file
+import{n as e,t}from"./compiler-runtime.js";import{w as n}from"./SignatureV4.js";var r=t(),i=e();function a(e){let t=(0,r.c)(10),a,o,s;t[0]===e?(a=t[1],o=t[2],s=t[3]):({className:a,size:s,...o}=e,t[0]=e,t[1]=a,t[2]=o,t[3]=s);let c=s===void 0?`default`:s,l;t[4]===a?l=t[5]:(l=n(`group/card flex flex-col gap-4 overflow-hidden rounded-lg bg-card py-4 text-xs/relaxed text-card-foreground ring-1 ring-foreground/10 has-[>img:first-child]:pt-0 data-[size=sm]:gap-3 data-[size=sm]:py-3 *:[img:first-child]:rounded-t-lg *:[img:last-child]:rounded-b-lg`,a),t[4]=a,t[5]=l);let u;return t[6]!==o||t[7]!==c||t[8]!==l?(u=(0,i.jsx)(`div`,{className:l,"data-size":c,"data-slot":`card`,...o}),t[6]=o,t[7]=c,t[8]=l,t[9]=u):u=t[9],u}function o(e){let t=(0,r.c)(8),a,o;t[0]===e?(a=t[1],o=t[2]):({className:a,...o}=e,t[0]=e,t[1]=a,t[2]=o);let s;t[3]===a?s=t[4]:(s=n(`group/card-header @container/card-header grid auto-rows-min items-start gap-1 rounded-t-lg px-4 group-data-[size=sm]/card:px-3 has-data-[slot=card-action]:grid-cols-[1fr_auto] has-data-[slot=card-description]:grid-rows-[auto_auto] [.border-b]:pb-4 group-data-[size=sm]/card:[.border-b]:pb-3`,a),t[3]=a,t[4]=s);let c;return t[5]!==o||t[6]!==s?(c=(0,i.jsx)(`div`,{className:s,"data-slot":`card-header`,...o}),t[5]=o,t[6]=s,t[7]=c):c=t[7],c}function s(e){let t=(0,r.c)(8),a,o;t[0]===e?(a=t[1],o=t[2]):({className:a,...o}=e,t[0]=e,t[1]=a,t[2]=o);let s;t[3]===a?s=t[4]:(s=n(`text-sm font-medium`,a),t[3]=a,t[4]=s);let c;return t[5]!==o||t[6]!==s?(c=(0,i.jsx)(`div`,{className:s,"data-slot":`card-title`,...o}),t[5]=o,t[6]=s,t[7]=c):c=t[7],c}function c(e){let t=(0,r.c)(8),a,o;t[0]===e?(a=t[1],o=t[2]):({className:a,...o}=e,t[0]=e,t[1]=a,t[2]=o);let s;t[3]===a?s=t[4]:(s=n(`col-start-2 row-span-2 row-start-1 self-start justify-self-end`,a),t[3]=a,t[4]=s);let c;return t[5]!==o||t[6]!==s?(c=(0,i.jsx)(`div`,{className:s,"data-slot":`card-action`,...o}),t[5]=o,t[6]=s,t[7]=c):c=t[7],c}function l(e){let t=(0,r.c)(8),a,o;t[0]===e?(a=t[1],o=t[2]):({className:a,...o}=e,t[0]=e,t[1]=a,t[2]=o);let s;t[3]===a?s=t[4]:(s=n(`px-4 group-data-[size=sm]/card:px-3`,a),t[3]=a,t[4]=s);let c;return t[5]!==o||t[6]!==s?(c=(0,i.jsx)(`div`,{className:s,"data-slot":`card-content`,...o}),t[5]=o,t[6]=s,t[7]=c):c=t[7],c}function u(e){let t=(0,r.c)(8),a,o;t[0]===e?(a=t[1],o=t[2]):({className:a,...o}=e,t[0]=e,t[1]=a,t[2]=o);let s;t[3]===a?s=t[4]:(s=n(`flex items-center rounded-b-lg px-4 group-data-[size=sm]/card:px-3 [.border-t]:pt-4 group-data-[size=sm]/card:[.border-t]:pt-3`,a),t[3]=a,t[4]=s);let c;return t[5]!==o||t[6]!==s?(c=(0,i.jsx)(`div`,{className:s,"data-slot":`card-footer`,...o}),t[5]=o,t[6]=s,t[7]=c):c=t[7],c}export{o as a,u as i,c as n,s as o,l as r,a as t};
\ No newline at end of file
diff --git a/spinifex/services/spinifexui/frontend/dist/assets/certificate-import-dialog.js b/spinifex/services/spinifexui/frontend/dist/assets/certificate-import-dialog.js
new file mode 100644
index 000000000..b0a0fec2d
--- /dev/null
+++ b/spinifex/services/spinifexui/frontend/dist/assets/certificate-import-dialog.js
@@ -0,0 +1 @@
+import{n as e,o as t,r as n,t as r}from"./compiler-runtime.js";import{n as i,t as a}from"./error-banner.js";import{Cr as o,Tr as s,_r as c,hr as l,n as u}from"./awsClient.js";import{Lr as d}from"./index.js";import{a as f,t as p}from"./field.js";import{a as m,i as h,l as g,n as _,o as v,r as y,s as ee,t as te}from"./alert-dialog.js";import{t as b}from"./textarea.js";var x=class extends s.classBuilder().ep(c).m(function(e,t,n,r){return[o(n,e.getEndpointParameterInstructions())]}).s(`CertificateManager`,`ImportCertificate`,{}).n(`ACMClient`,`ImportCertificateCommand`).sc(l).build(){},S=t(n(),1),C=r(),w=new TextEncoder;function T(){let e=(0,C.c)(2),t=d(),n;return e[0]===t?n=e[1]:(n={mutationFn:E,onSuccess:()=>{t.invalidateQueries({queryKey:[`acm`,`certificates`]})}},e[0]=t,e[1]=n),i(n)}async function E(e){let t=new x({Certificate:w.encode(e.certificate),PrivateKey:w.encode(e.privateKey),CertificateChain:e.certificateChain&&e.certificateChain.length>0?w.encode(e.certificateChain):void 0});return await u().send(t)}var D=e();function O(e){let t=(0,C.c)(54),{open:n,onOpenChange:r,onImported:i}=e,[o,s]=(0,S.useState)(``),[c,l]=(0,S.useState)(``),[u,d]=(0,S.useState)(``),x=T(),w;t[0]===x?w=t[1]:(w=()=>{s(``),l(``),d(``),x.reset()},t[0]=x,t[1]=w);let E=w,O;t[2]!==r||t[3]!==E?(O=e=>{e||E(),r(e)},t[2]=r,t[3]=E,t[4]=O):O=t[4];let k=O,A;t[5]!==o||t[6]!==c?(A=o.trim().length>0&&c.trim().length>0,t[5]=o,t[6]=c,t[7]=A):A=t[7];let j=A,M;t[8]!==j||t[9]!==o||t[10]!==u||t[11]!==x||t[12]!==i||t[13]!==r||t[14]!==c||t[15]!==E?(M=async()=>{if(j)try{let e=await x.mutateAsync({certificate:o,privateKey:c,certificateChain:u});e.CertificateArn&&i(e.CertificateArn),E(),r(!1)}catch{}},t[8]=j,t[9]=o,t[10]=u,t[11]=x,t[12]=i,t[13]=r,t[14]=c,t[15]=E,t[16]=M):M=t[16];let N=M,P;t[17]===Symbol.for(`react.memo_cache_sentinel`)?(P={maxWidth:`36rem`,width:`calc(100vw - 2rem)`},t[17]=P):P=t[17];let F;t[18]===Symbol.for(`react.memo_cache_sentinel`)?(F=(0,D.jsxs)(ee,{children:[(0,D.jsx)(g,{children:`Import certificate`}),(0,D.jsx)(m,{children:`Paste a PEM-encoded certificate and its private key. The optional chain is appended for intermediate CAs.`})]}),t[18]=F):F=t[18];let I;t[19]===x.error?I=t[20]:(I=x.error&&(0,D.jsx)(a,{error:x.error,msg:`Failed to import certificate`}),t[19]=x.error,t[20]=I);let L;t[21]===Symbol.for(`react.memo_cache_sentinel`)?(L=(0,D.jsx)(f,{children:(0,D.jsx)(`label`,{htmlFor:`cert-body`,children:`Certificate body`})}),t[21]=L):L=t[21];let R;t[22]===Symbol.for(`react.memo_cache_sentinel`)?(R=e=>s(e.target.value),t[22]=R):R=t[22];let z;t[23]===o?z=t[24]:(z=(0,D.jsxs)(p,{children:[L,(0,D.jsx)(b,{className:`font-mono text-xs`,id:`cert-body`,onChange:R,placeholder:`-----BEGIN CERTIFICATE-----`,rows:5,value:o})]}),t[23]=o,t[24]=z);let B;t[25]===Symbol.for(`react.memo_cache_sentinel`)?(B=(0,D.jsx)(f,{children:(0,D.jsx)(`label`,{htmlFor:`cert-key`,children:`Private key`})}),t[25]=B):B=t[25];let V;t[26]===Symbol.for(`react.memo_cache_sentinel`)?(V=e=>l(e.target.value),t[26]=V):V=t[26];let H;t[27]===c?H=t[28]:(H=(0,D.jsxs)(p,{children:[B,(0,D.jsx)(b,{className:`font-mono text-xs`,id:`cert-key`,onChange:V,placeholder:`-----BEGIN PRIVATE KEY-----`,rows:5,value:c})]}),t[27]=c,t[28]=H);let U;t[29]===Symbol.for(`react.memo_cache_sentinel`)?(U=(0,D.jsx)(f,{children:(0,D.jsx)(`label`,{htmlFor:`cert-chain`,children:`Certificate chain (optional)`})}),t[29]=U):U=t[29];let W;t[30]===Symbol.for(`react.memo_cache_sentinel`)?(W=e=>d(e.target.value),t[30]=W):W=t[30];let G;t[31]===u?G=t[32]:(G=(0,D.jsxs)(p,{children:[U,(0,D.jsx)(b,{className:`font-mono text-xs`,id:`cert-chain`,onChange:W,placeholder:`-----BEGIN CERTIFICATE-----`,rows:4,value:u})]}),t[31]=u,t[32]=G);let K;t[33]!==z||t[34]!==H||t[35]!==G?(K=(0,D.jsxs)(`div`,{className:`min-w-0 space-y-4`,children:[z,H,G]}),t[33]=z,t[34]=H,t[35]=G,t[36]=K):K=t[36];let q;t[37]===x.isPending?q=t[38]:(q=(0,D.jsx)(y,{disabled:x.isPending,children:`Cancel`}),t[37]=x.isPending,t[38]=q);let J=!j||x.isPending,Y=x.isPending?`Importing…`:`Import`,X;t[39]!==N||t[40]!==J||t[41]!==Y?(X=(0,D.jsx)(_,{disabled:J,onClick:N,children:Y}),t[39]=N,t[40]=J,t[41]=Y,t[42]=X):X=t[42];let Z;t[43]!==q||t[44]!==X?(Z=(0,D.jsxs)(v,{children:[q,X]}),t[43]=q,t[44]=X,t[45]=Z):Z=t[45];let Q;t[46]!==K||t[47]!==Z||t[48]!==I?(Q=(0,D.jsxs)(h,{className:`grid-cols-[minmax(0,1fr)]`,style:P,children:[F,I,K,Z]}),t[46]=K,t[47]=Z,t[48]=I,t[49]=Q):Q=t[49];let $;return t[50]!==k||t[51]!==n||t[52]!==Q?($=(0,D.jsx)(te,{onOpenChange:k,open:n,children:Q}),t[50]=k,t[51]=n,t[52]=Q,t[53]=$):$=t[53],$}export{O as t};
\ No newline at end of file
diff --git a/spinifex/services/spinifexui/frontend/dist/assets/check.js b/spinifex/services/spinifexui/frontend/dist/assets/check.js
index 9411d3eb2..7ff6da16f 100644
--- a/spinifex/services/spinifexui/frontend/dist/assets/check.js
+++ b/spinifex/services/spinifexui/frontend/dist/assets/check.js
@@ -1 +1 @@
-import{wo as e}from"./index.js";var t=e(`check`,[[`path`,{d:`M20 6 9 17l-5-5`,key:`1gmf2c`}]]);export{t};
\ No newline at end of file
+import{Nr as e}from"./index.js";var t=e(`check`,[[`path`,{d:`M20 6 9 17l-5-5`,key:`1gmf2c`}]]);export{t};
\ No newline at end of file
diff --git a/spinifex/services/spinifexui/frontend/dist/assets/chevron-down.js b/spinifex/services/spinifexui/frontend/dist/assets/chevron-down.js
index 3d9142764..76200db22 100644
--- a/spinifex/services/spinifexui/frontend/dist/assets/chevron-down.js
+++ b/spinifex/services/spinifexui/frontend/dist/assets/chevron-down.js
@@ -1 +1 @@
-import{wo as e}from"./index.js";var t=e(`chevron-down`,[[`path`,{d:`m6 9 6 6 6-6`,key:`qrunsl`}]]);export{t};
\ No newline at end of file
+import{Nr as e}from"./index.js";var t=e(`chevron-down`,[[`path`,{d:`m6 9 6 6 6-6`,key:`qrunsl`}]]);export{t};
\ No newline at end of file
diff --git a/spinifex/services/spinifexui/frontend/dist/assets/chevron-right.js b/spinifex/services/spinifexui/frontend/dist/assets/chevron-right.js
index 9977653e5..c14ab0b4f 100644
--- a/spinifex/services/spinifexui/frontend/dist/assets/chevron-right.js
+++ b/spinifex/services/spinifexui/frontend/dist/assets/chevron-right.js
@@ -1 +1 @@
-import{wo as e}from"./index.js";var t=e(`chevron-right`,[[`path`,{d:`m9 18 6-6-6-6`,key:`mthhwq`}]]);export{t};
\ No newline at end of file
+import{Nr as e}from"./index.js";var t=e(`chevron-right`,[[`path`,{d:`m9 18 6-6-6-6`,key:`mthhwq`}]]);export{t};
\ No newline at end of file
diff --git a/spinifex/services/spinifexui/frontend/dist/assets/cli-command-panel.js b/spinifex/services/spinifexui/frontend/dist/assets/cli-command-panel.js
index cf848852d..1027541b4 100644
--- a/spinifex/services/spinifexui/frontend/dist/assets/cli-command-panel.js
+++ b/spinifex/services/spinifexui/frontend/dist/assets/cli-command-panel.js
@@ -1,3 +1,3 @@
-import{t as e}from"./check.js";import{t}from"./chevron-down.js";import{t as n}from"./copy.js";import{$a as r,Fo as i,Ho as a,Uo as o,Xa as s,cs as c,wo as l}from"./index.js";import{t as u}from"./use-copy-to-clipboard.js";var d=l(`square-terminal`,[[`path`,{d:`m7 11 2-2-2-2`,key:`1lz0vl`}],[`path`,{d:`M11 13h4`,key:`1p7l4v`}],[`rect`,{width:`18`,height:`18`,x:`3`,y:`3`,rx:`2`,ry:`2`,key:`1m3agn`}]]),f=i(),p=c(o(),1),m=a(),h={bin:`text-tactical-cyan font-semibold`,flag:`text-tactical-amber`,value:`text-foreground`,comment:`text-muted-foreground italic`,variable:`text-tactical-green font-semibold`};function g(e){return e.map(e=>e.parts.map(e=>e.value).join(``)).join(`
+import{n as e,o as t,r as n,t as r}from"./compiler-runtime.js";import{w as i}from"./SignatureV4.js";import{t as a}from"./check.js";import{t as o}from"./chevron-down.js";import{t as s}from"./copy.js";import{t as c}from"./button.js";import{Nr as l}from"./index.js";import{t as u}from"./use-copy-to-clipboard.js";var d=l(`square-terminal`,[[`path`,{d:`m7 11 2-2-2-2`,key:`1lz0vl`}],[`path`,{d:`M11 13h4`,key:`1p7l4v`}],[`rect`,{width:`18`,height:`18`,x:`3`,y:`3`,rx:`2`,ry:`2`,key:`1m3agn`}]]),f=r(),p=t(n(),1),m=e(),h={bin:`text-tactical-cyan font-semibold`,flag:`text-tactical-amber`,value:`text-foreground`,comment:`text-muted-foreground italic`,variable:`text-tactical-green font-semibold`};function g(e){return e.map(e=>e.parts.map(e=>e.value).join(``)).join(`
-`)}function _(i){let a=(0,f.c)(18),{commands:o}=i,[c,l]=(0,p.useState)(!1),{copied:h,copy:_}=u();if(o.length===0)return null;let b;a[0]===Symbol.for(`react.memo_cache_sentinel`)?(b=()=>l(y),a[0]=b):b=a[0];let x,S;a[1]===Symbol.for(`react.memo_cache_sentinel`)?(x=(0,m.jsx)(d,{className:`size-3.5`}),S=(0,m.jsx)(`span`,{children:`AWS CLI`}),a[1]=x,a[2]=S):(x=a[1],S=a[2]);let C=c&&`rotate-180`,w;a[3]===C?w=a[4]:(w=r(`ml-auto size-3.5 transition-transform`,C),a[3]=C,a[4]=w);let T;a[5]===w?T=a[6]:(T=(0,m.jsx)(t,{className:w}),a[5]=w,a[6]=T);let E;a[7]!==c||a[8]!==T?(E=(0,m.jsxs)(`button`,{type:`button`,onClick:b,className:`flex w-full items-center gap-2 px-3 py-2 text-xs font-medium text-muted-foreground transition-colors hover:text-foreground`,"aria-expanded":c,children:[x,S,T]}),a[7]=c,a[8]=T,a[9]=E):E=a[9];let D;a[10]!==o||a[11]!==h||a[12]!==_||a[13]!==c?(D=c&&(0,m.jsxs)(`div`,{className:`border-t border-border`,children:[(0,m.jsxs)(`div`,{className:`flex items-center justify-between border-b border-border px-3 py-1.5`,children:[(0,m.jsx)(`span`,{className:`text-[0.625rem] text-muted-foreground`,children:o.length>1?`${o.length} commands`:`command`}),(0,m.jsxs)(s,{type:`button`,variant:`ghost`,size:`xs`,onClick:async()=>await _(g(o)),children:[h?(0,m.jsx)(e,{className:`size-2.5`}):(0,m.jsx)(n,{className:`size-2.5`}),h?`Copied`:`Copy`]})]}),(0,m.jsx)(`div`,{className:`overflow-x-auto p-3`,children:o.map((e,t)=>(0,m.jsxs)(`div`,{children:[o.length>1&&(0,m.jsx)(`div`,{className:`mb-1 text-[0.625rem] font-medium text-muted-foreground`,children:e.label}),(0,m.jsx)(`pre`,{className:`font-mono text-xs/relaxed`,children:(0,m.jsx)(`code`,{children:e.parts.map(v)})}),tl(y),t[0]=b):b=t[0];let x,S;t[1]===Symbol.for(`react.memo_cache_sentinel`)?(x=(0,m.jsx)(d,{className:`size-3.5`}),S=(0,m.jsx)(`span`,{children:`AWS CLI`}),t[1]=x,t[2]=S):(x=t[1],S=t[2]);let C=r&&`rotate-180`,w;t[3]===C?w=t[4]:(w=i(`ml-auto size-3.5 transition-transform`,C),t[3]=C,t[4]=w);let T;t[5]===w?T=t[6]:(T=(0,m.jsx)(o,{className:w}),t[5]=w,t[6]=T);let E;t[7]!==r||t[8]!==T?(E=(0,m.jsxs)(`button`,{type:`button`,onClick:b,className:`flex w-full items-center gap-2 px-3 py-2 text-xs font-medium text-muted-foreground transition-colors hover:text-foreground`,"aria-expanded":r,children:[x,S,T]}),t[7]=r,t[8]=T,t[9]=E):E=t[9];let D;t[10]!==n||t[11]!==h||t[12]!==_||t[13]!==r?(D=r&&(0,m.jsxs)(`div`,{className:`border-t border-border`,children:[(0,m.jsxs)(`div`,{className:`flex items-center justify-between border-b border-border px-3 py-1.5`,children:[(0,m.jsx)(`span`,{className:`text-[0.625rem] text-muted-foreground`,children:n.length>1?`${n.length} commands`:`command`}),(0,m.jsxs)(c,{type:`button`,variant:`ghost`,size:`xs`,onClick:async()=>await _(g(n)),children:[h?(0,m.jsx)(a,{className:`size-2.5`}):(0,m.jsx)(s,{className:`size-2.5`}),h?`Copied`:`Copy`]})]}),(0,m.jsx)(`div`,{className:`overflow-x-auto p-3`,children:n.map((e,t)=>(0,m.jsxs)(`div`,{children:[n.length>1&&(0,m.jsx)(`div`,{className:`mb-1 text-[0.625rem] font-medium text-muted-foreground`,children:e.label}),(0,m.jsx)(`pre`,{className:`font-mono text-xs/relaxed`,children:(0,m.jsx)(`code`,{children:e.parts.map(v)})}),t{let t=!c,n=o(p,e.nativeEvent);if(r(t,n),n.isCanceled)return;let i=I.current;P.current===`css-animation`&&i!=null&&i.style.removeProperty(`animation-name`),!k&&!j&&(P.current!=null&&P.current!==`css-animation`&&!d&&t&&f(!0),P.current===`css-animation`&&(!h&&t&&g(!0),!d&&t&&f(!0))),l(t),P.current===`none`&&d&&!t&&f(!1)});return C(()=>{a&&P.current===`none`&&!c&&f(!1)},[a,c,t,f]),w.useMemo(()=>({abortControllerRef:N,animationTypeRef:P,disabled:i,handleTrigger:R,height:_,mounted:d,open:c,panelId:O,panelRef:I,runOnceAnimationsFinish:L,setDimensions:x,setHiddenUntilFound:A,setKeepMounted:M,setMounted:f,setOpen:l,setPanelIdState:D,setVisible:g,transitionDimensionRef:F,transitionStatus:m,visible:h,width:v}),[N,P,i,R,_,d,c,O,I,L,x,A,M,f,l,g,F,m,h,v])}var E=w.createContext(void 0);function D(){let e=w.useContext(E);if(e===void 0)throw Error(i(15));return e}var O=function(e){return e.open=`data-open`,e.closed=`data-closed`,e[e.startingStyle=l.startingStyle]=`startingStyle`,e[e.endingStyle=l.endingStyle]=`endingStyle`,e}({}),k=function(e){return e.panelOpen=`data-panel-open`,e}({}),A={[O.open]:``},j={[O.closed]:``},M={open(e){return e?{[k.panelOpen]:``}:null}},N={open(e){return e?A:j},...d},P=r(),F=w.forwardRef(function(e,n){let{render:r,className:i,defaultOpen:a=!1,disabled:o=!1,onOpenChange:s,open:c,style:l,...d}=e,f=u(s),p=T({open:c,defaultOpen:a,onOpenChange:f,disabled:o}),m=w.useMemo(()=>({open:p.open,disabled:p.disabled,transitionStatus:p.transitionStatus}),[p.open,p.disabled,p.transitionStatus]),h=w.useMemo(()=>({...p,onOpenChange:f,state:m}),[p,f,m]),g=t(`div`,e,{state:m,ref:n,props:d,stateAttributesMapping:N});return(0,P.jsx)(E.Provider,{value:h,children:g})}),I={...M,...d},L=w.forwardRef(function(e,n){let{panelId:r,open:i,handleTrigger:a,state:o,disabled:s}=D(),{className:c,disabled:l=s,id:u,render:d,nativeButton:f=!0,style:p,...m}=e,{getButtonProps:h,buttonRef:g}=x({disabled:l,focusableWhenDisabled:!0,native:f}),_=w.useMemo(()=>({"aria-controls":i?r:void 0,"aria-expanded":i,onClick:a}),[r,i,a]);return t(`button`,e,{state:o,ref:[n,g],props:[_,m,h],stateAttributesMapping:I})}),R=function(e){return e.disabled=`data-disabled`,e.orientation=`data-orientation`,e}({});function z(t){let{abortControllerRef:n,animationTypeRef:r,externalRef:i,height:a,hiddenUntilFound:s,keepMounted:c,id:l,mounted:d,onOpenChange:p,open:h,panelRef:y,runOnceAnimationsFinish:b,setDimensions:x,setMounted:S,setOpen:T,setVisible:E,transitionDimensionRef:D,visible:k,width:A}=t,j=w.useRef(!1),M=w.useRef(null),N=w.useRef(h),P=w.useRef(h),F=g(),I=w.useMemo(()=>r.current===`css-animation`?!k:!h&&!d,[h,d,k,r]),L=m(i,y,u(e=>{if(!e)return;if(r.current==null||D.current==null){let t=getComputedStyle(e),n=t.animationName!==`none`&&t.animationName!==``,i=t.transitionDuration!==`0s`&&t.transitionDuration!==``;n&&i||(t.animationName===`none`&&t.transitionDuration!==`0s`?r.current=`css-transition`:t.animationName!==`none`&&t.transitionDuration===`0s`?r.current=`css-animation`:r.current=`none`),e.getAttribute(R.orientation)===`horizontal`||t.transitionProperty.indexOf(`width`)>-1?D.current=`width`:D.current=`height`}if(r.current!==`css-transition`)return;(a===void 0||A===void 0)&&(x({height:e.scrollHeight,width:e.scrollWidth}),P.current&&e.style.setProperty(`transition-duration`,`0s`));let t=-1,n=-1;return t=v.request(()=>{P.current=!1,n=v.request(()=>{setTimeout(()=>{e.style.removeProperty(`transition-duration`)})})}),()=>{v.cancel(t),v.cancel(n)}}));return C(()=>{if(r.current!==`css-transition`)return;let e=y.current;if(!e)return;let t=-1;if(n.current!=null&&(n.current.abort(),n.current=null),h){let n={"justify-content":e.style.justifyContent,"align-items":e.style.alignItems,"align-content":e.style.alignContent,"justify-items":e.style.justifyItems};Object.keys(n).forEach(t=>{e.style.setProperty(t,`initial`,`important`)}),!P.current&&!c&&e.setAttribute(O.startingStyle,``),x({height:e.scrollHeight,width:e.scrollWidth}),t=v.request(()=>{Object.entries(n).forEach(([t,n])=>{n===``?e.style.removeProperty(t):e.style.setProperty(t,n)})})}else{if(e.scrollHeight===0&&e.scrollWidth===0)return;x({height:e.scrollHeight,width:e.scrollWidth});let t=new AbortController;n.current=t;let r=t.signal,i=null,a=O.endingStyle;return i=new MutationObserver(o=>{o.some(e=>e.type===`attributes`&&e.attributeName===a)&&(i?.disconnect(),i=null,b(()=>{x({height:0,width:0}),e.style.removeProperty(`content-visibility`),S(!1),n.current===t&&(n.current=null)},r))}),i.observe(e,{attributes:!0,attributeFilter:[a]}),()=>{i?.disconnect(),F.cancel(),n.current===t&&(t.abort(),n.current=null)}}return()=>{v.cancel(t)}},[n,r,F,s,c,d,h,y,b,x,S]),C(()=>{if(r.current!==`css-animation`)return;let e=y.current;e&&(M.current=e.style.animationName||M.current,e.style.setProperty(`animation-name`,`none`),x({height:e.scrollHeight,width:e.scrollWidth}),!N.current&&!j.current&&e.style.removeProperty(`animation-name`),h?(n.current!=null&&(n.current.abort(),n.current=null),S(!0),E(!0)):(n.current=new AbortController,b(()=>{S(!1),E(!1),n.current=null},n.current.signal)))},[n,r,h,y,b,x,S,E,k]),f(()=>{let e=v.request(()=>{N.current=!1});return()=>v.cancel(e)}),C(()=>{if(!s)return;let e=y.current;if(!e)return;let t=-1,n=-1;return h&&j.current&&(e.style.transitionDuration=`0s`,x({height:e.scrollHeight,width:e.scrollWidth}),t=v.request(()=>{j.current=!1,n=v.request(()=>{setTimeout(()=>{e.style.removeProperty(`transition-duration`)})})})),()=>{v.cancel(t),v.cancel(n)}},[s,h,y,x]),C(()=>{let e=y.current;e&&s&&I&&(e.setAttribute(`hidden`,`until-found`),r.current===`css-transition`&&e.setAttribute(O.startingStyle,``))},[s,I,r,y]),w.useEffect(function(){let t=y.current;if(!t)return;function n(e){j.current=!0,T(!0),p(!0,o(_,e))}return e(t,`beforematch`,n)},[p,y,T]),w.useMemo(()=>({props:{hidden:I,id:l,ref:L}}),[I,l,L])}var B=function(e){return e.collapsiblePanelHeight=`--collapsible-panel-height`,e.collapsiblePanelWidth=`--collapsible-panel-width`,e}({}),V=w.forwardRef(function(e,n){let{className:r,hiddenUntilFound:i,keepMounted:a,render:o,id:s,style:c,...l}=e,{abortControllerRef:u,animationTypeRef:d,height:f,mounted:p,onOpenChange:m,open:g,panelId:_,panelRef:v,runOnceAnimationsFinish:y,setDimensions:b,setHiddenUntilFound:x,setKeepMounted:S,setMounted:T,setPanelIdState:E,setOpen:O,setVisible:k,state:A,transitionDimensionRef:j,visible:M,width:P,transitionStatus:F}=D(),I=i??!1,L=a??!1;C(()=>{if(s)return E(s),()=>{E(void 0)}},[s,E]),C(()=>{x(I)},[x,I]),C(()=>{S(L)},[S,L]);let{props:R}=z({abortControllerRef:u,animationTypeRef:d,externalRef:n,height:f,hiddenUntilFound:I,id:_,keepMounted:L,mounted:p,onOpenChange:m,open:g,panelRef:v,runOnceAnimationsFinish:y,setDimensions:b,setMounted:T,setOpen:O,setVisible:k,transitionDimensionRef:j,visible:M,width:P});h({open:g&&F===`idle`,ref:v,onComplete(){g&&b({height:void 0,width:void 0})}});let V=t(`div`,e,{state:w.useMemo(()=>({...A,transitionStatus:F}),[A,F]),ref:[n,v],props:[R,{style:{[B.collapsiblePanelHeight]:f===void 0?`auto`:`${f}px`,[B.collapsiblePanelWidth]:P===void 0?`auto`:`${P}px`}},l],stateAttributesMapping:N});return L||I||p?V:null}),H=n();function U(e){let t=(0,H.c)(4),n;t[0]===e?n=t[1]:({...n}=e,t[0]=e,t[1]=n);let r;return t[2]===n?r=t[3]:(r=(0,P.jsx)(F,{"data-slot":`collapsible`,...n}),t[2]=n,t[3]=r),r}function W(e){let t=(0,H.c)(4),n;t[0]===e?n=t[1]:({...n}=e,t[0]=e,t[1]=n);let r;return t[2]===n?r=t[3]:(r=(0,P.jsx)(L,{"data-slot":`collapsible-trigger`,...n}),t[2]=n,t[3]=r),r}function G(e){let t=(0,H.c)(4),n;t[0]===e?n=t[1]:({...n}=e,t[0]=e,t[1]=n);let r;return t[2]===n?r=t[3]:(r=(0,P.jsx)(V,{"data-slot":`collapsible-content`,...n}),t[2]=n,t[3]=r),r}export{G as n,W as r,U as t};
\ No newline at end of file
+import{n as e,o as t,r as n,t as r}from"./compiler-runtime.js";import{c as i,n as a,r as o,u as s}from"./dist.js";import{_ as c,o as l,r as u,s as d}from"./button.js";import{S as f,f as p,g as m,t as h}from"./createBaseUIEventDetails.js";import{An as g,Bn as _,Gn as v,Hn as y,Kn as b,Un as x,Vn as S,Wn as C,kn as w}from"./index.js";var T=t(n());function E(e){let{open:t,defaultOpen:n,onOpenChange:r,disabled:i}=e,[a,o]=_({controlled:t,default:n,name:`Collapsible`,state:`open`}),{mounted:s,setMounted:c,transitionStatus:l}=S(a,!0,!0),u=f(),[p,g]=T.useState(),v=p??u,y=d(e=>{let t=!a,n=h(m,e.nativeEvent);r(t,n),!n.isCanceled&&o(t)});return T.useMemo(()=>({disabled:i,handleTrigger:y,mounted:s,open:a,panelId:v,setMounted:c,setOpen:o,setPanelIdState:g,transitionStatus:l}),[i,y,s,a,v,c,o,g,l])}var D=T.createContext(void 0);function O(){let e=T.useContext(D);if(e===void 0)throw Error(s(15));return e}var k=function(e){return e.open=`data-open`,e.closed=`data-closed`,e[e.startingStyle=C.startingStyle]=`startingStyle`,e[e.endingStyle=C.endingStyle]=`endingStyle`,e}({}),A=function(e){return e.panelOpen=`data-panel-open`,e}({}),j={[k.open]:``},M={[k.closed]:``},N={open(e){return e?{[A.panelOpen]:``}:null}},P={open(e){return e?j:M},...v},F=e(),I=T.forwardRef(function(e,t){let{render:n,className:r,defaultOpen:i=!1,disabled:o=!1,onOpenChange:s,open:c,style:l,...u}=e,f=d(s),p=E({open:c,defaultOpen:i,onOpenChange:f,disabled:o}),m=T.useMemo(()=>({open:p.open,disabled:p.disabled,transitionStatus:p.transitionStatus}),[p.open,p.disabled,p.transitionStatus]),h=T.useMemo(()=>({...p,onOpenChange:f,state:m}),[p,f,m]),g=a(`div`,e,{state:m,ref:t,props:u,stateAttributesMapping:P});return(0,F.jsx)(D.Provider,{value:h,children:g})}),L={...N,...v},R=T.forwardRef(function(e,t){let{panelId:n,open:r,handleTrigger:i,state:o,disabled:s}=O(),{className:c,disabled:l=s,id:d,render:f,nativeButton:p=!0,style:m,...h}=e,{getButtonProps:g,buttonRef:_}=u({disabled:l,focusableWhenDisabled:!0,native:p});return a(`button`,e,{state:o,ref:[t,_],props:[{"aria-controls":r?n:void 0,"aria-expanded":r,onClick:i},h,g],stateAttributesMapping:L})}),z={height:void 0,width:void 0};function B(e){let{externalRef:t,hiddenUntilFound:n,id:r,keepMounted:a,mounted:o,onOpenChange:s,open:c,setMounted:u,setOpen:f,transitionStatus:m}=e,_=T.useRef(null),v=T.useRef(null),[S,C]=T.useState(z),E=T.useRef(z),D=T.useRef(!1),O=T.useRef(c),A=T.useRef(!1),[j,M]=T.useState(!1),N=T.useRef(null),P=i(t,_),F=w({mounted:o,open:c}),I=x(_,!1,!1),L=!c&&!o,R=j?`idle`:m,B=c&&(O.current||A.current),U=!c&&o&&v.current===`css-animation`&&S.height===void 0&&S.width===void 0?E.current:S,K=n&&L&&v.current!==`css-animation`,q=d((e,t=!0)=>{t&&(E.current=e),C(e)}),J=d(()=>{N.current?.(),N.current=null}),Y=d(e=>{J(),N.current=()=>{N.current=null,e()}}),X=d(()=>{c&&o&&v.current===`css-animation`&&(A.current=!0)});l(()=>{!j||m===`starting`||M(!1)},[j,m]),T.useEffect(()=>()=>{X(),J()},[X,J]),l(()=>{let e=_.current;if(!e)return;!c&&N.current&&J();let t=H(e,B);if(v.current=t,c&&m===`idle`&&O.current&&t===`css-animation`){E.current=V(e);return}if(c&&m===`starting`){let n=D.current;if(D.current=!1,t===`none`){q(V(e)),M(!0);return}if(t===`css-transition`){let t=G(e);return q(V(e)),n?(Y(W(e,`transition-duration`,`0s`)),M(!0),t):t}if(t===`css-animation`){if(q(V(e)),!n){W(e,`animation-name`,`none`)();return}let t=W(e,`animation-name`,`none`),r=W(e,`animation-duration`,`0s`);t(),Y(r),M(!0);return}}if(!c&&o&&(m===`idle`||m===`starting`)){if(t===`none`){q(z,!1),u(!1);return}t===`css-animation`&&(O.current=!1,A.current=!1),q(V(e));return}if(m!==`ending`)return;if(t===`none`){u(!1);return}let n=V(e);if(!((n.height??0)>0||(n.width??0)>0)){u(!1);return}q(n),t===`css-animation`&&W(e,`animation-name`,`none`)()},[o,c,J,q,u,Y,B,m]),y({enabled:c&&o&&R===`idle`,open:!0,ref:_,onComplete(){c&&q(z,!1)}}),T.useEffect(()=>{if(c||!o||R!==`ending`||!_.current)return;let e=new AbortController,t=-1;function n(){F.current.open||(u(!1),q(z,!1))}return t=b.request(()=>{e.signal.aborted||I(n,e.signal)}),()=>{b.cancel(t),e.abort()}},[F,o,c,R,I,q,u]),l(()=>{let e=_.current;!e||!n||!L||e.setAttribute(`hidden`,`until-found`)},[L,n]),T.useEffect(function(){let e=_.current;if(!e)return;function t(e){D.current=!0,f(!0),s(!0,h(p,e))}return g(e,`beforematch`,t)},[s,f]);let Z=a||n||o||c;return{height:U.height,props:{...K?{[k.startingStyle]:``}:void 0,hidden:L,id:r},ref:P,shouldPreventOpenAnimation:B,shouldRender:Z,transitionStatus:R,width:U.width}}function V(e){return{height:e.scrollHeight,width:e.scrollWidth}}function H(e,t=!1){let n=c(e).getComputedStyle(e),r=(n.animationName.split(`,`).map(e=>e.trim()).some(e=>e!==``&&e!==`none`)||t)&&U(n.animationDuration),i=U(n.transitionDuration);return r&&i||i?`css-transition`:r?`css-animation`:`none`}function U(e){return e.split(`,`).map(e=>e.trim()).some(e=>e!==``&&Number.parseFloat(e)>0)}function W(e,t,n){let r=e.style.getPropertyValue(t),i=e.style.getPropertyPriority(t);return e.style.setProperty(t,n),()=>{if(r===``){e.style.removeProperty(t);return}e.style.setProperty(t,r,i)}}function G(e){let t={"justify-content":e.style.justifyContent,"align-items":e.style.alignItems,"align-content":e.style.alignContent,"justify-items":e.style.justifyItems};Object.keys(t).forEach(t=>{e.style.setProperty(t,`initial`,`important`)});function n(){Object.entries(t).forEach(([t,n])=>{if(n===``){e.style.removeProperty(t);return}e.style.setProperty(t,n)})}let r=b.request(n);return()=>{b.cancel(r),n()}}var K=function(e){return e.collapsiblePanelHeight=`--collapsible-panel-height`,e.collapsiblePanelWidth=`--collapsible-panel-width`,e}({}),q=T.forwardRef(function(e,t){let{className:n,hiddenUntilFound:r,keepMounted:i,render:s,id:c,style:u,...d}=e,{mounted:f,onOpenChange:p,open:m,panelId:h,setMounted:g,setPanelIdState:_,setOpen:v,state:y,transitionStatus:b}=O(),x=r??!1,S=i??!1;l(()=>{if(c)return _(c),()=>{_(void 0)}},[c,_]);let{height:C,props:w,ref:T,shouldPreventOpenAnimation:E,shouldRender:D,transitionStatus:k,width:A}=B({externalRef:t,hiddenUntilFound:x,id:h,keepMounted:S,mounted:f,onOpenChange:p,open:m,setMounted:g,setOpen:v,transitionStatus:b}),j={...y,transitionStatus:k},M=o(u,j),N=a(`div`,{...e,style:void 0},{state:j,ref:T,props:[w,{style:{[K.collapsiblePanelHeight]:C===void 0?`auto`:`${C}px`,[K.collapsiblePanelWidth]:A===void 0?`auto`:`${A}px`}},d,M?{style:M}:void 0,E?{style:{animationName:`none`}}:void 0],stateAttributesMapping:P});return D?N:null}),J=r();function Y(e){let t=(0,J.c)(4),n;t[0]===e?n=t[1]:({...n}=e,t[0]=e,t[1]=n);let r;return t[2]===n?r=t[3]:(r=(0,F.jsx)(I,{"data-slot":`collapsible`,...n}),t[2]=n,t[3]=r),r}function X(e){let t=(0,J.c)(4),n;t[0]===e?n=t[1]:({...n}=e,t[0]=e,t[1]=n);let r;return t[2]===n?r=t[3]:(r=(0,F.jsx)(R,{"data-slot":`collapsible-trigger`,...n}),t[2]=n,t[3]=r),r}function Z(e){let t=(0,J.c)(4),n;t[0]===e?n=t[1]:({...n}=e,t[0]=e,t[1]=n);let r;return t[2]===n?r=t[3]:(r=(0,F.jsx)(q,{"data-slot":`collapsible-content`,...n}),t[2]=n,t[3]=r),r}export{Z as n,X as r,Y as t};
\ No newline at end of file
diff --git a/spinifex/services/spinifexui/frontend/dist/assets/compiler-runtime.js b/spinifex/services/spinifexui/frontend/dist/assets/compiler-runtime.js
new file mode 100644
index 000000000..3a985cd6b
--- /dev/null
+++ b/spinifex/services/spinifexui/frontend/dist/assets/compiler-runtime.js
@@ -0,0 +1 @@
+var e=Object.create,t=Object.defineProperty,n=Object.getOwnPropertyDescriptor,r=Object.getOwnPropertyNames,i=Object.getPrototypeOf,a=Object.prototype.hasOwnProperty,o=(e,t)=>()=>(t||(e((t={exports:{}}).exports,t),e=null),t.exports),s=(e,n)=>{let r={};for(var i in e)t(r,i,{get:e[i],enumerable:!0});return n||t(r,Symbol.toStringTag,{value:`Module`}),r},c=(e,i,o,s)=>{if(i&&typeof i==`object`||typeof i==`function`)for(var c=r(i),l=0,u=c.length,d;li[e]).bind(null,d),enumerable:!(s=n(i,d))||s.enumerable});return e},l=(n,r,a)=>(a=n==null?{}:e(i(n)),c(r||!n||!n.__esModule?t(a,`default`,{value:n,enumerable:!0}):a,n)),u=o((e=>{var t=Symbol.for(`react.transitional.element`),n=Symbol.for(`react.portal`),r=Symbol.for(`react.fragment`),i=Symbol.for(`react.strict_mode`),a=Symbol.for(`react.profiler`),o=Symbol.for(`react.consumer`),s=Symbol.for(`react.context`),c=Symbol.for(`react.forward_ref`),l=Symbol.for(`react.suspense`),u=Symbol.for(`react.memo`),d=Symbol.for(`react.lazy`),f=Symbol.for(`react.activity`),p=Symbol.iterator;function m(e){return typeof e!=`object`||!e?null:(e=p&&e[p]||e[`@@iterator`],typeof e==`function`?e:null)}var h={isMounted:function(){return!1},enqueueForceUpdate:function(){},enqueueReplaceState:function(){},enqueueSetState:function(){}},g=Object.assign,_={};function v(e,t,n){this.props=e,this.context=t,this.refs=_,this.updater=n||h}v.prototype.isReactComponent={},v.prototype.setState=function(e,t){if(typeof e!=`object`&&typeof e!=`function`&&e!=null)throw Error(`takes an object of state variables to update or a function which returns an object of state variables.`);this.updater.enqueueSetState(this,e,t,`setState`)},v.prototype.forceUpdate=function(e){this.updater.enqueueForceUpdate(this,e,`forceUpdate`)};function y(){}y.prototype=v.prototype;function b(e,t,n){this.props=e,this.context=t,this.refs=_,this.updater=n||h}var x=b.prototype=new y;x.constructor=b,g(x,v.prototype),x.isPureReactComponent=!0;var S=Array.isArray;function C(){}var w={H:null,A:null,T:null,S:null},T=Object.prototype.hasOwnProperty;function E(e,n,r){var i=r.ref;return{$$typeof:t,type:e,key:n,ref:i===void 0?null:i,props:r}}function D(e,t){return E(e.type,t,e.props)}function O(e){return typeof e==`object`&&!!e&&e.$$typeof===t}function k(e){var t={"=":`=0`,":":`=2`};return`$`+e.replace(/[=:]/g,function(e){return t[e]})}var A=/\/+/g;function j(e,t){return typeof e==`object`&&e&&e.key!=null?k(``+e.key):t.toString(36)}function M(e){switch(e.status){case`fulfilled`:return e.value;case`rejected`:throw e.reason;default:switch(typeof e.status==`string`?e.then(C,C):(e.status=`pending`,e.then(function(t){e.status===`pending`&&(e.status=`fulfilled`,e.value=t)},function(t){e.status===`pending`&&(e.status=`rejected`,e.reason=t)})),e.status){case`fulfilled`:return e.value;case`rejected`:throw e.reason}}throw e}function N(e,r,i,a,o){var s=typeof e;(s===`undefined`||s===`boolean`)&&(e=null);var c=!1;if(e===null)c=!0;else switch(s){case`bigint`:case`string`:case`number`:c=!0;break;case`object`:switch(e.$$typeof){case t:case n:c=!0;break;case d:return c=e._init,N(c(e._payload),r,i,a,o)}}if(c)return o=o(e),c=a===``?`.`+j(e,0):a,S(o)?(i=``,c!=null&&(i=c.replace(A,`$&/`)+`/`),N(o,r,i,``,function(e){return e})):o!=null&&(O(o)&&(o=D(o,i+(o.key==null||e&&e.key===o.key?``:(``+o.key).replace(A,`$&/`)+`/`)+c)),r.push(o)),1;c=0;var l=a===``?`.`:a+`:`;if(S(e))for(var u=0;u{t.exports=u()})),f=o((e=>{var t=Symbol.for(`react.transitional.element`),n=Symbol.for(`react.fragment`);function r(e,n,r){var i=null;if(r!==void 0&&(i=``+r),n.key!==void 0&&(i=``+n.key),`key`in n)for(var a in r={},n)a!==`key`&&(r[a]=n[a]);else r=n;return n=r.ref,{$$typeof:t,type:e,key:i,ref:n===void 0?null:n,props:r}}e.Fragment=n,e.jsx=r,e.jsxs=r})),p=o(((e,t)=>{t.exports=f()})),m=o((e=>{var t=d().__CLIENT_INTERNALS_DO_NOT_USE_OR_WARN_USERS_THEY_CANNOT_UPGRADE;e.c=function(e){return t.H.useMemoCache(e)}})),h=o(((e,t)=>{t.exports=m()}));export{s as a,o as i,p as n,l as o,d as r,h as t};
\ No newline at end of file
diff --git a/spinifex/services/spinifexui/frontend/dist/assets/copy.js b/spinifex/services/spinifexui/frontend/dist/assets/copy.js
index 8077f5021..a61ff1fb8 100644
--- a/spinifex/services/spinifexui/frontend/dist/assets/copy.js
+++ b/spinifex/services/spinifexui/frontend/dist/assets/copy.js
@@ -1 +1 @@
-import{wo as e}from"./index.js";var t=e(`copy`,[[`rect`,{width:`14`,height:`14`,x:`8`,y:`8`,rx:`2`,ry:`2`,key:`17jyea`}],[`path`,{d:`M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2`,key:`zix9uf`}]]);export{t};
\ No newline at end of file
+import{Nr as e}from"./index.js";var t=e(`copy`,[[`rect`,{width:`14`,height:`14`,x:`8`,y:`8`,rx:`2`,ry:`2`,key:`17jyea`}],[`path`,{d:`M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2`,key:`zix9uf`}]]);export{t};
\ No newline at end of file
diff --git a/spinifex/services/spinifexui/frontend/dist/assets/create-bucket.js b/spinifex/services/spinifexui/frontend/dist/assets/create-bucket.js
index 8ac14ada0..29123ee6c 100644
--- a/spinifex/services/spinifexui/frontend/dist/assets/create-bucket.js
+++ b/spinifex/services/spinifexui/frontend/dist/assets/create-bucket.js
@@ -1 +1 @@
-import{t as e}from"./error-banner.js";import{t}from"./back-link.js";import{t as n}from"./cli-command-panel.js";import{t as r}from"./s3.js";import{Fo as i,Ho as a,Ro as o,Ur as s,Vr as c,Xi as l}from"./index.js";import{i as u,t as d}from"./zod.js";import{t as f}from"./form-actions.js";import{t as p}from"./page-heading.js";import{a as m,r as h,t as g}from"./field.js";var _=i(),v=c({bucketName:s().min(3,`Bucket name must be at least 3 characters`).max(63,`Bucket name must be at most 63 characters`).regex(/^[a-z0-9][a-z0-9.-]*[a-z0-9]$/,`Must start and end with a lowercase letter or number`).regex(/^[a-z0-9.-]+$/,`Only lowercase letters, numbers, hyphens, and periods allowed`).refine(e=>!e.includes(`..`),`Must not contain consecutive periods`).refine(e=>!(e.includes(`.-`)||e.includes(`-.`)),`Must not contain adjacent period and hyphen`)}),y=a();function b(){let i=(0,_.c)(40),a=o(),s=r(),c;i[0]===Symbol.for(`react.memo_cache_sentinel`)?(c={resolver:d(v)},i[0]=c):c=i[0];let{handleSubmit:b,register:S,watch:C,formState:w}=u(c),{errors:T,isSubmitting:E}=w,D;i[1]!==s||i[2]!==a?(D=async e=>{await s.mutateAsync(e),a({to:`/s3/ls`})},i[1]=s,i[2]=a,i[3]=D):D=i[3];let O=D,k,A;i[4]===Symbol.for(`react.memo_cache_sentinel`)?(k=(0,y.jsx)(t,{to:`/s3/ls`,children:`Back to buckets`}),A=(0,y.jsx)(p,{title:`Create Bucket`}),i[4]=k,i[5]=A):(k=i[4],A=i[5]);let j;i[6]===s.error?j=i[7]:(j=s.error&&(0,y.jsx)(e,{error:s.error,msg:`Failed to create bucket`}),i[6]=s.error,i[7]=j);let M;i[8]!==b||i[9]!==O?(M=b(O),i[8]=b,i[9]=O,i[10]=M):M=i[10];let N;i[11]===Symbol.for(`react.memo_cache_sentinel`)?(N=(0,y.jsx)(m,{children:(0,y.jsx)(`label`,{htmlFor:`bucketName`,children:`Bucket Name`})}),i[11]=N):N=i[11];let P=!!T.bucketName,F;i[12]===S?F=i[13]:(F=S(`bucketName`),i[12]=S,i[13]=F);let I;i[14]!==P||i[15]!==F?(I=(0,y.jsx)(l,{"aria-invalid":P,id:`bucketName`,placeholder:`my-bucket`,type:`text`,...F}),i[14]=P,i[15]=F,i[16]=I):I=i[16];let L;i[17]===T.bucketName?L=i[18]:(L=(0,y.jsx)(h,{errors:[T.bucketName]}),i[17]=T.bucketName,i[18]=L);let R;i[19]!==I||i[20]!==L?(R=(0,y.jsxs)(g,{children:[N,I,L]}),i[19]=I,i[20]=L,i[21]=R):R=i[21];let z;i[22]===C?z=i[23]:(z=x(C),i[22]=C,i[23]=z);let B;i[24]===z?B=i[25]:(B=(0,y.jsx)(n,{commands:z}),i[24]=z,i[25]=B);let V;i[26]===a?V=i[27]:(V=async()=>await a({to:`/s3/ls`}),i[26]=a,i[27]=V);let H;i[28]!==s.isPending||i[29]!==E||i[30]!==V?(H=(0,y.jsx)(f,{isPending:s.isPending,isSubmitting:E,onCancel:V,pendingLabel:`Creating…`,submitLabel:`Create Bucket`}),i[28]=s.isPending,i[29]=E,i[30]=V,i[31]=H):H=i[31];let U;i[32]!==R||i[33]!==B||i[34]!==H||i[35]!==M?(U=(0,y.jsxs)(`form`,{className:`max-w-4xl space-y-6`,onSubmit:M,children:[R,B,H]}),i[32]=R,i[33]=B,i[34]=H,i[35]=M,i[36]=U):U=i[36];let W;return i[37]!==U||i[38]!==j?(W=(0,y.jsxs)(y.Fragment,{children:[k,A,j,U]}),i[37]=U,i[38]=j,i[39]=W):W=i[39],W}function x(e){let t=e(`bucketName`);return[{label:`Create Bucket`,parts:[{type:`bin`,value:`AWS_PROFILE=spinifex aws s3api create-bucket`},{type:`flag`,value:` --bucket`},{type:`value`,value:` ${(typeof t==`string`?t:``)||``}`}]}]}export{b as component};
\ No newline at end of file
+import{n as e,t}from"./compiler-runtime.js";import{t as n}from"./error-banner.js";import{_ as r,y as i}from"./SignatureV4.js";import{t as a}from"./back-link.js";import{t as o}from"./cli-command-panel.js";import{t as s}from"./s3.js";import{Ir as c,Rn as l}from"./index.js";import{a as u,i as d,t as f}from"./zod.js";import{t as p}from"./form-actions.js";import{t as m}from"./page-heading.js";import{a as h,r as g,t as _}from"./field.js";var v=t(),y=r({bucketName:i().min(3,`Bucket name must be at least 3 characters`).max(63,`Bucket name must be at most 63 characters`).regex(/^[a-z0-9][a-z0-9.-]*[a-z0-9]$/,`Must start and end with a lowercase letter or number`).regex(/^[a-z0-9.-]+$/,`Only lowercase letters, numbers, hyphens, and periods allowed`).refine(e=>!e.includes(`..`),`Must not contain consecutive periods`).refine(e=>!(e.includes(`.-`)||e.includes(`-.`)),`Must not contain adjacent period and hyphen`)}),b=e();function x(){let e=(0,v.c)(44),t=c(),r=s(),i;e[0]===Symbol.for(`react.memo_cache_sentinel`)?(i={resolver:f(y)},e[0]=i):i=e[0];let{handleSubmit:x,register:C,control:w,formState:T}=d(i),{errors:E,isSubmitting:D}=T,O;e[1]===w?O=e[2]:(O={control:w},e[1]=w,e[2]=O);let k=u(O),A;e[3]===k?A=e[4]:(A=e=>e?k[e]:void 0,e[3]=k,e[4]=A);let j=A,M;e[5]!==r||e[6]!==t?(M=async e=>{await r.mutateAsync(e),t({to:`/s3/ls`})},e[5]=r,e[6]=t,e[7]=M):M=e[7];let N=M,P,F;e[8]===Symbol.for(`react.memo_cache_sentinel`)?(P=(0,b.jsx)(a,{to:`/s3/ls`,children:`Back to buckets`}),F=(0,b.jsx)(m,{title:`Create Bucket`}),e[8]=P,e[9]=F):(P=e[8],F=e[9]);let I;e[10]===r.error?I=e[11]:(I=r.error&&(0,b.jsx)(n,{error:r.error,msg:`Failed to create bucket`}),e[10]=r.error,e[11]=I);let L;e[12]!==x||e[13]!==N?(L=x(N),e[12]=x,e[13]=N,e[14]=L):L=e[14];let R;e[15]===Symbol.for(`react.memo_cache_sentinel`)?(R=(0,b.jsx)(h,{children:(0,b.jsx)(`label`,{htmlFor:`bucketName`,children:`Bucket Name`})}),e[15]=R):R=e[15];let z=!!E.bucketName,B;e[16]===C?B=e[17]:(B=C(`bucketName`),e[16]=C,e[17]=B);let V;e[18]!==z||e[19]!==B?(V=(0,b.jsx)(l,{"aria-invalid":z,id:`bucketName`,placeholder:`my-bucket`,type:`text`,...B}),e[18]=z,e[19]=B,e[20]=V):V=e[20];let H;e[21]===E.bucketName?H=e[22]:(H=(0,b.jsx)(g,{errors:[E.bucketName]}),e[21]=E.bucketName,e[22]=H);let U;e[23]!==V||e[24]!==H?(U=(0,b.jsxs)(_,{children:[R,V,H]}),e[23]=V,e[24]=H,e[25]=U):U=e[25];let W;e[26]===j?W=e[27]:(W=S(j),e[26]=j,e[27]=W);let G;e[28]===W?G=e[29]:(G=(0,b.jsx)(o,{commands:W}),e[28]=W,e[29]=G);let K;e[30]===t?K=e[31]:(K=async()=>await t({to:`/s3/ls`}),e[30]=t,e[31]=K);let q;e[32]!==r.isPending||e[33]!==D||e[34]!==K?(q=(0,b.jsx)(p,{isPending:r.isPending,isSubmitting:D,onCancel:K,pendingLabel:`Creating…`,submitLabel:`Create Bucket`}),e[32]=r.isPending,e[33]=D,e[34]=K,e[35]=q):q=e[35];let J;e[36]!==U||e[37]!==G||e[38]!==q||e[39]!==L?(J=(0,b.jsxs)(`form`,{className:`max-w-4xl space-y-6`,onSubmit:L,children:[U,G,q]}),e[36]=U,e[37]=G,e[38]=q,e[39]=L,e[40]=J):J=e[40];let Y;return e[41]!==J||e[42]!==I?(Y=(0,b.jsxs)(b.Fragment,{children:[P,F,I,J]}),e[41]=J,e[42]=I,e[43]=Y):Y=e[43],Y}function S(e){let t=e(`bucketName`);return[{label:`Create Bucket`,parts:[{type:`bin`,value:`AWS_PROFILE=spinifex aws s3api create-bucket`},{type:`flag`,value:` --bucket`},{type:`value`,value:` ${(typeof t==`string`?t:``)||``}`}]}]}export{x as component};
\ No newline at end of file
diff --git a/spinifex/services/spinifexui/frontend/dist/assets/create-cluster.js b/spinifex/services/spinifexui/frontend/dist/assets/create-cluster.js
new file mode 100644
index 000000000..54ac7ca29
--- /dev/null
+++ b/spinifex/services/spinifexui/frontend/dist/assets/create-cluster.js
@@ -0,0 +1 @@
+import{n as e,o as t,r as n,t as r}from"./compiler-runtime.js";import{t as i}from"./error-banner.js";import{t as a}from"./useSuspenseQuery.js";import{A as o}from"./SignatureV4.js";import{t as s}from"./back-link.js";import{t as c}from"./check.js";import{c as l,i as u,n as d,s as f,t as p}from"./select.js";import{t as m}from"./copy.js";import{t as h}from"./rotate-cw.js";import{t as g}from"./triangle-alert.js";import{t as _}from"./button.js";import{c as v,i as y,p as b}from"./eks.js";import{Ir as x,Lr as S,Lt as C,Nt as w,Rn as T,pt as E,rt as D}from"./index.js";import{i as O,n as k,t as ee}from"./zod.js";import{t as A}from"./use-copy-to-clipboard.js";import{t as te}from"./form-actions.js";import{t as j}from"./page-heading.js";import{a as M,r as N,t as P}from"./field.js";import{t as F}from"./system-managed.js";var I=t(n(),1),L=r(),R=e(),z=`make import-eks-node-image`;function B(e){let t=(0,L.c)(21),{isRechecking:n,onRecheck:r}=e,{copied:i,copy:a}=A(),o;t[0]===Symbol.for(`react.memo_cache_sentinel`)?(o=(0,R.jsx)(g,{className:`mt-0.5 size-5 shrink-0 text-tactical-amber`}),t[0]=o):o=t[0];let s;t[1]===Symbol.for(`react.memo_cache_sentinel`)?(s=(0,R.jsxs)(`div`,{className:`space-y-1`,children:[(0,R.jsx)(`h2`,{className:`text-sm font-semibold text-foreground`,children:`EKS system image not found`}),(0,R.jsx)(`p`,{className:`text-xs text-muted-foreground`,children:`EKS clusters boot a Spinifex-managed node image (K3s) that is not shipped with the platform. Install it on the host before creating a cluster.`})]}),t[1]=s):s=t[1];let l,u;t[2]===Symbol.for(`react.memo_cache_sentinel`)?(l=(0,R.jsx)(`span`,{className:`text-muted-foreground select-none`,children:`$`}),u=(0,R.jsx)(`span`,{className:`flex-1 text-foreground`,children:z}),t[2]=l,t[3]=u):(l=t[2],u=t[3]);let d;t[4]===a?d=t[5]:(d=()=>void a(z),t[4]=a,t[5]=d);let f;t[6]===i?f=t[7]:(f=i?(0,R.jsx)(c,{className:`size-3.5 text-tactical-green`}):(0,R.jsx)(m,{className:`size-3.5`}),t[6]=i,t[7]=f);let p;t[8]!==d||t[9]!==f?(p=(0,R.jsxs)(`div`,{className:`flex items-center gap-2 rounded border border-border bg-background px-3 py-2 font-mono text-xs`,children:[l,u,(0,R.jsx)(`button`,{"aria-label":`Copy command`,className:`text-muted-foreground transition-colors hover:text-foreground`,onClick:d,type:`button`,children:f})]}),t[8]=d,t[9]=f,t[10]=p):p=t[10];let v;t[11]===Symbol.for(`react.memo_cache_sentinel`)?(v=(0,R.jsx)(`p`,{className:`text-xs text-muted-foreground`,children:`Run from the Spinifex repo root. The command builds and registers the node image, then click Recheck.`}),t[11]=v):v=t[11];let y=n?`size-4 animate-spin`:`size-4`,b;t[12]===y?b=t[13]:(b=(0,R.jsx)(h,{className:y}),t[12]=y,t[13]=b);let x;t[14]!==n||t[15]!==r||t[16]!==b?(x=(0,R.jsxs)(_,{disabled:n,onClick:r,size:`sm`,variant:`outline`,children:[b,`Recheck`]}),t[14]=n,t[15]=r,t[16]=b,t[17]=x):x=t[17];let S;return t[18]!==x||t[19]!==p?(S=(0,R.jsx)(`div`,{className:`max-w-2xl rounded-md border border-tactical-amber/40 bg-tactical-amber/5 p-6`,children:(0,R.jsxs)(`div`,{className:`flex items-start gap-3`,children:[o,(0,R.jsxs)(`div`,{className:`space-y-4`,children:[s,p,v,x]})]})}),t[18]=x,t[19]=p,t[20]=S):S=t[20],S}function V(e){let t=o(e.Tags);return t?`${e.VpcId} (${t})`:`${e.VpcId} (${e.CidrBlock})`}function H(e){let t=o(e.Tags);return`${t?`${e.SubnetId} (${t})`:e.SubnetId} · ${e.CidrBlock}`}function U(){let e=x(),t=S(),[n,r]=(0,I.useState)(!1),{data:o}=a(C),{data:c}=a(w),{data:m}=a(D),{data:h}=a(E),g=b(),A=(h.Images??[]).some(F),L=async()=>{r(!0);try{await t.invalidateQueries({queryKey:E.queryKey})}finally{r(!1)}},z=o.Vpcs??[],U=c.Subnets??[],W=m.Roles??[],{control:G,formState:{errors:K,isSubmitting:q},handleSubmit:J,register:ne,setValue:Y,watch:X}=O({resolver:ee(v),defaultValues:{name:``,version:y[0],roleArn:``,vpcId:``,subnetIds:[],bootstrapClusterCreatorAdminPermissions:!0,endpointPublicAccess:!0,endpointPrivateAccess:!1,publicAccessCidrs:[`0.0.0.0/0`]}}),re=X(`vpcId`),Z=X(`subnetIds`),ie=X(`endpointPublicAccess`),Q=X(`publicAccessCidrs`),$=U.filter(e=>e.VpcId===re),ae=(e=``)=>{Y(`vpcId`,e??``,{shouldValidate:!0}),Y(`subnetIds`,[])},oe=e=>{Y(`subnetIds`,Z.includes(e)?Z.filter(t=>t!==e):[...Z,e],{shouldValidate:!0})},se=(e,t)=>{let n=[...Q];n[e]=t,Y(`publicAccessCidrs`,n,{shouldValidate:!0})},ce=()=>Y(`publicAccessCidrs`,[...Q,``],{shouldValidate:!0}),le=e=>Y(`publicAccessCidrs`,Q.filter((t,n)=>n!==e),{shouldValidate:!0});return A?(0,R.jsxs)(R.Fragment,{children:[(0,R.jsx)(s,{to:`/eks/list-clusters`,children:`Clusters`}),(0,R.jsx)(j,{subtitle:`EKS`,title:`Create Cluster`}),g.error&&(0,R.jsx)(i,{error:g.error,msg:`Failed to create cluster`}),(0,R.jsxs)(`form`,{className:`max-w-4xl space-y-6`,onSubmit:J(async t=>{await g.mutateAsync(t),await e({to:`/eks/list-clusters/$clusterName`,params:{clusterName:t.name}})}),children:[(0,R.jsxs)(P,{children:[(0,R.jsx)(M,{children:(0,R.jsx)(`label`,{htmlFor:`cluster-name`,children:`Name`})}),(0,R.jsx)(T,{"aria-invalid":!!K.name,id:`cluster-name`,placeholder:`my-cluster`,...ne(`name`)}),(0,R.jsx)(N,{errors:[K.name]})]}),(0,R.jsxs)(P,{children:[(0,R.jsx)(M,{children:(0,R.jsx)(`label`,{htmlFor:`cluster-version`,children:`Kubernetes version`})}),(0,R.jsx)(k,{control:G,name:`version`,render:({field:e})=>(0,R.jsxs)(p,{onValueChange:e.onChange,value:e.value,children:[(0,R.jsx)(f,{className:`w-full`,id:`cluster-version`,children:(0,R.jsx)(l,{placeholder:`Select version`})}),(0,R.jsx)(d,{children:y.map(e=>(0,R.jsx)(u,{value:e,children:e},e))})]})}),(0,R.jsx)(N,{errors:[K.version]})]}),(0,R.jsxs)(P,{children:[(0,R.jsx)(M,{children:(0,R.jsx)(`label`,{htmlFor:`cluster-role`,children:`Cluster IAM role`})}),(0,R.jsx)(k,{control:G,name:`roleArn`,render:({field:e})=>(0,R.jsxs)(p,{onValueChange:e.onChange,value:e.value,children:[(0,R.jsx)(f,{"aria-invalid":!!K.roleArn,className:`w-full`,id:`cluster-role`,children:(0,R.jsx)(l,{placeholder:`Select role`})}),(0,R.jsx)(d,{children:W.map(e=>(0,R.jsx)(u,{value:e.Arn??``,children:e.RoleName},e.Arn))})]})}),(0,R.jsx)(N,{errors:[K.roleArn]})]}),(0,R.jsxs)(P,{children:[(0,R.jsx)(M,{children:(0,R.jsx)(`label`,{htmlFor:`cluster-vpc`,children:`VPC`})}),(0,R.jsx)(k,{control:G,name:`vpcId`,render:({field:e})=>(0,R.jsxs)(p,{onValueChange:e=>ae(e),value:e.value,children:[(0,R.jsx)(f,{"aria-invalid":!!K.vpcId,className:`w-full`,id:`cluster-vpc`,children:(0,R.jsx)(l,{placeholder:`Select VPC`})}),(0,R.jsx)(d,{children:z.map(e=>(0,R.jsx)(u,{value:e.VpcId??``,children:V(e)},e.VpcId))})]})}),(0,R.jsx)(N,{errors:[K.vpcId]})]}),(0,R.jsxs)(P,{children:[(0,R.jsx)(M,{children:`Subnets (select at least 1)`}),$.length===0?(0,R.jsx)(`p`,{className:`text-xs text-muted-foreground`,children:`No subnets in the selected VPC.`}):(0,R.jsx)(`div`,{className:`space-y-1`,children:$.map(e=>(0,R.jsxs)(`label`,{className:`flex items-center gap-2 text-xs`,children:[(0,R.jsx)(`input`,{"aria-label":`Subnet ${H(e)}`,checked:Z.includes(e.SubnetId??``),onChange:()=>oe(e.SubnetId??``),type:`checkbox`}),(0,R.jsx)(`span`,{className:`font-mono`,children:H(e)})]},e.SubnetId))}),(0,R.jsx)(N,{errors:[K.subnetIds]})]}),(0,R.jsxs)(P,{children:[(0,R.jsx)(M,{children:`Endpoint access`}),(0,R.jsx)(`p`,{className:`text-xs text-muted-foreground`,children:`Public access exposes the API server on an internet-facing endpoint; private access keeps it reachable only from within the VPC. At least one must be enabled.`}),(0,R.jsx)(k,{control:G,name:`endpointPublicAccess`,render:({field:e})=>(0,R.jsxs)(`label`,{className:`mt-2 flex items-center gap-2 text-xs`,children:[(0,R.jsx)(`input`,{"aria-label":`Enable public access`,checked:e.value,onChange:t=>e.onChange(t.target.checked),type:`checkbox`}),`Public`]})}),(0,R.jsx)(k,{control:G,name:`endpointPrivateAccess`,render:({field:e})=>(0,R.jsxs)(`label`,{className:`flex items-center gap-2 text-xs`,children:[(0,R.jsx)(`input`,{"aria-label":`Enable private access`,checked:e.value,onChange:t=>e.onChange(t.target.checked),type:`checkbox`}),`Private`]})}),(0,R.jsx)(N,{errors:[K.endpointPublicAccess]}),ie&&(0,R.jsxs)(`div`,{className:`mt-2 space-y-2`,children:[(0,R.jsxs)(`p`,{className:`text-xs text-muted-foreground`,children:[`Public access source ranges (CIDR). Defaults to`,` `,(0,R.jsx)(`span`,{className:`font-mono`,children:`0.0.0.0/0`}),`.`]}),Q.map((e,t)=>(0,R.jsxs)(`div`,{className:`flex items-center gap-2`,children:[(0,R.jsx)(T,{"aria-label":`Public access CIDR ${t+1}`,className:`font-mono`,onChange:e=>se(t,e.target.value),placeholder:`203.0.113.0/24`,value:e}),(0,R.jsx)(_,{onClick:()=>le(t),size:`sm`,type:`button`,variant:`ghost`,children:`Remove`})]},t)),(0,R.jsx)(_,{onClick:ce,size:`sm`,type:`button`,variant:`outline`,children:`Add CIDR`}),(0,R.jsx)(N,{errors:[K.publicAccessCidrs]})]})]}),(0,R.jsxs)(P,{children:[(0,R.jsx)(M,{children:`Access`}),(0,R.jsxs)(`p`,{className:`text-xs text-muted-foreground`,children:[`Authentication mode is `,(0,R.jsx)(`span`,{className:`font-mono`,children:`API`}),` (IAM access entries). The legacy aws-auth ConfigMap is not supported.`]}),(0,R.jsx)(k,{control:G,name:`bootstrapClusterCreatorAdminPermissions`,render:({field:e})=>(0,R.jsxs)(`label`,{className:`mt-2 flex items-center gap-2 text-xs`,children:[(0,R.jsx)(`input`,{"aria-label":`Grant the cluster creator admin permissions`,checked:e.value,onChange:t=>e.onChange(t.target.checked),type:`checkbox`}),`Grant the cluster creator admin permissions`]})})]}),(0,R.jsx)(te,{isPending:g.isPending,isSubmitting:q,onCancel:async()=>await e({to:`/eks/list-clusters`}),pendingLabel:`Creating…`,submitLabel:`Create Cluster`})]})]}):(0,R.jsxs)(R.Fragment,{children:[(0,R.jsx)(s,{to:`/eks/list-clusters`,children:`Clusters`}),(0,R.jsx)(j,{subtitle:`EKS`,title:`Create Cluster`}),(0,R.jsx)(B,{isRechecking:n,onRecheck:L})]})}var W=U;export{W as component};
\ No newline at end of file
diff --git a/spinifex/services/spinifexui/frontend/dist/assets/create-instance-profile.js b/spinifex/services/spinifexui/frontend/dist/assets/create-instance-profile.js
new file mode 100644
index 000000000..ca13eeb63
--- /dev/null
+++ b/spinifex/services/spinifexui/frontend/dist/assets/create-instance-profile.js
@@ -0,0 +1 @@
+import{n as e,t}from"./compiler-runtime.js";import{t as n}from"./error-banner.js";import{t as r}from"./back-link.js";import{t as i}from"./cli-command-panel.js";import{a}from"./iam.js";import{Ir as o,Rn as s}from"./index.js";import{a as c,i as l,t as u}from"./zod.js";import{t as d}from"./form-actions.js";import{t as f}from"./page-heading.js";import{a as p,r as m,t as h}from"./field.js";import{n as g}from"./iam2.js";var _=t(),v=e();function y(){let e=(0,_.c)(55),t=o(),y=a(),b;e[0]===Symbol.for(`react.memo_cache_sentinel`)?(b={resolver:u(g)},e[0]=b):b=e[0];let{handleSubmit:x,register:S,control:C,formState:w}=l(b),{errors:T,isSubmitting:E}=w,D;e[1]===C?D=e[2]:(D={control:C},e[1]=C,e[2]=D);let O=c(D),k;e[3]===O?k=e[4]:(k=e=>e?O[e]:void 0,e[3]=O,e[4]=k);let A=k,j;e[5]!==y||e[6]!==t?(j=async e=>{await y.mutateAsync(e),t({to:`/iam/list-instance-profiles`})},e[5]=y,e[6]=t,e[7]=j):j=e[7];let M=j,N,P;e[8]===Symbol.for(`react.memo_cache_sentinel`)?(N=(0,v.jsx)(r,{to:`/iam/list-instance-profiles`,children:`Back to instance profiles`}),P=(0,v.jsx)(f,{title:`Create Instance Profile`}),e[8]=N,e[9]=P):(N=e[8],P=e[9]);let F;e[10]===y.error?F=e[11]:(F=y.error&&(0,v.jsx)(n,{error:y.error,msg:`Failed to create instance profile`}),e[10]=y.error,e[11]=F);let I;e[12]!==x||e[13]!==M?(I=x(M),e[12]=x,e[13]=M,e[14]=I):I=e[14];let L;e[15]===Symbol.for(`react.memo_cache_sentinel`)?(L=(0,v.jsx)(p,{children:(0,v.jsx)(`label`,{htmlFor:`instanceProfileName`,children:`Instance Profile Name`})}),e[15]=L):L=e[15];let R=!!T.instanceProfileName,z;e[16]===S?z=e[17]:(z=S(`instanceProfileName`),e[16]=S,e[17]=z);let B;e[18]!==R||e[19]!==z?(B=(0,v.jsx)(s,{"aria-invalid":R,id:`instanceProfileName`,placeholder:`my-instance-profile...`,...z}),e[18]=R,e[19]=z,e[20]=B):B=e[20];let V;e[21]===T.instanceProfileName?V=e[22]:(V=(0,v.jsx)(m,{errors:[T.instanceProfileName]}),e[21]=T.instanceProfileName,e[22]=V);let H;e[23]!==B||e[24]!==V?(H=(0,v.jsxs)(h,{children:[L,B,V]}),e[23]=B,e[24]=V,e[25]=H):H=e[25];let U;e[26]===Symbol.for(`react.memo_cache_sentinel`)?(U=(0,v.jsx)(p,{children:(0,v.jsx)(`label`,{htmlFor:`path`,children:`Path (optional)`})}),e[26]=U):U=e[26];let W;e[27]===S?W=e[28]:(W=S(`path`),e[27]=S,e[28]=W);let G;e[29]===W?G=e[30]:(G=(0,v.jsx)(s,{id:`path`,placeholder:`/`,...W}),e[29]=W,e[30]=G);let K;e[31]===T.path?K=e[32]:(K=(0,v.jsx)(m,{errors:[T.path]}),e[31]=T.path,e[32]=K);let q;e[33]!==G||e[34]!==K?(q=(0,v.jsxs)(h,{children:[U,G,K]}),e[33]=G,e[34]=K,e[35]=q):q=e[35];let J;e[36]===A?J=e[37]:(J=ee(A),e[36]=A,e[37]=J);let Y;e[38]===J?Y=e[39]:(Y=(0,v.jsx)(i,{commands:J}),e[38]=J,e[39]=Y);let X;e[40]===t?X=e[41]:(X=async()=>await t({to:`/iam/list-instance-profiles`}),e[40]=t,e[41]=X);let Z;e[42]!==y.isPending||e[43]!==E||e[44]!==X?(Z=(0,v.jsx)(d,{isPending:y.isPending,isSubmitting:E,onCancel:X,pendingLabel:`Creating...`,submitLabel:`Create Instance Profile`}),e[42]=y.isPending,e[43]=E,e[44]=X,e[45]=Z):Z=e[45];let Q;e[46]!==H||e[47]!==q||e[48]!==Y||e[49]!==Z||e[50]!==I?(Q=(0,v.jsxs)(`form`,{className:`max-w-4xl space-y-6`,onSubmit:I,children:[H,q,Y,Z]}),e[46]=H,e[47]=q,e[48]=Y,e[49]=Z,e[50]=I,e[51]=Q):Q=e[51];let $;return e[52]!==Q||e[53]!==F?($=(0,v.jsxs)(v.Fragment,{children:[N,P,F,Q]}),e[52]=Q,e[53]=F,e[54]=$):$=e[54],$}function ee(e){let t=e(`instanceProfileName`),n=typeof t==`string`?t:``,r=e(`path`),i=typeof r==`string`?r:``,a=[{type:`bin`,value:`AWS_PROFILE=spinifex aws iam create-instance-profile`},{type:`flag`,value:` --instance-profile-name`},{type:`value`,value:` ${n||``}`}];return i&&i!==`/`&&a.push({type:`flag`,value:` --path`},{type:`value`,value:` ${i}`}),[{label:`Create Instance Profile`,parts:a}]}export{y as component};
\ No newline at end of file
diff --git a/spinifex/services/spinifexui/frontend/dist/assets/create-internet-gateway.js b/spinifex/services/spinifexui/frontend/dist/assets/create-internet-gateway.js
index aade829a6..df93cc994 100644
--- a/spinifex/services/spinifexui/frontend/dist/assets/create-internet-gateway.js
+++ b/spinifex/services/spinifexui/frontend/dist/assets/create-internet-gateway.js
@@ -1,2 +1,2 @@
-import{t as e}from"./error-banner.js";import{t}from"./back-link.js";import{t as n}from"./cli-command-panel.js";import{d as r}from"./ec2.js";import{Fo as i,Ho as a,Ro as o,Xi as s}from"./index.js";import{a as c,i as l,t as u}from"./zod.js";import{t as d}from"./form-actions.js";import{t as f}from"./page-heading.js";import{a as p,t as m}from"./field.js";import{a as h}from"./ec22.js";var g=i(),_=a();function v(){let i=(0,g.c)(36),a=o(),v=r(),b;i[0]===Symbol.for(`react.memo_cache_sentinel`)?(b={resolver:u(h),defaultValues:{name:``}},i[0]=b):b=i[0];let{control:x,handleSubmit:S,register:C,formState:w}=l(b),{isSubmitting:T}=w,E;i[1]===x?E=i[2]:(E={control:x},i[1]=x,i[2]=E);let D=c(E),O;i[3]!==v||i[4]!==a?(O=async e=>{let t=(await v.mutateAsync({name:e.name?.trim()??void 0})).InternetGateway?.InternetGatewayId;a(t?{to:`/ec2/describe-internet-gateways/$id`,params:{id:t}}:{to:`/ec2/describe-internet-gateways`})},i[3]=v,i[4]=a,i[5]=O):O=i[5];let k=O,A,j;i[6]===Symbol.for(`react.memo_cache_sentinel`)?(A=(0,_.jsx)(t,{to:`/ec2/describe-internet-gateways`,children:`Back to Internet Gateways`}),j=(0,_.jsx)(f,{title:`Create Internet Gateway`}),i[6]=A,i[7]=j):(A=i[6],j=i[7]);let M;i[8]===v.error?M=i[9]:(M=v.error&&(0,_.jsx)(e,{error:v.error,msg:`Failed to create Internet Gateway`}),i[8]=v.error,i[9]=M);let N;i[10]!==S||i[11]!==k?(N=S(k),i[10]=S,i[11]=k,i[12]=N):N=i[12];let P;i[13]===Symbol.for(`react.memo_cache_sentinel`)?(P=(0,_.jsx)(p,{children:(0,_.jsx)(`label`,{htmlFor:`name`,children:`Name (optional)`})}),i[13]=P):P=i[13];let F;i[14]===C?F=i[15]:(F=C(`name`),i[14]=C,i[15]=F);let I;i[16]===F?I=i[17]:(I=(0,_.jsxs)(m,{children:[P,(0,_.jsx)(s,{id:`name`,placeholder:`my-internet-gateway`,...F})]}),i[16]=F,i[17]=I);let L=D.name??``,R;i[18]===L?R=i[19]:(R=y(L),i[18]=L,i[19]=R);let z;i[20]===R?z=i[21]:(z=(0,_.jsx)(n,{commands:R}),i[20]=R,i[21]=z);let B;i[22]===a?B=i[23]:(B=async()=>await a({to:`/ec2/describe-internet-gateways`}),i[22]=a,i[23]=B);let V;i[24]!==v.isPending||i[25]!==T||i[26]!==B?(V=(0,_.jsx)(d,{isPending:v.isPending,isSubmitting:T,onCancel:B,pendingLabel:`Creating…`,submitLabel:`Create`}),i[24]=v.isPending,i[25]=T,i[26]=B,i[27]=V):V=i[27];let H;i[28]!==I||i[29]!==z||i[30]!==V||i[31]!==N?(H=(0,_.jsxs)(`form`,{className:`max-w-4xl space-y-6`,onSubmit:N,children:[I,z,V]}),i[28]=I,i[29]=z,i[30]=V,i[31]=N,i[32]=H):H=i[32];let U;return i[33]!==H||i[34]!==M?(U=(0,_.jsxs)(_.Fragment,{children:[A,j,M,H]}),i[33]=H,i[34]=M,i[35]=U):U=i[35],U}function y(e){let t=[{type:`bin`,value:`AWS_PROFILE=spinifex aws ec2 create-internet-gateway`}];return e.trim()&&t.push({type:`flag`,value:` \\
+import{n as e,t}from"./compiler-runtime.js";import{t as n}from"./error-banner.js";import{t as r}from"./back-link.js";import{t as i}from"./cli-command-panel.js";import{f as a}from"./ec2.js";import{Ir as o,Rn as s}from"./index.js";import{a as c,i as l,t as u}from"./zod.js";import{t as d}from"./form-actions.js";import{t as f}from"./page-heading.js";import{a as p,t as m}from"./field.js";import{a as h}from"./ec22.js";var g=t(),_=e();function v(){let e=(0,g.c)(36),t=o(),v=a(),b;e[0]===Symbol.for(`react.memo_cache_sentinel`)?(b={resolver:u(h),defaultValues:{name:``}},e[0]=b):b=e[0];let{control:x,handleSubmit:S,register:C,formState:w}=l(b),{isSubmitting:T}=w,E;e[1]===x?E=e[2]:(E={control:x},e[1]=x,e[2]=E);let D=c(E),O;e[3]!==v||e[4]!==t?(O=async e=>{let n=(await v.mutateAsync({name:e.name?.trim()??void 0})).InternetGateway?.InternetGatewayId;t(n?{to:`/ec2/describe-internet-gateways/$id`,params:{id:n}}:{to:`/ec2/describe-internet-gateways`})},e[3]=v,e[4]=t,e[5]=O):O=e[5];let k=O,A,j;e[6]===Symbol.for(`react.memo_cache_sentinel`)?(A=(0,_.jsx)(r,{to:`/ec2/describe-internet-gateways`,children:`Back to Internet Gateways`}),j=(0,_.jsx)(f,{title:`Create Internet Gateway`}),e[6]=A,e[7]=j):(A=e[6],j=e[7]);let M;e[8]===v.error?M=e[9]:(M=v.error&&(0,_.jsx)(n,{error:v.error,msg:`Failed to create Internet Gateway`}),e[8]=v.error,e[9]=M);let N;e[10]!==S||e[11]!==k?(N=S(k),e[10]=S,e[11]=k,e[12]=N):N=e[12];let P;e[13]===Symbol.for(`react.memo_cache_sentinel`)?(P=(0,_.jsx)(p,{children:(0,_.jsx)(`label`,{htmlFor:`name`,children:`Name (optional)`})}),e[13]=P):P=e[13];let F;e[14]===C?F=e[15]:(F=C(`name`),e[14]=C,e[15]=F);let I;e[16]===F?I=e[17]:(I=(0,_.jsxs)(m,{children:[P,(0,_.jsx)(s,{id:`name`,placeholder:`my-internet-gateway`,...F})]}),e[16]=F,e[17]=I);let L=D.name??``,R;e[18]===L?R=e[19]:(R=y(L),e[18]=L,e[19]=R);let z;e[20]===R?z=e[21]:(z=(0,_.jsx)(i,{commands:R}),e[20]=R,e[21]=z);let B;e[22]===t?B=e[23]:(B=async()=>await t({to:`/ec2/describe-internet-gateways`}),e[22]=t,e[23]=B);let V;e[24]!==v.isPending||e[25]!==T||e[26]!==B?(V=(0,_.jsx)(d,{isPending:v.isPending,isSubmitting:T,onCancel:B,pendingLabel:`Creating…`,submitLabel:`Create`}),e[24]=v.isPending,e[25]=T,e[26]=B,e[27]=V):V=e[27];let H;e[28]!==I||e[29]!==z||e[30]!==V||e[31]!==N?(H=(0,_.jsxs)(`form`,{className:`max-w-4xl space-y-6`,onSubmit:N,children:[I,z,V]}),e[28]=I,e[29]=z,e[30]=V,e[31]=N,e[32]=H):H=e[32];let U;return e[33]!==H||e[34]!==M?(U=(0,_.jsxs)(_.Fragment,{children:[A,j,M,H]}),e[33]=H,e[34]=M,e[35]=U):U=e[35],U}function y(e){let t=[{type:`bin`,value:`AWS_PROFILE=spinifex aws ec2 create-internet-gateway`}];return e.trim()&&t.push({type:`flag`,value:` \\
--tag-specifications`},{type:`value`,value:` 'ResourceType=internet-gateway,Tags=[{Key=Name,Value=${e.trim()}}]'`}),[{label:`Create Internet Gateway`,parts:t}]}export{v as component};
\ No newline at end of file
diff --git a/spinifex/services/spinifexui/frontend/dist/assets/create-key-pair.js b/spinifex/services/spinifexui/frontend/dist/assets/create-key-pair.js
index 834ccbda7..5b713973b 100644
--- a/spinifex/services/spinifexui/frontend/dist/assets/create-key-pair.js
+++ b/spinifex/services/spinifexui/frontend/dist/assets/create-key-pair.js
@@ -1 +1 @@
-import{t as e}from"./error-banner.js";import{t}from"./back-link.js";import{t as n}from"./check.js";import{t as r}from"./download.js";import{t as i}from"./cli-command-panel.js";import{t as a}from"./triangle-alert.js";import{f as o}from"./ec2.js";import{Fo as s,Ho as c,Ro as l,Uo as u,Xa as d,Xi as f,cs as p}from"./index.js";import{i as m,t as h}from"./zod.js";import{t as g}from"./use-copy-to-clipboard.js";import{t as _}from"./form-actions.js";import{t as v}from"./page-heading.js";import{a as y,r as b,t as x}from"./field.js";import{o as S}from"./ec22.js";import{a as C,c as w,i as T,l as E,o as D,r as O,s as k,t as A}from"./alert-dialog.js";var j=p(u()),M=s(),N=c();function P(e){let t=(0,M.c)(38),{open:i,keyName:o,keyMaterial:s}=e,c=l(),{copied:u,copy:f}=g(),p;t[0]!==s||t[1]!==o?(p=()=>{let e=new Blob([s],{type:`text/plain`}),t=URL.createObjectURL(e),n=document.createElement(`a`);n.href=t,n.download=`${o}.pem`,n.click(),URL.revokeObjectURL(t)},t[0]=s,t[1]=o,t[2]=p):p=t[2];let m=p,h;t[3]===c?h=t[4]:(h=()=>{c({to:`/ec2/describe-key-pairs`})},t[3]=c,t[4]=h);let _=h,v,y;t[5]===Symbol.for(`react.memo_cache_sentinel`)?(v=(0,N.jsx)(w,{children:(0,N.jsx)(a,{className:`text-destructive`})}),y=(0,N.jsx)(E,{children:`Save Your Private Key`}),t[5]=v,t[6]=y):(v=t[5],y=t[6]);let b;t[7]===o?b=t[8]:(b=(0,N.jsxs)(k,{children:[v,y,(0,N.jsxs)(C,{children:[`This is your only chance to save the private key file for`,` `,(0,N.jsx)(`strong`,{children:o}),`. Please download or copy it now. You won't be able to retrieve it again.`]})]}),t[7]=o,t[8]=b);let x=`Private key material for ${o}`,S;t[9]!==s||t[10]!==x?(S=(0,N.jsx)(`textarea`,{"aria-label":x,className:`w-full resize-none overflow-y-auto rounded-md border border-input bg-input/20 px-2 py-2 font-mono text-xs`,readOnly:!0,rows:4,value:s}),t[9]=s,t[10]=x,t[11]=S):S=t[11];let j;t[12]!==f||t[13]!==s?(j=async()=>await f(s),t[12]=f,t[13]=s,t[14]=j):j=t[14];let P;t[15]===u?P=t[16]:(P=u?(0,N.jsxs)(N.Fragment,{children:[(0,N.jsx)(n,{className:`mr-2 size-4`}),`Copied!`]}):`Copy to Clipboard`,t[15]=u,t[16]=P);let F;t[17]!==j||t[18]!==P?(F=(0,N.jsx)(d,{className:`flex-1`,onClick:j,type:`button`,variant:`outline`,children:P}),t[17]=j,t[18]=P,t[19]=F):F=t[19];let I;t[20]===Symbol.for(`react.memo_cache_sentinel`)?(I=(0,N.jsx)(r,{className:`mr-2 size-4`}),t[20]=I):I=t[20];let L;t[21]===m?L=t[22]:(L=(0,N.jsxs)(d,{className:`flex-1`,onClick:m,type:`button`,variant:`outline`,children:[I,`Download .pem File`]}),t[21]=m,t[22]=L);let R;t[23]!==F||t[24]!==L?(R=(0,N.jsxs)(`div`,{className:`flex gap-2`,children:[F,L]}),t[23]=F,t[24]=L,t[25]=R):R=t[25];let z;t[26]!==R||t[27]!==S?(z=(0,N.jsxs)(`div`,{className:`space-y-3`,children:[S,R]}),t[26]=R,t[27]=S,t[28]=z):z=t[28];let B;t[29]===_?B=t[30]:(B=(0,N.jsx)(D,{children:(0,N.jsx)(O,{onClick:_,children:`Close and Continue`})}),t[29]=_,t[30]=B);let V;t[31]!==z||t[32]!==B||t[33]!==b?(V=(0,N.jsxs)(T,{className:`max-w-2xl`,children:[b,z,B]}),t[31]=z,t[32]=B,t[33]=b,t[34]=V):V=t[34];let H;return t[35]!==i||t[36]!==V?(H=(0,N.jsx)(A,{open:i,children:V}),t[35]=i,t[36]=V,t[37]=H):H=t[37],H}function F(){let n=(0,M.c)(42),r=l(),a=o(),[s,c]=(0,j.useState)(null),u;n[0]===Symbol.for(`react.memo_cache_sentinel`)?(u={resolver:h(S)},n[0]=u):u=n[0];let{handleSubmit:d,register:p,watch:g,formState:C}=m(u),{errors:w,isSubmitting:T}=C,E;n[1]===a?E=n[2]:(E=async e=>{let t=await a.mutateAsync(e);t.KeyMaterial&&c({keyName:e.keyName,material:t.KeyMaterial})},n[1]=a,n[2]=E);let D=E,O,k;n[3]===Symbol.for(`react.memo_cache_sentinel`)?(O=(0,N.jsx)(t,{to:`/ec2/describe-key-pairs`,children:`Back to key pairs`}),k=(0,N.jsx)(v,{title:`Create Key Pair`}),n[3]=O,n[4]=k):(O=n[3],k=n[4]);let A;n[5]===a.error?A=n[6]:(A=a.error&&(0,N.jsx)(e,{error:a.error,msg:`Failed to create key pair`}),n[5]=a.error,n[6]=A);let F;n[7]!==d||n[8]!==D?(F=d(D),n[7]=d,n[8]=D,n[9]=F):F=n[9];let L;n[10]===Symbol.for(`react.memo_cache_sentinel`)?(L=(0,N.jsx)(y,{children:(0,N.jsx)(`label`,{htmlFor:`keyName`,children:`Key Pair Name`})}),n[10]=L):L=n[10];let R=!!w.keyName,z;n[11]===p?z=n[12]:(z=p(`keyName`),n[11]=p,n[12]=z);let B;n[13]!==R||n[14]!==z?(B=(0,N.jsx)(f,{"aria-invalid":R,id:`keyName`,placeholder:`my-key-pair…`,...z}),n[13]=R,n[14]=z,n[15]=B):B=n[15];let V;n[16]===w.keyName?V=n[17]:(V=(0,N.jsx)(b,{errors:[w.keyName]}),n[16]=w.keyName,n[17]=V);let H;n[18]!==B||n[19]!==V?(H=(0,N.jsxs)(x,{children:[L,B,V]}),n[18]=B,n[19]=V,n[20]=H):H=n[20];let U;n[21]===g?U=n[22]:(U=I(g),n[21]=g,n[22]=U);let W;n[23]===U?W=n[24]:(W=(0,N.jsx)(i,{commands:U}),n[23]=U,n[24]=W);let G;n[25]===r?G=n[26]:(G=async()=>await r({to:`/ec2/describe-key-pairs`}),n[25]=r,n[26]=G);let K;n[27]!==a.isPending||n[28]!==T||n[29]!==G?(K=(0,N.jsx)(_,{isPending:a.isPending,isSubmitting:T,onCancel:G,pendingLabel:`Creating…`,submitLabel:`Create Key Pair`}),n[27]=a.isPending,n[28]=T,n[29]=G,n[30]=K):K=n[30];let q;n[31]!==H||n[32]!==W||n[33]!==K||n[34]!==F?(q=(0,N.jsxs)(`form`,{className:`max-w-4xl space-y-6`,onSubmit:F,children:[H,W,K]}),n[31]=H,n[32]=W,n[33]=K,n[34]=F,n[35]=q):q=n[35];let J;n[36]===s?J=n[37]:(J=s&&(0,N.jsx)(P,{keyMaterial:s.material,keyName:s.keyName,open:!!s}),n[36]=s,n[37]=J);let Y;return n[38]!==q||n[39]!==J||n[40]!==A?(Y=(0,N.jsxs)(N.Fragment,{children:[O,k,A,q,J]}),n[38]=q,n[39]=J,n[40]=A,n[41]=Y):Y=n[41],Y}function I(e){let t=e(`keyName`);return[{label:`Create Key Pair`,parts:[{type:`bin`,value:`AWS_PROFILE=spinifex aws ec2 create-key-pair`},{type:`flag`,value:` --key-name`},{type:`value`,value:` ${(typeof t==`string`?t:``)||``}`}]}]}export{F as component};
\ No newline at end of file
+import{n as e,o as t,r as n,t as r}from"./compiler-runtime.js";import{t as i}from"./error-banner.js";import{t as a}from"./back-link.js";import{t as o}from"./check.js";import{t as s}from"./download.js";import{t as c}from"./cli-command-panel.js";import{t as l}from"./triangle-alert.js";import{t as u}from"./button.js";import{p as d}from"./ec2.js";import{Ir as f,Rn as p}from"./index.js";import{a as m,i as h,t as g}from"./zod.js";import{t as _}from"./use-copy-to-clipboard.js";import{t as v}from"./form-actions.js";import{t as y}from"./page-heading.js";import{a as b,r as x,t as S}from"./field.js";import{o as C}from"./ec22.js";import{a as ee,c as w,i as T,l as E,o as D,r as O,s as k,t as A}from"./alert-dialog.js";var j=t(n()),M=r(),N=e();function P(e){let t=(0,M.c)(38),{open:n,keyName:r,keyMaterial:i}=e,a=f(),{copied:c,copy:d}=_(),p;t[0]!==i||t[1]!==r?(p=()=>{let e=new Blob([i],{type:`text/plain`}),t=URL.createObjectURL(e),n=document.createElement(`a`);n.href=t,n.download=`${r}.pem`,n.click(),URL.revokeObjectURL(t)},t[0]=i,t[1]=r,t[2]=p):p=t[2];let m=p,h;t[3]===a?h=t[4]:(h=()=>{a({to:`/ec2/describe-key-pairs`})},t[3]=a,t[4]=h);let g=h,v,y;t[5]===Symbol.for(`react.memo_cache_sentinel`)?(v=(0,N.jsx)(w,{children:(0,N.jsx)(l,{className:`text-destructive`})}),y=(0,N.jsx)(E,{children:`Save Your Private Key`}),t[5]=v,t[6]=y):(v=t[5],y=t[6]);let b;t[7]===r?b=t[8]:(b=(0,N.jsxs)(k,{children:[v,y,(0,N.jsxs)(ee,{children:[`This is your only chance to save the private key file for`,` `,(0,N.jsx)(`strong`,{children:r}),`. Please download or copy it now. You won't be able to retrieve it again.`]})]}),t[7]=r,t[8]=b);let x=`Private key material for ${r}`,S;t[9]!==i||t[10]!==x?(S=(0,N.jsx)(`textarea`,{"aria-label":x,className:`w-full resize-none overflow-y-auto rounded-md border border-input bg-input/20 px-2 py-2 font-mono text-xs`,readOnly:!0,rows:4,value:i}),t[9]=i,t[10]=x,t[11]=S):S=t[11];let C;t[12]!==d||t[13]!==i?(C=async()=>await d(i),t[12]=d,t[13]=i,t[14]=C):C=t[14];let j;t[15]===c?j=t[16]:(j=c?(0,N.jsxs)(N.Fragment,{children:[(0,N.jsx)(o,{className:`mr-2 size-4`}),`Copied!`]}):`Copy to Clipboard`,t[15]=c,t[16]=j);let P;t[17]!==C||t[18]!==j?(P=(0,N.jsx)(u,{className:`flex-1`,onClick:C,type:`button`,variant:`outline`,children:j}),t[17]=C,t[18]=j,t[19]=P):P=t[19];let F;t[20]===Symbol.for(`react.memo_cache_sentinel`)?(F=(0,N.jsx)(s,{className:`mr-2 size-4`}),t[20]=F):F=t[20];let I;t[21]===m?I=t[22]:(I=(0,N.jsxs)(u,{className:`flex-1`,onClick:m,type:`button`,variant:`outline`,children:[F,`Download .pem File`]}),t[21]=m,t[22]=I);let L;t[23]!==P||t[24]!==I?(L=(0,N.jsxs)(`div`,{className:`flex gap-2`,children:[P,I]}),t[23]=P,t[24]=I,t[25]=L):L=t[25];let R;t[26]!==L||t[27]!==S?(R=(0,N.jsxs)(`div`,{className:`space-y-3`,children:[S,L]}),t[26]=L,t[27]=S,t[28]=R):R=t[28];let z;t[29]===g?z=t[30]:(z=(0,N.jsx)(D,{children:(0,N.jsx)(O,{onClick:g,children:`Close and Continue`})}),t[29]=g,t[30]=z);let B;t[31]!==R||t[32]!==z||t[33]!==b?(B=(0,N.jsxs)(T,{className:`max-w-2xl`,children:[b,R,z]}),t[31]=R,t[32]=z,t[33]=b,t[34]=B):B=t[34];let V;return t[35]!==n||t[36]!==B?(V=(0,N.jsx)(A,{open:n,children:B}),t[35]=n,t[36]=B,t[37]=V):V=t[37],V}function F(){let e=(0,M.c)(46),t=f(),n=d(),[r,o]=(0,j.useState)(null),s;e[0]===Symbol.for(`react.memo_cache_sentinel`)?(s={resolver:g(C)},e[0]=s):s=e[0];let{handleSubmit:l,register:u,control:_,formState:ee}=h(s),{errors:w,isSubmitting:T}=ee,E;e[1]===_?E=e[2]:(E={control:_},e[1]=_,e[2]=E);let D=m(E),O;e[3]===D?O=e[4]:(O=e=>e?D[e]:void 0,e[3]=D,e[4]=O);let k=O,A;e[5]===n?A=e[6]:(A=async e=>{let t=await n.mutateAsync(e);t.KeyMaterial&&o({keyName:e.keyName,material:t.KeyMaterial})},e[5]=n,e[6]=A);let F=A,L,R;e[7]===Symbol.for(`react.memo_cache_sentinel`)?(L=(0,N.jsx)(a,{to:`/ec2/describe-key-pairs`,children:`Back to key pairs`}),R=(0,N.jsx)(y,{title:`Create Key Pair`}),e[7]=L,e[8]=R):(L=e[7],R=e[8]);let z;e[9]===n.error?z=e[10]:(z=n.error&&(0,N.jsx)(i,{error:n.error,msg:`Failed to create key pair`}),e[9]=n.error,e[10]=z);let B;e[11]!==l||e[12]!==F?(B=l(F),e[11]=l,e[12]=F,e[13]=B):B=e[13];let V;e[14]===Symbol.for(`react.memo_cache_sentinel`)?(V=(0,N.jsx)(b,{children:(0,N.jsx)(`label`,{htmlFor:`keyName`,children:`Key Pair Name`})}),e[14]=V):V=e[14];let H=!!w.keyName,U;e[15]===u?U=e[16]:(U=u(`keyName`),e[15]=u,e[16]=U);let W;e[17]!==H||e[18]!==U?(W=(0,N.jsx)(p,{"aria-invalid":H,id:`keyName`,placeholder:`my-key-pair…`,...U}),e[17]=H,e[18]=U,e[19]=W):W=e[19];let G;e[20]===w.keyName?G=e[21]:(G=(0,N.jsx)(x,{errors:[w.keyName]}),e[20]=w.keyName,e[21]=G);let K;e[22]!==W||e[23]!==G?(K=(0,N.jsxs)(S,{children:[V,W,G]}),e[22]=W,e[23]=G,e[24]=K):K=e[24];let q;e[25]===k?q=e[26]:(q=I(k),e[25]=k,e[26]=q);let J;e[27]===q?J=e[28]:(J=(0,N.jsx)(c,{commands:q}),e[27]=q,e[28]=J);let Y;e[29]===t?Y=e[30]:(Y=async()=>await t({to:`/ec2/describe-key-pairs`}),e[29]=t,e[30]=Y);let X;e[31]!==n.isPending||e[32]!==T||e[33]!==Y?(X=(0,N.jsx)(v,{isPending:n.isPending,isSubmitting:T,onCancel:Y,pendingLabel:`Creating…`,submitLabel:`Create Key Pair`}),e[31]=n.isPending,e[32]=T,e[33]=Y,e[34]=X):X=e[34];let Z;e[35]!==K||e[36]!==J||e[37]!==X||e[38]!==B?(Z=(0,N.jsxs)(`form`,{className:`max-w-4xl space-y-6`,onSubmit:B,children:[K,J,X]}),e[35]=K,e[36]=J,e[37]=X,e[38]=B,e[39]=Z):Z=e[39];let Q;e[40]===r?Q=e[41]:(Q=r&&(0,N.jsx)(P,{keyMaterial:r.material,keyName:r.keyName,open:!!r}),e[40]=r,e[41]=Q);let $;return e[42]!==Z||e[43]!==Q||e[44]!==z?($=(0,N.jsxs)(N.Fragment,{children:[L,R,z,Z,Q]}),e[42]=Z,e[43]=Q,e[44]=z,e[45]=$):$=e[45],$}function I(e){let t=e(`keyName`);return[{label:`Create Key Pair`,parts:[{type:`bin`,value:`AWS_PROFILE=spinifex aws ec2 create-key-pair`},{type:`flag`,value:` --key-name`},{type:`value`,value:` ${(typeof t==`string`?t:``)||``}`}]}]}export{F as component};
\ No newline at end of file
diff --git a/spinifex/services/spinifexui/frontend/dist/assets/create-load-balancer.js b/spinifex/services/spinifexui/frontend/dist/assets/create-load-balancer.js
index e2dfc7de5..dda7d0de3 100644
--- a/spinifex/services/spinifexui/frontend/dist/assets/create-load-balancer.js
+++ b/spinifex/services/spinifexui/frontend/dist/assets/create-load-balancer.js
@@ -1,17 +1,21 @@
-import{t as e}from"./error-banner.js";import{t}from"./useSuspenseQuery.js";import{t as n}from"./back-link.js";import{c as r,i,n as a,s as o,t as s}from"./select.js";import{t as c}from"./plus.js";import{t as l}from"./cli-command-panel.js";import{t as u}from"./trash-2.js";import{n as d}from"./elbv2.js";import{Ho as f,I as p,Ro as m,Uo as h,Xa as g,Xi as _,ao as v,cs as y,dt as ee,ht as te,st as ne}from"./index.js";import{i as b,n as x,t as S}from"./zod.js";import{t as C}from"./form-actions.js";import{t as re}from"./page-heading.js";import{a as w,r as T,t as E}from"./field.js";import{t as ie}from"./target-group-form.js";import{i as ae,n as D}from"./elbv22.js";var O=y(h(),1),k=f();function A(e){let t=v(e.Tags);return t?`${e.VpcId} (${t})`:`${e.VpcId} (${e.CidrBlock})`}function j(e){let t=v(e.Tags);return`${t?`${e.SubnetId} (${t})`:e.SubnetId} · ${e.CidrBlock}`}function oe(e){let t=new Map;for(let n of e){let e=n.AvailabilityZone??`unknown`,r=t.get(e)??[];r.push(n),t.set(e,r)}return[...t.entries()].toSorted((e,t)=>e[0].localeCompare(t[0])).map(([e,t])=>({az:e,subnets:t}))}function M(){let f=m(),{data:h}=t(te),{data:v}=t(ee),{data:y}=t(ne),{data:M}=t(p),N=d(),[P,F]=(0,O.useState)(null),I=h.Vpcs??[],ce=v.Subnets??[],L=y.SecurityGroups??[],R=M.TargetGroups??[],z=b({resolver:S(D),defaultValues:{name:``,scheme:`internet-facing`,vpcId:I[0]?.VpcId??``,subnetIds:[],securityGroupIds:[],tags:[],listener:{protocol:`HTTP`,port:80,targetGroupMode:`new`,existingTargetGroupArn:``}}}),B=b({resolver:S(ae),defaultValues:{name:``,protocol:`HTTP`,port:80,vpcId:I[0]?.VpcId??``,healthCheck:{protocol:`HTTP`,path:`/`,port:`traffic-port`,intervalSeconds:30,timeoutSeconds:5,healthyThresholdCount:5,unhealthyThresholdCount:2,matcher:`200`},tags:[]}}),{handleSubmit:V,register:H,watch:U,control:W,setValue:G,getValues:K,formState:{errors:q,isSubmitting:le}}=z,J=U(`vpcId`),ue=U(`subnetIds`),de=U(`securityGroupIds`),Y=U(`listener.targetGroupMode`),fe=U(`tags`),X=ce.filter(e=>e.VpcId===J),Z=L.filter(e=>e.VpcId===J),Q=R.filter(e=>e.VpcId===J),pe=(e=``)=>{let t=e??``;G(`vpcId`,t,{shouldValidate:!0}),G(`subnetIds`,[],{shouldValidate:!0}),G(`securityGroupIds`,[]),G(`listener.existingTargetGroupArn`,``),B.setValue(`vpcId`,t)},me=e=>{let t=K(`subnetIds`);G(`subnetIds`,t.includes(e)?t.filter(t=>t!==e):[...t,e],{shouldValidate:!0})},he=e=>{let t=K(`securityGroupIds`);G(`securityGroupIds`,t.includes(e)?t.filter(t=>t!==e):[...t,e])},ge=async e=>{F(null);let t;if(e.listener.targetGroupMode===`new`){if(!await B.trigger())return;t=B.getValues()}let n=await N.mutateAsync({lb:{name:e.name,scheme:e.scheme,vpcId:e.vpcId,subnetIds:e.subnetIds,securityGroupIds:e.securityGroupIds,tags:e.tags},listener:{protocol:e.listener.protocol,port:e.listener.port,targetGroupMode:e.listener.targetGroupMode,existingTargetGroupArn:e.listener.existingTargetGroupArn,newTargetGroup:t}});F(n),!n.error&&n.loadBalancerArn&&f({to:`/ec2/describe-load-balancers/$id`,params:{id:encodeURIComponent(n.loadBalancerArn)}})},$=oe(X);return(0,k.jsxs)(k.Fragment,{children:[(0,k.jsx)(n,{to:`/ec2/describe-load-balancers`,children:`Back to load balancers`}),(0,k.jsx)(re,{title:`Create Load Balancer`}),N.error&&(0,k.jsx)(e,{error:N.error,msg:`Failed to create load balancer`}),P?.error&&(0,k.jsxs)(`div`,{className:`mb-6 max-w-4xl rounded-md border border-destructive bg-destructive/10 p-4`,children:[(0,k.jsxs)(`h2`,{className:`text-sm font-medium text-destructive`,children:[`Wizard failed: `,P.failedStep]}),(0,k.jsx)(`p`,{className:`mt-1 text-sm text-destructive`,children:P.error.message}),P.created.length>0&&(0,k.jsxs)(`div`,{className:`mt-3`,children:[(0,k.jsx)(`p`,{className:`text-xs font-medium text-destructive`,children:`Successfully created resources (clean up manually if needed):`}),(0,k.jsx)(`ul`,{className:`mt-1 list-inside list-disc text-xs text-destructive`,children:P.created.map((e,t)=>(0,k.jsxs)(`li`,{children:[e.type,`: `,e.id??`(created)`]},t))})]})]}),(0,k.jsxs)(`form`,{className:`max-w-4xl space-y-6`,onSubmit:V(ge),children:[(0,k.jsxs)(E,{children:[(0,k.jsx)(w,{children:(0,k.jsx)(`label`,{htmlFor:`lb-name`,children:`Name`})}),(0,k.jsx)(_,{"aria-invalid":!!q.name,id:`lb-name`,placeholder:`my-load-balancer`,...H(`name`)}),(0,k.jsx)(T,{errors:[q.name]})]}),(0,k.jsxs)(E,{children:[(0,k.jsx)(w,{children:`Scheme`}),(0,k.jsx)(x,{control:W,name:`scheme`,render:({field:e})=>(0,k.jsxs)(`div`,{className:`flex gap-4`,children:[(0,k.jsxs)(`label`,{className:`flex items-center gap-2 text-xs`,children:[(0,k.jsx)(`input`,{"aria-label":`Internet-facing`,checked:e.value===`internet-facing`,name:`scheme`,onChange:()=>e.onChange(`internet-facing`),type:`radio`}),`Internet-facing`]}),(0,k.jsxs)(`label`,{className:`flex items-center gap-2 text-xs`,children:[(0,k.jsx)(`input`,{"aria-label":`Internal`,checked:e.value===`internal`,name:`scheme`,onChange:()=>e.onChange(`internal`),type:`radio`}),`Internal`]})]})})]}),(0,k.jsxs)(E,{children:[(0,k.jsx)(w,{children:(0,k.jsx)(`label`,{htmlFor:`lb-vpc`,children:`VPC`})}),(0,k.jsx)(x,{control:W,name:`vpcId`,render:({field:e})=>(0,k.jsxs)(s,{onValueChange:e=>pe(e),value:e.value??``,children:[(0,k.jsx)(o,{"aria-invalid":!!q.vpcId,className:`w-full`,id:`lb-vpc`,children:(0,k.jsx)(r,{placeholder:`Select VPC`})}),(0,k.jsx)(a,{children:I.map(e=>(0,k.jsx)(i,{value:e.VpcId??``,children:A(e)},e.VpcId))})]})}),(0,k.jsx)(T,{errors:[q.vpcId]})]}),(0,k.jsxs)(E,{children:[(0,k.jsx)(w,{children:`Subnets (select at least 2)`}),$.length===0?(0,k.jsx)(`p`,{className:`text-xs text-muted-foreground`,children:`No subnets in the selected VPC.`}):(0,k.jsx)(`div`,{className:`space-y-3`,children:$.map(e=>(0,k.jsxs)(`div`,{children:[(0,k.jsx)(`p`,{className:`mb-1 text-xs font-medium text-muted-foreground`,children:e.az}),(0,k.jsx)(`div`,{className:`space-y-1`,children:e.subnets.map(e=>(0,k.jsxs)(`label`,{className:`flex items-center gap-2 text-xs`,children:[(0,k.jsx)(`input`,{"aria-label":`Subnet ${j(e)}`,checked:ue.includes(e.SubnetId??``),onChange:()=>me(e.SubnetId??``),type:`checkbox`}),(0,k.jsx)(`span`,{className:`font-mono`,children:j(e)})]},e.SubnetId))})]},e.az))}),(0,k.jsx)(T,{errors:[q.subnetIds]})]}),(0,k.jsxs)(E,{children:[(0,k.jsx)(w,{children:`Security groups`}),Z.length===0?(0,k.jsx)(`p`,{className:`text-xs text-muted-foreground`,children:`No security groups in the selected VPC.`}):(0,k.jsx)(`div`,{className:`space-y-1`,children:Z.map(e=>(0,k.jsxs)(`label`,{className:`flex items-center gap-2 text-xs`,children:[(0,k.jsx)(`input`,{"aria-label":`Security group ${e.GroupId} (${e.GroupName})`,checked:de.includes(e.GroupId??``),onChange:()=>he(e.GroupId??``),type:`checkbox`}),(0,k.jsxs)(`span`,{className:`font-mono`,children:[e.GroupId,` (`,e.GroupName,`)`]})]},e.GroupId))})]}),(0,k.jsxs)(E,{children:[(0,k.jsx)(w,{children:`Tags`}),(0,k.jsxs)(`div`,{className:`space-y-2`,children:[fe.map((e,t)=>(0,k.jsxs)(`div`,{className:`flex items-center gap-2`,children:[(0,k.jsx)(_,{placeholder:`Key`,...H(`tags.${t}.key`)}),(0,k.jsx)(_,{placeholder:`Value`,...H(`tags.${t}.value`)}),(0,k.jsx)(g,{onClick:()=>G(`tags`,K(`tags`).filter((e,n)=>n!==t)),size:`icon`,type:`button`,variant:`ghost`,children:(0,k.jsx)(u,{className:`size-3.5`})})]},t)),(0,k.jsxs)(g,{onClick:()=>G(`tags`,[...K(`tags`),{key:``,value:``}]),size:`sm`,type:`button`,variant:`outline`,children:[(0,k.jsx)(c,{className:`size-3.5`}),`Add tag`]})]})]}),(0,k.jsxs)(`div`,{className:`space-y-4 rounded-md border bg-card p-4`,children:[(0,k.jsx)(`h2`,{className:`text-sm font-semibold`,children:`First listener & default target group`}),(0,k.jsxs)(E,{children:[(0,k.jsx)(w,{children:(0,k.jsx)(`label`,{htmlFor:`listener-protocol`,children:`Protocol`})}),(0,k.jsx)(x,{control:W,name:`listener.protocol`,render:({field:e})=>(0,k.jsxs)(s,{onValueChange:e.onChange,value:e.value,children:[(0,k.jsx)(o,{className:`w-full`,id:`listener-protocol`,children:(0,k.jsx)(r,{})}),(0,k.jsx)(a,{children:(0,k.jsx)(i,{value:`HTTP`,children:`HTTP`})})]})})]}),(0,k.jsxs)(E,{children:[(0,k.jsx)(w,{children:(0,k.jsx)(`label`,{htmlFor:`listener-port`,children:`Port`})}),(0,k.jsx)(_,{"aria-invalid":!!q.listener?.port,id:`listener-port`,inputMode:`numeric`,type:`number`,...H(`listener.port`,{valueAsNumber:!0})}),(0,k.jsx)(T,{errors:[q.listener?.port]})]}),(0,k.jsxs)(E,{children:[(0,k.jsx)(w,{children:`Default target group`}),(0,k.jsx)(x,{control:W,name:`listener.targetGroupMode`,render:({field:e})=>(0,k.jsxs)(`div`,{className:`flex gap-4`,children:[(0,k.jsxs)(`label`,{className:`flex items-center gap-2 text-xs`,children:[(0,k.jsx)(`input`,{"aria-label":`Create new target group`,checked:e.value===`new`,name:`tg-mode`,onChange:()=>e.onChange(`new`),type:`radio`}),`Create new`]}),(0,k.jsxs)(`label`,{className:`flex items-center gap-2 text-xs`,children:[(0,k.jsx)(`input`,{"aria-label":`Use existing target group`,checked:e.value===`existing`,name:`tg-mode`,onChange:()=>e.onChange(`existing`),type:`radio`}),`Use existing`]})]})})]}),Y===`existing`&&(0,k.jsxs)(E,{children:[(0,k.jsx)(w,{children:(0,k.jsx)(`label`,{htmlFor:`tg-existing`,children:`Target group`})}),(0,k.jsx)(x,{control:W,name:`listener.existingTargetGroupArn`,render:({field:e})=>(0,k.jsxs)(s,{onValueChange:e.onChange,value:e.value??``,children:[(0,k.jsx)(o,{"aria-invalid":!!q.listener?.existingTargetGroupArn,className:`w-full`,id:`tg-existing`,children:(0,k.jsx)(r,{placeholder:`Select target group`})}),(0,k.jsx)(a,{children:Q.map(e=>(0,k.jsxs)(i,{value:e.TargetGroupArn??``,children:[e.TargetGroupName,` · `,e.Protocol,`:`,e.Port]},e.TargetGroupArn))})]})}),(0,k.jsx)(T,{errors:[q.listener?.existingTargetGroupArn]}),Q.length===0&&(0,k.jsx)(`p`,{className:`mt-1 text-xs text-muted-foreground`,children:`No target groups in this VPC. Choose “Create new” instead.`})]}),Y===`new`&&(0,k.jsx)(`div`,{className:`space-y-4 border-l-2 border-muted pl-4`,children:(0,k.jsx)(ie,{form:B,vpcs:I})})]}),(0,k.jsx)(l,{commands:se(U,B.watch)}),(0,k.jsx)(C,{isPending:N.isPending,isSubmitting:le,onCancel:async()=>await f({to:`/ec2/describe-load-balancers`}),pendingLabel:`Creating…`,submitLabel:`Create Load Balancer`})]})]})}function se(e,t){let n=t=>{let n=e(t);return typeof n==`string`?n:``},r=t=>{let n=e(t);return Array.isArray(n)?n.filter(e=>typeof e==`string`):[]},i=t=>{let n=e(t);return typeof n==`number`&&Number.isFinite(n)?n:void 0},a=n(`name`)||``,o=n(`scheme`)||`internet-facing`,s=r(`subnetIds`),c=r(`securityGroupIds`),l=n(`listener.protocol`)||`HTTP`,u=i(`listener.port`)??80,d=n(`listener.targetGroupMode`),f=n(`listener.existingTargetGroupArn`),p=[],m={type:`comment`,value:`# Create ALB with listener and default target group
+import{n as e,o as t,r as n}from"./compiler-runtime.js";import{t as r}from"./error-banner.js";import{t as i}from"./useSuspenseQuery.js";import{A as a}from"./SignatureV4.js";import{t as o}from"./back-link.js";import{c as s,i as c,n as l,s as u,t as d}from"./select.js";import{t as f}from"./plus.js";import{t as p}from"./cli-command-panel.js";import{t as ee}from"./trash-2.js";import{t as m}from"./button.js";import{t as h}from"./certificate-import-dialog.js";import{n as g}from"./elbv2.js";import{G as _,Ir as v,Lt as y,Nt as b,Rn as x,U as te,kt as ne,z as re}from"./index.js";import{i as S,n as C,t as w}from"./zod.js";import{t as ie}from"./form-actions.js";import{t as ae}from"./page-heading.js";import{a as T,r as E,t as D}from"./field.js";import{t as oe}from"./target-group-form.js";import{a as se,c as ce,o as le,r as ue,s as O,t as de}from"./elbv22.js";var k=t(n(),1),A=e();function fe(e){let t=a(e.Tags);return t?`${e.VpcId} (${t})`:`${e.VpcId} (${e.CidrBlock})`}function j(e){let t=a(e.Tags);return`${t?`${e.SubnetId} (${t})`:e.SubnetId} · ${e.CidrBlock}`}function pe(e){return e===`TLS`||e===`TCP`?`TCP`:e===`UDP`?`UDP`:e===`TCP_UDP`?`TCP_UDP`:`HTTP`}function me(e){let t=new Map;for(let n of e){let e=n.AvailabilityZone??`unknown`,r=t.get(e)??[];r.push(n),t.set(e,r)}return[...t.entries()].toSorted((e,t)=>e[0].localeCompare(t[0])).map(([e,t])=>({az:e,subnets:t}))}function M(){let e=v(),{data:t}=i(y),{data:n}=i(b),{data:a}=i(ne),{data:M}=i(te),{data:N}=i(_),{data:ge}=i(re),P=g(),[F,I]=(0,k.useState)(null),[_e,L]=(0,k.useState)(!1),R=t.Vpcs??[],ve=n.Subnets??[],ye=a.SecurityGroups??[],be=M.TargetGroups??[],z=N.CertificateSummaryList??[],xe=ge.SslPolicies??[],Se=S({resolver:w(ue),defaultValues:{name:``,type:`application`,scheme:`internet-facing`,vpcId:R[0]?.VpcId??``,subnetIds:[],securityGroupIds:[],tags:[],listener:{protocol:`HTTP`,port:80,targetGroupMode:`new`,existingTargetGroupArn:``,certificateArn:void 0,sslPolicy:void 0}}}),B=S({resolver:w(se),defaultValues:{name:``,protocol:`HTTP`,port:80,vpcId:R[0]?.VpcId??``,healthCheck:{protocol:`HTTP`,path:`/`,port:`traffic-port`,intervalSeconds:30,timeoutSeconds:5,healthyThresholdCount:5,unhealthyThresholdCount:2,matcher:`200`},tags:[]}}),{handleSubmit:Ce,register:V,watch:H,control:U,setValue:W,getValues:G,formState:{errors:K,isSubmitting:we}}=Se,q=H(`vpcId`),Te=H(`subnetIds`),Ee=H(`securityGroupIds`),J=H(`type`),Y=H(`listener.protocol`),X=H(`listener.targetGroupMode`),De=H(`tags`),Z=J===`network`,Oe=O(Y),ke=ce(J),Ae=Z?[`TCP`,`UDP`,`TLS`,`TCP_UDP`]:[`HTTP`,`HTTPS`],je=ve.filter(e=>e.VpcId===q),Q=ye.filter(e=>e.VpcId===q),$=be.filter(e=>e.VpcId===q),Me=e=>{let t=pe(e);B.setValue(`protocol`,t),B.setValue(`healthCheck.protocol`,t===`HTTP`?`HTTP`:`TCP`)},Ne=e=>{W(`type`,e);let t=le(e);W(`listener.protocol`,t),W(`listener.port`,80),W(`listener.certificateArn`,void 0),W(`listener.sslPolicy`,void 0),e===`network`&&W(`securityGroupIds`,[]),Me(t)},Pe=e=>{W(`listener.protocol`,e);let t=G(`listener.port`);O(e)?(t===80&&W(`listener.port`,443),e===`HTTPS`&&!G(`listener.sslPolicy`)&&W(`listener.sslPolicy`,de)):(t===443&&W(`listener.port`,80),W(`listener.certificateArn`,void 0),W(`listener.sslPolicy`,void 0)),Me(e)},Fe=(e=``)=>{let t=e??``;W(`vpcId`,t,{shouldValidate:!0}),W(`subnetIds`,[],{shouldValidate:!0}),W(`securityGroupIds`,[]),W(`listener.existingTargetGroupArn`,``),B.setValue(`vpcId`,t)},Ie=e=>{let t=G(`subnetIds`);W(`subnetIds`,t.includes(e)?t.filter(t=>t!==e):[...t,e],{shouldValidate:!0})},Le=e=>{let t=G(`securityGroupIds`);W(`securityGroupIds`,t.includes(e)?t.filter(t=>t!==e):[...t,e])},Re=async t=>{I(null);let n;if(t.listener.targetGroupMode===`new`){if(!await B.trigger())return;n=B.getValues()}let r=await P.mutateAsync({lb:{name:t.name,type:t.type,scheme:t.scheme,vpcId:t.vpcId,subnetIds:t.subnetIds,securityGroupIds:t.securityGroupIds,tags:t.tags},listener:{protocol:t.listener.protocol,port:t.listener.port,targetGroupMode:t.listener.targetGroupMode,existingTargetGroupArn:t.listener.existingTargetGroupArn,certificateArn:t.listener.certificateArn,sslPolicy:t.listener.sslPolicy,newTargetGroup:n}});I(r),!r.error&&r.loadBalancerArn&&e({to:`/ec2/describe-load-balancers/$id`,params:{id:encodeURIComponent(r.loadBalancerArn)}})},ze=me(je);return(0,A.jsxs)(A.Fragment,{children:[(0,A.jsx)(o,{to:`/ec2/describe-load-balancers`,children:`Back to load balancers`}),(0,A.jsx)(ae,{title:`Create Load Balancer`}),P.error&&(0,A.jsx)(r,{error:P.error,msg:`Failed to create load balancer`}),F?.error&&(0,A.jsxs)(`div`,{className:`mb-6 max-w-4xl rounded-md border border-destructive bg-destructive/10 p-4`,children:[(0,A.jsxs)(`h2`,{className:`text-sm font-medium text-destructive`,children:[`Wizard failed: `,F.failedStep]}),(0,A.jsx)(`p`,{className:`mt-1 text-sm text-destructive`,children:F.error.message}),F.created.length>0&&(0,A.jsxs)(`div`,{className:`mt-3`,children:[(0,A.jsx)(`p`,{className:`text-xs font-medium text-destructive`,children:`Successfully created resources (clean up manually if needed):`}),(0,A.jsx)(`ul`,{className:`mt-1 list-inside list-disc text-xs text-destructive`,children:F.created.map((e,t)=>(0,A.jsxs)(`li`,{children:[e.type,`: `,e.id??`(created)`]},t))})]})]}),(0,A.jsxs)(`form`,{className:`max-w-4xl space-y-6`,onSubmit:Ce(Re),children:[(0,A.jsxs)(D,{children:[(0,A.jsx)(T,{children:(0,A.jsx)(`label`,{htmlFor:`lb-name`,children:`Name`})}),(0,A.jsx)(x,{"aria-invalid":!!K.name,id:`lb-name`,placeholder:`my-load-balancer`,...V(`name`)}),(0,A.jsx)(E,{errors:[K.name]})]}),(0,A.jsxs)(D,{children:[(0,A.jsx)(T,{children:`Type`}),(0,A.jsx)(C,{control:U,name:`type`,render:({field:e})=>(0,A.jsxs)(`div`,{className:`flex gap-4`,children:[(0,A.jsxs)(`label`,{className:`flex items-center gap-2 text-xs`,children:[(0,A.jsx)(`input`,{"aria-label":`Application Load Balancer`,checked:e.value===`application`,name:`lb-type`,onChange:()=>Ne(`application`),type:`radio`}),`Application (ALB)`]}),(0,A.jsxs)(`label`,{className:`flex items-center gap-2 text-xs`,children:[(0,A.jsx)(`input`,{"aria-label":`Network Load Balancer`,checked:e.value===`network`,name:`lb-type`,onChange:()=>Ne(`network`),type:`radio`}),`Network (NLB)`]})]})})]}),(0,A.jsxs)(D,{children:[(0,A.jsx)(T,{children:`Scheme`}),(0,A.jsx)(C,{control:U,name:`scheme`,render:({field:e})=>(0,A.jsxs)(`div`,{className:`flex gap-4`,children:[(0,A.jsxs)(`label`,{className:`flex items-center gap-2 text-xs`,children:[(0,A.jsx)(`input`,{"aria-label":`Internet-facing`,checked:e.value===`internet-facing`,name:`scheme`,onChange:()=>e.onChange(`internet-facing`),type:`radio`}),`Internet-facing`]}),(0,A.jsxs)(`label`,{className:`flex items-center gap-2 text-xs`,children:[(0,A.jsx)(`input`,{"aria-label":`Internal`,checked:e.value===`internal`,name:`scheme`,onChange:()=>e.onChange(`internal`),type:`radio`}),`Internal`]})]})})]}),(0,A.jsxs)(D,{children:[(0,A.jsx)(T,{children:(0,A.jsx)(`label`,{htmlFor:`lb-vpc`,children:`VPC`})}),(0,A.jsx)(C,{control:U,name:`vpcId`,render:({field:e})=>(0,A.jsxs)(d,{onValueChange:e=>Fe(e),value:e.value??``,children:[(0,A.jsx)(u,{"aria-invalid":!!K.vpcId,className:`w-full`,id:`lb-vpc`,children:(0,A.jsx)(s,{placeholder:`Select VPC`})}),(0,A.jsx)(l,{children:R.map(e=>(0,A.jsx)(c,{value:e.VpcId??``,children:fe(e)},e.VpcId))})]})}),(0,A.jsx)(E,{errors:[K.vpcId]})]}),(0,A.jsxs)(D,{children:[(0,A.jsx)(T,{children:`Subnets (select at least 1)`}),ze.length===0?(0,A.jsx)(`p`,{className:`text-xs text-muted-foreground`,children:`No subnets in the selected VPC.`}):(0,A.jsx)(`div`,{className:`space-y-3`,children:ze.map(e=>(0,A.jsxs)(`div`,{children:[(0,A.jsx)(`p`,{className:`mb-1 text-xs font-medium text-muted-foreground`,children:e.az}),(0,A.jsx)(`div`,{className:`space-y-1`,children:e.subnets.map(e=>(0,A.jsxs)(`label`,{className:`flex items-center gap-2 text-xs`,children:[(0,A.jsx)(`input`,{"aria-label":`Subnet ${j(e)}`,checked:Te.includes(e.SubnetId??``),onChange:()=>Ie(e.SubnetId??``),type:`checkbox`}),(0,A.jsx)(`span`,{className:`font-mono`,children:j(e)})]},e.SubnetId))})]},e.az))}),(0,A.jsx)(E,{errors:[K.subnetIds]})]}),!Z&&(0,A.jsxs)(D,{children:[(0,A.jsx)(T,{children:`Security groups`}),Q.length===0?(0,A.jsx)(`p`,{className:`text-xs text-muted-foreground`,children:`No security groups in the selected VPC.`}):(0,A.jsx)(`div`,{className:`space-y-1`,children:Q.map(e=>(0,A.jsxs)(`label`,{className:`flex items-center gap-2 text-xs`,children:[(0,A.jsx)(`input`,{"aria-label":`Security group ${e.GroupId} (${e.GroupName})`,checked:Ee.includes(e.GroupId??``),onChange:()=>Le(e.GroupId??``),type:`checkbox`}),(0,A.jsxs)(`span`,{className:`font-mono`,children:[e.GroupId,` (`,e.GroupName,`)`]})]},e.GroupId))})]}),(0,A.jsxs)(D,{children:[(0,A.jsx)(T,{children:`Tags`}),(0,A.jsxs)(`div`,{className:`space-y-2`,children:[De.map((e,t)=>(0,A.jsxs)(`div`,{className:`flex items-center gap-2`,children:[(0,A.jsx)(x,{placeholder:`Key`,...V(`tags.${t}.key`)}),(0,A.jsx)(x,{placeholder:`Value`,...V(`tags.${t}.value`)}),(0,A.jsx)(m,{onClick:()=>W(`tags`,G(`tags`).filter((e,n)=>n!==t)),size:`icon`,type:`button`,variant:`ghost`,children:(0,A.jsx)(ee,{className:`size-3.5`})})]},t)),(0,A.jsxs)(m,{onClick:()=>W(`tags`,[...G(`tags`),{key:``,value:``}]),size:`sm`,type:`button`,variant:`outline`,children:[(0,A.jsx)(f,{className:`size-3.5`}),`Add tag`]})]})]}),(0,A.jsxs)(`div`,{className:`space-y-4 rounded-md border bg-card p-4`,children:[(0,A.jsx)(`h2`,{className:`text-sm font-semibold`,children:`First listener & default target group`}),(0,A.jsxs)(D,{children:[(0,A.jsx)(T,{children:(0,A.jsx)(`label`,{htmlFor:`listener-protocol`,children:`Protocol`})}),(0,A.jsx)(C,{control:U,name:`listener.protocol`,render:({field:e})=>(0,A.jsxs)(d,{onValueChange:e=>{e&&Pe(e)},value:e.value,children:[(0,A.jsx)(u,{className:`w-full`,id:`listener-protocol`,children:(0,A.jsx)(s,{})}),(0,A.jsx)(l,{children:ke.map(e=>(0,A.jsx)(c,{value:e,children:e},e))})]})})]}),(0,A.jsxs)(D,{children:[(0,A.jsx)(T,{children:(0,A.jsx)(`label`,{htmlFor:`listener-port`,children:`Port`})}),(0,A.jsx)(x,{"aria-invalid":!!K.listener?.port,id:`listener-port`,inputMode:`numeric`,type:`number`,...V(`listener.port`,{valueAsNumber:!0})}),(0,A.jsx)(E,{errors:[K.listener?.port]})]}),Oe&&(0,A.jsxs)(A.Fragment,{children:[(0,A.jsxs)(D,{children:[(0,A.jsx)(T,{children:(0,A.jsx)(`label`,{htmlFor:`listener-certificate`,children:`Certificate`})}),(0,A.jsx)(C,{control:U,name:`listener.certificateArn`,render:({field:e})=>(0,A.jsxs)(d,{onValueChange:e.onChange,value:e.value??``,children:[(0,A.jsx)(u,{"aria-invalid":!!K.listener?.certificateArn,className:`w-full`,id:`listener-certificate`,children:(0,A.jsx)(s,{placeholder:`Select certificate`})}),(0,A.jsx)(l,{children:z.map(e=>(0,A.jsx)(c,{value:e.CertificateArn??``,children:e.DomainName??e.CertificateArn?.split(`/`).pop()},e.CertificateArn))})]})}),(0,A.jsx)(E,{errors:[K.listener?.certificateArn]}),(0,A.jsxs)(`div`,{className:`mt-1 flex items-center justify-between`,children:[z.length===0&&(0,A.jsx)(`p`,{className:`text-xs text-muted-foreground`,children:`No certificates imported yet.`}),(0,A.jsx)(m,{className:`ml-auto`,onClick:()=>L(!0),size:`sm`,type:`button`,variant:`ghost`,children:`Import certificate…`})]})]}),Y===`HTTPS`&&(0,A.jsxs)(D,{children:[(0,A.jsx)(T,{children:(0,A.jsx)(`label`,{htmlFor:`listener-ssl-policy`,children:`Security policy`})}),(0,A.jsx)(C,{control:U,name:`listener.sslPolicy`,render:({field:e})=>(0,A.jsxs)(d,{onValueChange:e.onChange,value:e.value??`ELBSecurityPolicy-2016-08`,children:[(0,A.jsx)(u,{className:`w-full`,id:`listener-ssl-policy`,children:(0,A.jsx)(s,{})}),(0,A.jsx)(l,{children:xe.map(e=>(0,A.jsx)(c,{value:e.Name??``,children:e.Name},e.Name))})]})})]})]}),(0,A.jsxs)(D,{children:[(0,A.jsx)(T,{children:`Default target group`}),(0,A.jsx)(C,{control:U,name:`listener.targetGroupMode`,render:({field:e})=>(0,A.jsxs)(`div`,{className:`flex gap-4`,children:[(0,A.jsxs)(`label`,{className:`flex items-center gap-2 text-xs`,children:[(0,A.jsx)(`input`,{"aria-label":`Create new target group`,checked:e.value===`new`,name:`tg-mode`,onChange:()=>e.onChange(`new`),type:`radio`}),`Create new`]}),(0,A.jsxs)(`label`,{className:`flex items-center gap-2 text-xs`,children:[(0,A.jsx)(`input`,{"aria-label":`Use existing target group`,checked:e.value===`existing`,name:`tg-mode`,onChange:()=>e.onChange(`existing`),type:`radio`}),`Use existing`]})]})})]}),X===`existing`&&(0,A.jsxs)(D,{children:[(0,A.jsx)(T,{children:(0,A.jsx)(`label`,{htmlFor:`tg-existing`,children:`Target group`})}),(0,A.jsx)(C,{control:U,name:`listener.existingTargetGroupArn`,render:({field:e})=>(0,A.jsxs)(d,{onValueChange:e.onChange,value:e.value??``,children:[(0,A.jsx)(u,{"aria-invalid":!!K.listener?.existingTargetGroupArn,className:`w-full`,id:`tg-existing`,children:(0,A.jsx)(s,{placeholder:`Select target group`})}),(0,A.jsx)(l,{children:$.map(e=>(0,A.jsxs)(c,{value:e.TargetGroupArn??``,children:[e.TargetGroupName,` · `,e.Protocol,`:`,e.Port]},e.TargetGroupArn))})]})}),(0,A.jsx)(E,{errors:[K.listener?.existingTargetGroupArn]}),$.length===0&&(0,A.jsx)(`p`,{className:`mt-1 text-xs text-muted-foreground`,children:`No target groups in this VPC. Choose “Create new” instead.`})]}),X===`new`&&(0,A.jsx)(`div`,{className:`space-y-4 border-l-2 border-muted pl-4`,children:(0,A.jsx)(oe,{allowedProtocols:Ae,form:B,vpcs:R})})]}),(0,A.jsx)(h,{onImported:e=>W(`listener.certificateArn`,e,{shouldValidate:!0}),onOpenChange:L,open:_e}),(0,A.jsx)(p,{commands:he(H,B.watch)}),(0,A.jsx)(ie,{isPending:P.isPending,isSubmitting:we,onCancel:async()=>await e({to:`/ec2/describe-load-balancers`}),pendingLabel:`Creating…`,submitLabel:`Create Load Balancer`})]})]})}function he(e,t){let n=t=>{let n=e(t);return typeof n==`string`?n:``},r=t=>{let n=e(t);return Array.isArray(n)?n.filter(e=>typeof e==`string`):[]},i=t=>{let n=e(t);return typeof n==`number`&&Number.isFinite(n)?n:void 0},a=n(`name`)||``,o=n(`type`)||`application`,s=n(`scheme`)||`internet-facing`,c=r(`subnetIds`),l=r(`securityGroupIds`),u=n(`listener.protocol`)||`HTTP`,d=i(`listener.port`)??80,f=n(`listener.certificateArn`),p=n(`listener.sslPolicy`),ee=n(`listener.targetGroupMode`),m=n(`listener.existingTargetGroupArn`),h=[],g={type:`comment`,value:o===`network`?`# Create NLB with listener and default target group
-`},h=e=>{let n=t(e);return typeof n==`string`?n:``},g=e=>{let n=t(e);return typeof n==`number`&&Number.isFinite(n)?n:void 0};if(d===`new`){let e=h(`name`)||``,t=g(`port`)??80,n=h(`vpcId`);p.push({label:`Create Target Group`,parts:[m,{type:`variable`,value:`TG_ARN=`},{type:`bin`,value:`$(AWS_PROFILE=spinifex aws elbv2 create-target-group`},{type:`flag`,value:` \\
+`:`# Create ALB with listener and default target group
+
+`},_=e=>{let n=t(e);return typeof n==`string`?n:``},v=e=>{let n=t(e);return typeof n==`number`&&Number.isFinite(n)?n:void 0};if(ee===`new`){let e=_(`name`)||``,t=v(`port`)??80,n=_(`vpcId`),r=_(`protocol`)||`HTTP`;h.push({label:`Create Target Group`,parts:[g,{type:`variable`,value:`TG_ARN=`},{type:`bin`,value:`$(AWS_PROFILE=spinifex aws elbv2 create-target-group`},{type:`flag`,value:` \\
--name`},{type:`value`,value:` ${e}`},{type:`flag`,value:` \\
- --protocol HTTP --port`},{type:`value`,value:` ${t}`},{type:`flag`,value:` \\
+ --protocol`},{type:`value`,value:` ${r}`},{type:`flag`,value:` --port`},{type:`value`,value:` ${t}`},{type:`flag`,value:` \\
--target-type instance --vpc-id`},{type:`value`,value:` ${n||``}`},{type:`flag`,value:` \\
- --query`},{type:`value`,value:` 'TargetGroups[0].TargetGroupArn'`},{type:`flag`,value:` --output`},{type:`value`,value:` text)`}]})}else p.push({label:`Use Existing Target Group`,parts:[m,{type:`variable`,value:`TG_ARN=`},{type:`value`,value:f||``}]});let _=[{type:`variable`,value:`LB_ARN=`},{type:`bin`,value:`$(AWS_PROFILE=spinifex aws elbv2 create-load-balancer`},{type:`flag`,value:` \\
+ --query`},{type:`value`,value:` 'TargetGroups[0].TargetGroupArn'`},{type:`flag`,value:` --output`},{type:`value`,value:` text)`}]})}else h.push({label:`Use Existing Target Group`,parts:[g,{type:`variable`,value:`TG_ARN=`},{type:`value`,value:m||``}]});let y=[{type:`variable`,value:`LB_ARN=`},{type:`bin`,value:`$(AWS_PROFILE=spinifex aws elbv2 create-load-balancer`},{type:`flag`,value:` \\
--name`},{type:`value`,value:` ${a}`},{type:`flag`,value:` \\
- --scheme`},{type:`value`,value:` ${o}`},{type:`flag`,value:` \\
- --type application`}];return s.length>0&&_.push({type:`flag`,value:` \\
- --subnets`},{type:`value`,value:` ${s.join(` `)}`}),c.length>0&&_.push({type:`flag`,value:` \\
- --security-groups`},{type:`value`,value:` ${c.join(` `)}`}),_.push({type:`flag`,value:` \\
- --query`},{type:`value`,value:` 'LoadBalancers[0].LoadBalancerArn'`},{type:`flag`,value:` --output`},{type:`value`,value:` text)`}),p.push({label:`Create Load Balancer`,parts:_}),p.push({label:`Create Listener`,parts:[{type:`bin`,value:`AWS_PROFILE=spinifex aws elbv2 create-listener`},{type:`flag`,value:` \\
+ --scheme`},{type:`value`,value:` ${s}`},{type:`flag`,value:` \\
+ --type`},{type:`value`,value:` ${o}`}];c.length>0&&y.push({type:`flag`,value:` \\
+ --subnets`},{type:`value`,value:` ${c.join(` `)}`}),l.length>0&&y.push({type:`flag`,value:` \\
+ --security-groups`},{type:`value`,value:` ${l.join(` `)}`}),y.push({type:`flag`,value:` \\
+ --query`},{type:`value`,value:` 'LoadBalancers[0].LoadBalancerArn'`},{type:`flag`,value:` --output`},{type:`value`,value:` text)`}),h.push({label:`Create Load Balancer`,parts:y});let b=[{type:`bin`,value:`AWS_PROFILE=spinifex aws elbv2 create-listener`},{type:`flag`,value:` \\
--load-balancer-arn`},{type:`variable`,value:` "$LB_ARN"`},{type:`flag`,value:` \\
- --protocol`},{type:`value`,value:` ${l}`},{type:`flag`,value:` \\
- --port`},{type:`value`,value:` ${u}`},{type:`flag`,value:` \\
- --default-actions`},{type:`value`,value:` Type=forward,TargetGroupArn="$TG_ARN"`}]}),p}var N=M;export{N as component};
\ No newline at end of file
+ --protocol`},{type:`value`,value:` ${u}`},{type:`flag`,value:` \\
+ --port`},{type:`value`,value:` ${d}`}];return(u===`HTTPS`||u===`TLS`)&&f&&(b.push({type:`flag`,value:` \\
+ --certificates`},{type:`value`,value:` CertificateArn=${f}`}),u===`HTTPS`&&p&&b.push({type:`flag`,value:` \\
+ --ssl-policy`},{type:`value`,value:` ${p}`})),b.push({type:`flag`,value:` \\
+ --default-actions`},{type:`value`,value:` Type=forward,TargetGroupArn="$TG_ARN"`}),h.push({label:`Create Listener`,parts:b}),h}var N=M;export{N as component};
\ No newline at end of file
diff --git a/spinifex/services/spinifexui/frontend/dist/assets/create-nat-gateway.js b/spinifex/services/spinifexui/frontend/dist/assets/create-nat-gateway.js
index 0d567e5d4..0b89d11b2 100644
--- a/spinifex/services/spinifexui/frontend/dist/assets/create-nat-gateway.js
+++ b/spinifex/services/spinifexui/frontend/dist/assets/create-nat-gateway.js
@@ -1,4 +1,4 @@
-import{t as e}from"./error-banner.js";import{t}from"./useSuspenseQuery.js";import{t as ee}from"./back-link.js";import{c as n,i as r,n as i,s as a,t as o}from"./select.js";import{t as te}from"./cli-command-panel.js";import{p as ne}from"./ec2.js";import{Fo as s,H as re,Ho as c,Lo as ie,Ro as ae,Xi as oe,ao as l,dt as se}from"./index.js";import{a as ce,i as le,n as ue,t as de}from"./zod.js";import{t as fe}from"./form-actions.js";import{t as pe}from"./page-heading.js";import{a as u,r as me,t as d}from"./field.js";import{s as he}from"./ec22.js";var ge=s(),f=c();function p(){let r=(0,ge.c)(70),s=ae(),{data:c}=t(se),{data:l}=t(re),p=ne(),m;r[0]===c.Subnets?m=r[1]:(m=c.Subnets??[],r[0]=c.Subnets,r[1]=m);let h=m,g;r[2]===h?g=r[3]:(g=h.filter(be),r[2]=h,r[3]=g);let Se=g,_=Se.length>0?Se:h,v;r[4]===l.Addresses?v=r[5]:(v=l.Addresses??[],r[4]=l.Addresses,r[5]=v);let y;r[6]===v?y=r[7]:(y=v.filter(ye),r[6]=v,r[7]=y);let b=y,x;r[8]===Symbol.for(`react.memo_cache_sentinel`)?(x={resolver:de(he),defaultValues:{subnetId:``,allocationId:``,name:``}},r[8]=x):x=r[8];let{control:S,handleSubmit:C,register:Ce,formState:we}=le(x),{errors:w,isSubmitting:T}=we,E;r[9]===S?E=r[10]:(E={control:S},r[9]=S,r[10]=E);let D=ce(E),O;r[11]===D?O=r[12]:(O=e=>e?D[e]:void 0,r[11]=D,r[12]=O);let k=O,A;r[13]!==p||r[14]!==s?(A=async e=>{let t=(await p.mutateAsync({subnetId:e.subnetId,allocationId:e.allocationId,name:e.name?.trim()??void 0})).NatGateway?.NatGatewayId;s(t?{to:`/ec2/describe-nat-gateways/$id`,params:{id:t}}:{to:`/ec2/describe-nat-gateways`})},r[13]=p,r[14]=s,r[15]=A):A=r[15];let j=A,M,N;r[16]===Symbol.for(`react.memo_cache_sentinel`)?(N=(0,f.jsx)(ee,{to:`/ec2/describe-nat-gateways`,children:`Back to NAT Gateways`}),M=(0,f.jsx)(pe,{title:`Create NAT Gateway`}),r[16]=M,r[17]=N):(M=r[16],N=r[17]);let P;r[18]===p.error?P=r[19]:(P=p.error&&(0,f.jsx)(e,{error:p.error,msg:`Failed to create NAT Gateway`}),r[18]=p.error,r[19]=P);let F;r[20]!==C||r[21]!==j?(F=C(j),r[20]=C,r[21]=j,r[22]=F):F=r[22];let I;r[23]===Symbol.for(`react.memo_cache_sentinel`)?(I=(0,f.jsx)(u,{children:(0,f.jsx)(`label`,{htmlFor:`name`,children:`Name (optional)`})}),r[23]=I):I=r[23];let L;r[24]===Ce?L=r[25]:(L=Ce(`name`),r[24]=Ce,r[25]=L);let R;r[26]===L?R=r[27]:(R=(0,f.jsxs)(d,{children:[I,(0,f.jsx)(oe,{id:`name`,placeholder:`my-nat-gateway`,...L})]}),r[26]=L,r[27]=R);let z;r[28]===Symbol.for(`react.memo_cache_sentinel`)?(z=(0,f.jsx)(u,{children:(0,f.jsx)(`label`,{htmlFor:`subnetId`,children:`Public subnet`})}),r[28]=z):z=r[28];let B;r[29]!==w.subnetId||r[30]!==_?(B=e=>{let{field:t}=e;return(0,f.jsxs)(o,{onValueChange:e=>t.onChange(e),value:t.value??``,children:[(0,f.jsx)(a,{"aria-invalid":!!w.subnetId,className:`w-full`,id:`subnetId`,children:(0,f.jsx)(n,{placeholder:`Select a public subnet`})}),(0,f.jsx)(i,{children:_.map(ve)})]})},r[29]=w.subnetId,r[30]=_,r[31]=B):B=r[31];let V;r[32]!==S||r[33]!==B?(V=(0,f.jsx)(ue,{control:S,name:`subnetId`,render:B}),r[32]=S,r[33]=B,r[34]=V):V=r[34];let H;r[35]===w.subnetId?H=r[36]:(H=(0,f.jsx)(me,{errors:[w.subnetId]}),r[35]=w.subnetId,r[36]=H);let U;r[37]!==V||r[38]!==H?(U=(0,f.jsxs)(d,{children:[z,V,H]}),r[37]=V,r[38]=H,r[39]=U):U=r[39];let W;r[40]===Symbol.for(`react.memo_cache_sentinel`)?(W=(0,f.jsx)(u,{children:(0,f.jsx)(`label`,{htmlFor:`allocationId`,children:`Elastic IP`})}),r[40]=W):W=r[40];let G;r[41]!==b||r[42]!==S||r[43]!==w.allocationId?(G=b.length>0?(0,f.jsx)(ue,{control:S,name:`allocationId`,render:e=>{let{field:t}=e;return(0,f.jsxs)(o,{onValueChange:e=>t.onChange(e),value:t.value??``,children:[(0,f.jsx)(a,{"aria-invalid":!!w.allocationId,className:`w-full`,id:`allocationId`,children:(0,f.jsx)(n,{placeholder:`Select an Elastic IP`})}),(0,f.jsx)(i,{children:b.map(_e)})]})}}):(0,f.jsxs)(`p`,{className:`text-sm text-muted-foreground`,children:[`No available Elastic IPs.`,` `,(0,f.jsx)(ie,{className:`text-primary hover:underline`,to:`/ec2/allocate-address`,children:`Allocate one`}),` `,`first.`]}),r[41]=b,r[42]=S,r[43]=w.allocationId,r[44]=G):G=r[44];let K;r[45]===w.allocationId?K=r[46]:(K=(0,f.jsx)(me,{errors:[w.allocationId]}),r[45]=w.allocationId,r[46]=K);let q;r[47]!==G||r[48]!==K?(q=(0,f.jsxs)(d,{children:[W,G,K]}),r[47]=G,r[48]=K,r[49]=q):q=r[49];let J;r[50]===k?J=r[51]:(J=xe(k),r[50]=k,r[51]=J);let Y;r[52]===J?Y=r[53]:(Y=(0,f.jsx)(te,{commands:J}),r[52]=J,r[53]=Y);let X;r[54]===s?X=r[55]:(X=async()=>await s({to:`/ec2/describe-nat-gateways`}),r[54]=s,r[55]=X);let Z;r[56]!==p.isPending||r[57]!==T||r[58]!==X?(Z=(0,f.jsx)(fe,{isPending:p.isPending,isSubmitting:T,onCancel:X,pendingLabel:`Creating…`,submitLabel:`Create NAT Gateway`}),r[56]=p.isPending,r[57]=T,r[58]=X,r[59]=Z):Z=r[59];let Q;r[60]!==F||r[61]!==R||r[62]!==U||r[63]!==q||r[64]!==Y||r[65]!==Z?(Q=(0,f.jsxs)(`form`,{className:`max-w-4xl space-y-6`,onSubmit:F,children:[R,U,q,Y,Z]}),r[60]=F,r[61]=R,r[62]=U,r[63]=q,r[64]=Y,r[65]=Z,r[66]=Q):Q=r[66];let $;return r[67]!==P||r[68]!==Q?($=(0,f.jsxs)(f.Fragment,{children:[N,M,P,Q]}),r[67]=P,r[68]=Q,r[69]=$):$=r[69],$}function _e(e){return(0,f.jsx)(r,{value:e.AllocationId??``,children:`${e.AllocationId} (${e.PublicIp})`},e.AllocationId)}function ve(e){let t=l(e.Tags);return(0,f.jsx)(r,{value:e.SubnetId??``,children:t?`${e.SubnetId} (${t})`:`${e.SubnetId} (${e.CidrBlock})`},e.SubnetId)}function ye(e){return!e.AssociationId}function be(e){return e.MapPublicIpOnLaunch}function xe(e){let t=e(`subnetId`),ee=typeof t==`string`?t:``,n=e(`allocationId`),r=typeof n==`string`?n:``,i=e(`name`),a=typeof i==`string`?i:``,o=[{type:`bin`,value:`AWS_PROFILE=spinifex aws ec2 create-nat-gateway`},{type:`flag`,value:` \\
- --subnet-id`},{type:`value`,value:` ${ee||``}`},{type:`flag`,value:` \\
- --allocation-id`},{type:`value`,value:` ${r||``}`}];return a.trim()&&o.push({type:`flag`,value:` \\
- --tag-specifications`},{type:`value`,value:` 'ResourceType=natgateway,Tags=[{Key=Name,Value=${a.trim()}}]'`}),[{label:`Create NAT Gateway`,parts:o}]}export{p as component};
\ No newline at end of file
+import{n as e,t}from"./compiler-runtime.js";import{t as n}from"./error-banner.js";import{t as r}from"./useSuspenseQuery.js";import{Or as ee}from"./awsClient.js";import{A as i}from"./SignatureV4.js";import{t as a}from"./back-link.js";import{c as o,i as s,n as te,s as ne,t as c}from"./select.js";import{t as re}from"./cli-command-panel.js";import{m as ie}from"./ec2.js";import{Ir as ae,Nt as oe,Rn as se,lt as ce}from"./index.js";import{a as le,i as ue,n as de,t as fe}from"./zod.js";import{t as pe}from"./form-actions.js";import{t as me}from"./page-heading.js";import{a as l,r as he,t as u}from"./field.js";import{s as ge}from"./ec22.js";var _e=t(),d=e();function f(){let e=(0,_e.c)(70),t=ae(),{data:i}=r(oe),{data:s}=r(ce),f=ie(),p;e[0]===i.Subnets?p=e[1]:(p=i.Subnets??[],e[0]=i.Subnets,e[1]=p);let m=p,h;e[2]===m?h=e[3]:(h=m.filter(xe),e[2]=m,e[3]=h);let Ce=h,g=Ce.length>0?Ce:m,_;e[4]===s.Addresses?_=e[5]:(_=s.Addresses??[],e[4]=s.Addresses,e[5]=_);let v;e[6]===_?v=e[7]:(v=_.filter(be),e[6]=_,e[7]=v);let y=v,b;e[8]===Symbol.for(`react.memo_cache_sentinel`)?(b={resolver:fe(ge),defaultValues:{subnetId:``,allocationId:``,name:``}},e[8]=b):b=e[8];let{control:x,handleSubmit:S,register:C,formState:we}=ue(b),{errors:w,isSubmitting:T}=we,E;e[9]===x?E=e[10]:(E={control:x},e[9]=x,e[10]=E);let D=le(E),O;e[11]===D?O=e[12]:(O=e=>e?D[e]:void 0,e[11]=D,e[12]=O);let k=O,A;e[13]!==f||e[14]!==t?(A=async e=>{let n=(await f.mutateAsync({subnetId:e.subnetId,allocationId:e.allocationId,name:e.name?.trim()??void 0})).NatGateway?.NatGatewayId;t(n?{to:`/ec2/describe-nat-gateways/$id`,params:{id:n}}:{to:`/ec2/describe-nat-gateways`})},e[13]=f,e[14]=t,e[15]=A):A=e[15];let j=A,M,N;e[16]===Symbol.for(`react.memo_cache_sentinel`)?(N=(0,d.jsx)(a,{to:`/ec2/describe-nat-gateways`,children:`Back to NAT Gateways`}),M=(0,d.jsx)(me,{title:`Create NAT Gateway`}),e[16]=M,e[17]=N):(M=e[16],N=e[17]);let P;e[18]===f.error?P=e[19]:(P=f.error&&(0,d.jsx)(n,{error:f.error,msg:`Failed to create NAT Gateway`}),e[18]=f.error,e[19]=P);let F;e[20]!==S||e[21]!==j?(F=S(j),e[20]=S,e[21]=j,e[22]=F):F=e[22];let I;e[23]===Symbol.for(`react.memo_cache_sentinel`)?(I=(0,d.jsx)(l,{children:(0,d.jsx)(`label`,{htmlFor:`name`,children:`Name (optional)`})}),e[23]=I):I=e[23];let L;e[24]===C?L=e[25]:(L=C(`name`),e[24]=C,e[25]=L);let R;e[26]===L?R=e[27]:(R=(0,d.jsxs)(u,{children:[I,(0,d.jsx)(se,{id:`name`,placeholder:`my-nat-gateway`,...L})]}),e[26]=L,e[27]=R);let z;e[28]===Symbol.for(`react.memo_cache_sentinel`)?(z=(0,d.jsx)(l,{children:(0,d.jsx)(`label`,{htmlFor:`subnetId`,children:`Public subnet`})}),e[28]=z):z=e[28];let B;e[29]!==w.subnetId||e[30]!==g?(B=e=>{let{field:t}=e;return(0,d.jsxs)(c,{onValueChange:e=>t.onChange(e),value:t.value??``,children:[(0,d.jsx)(ne,{"aria-invalid":!!w.subnetId,className:`w-full`,id:`subnetId`,children:(0,d.jsx)(o,{placeholder:`Select a public subnet`})}),(0,d.jsx)(te,{children:g.map(ye)})]})},e[29]=w.subnetId,e[30]=g,e[31]=B):B=e[31];let V;e[32]!==x||e[33]!==B?(V=(0,d.jsx)(de,{control:x,name:`subnetId`,render:B}),e[32]=x,e[33]=B,e[34]=V):V=e[34];let H;e[35]===w.subnetId?H=e[36]:(H=(0,d.jsx)(he,{errors:[w.subnetId]}),e[35]=w.subnetId,e[36]=H);let U;e[37]!==V||e[38]!==H?(U=(0,d.jsxs)(u,{children:[z,V,H]}),e[37]=V,e[38]=H,e[39]=U):U=e[39];let W;e[40]===Symbol.for(`react.memo_cache_sentinel`)?(W=(0,d.jsx)(l,{children:(0,d.jsx)(`label`,{htmlFor:`allocationId`,children:`Elastic IP`})}),e[40]=W):W=e[40];let G;e[41]!==y||e[42]!==x||e[43]!==w.allocationId?(G=y.length>0?(0,d.jsx)(de,{control:x,name:`allocationId`,render:e=>{let{field:t}=e;return(0,d.jsxs)(c,{onValueChange:e=>t.onChange(e),value:t.value??``,children:[(0,d.jsx)(ne,{"aria-invalid":!!w.allocationId,className:`w-full`,id:`allocationId`,children:(0,d.jsx)(o,{placeholder:`Select an Elastic IP`})}),(0,d.jsx)(te,{children:y.map(ve)})]})}}):(0,d.jsxs)(`p`,{className:`text-sm text-muted-foreground`,children:[`No available Elastic IPs.`,` `,(0,d.jsx)(ee,{className:`text-primary hover:underline`,to:`/ec2/allocate-address`,children:`Allocate one`}),` `,`first.`]}),e[41]=y,e[42]=x,e[43]=w.allocationId,e[44]=G):G=e[44];let K;e[45]===w.allocationId?K=e[46]:(K=(0,d.jsx)(he,{errors:[w.allocationId]}),e[45]=w.allocationId,e[46]=K);let q;e[47]!==G||e[48]!==K?(q=(0,d.jsxs)(u,{children:[W,G,K]}),e[47]=G,e[48]=K,e[49]=q):q=e[49];let J;e[50]===k?J=e[51]:(J=Se(k),e[50]=k,e[51]=J);let Y;e[52]===J?Y=e[53]:(Y=(0,d.jsx)(re,{commands:J}),e[52]=J,e[53]=Y);let X;e[54]===t?X=e[55]:(X=async()=>await t({to:`/ec2/describe-nat-gateways`}),e[54]=t,e[55]=X);let Z;e[56]!==f.isPending||e[57]!==T||e[58]!==X?(Z=(0,d.jsx)(pe,{isPending:f.isPending,isSubmitting:T,onCancel:X,pendingLabel:`Creating…`,submitLabel:`Create NAT Gateway`}),e[56]=f.isPending,e[57]=T,e[58]=X,e[59]=Z):Z=e[59];let Q;e[60]!==F||e[61]!==R||e[62]!==U||e[63]!==q||e[64]!==Y||e[65]!==Z?(Q=(0,d.jsxs)(`form`,{className:`max-w-4xl space-y-6`,onSubmit:F,children:[R,U,q,Y,Z]}),e[60]=F,e[61]=R,e[62]=U,e[63]=q,e[64]=Y,e[65]=Z,e[66]=Q):Q=e[66];let $;return e[67]!==P||e[68]!==Q?($=(0,d.jsxs)(d.Fragment,{children:[N,M,P,Q]}),e[67]=P,e[68]=Q,e[69]=$):$=e[69],$}function ve(e){return(0,d.jsx)(s,{value:e.AllocationId??``,children:`${e.AllocationId} (${e.PublicIp})`},e.AllocationId)}function ye(e){let t=i(e.Tags);return(0,d.jsx)(s,{value:e.SubnetId??``,children:t?`${e.SubnetId} (${t})`:`${e.SubnetId} (${e.CidrBlock})`},e.SubnetId)}function be(e){return!e.AssociationId}function xe(e){return e.MapPublicIpOnLaunch}function Se(e){let t=e(`subnetId`),n=typeof t==`string`?t:``,r=e(`allocationId`),ee=typeof r==`string`?r:``,i=e(`name`),a=typeof i==`string`?i:``,o=[{type:`bin`,value:`AWS_PROFILE=spinifex aws ec2 create-nat-gateway`},{type:`flag`,value:` \\
+ --subnet-id`},{type:`value`,value:` ${n||``}`},{type:`flag`,value:` \\
+ --allocation-id`},{type:`value`,value:` ${ee||``}`}];return a.trim()&&o.push({type:`flag`,value:` \\
+ --tag-specifications`},{type:`value`,value:` 'ResourceType=natgateway,Tags=[{Key=Name,Value=${a.trim()}}]'`}),[{label:`Create NAT Gateway`,parts:o}]}export{f as component};
\ No newline at end of file
diff --git a/spinifex/services/spinifexui/frontend/dist/assets/create-placement-group.js b/spinifex/services/spinifexui/frontend/dist/assets/create-placement-group.js
index 77e1e3c82..f6b5c3b4d 100644
--- a/spinifex/services/spinifexui/frontend/dist/assets/create-placement-group.js
+++ b/spinifex/services/spinifexui/frontend/dist/assets/create-placement-group.js
@@ -1,3 +1,3 @@
-import{t as e}from"./error-banner.js";import{t}from"./back-link.js";import{c as n,i as r,n as i,s as a,t as o}from"./select.js";import{t as s}from"./cli-command-panel.js";import{m as c}from"./ec2.js";import{Fo as l,Ho as u,Ro as ee,Xi as te}from"./index.js";import{i as d,n as f,t as p}from"./zod.js";import{t as m}from"./form-actions.js";import{t as ne}from"./page-heading.js";import{a as h,r as g,t as _}from"./field.js";import{c as v}from"./ec22.js";var y=l(),b=u();function x(){let l=(0,y.c)(52),u=ee(),x=c(),S;l[0]===Symbol.for(`react.memo_cache_sentinel`)?(S={resolver:p(v),defaultValues:{groupName:``,strategy:`spread`}},l[0]=S):S=l[0];let{control:w,handleSubmit:T,register:E,watch:D,formState:O}=d(S),{errors:k,isSubmitting:A}=O,j;l[1]!==x||l[2]!==u?(j=async e=>{u({to:`/ec2/describe-placement-groups/$id`,params:{id:(await x.mutateAsync(e)).PlacementGroup?.GroupId??``}})},l[1]=x,l[2]=u,l[3]=j):j=l[3];let M=j,N,P;l[4]===Symbol.for(`react.memo_cache_sentinel`)?(N=(0,b.jsx)(t,{to:`/ec2/describe-placement-groups`,children:`Back to placement groups`}),P=(0,b.jsx)(ne,{title:`Create Placement Group`}),l[4]=N,l[5]=P):(N=l[4],P=l[5]);let F;l[6]===x.error?F=l[7]:(F=x.error&&(0,b.jsx)(e,{error:x.error,msg:`Failed to create placement group`}),l[6]=x.error,l[7]=F);let I;l[8]!==T||l[9]!==M?(I=T(M),l[8]=T,l[9]=M,l[10]=I):I=l[10];let L;l[11]===Symbol.for(`react.memo_cache_sentinel`)?(L=(0,b.jsx)(h,{children:(0,b.jsx)(`label`,{htmlFor:`groupName`,children:`Group Name`})}),l[11]=L):L=l[11];let R=!!k.groupName,z;l[12]===E?z=l[13]:(z=E(`groupName`),l[12]=E,l[13]=z);let B;l[14]!==R||l[15]!==z?(B=(0,b.jsx)(te,{"aria-invalid":R,id:`groupName`,placeholder:`my-placement-group`,...z}),l[14]=R,l[15]=z,l[16]=B):B=l[16];let V;l[17]===k.groupName?V=l[18]:(V=(0,b.jsx)(g,{errors:[k.groupName]}),l[17]=k.groupName,l[18]=V);let H;l[19]!==B||l[20]!==V?(H=(0,b.jsxs)(_,{children:[L,B,V]}),l[19]=B,l[20]=V,l[21]=H):H=l[21];let U;l[22]===Symbol.for(`react.memo_cache_sentinel`)?(U=(0,b.jsx)(h,{children:(0,b.jsx)(`label`,{htmlFor:`strategy`,children:`Strategy`})}),l[22]=U):U=l[22];let W;l[23]===k.strategy?W=l[24]:(W=e=>{let{field:t}=e;return(0,b.jsxs)(o,{onValueChange:e=>t.onChange(e),value:t.value??``,children:[(0,b.jsx)(a,{"aria-invalid":!!k.strategy,className:`w-full`,id:`strategy`,children:(0,b.jsx)(n,{})}),(0,b.jsxs)(i,{children:[(0,b.jsx)(r,{value:`spread`,children:`spread`}),(0,b.jsx)(r,{value:`cluster`,children:`cluster`})]})]})},l[23]=k.strategy,l[24]=W);let G;l[25]!==w||l[26]!==W?(G=(0,b.jsx)(f,{control:w,name:`strategy`,render:W}),l[25]=w,l[26]=W,l[27]=G):G=l[27];let K;l[28]===k.strategy?K=l[29]:(K=(0,b.jsx)(g,{errors:[k.strategy]}),l[28]=k.strategy,l[29]=K);let q;l[30]!==G||l[31]!==K?(q=(0,b.jsxs)(_,{children:[U,G,K]}),l[30]=G,l[31]=K,l[32]=q):q=l[32];let J;l[33]===D?J=l[34]:(J=C(D),l[33]=D,l[34]=J);let Y;l[35]===J?Y=l[36]:(Y=(0,b.jsx)(s,{commands:J}),l[35]=J,l[36]=Y);let X;l[37]===u?X=l[38]:(X=async()=>await u({to:`/ec2/describe-placement-groups`}),l[37]=u,l[38]=X);let Z;l[39]!==x.isPending||l[40]!==A||l[41]!==X?(Z=(0,b.jsx)(m,{isPending:x.isPending,isSubmitting:A,onCancel:X,pendingLabel:`Creating…`,submitLabel:`Create Placement Group`}),l[39]=x.isPending,l[40]=A,l[41]=X,l[42]=Z):Z=l[42];let Q;l[43]!==H||l[44]!==q||l[45]!==Y||l[46]!==Z||l[47]!==I?(Q=(0,b.jsxs)(`form`,{className:`max-w-4xl space-y-6`,onSubmit:I,children:[H,q,Y,Z]}),l[43]=H,l[44]=q,l[45]=Y,l[46]=Z,l[47]=I,l[48]=Q):Q=l[48];let $;return l[49]!==Q||l[50]!==F?($=(0,b.jsxs)(b.Fragment,{children:[N,P,F,Q]}),l[49]=Q,l[50]=F,l[51]=$):$=l[51],$}function S(e){return`'${e.replaceAll(`'`,`'"'"'`)}'`}function C(e){let t=e(`groupName`),n=typeof t==`string`?t:``,r=e(`strategy`),i=typeof r==`string`?r:``;return[{label:`Create Placement Group`,parts:[{type:`bin`,value:`AWS_PROFILE=spinifex aws ec2 create-placement-group`},{type:`flag`,value:` \\
- --group-name`},{type:`value`,value:` ${n?S(n):``}`},{type:`flag`,value:` \\
- --strategy`},{type:`value`,value:` ${i||`spread`}`}]}]}export{x as component};
\ No newline at end of file
+import{n as e,t}from"./compiler-runtime.js";import{t as n}from"./error-banner.js";import{t as r}from"./back-link.js";import{c as i,i as a,n as ee,s as o,t as te}from"./select.js";import{t as ne}from"./cli-command-panel.js";import{h as s}from"./ec2.js";import{Ir as c,Rn as l}from"./index.js";import{a as u,i as d,n as f,t as p}from"./zod.js";import{t as m}from"./form-actions.js";import{t as h}from"./page-heading.js";import{a as g,r as _,t as v}from"./field.js";import{c as re}from"./ec22.js";var ie=t(),y=e();function b(){let e=(0,ie.c)(56),t=c(),b=s(),x;e[0]===Symbol.for(`react.memo_cache_sentinel`)?(x={resolver:p(re),defaultValues:{groupName:``,strategy:`spread`}},e[0]=x):x=e[0];let{control:S,handleSubmit:C,register:w,formState:oe}=d(x),{errors:T,isSubmitting:E}=oe,D;e[1]===S?D=e[2]:(D={control:S},e[1]=S,e[2]=D);let O=u(D),k;e[3]===O?k=e[4]:(k=e=>e?O[e]:void 0,e[3]=O,e[4]=k);let A=k,j;e[5]!==b||e[6]!==t?(j=async e=>{t({to:`/ec2/describe-placement-groups/$id`,params:{id:(await b.mutateAsync(e)).PlacementGroup?.GroupId??``}})},e[5]=b,e[6]=t,e[7]=j):j=e[7];let M=j,N,P;e[8]===Symbol.for(`react.memo_cache_sentinel`)?(N=(0,y.jsx)(r,{to:`/ec2/describe-placement-groups`,children:`Back to placement groups`}),P=(0,y.jsx)(h,{title:`Create Placement Group`}),e[8]=N,e[9]=P):(N=e[8],P=e[9]);let F;e[10]===b.error?F=e[11]:(F=b.error&&(0,y.jsx)(n,{error:b.error,msg:`Failed to create placement group`}),e[10]=b.error,e[11]=F);let I;e[12]!==C||e[13]!==M?(I=C(M),e[12]=C,e[13]=M,e[14]=I):I=e[14];let L;e[15]===Symbol.for(`react.memo_cache_sentinel`)?(L=(0,y.jsx)(g,{children:(0,y.jsx)(`label`,{htmlFor:`groupName`,children:`Group Name`})}),e[15]=L):L=e[15];let R=!!T.groupName,z;e[16]===w?z=e[17]:(z=w(`groupName`),e[16]=w,e[17]=z);let B;e[18]!==R||e[19]!==z?(B=(0,y.jsx)(l,{"aria-invalid":R,id:`groupName`,placeholder:`my-placement-group`,...z}),e[18]=R,e[19]=z,e[20]=B):B=e[20];let V;e[21]===T.groupName?V=e[22]:(V=(0,y.jsx)(_,{errors:[T.groupName]}),e[21]=T.groupName,e[22]=V);let H;e[23]!==B||e[24]!==V?(H=(0,y.jsxs)(v,{children:[L,B,V]}),e[23]=B,e[24]=V,e[25]=H):H=e[25];let U;e[26]===Symbol.for(`react.memo_cache_sentinel`)?(U=(0,y.jsx)(g,{children:(0,y.jsx)(`label`,{htmlFor:`strategy`,children:`Strategy`})}),e[26]=U):U=e[26];let W;e[27]===T.strategy?W=e[28]:(W=e=>{let{field:t}=e;return(0,y.jsxs)(te,{onValueChange:e=>t.onChange(e),value:t.value??``,children:[(0,y.jsx)(o,{"aria-invalid":!!T.strategy,className:`w-full`,id:`strategy`,children:(0,y.jsx)(i,{})}),(0,y.jsxs)(ee,{children:[(0,y.jsx)(a,{value:`spread`,children:`spread`}),(0,y.jsx)(a,{value:`cluster`,children:`cluster`})]})]})},e[27]=T.strategy,e[28]=W);let G;e[29]!==S||e[30]!==W?(G=(0,y.jsx)(f,{control:S,name:`strategy`,render:W}),e[29]=S,e[30]=W,e[31]=G):G=e[31];let K;e[32]===T.strategy?K=e[33]:(K=(0,y.jsx)(_,{errors:[T.strategy]}),e[32]=T.strategy,e[33]=K);let q;e[34]!==G||e[35]!==K?(q=(0,y.jsxs)(v,{children:[U,G,K]}),e[34]=G,e[35]=K,e[36]=q):q=e[36];let J;e[37]===A?J=e[38]:(J=ae(A),e[37]=A,e[38]=J);let Y;e[39]===J?Y=e[40]:(Y=(0,y.jsx)(ne,{commands:J}),e[39]=J,e[40]=Y);let X;e[41]===t?X=e[42]:(X=async()=>await t({to:`/ec2/describe-placement-groups`}),e[41]=t,e[42]=X);let Z;e[43]!==b.isPending||e[44]!==E||e[45]!==X?(Z=(0,y.jsx)(m,{isPending:b.isPending,isSubmitting:E,onCancel:X,pendingLabel:`Creating…`,submitLabel:`Create Placement Group`}),e[43]=b.isPending,e[44]=E,e[45]=X,e[46]=Z):Z=e[46];let Q;e[47]!==H||e[48]!==q||e[49]!==Y||e[50]!==Z||e[51]!==I?(Q=(0,y.jsxs)(`form`,{className:`max-w-4xl space-y-6`,onSubmit:I,children:[H,q,Y,Z]}),e[47]=H,e[48]=q,e[49]=Y,e[50]=Z,e[51]=I,e[52]=Q):Q=e[52];let $;return e[53]!==Q||e[54]!==F?($=(0,y.jsxs)(y.Fragment,{children:[N,P,F,Q]}),e[53]=Q,e[54]=F,e[55]=$):$=e[55],$}function x(e){return`'${e.replaceAll(`'`,`'"'"'`)}'`}function ae(e){let t=e(`groupName`),n=typeof t==`string`?t:``,r=e(`strategy`),i=typeof r==`string`?r:``;return[{label:`Create Placement Group`,parts:[{type:`bin`,value:`AWS_PROFILE=spinifex aws ec2 create-placement-group`},{type:`flag`,value:` \\
+ --group-name`},{type:`value`,value:` ${n?x(n):``}`},{type:`flag`,value:` \\
+ --strategy`},{type:`value`,value:` ${i||`spread`}`}]}]}export{b as component};
\ No newline at end of file
diff --git a/spinifex/services/spinifexui/frontend/dist/assets/create-policy.js b/spinifex/services/spinifexui/frontend/dist/assets/create-policy.js
index 968f23657..dacd1cfae 100644
--- a/spinifex/services/spinifexui/frontend/dist/assets/create-policy.js
+++ b/spinifex/services/spinifexui/frontend/dist/assets/create-policy.js
@@ -1,4 +1,4 @@
-import{t as e}from"./error-banner.js";import{t}from"./back-link.js";import{t as n}from"./cli-command-panel.js";import{r}from"./iam.js";import{Fo as i,Ho as a,Ro as o,Xi as s}from"./index.js";import{i as c,t as l}from"./zod.js";import{t as u}from"./form-actions.js";import{t as d}from"./page-heading.js";import{a as f,r as p,t as m}from"./field.js";import{t as h}from"./textarea.js";import{t as g}from"./iam2.js";var ee=i(),_=a(),te=JSON.stringify({Version:`2012-10-17`,Statement:[{Effect:`Allow`,Action:`*`,Resource:`*`}]},null,2);function v(){let i=(0,ee.c)(63),a=o(),v=r(),y;i[0]===Symbol.for(`react.memo_cache_sentinel`)?(y={resolver:l(g),defaultValues:{policyDocument:te}},i[0]=y):y=i[0];let{handleSubmit:b,register:x,watch:S,formState:re}=c(y),{errors:C,isSubmitting:w}=re,T;i[1]!==v||i[2]!==a?(T=async e=>{await v.mutateAsync(e),a({to:`/iam/list-policies`})},i[1]=v,i[2]=a,i[3]=T):T=i[3];let E=T,D,O;i[4]===Symbol.for(`react.memo_cache_sentinel`)?(D=(0,_.jsx)(t,{to:`/iam/list-policies`,children:`Back to policies`}),O=(0,_.jsx)(d,{title:`Create Policy`}),i[4]=D,i[5]=O):(D=i[4],O=i[5]);let k;i[6]===v.error?k=i[7]:(k=v.error&&(0,_.jsx)(e,{error:v.error,msg:`Failed to create policy`}),i[6]=v.error,i[7]=k);let A;i[8]!==b||i[9]!==E?(A=b(E),i[8]=b,i[9]=E,i[10]=A):A=i[10];let j;i[11]===Symbol.for(`react.memo_cache_sentinel`)?(j=(0,_.jsx)(f,{children:(0,_.jsx)(`label`,{htmlFor:`policyName`,children:`Policy Name`})}),i[11]=j):j=i[11];let M=!!C.policyName,N;i[12]===x?N=i[13]:(N=x(`policyName`),i[12]=x,i[13]=N);let P;i[14]!==M||i[15]!==N?(P=(0,_.jsx)(s,{"aria-invalid":M,id:`policyName`,placeholder:`my-policy...`,...N}),i[14]=M,i[15]=N,i[16]=P):P=i[16];let F;i[17]===C.policyName?F=i[18]:(F=(0,_.jsx)(p,{errors:[C.policyName]}),i[17]=C.policyName,i[18]=F);let I;i[19]!==P||i[20]!==F?(I=(0,_.jsxs)(m,{children:[j,P,F]}),i[19]=P,i[20]=F,i[21]=I):I=i[21];let L;i[22]===Symbol.for(`react.memo_cache_sentinel`)?(L=(0,_.jsx)(f,{children:(0,_.jsx)(`label`,{htmlFor:`description`,children:`Description (optional)`})}),i[22]=L):L=i[22];let R;i[23]===x?R=i[24]:(R=x(`description`),i[23]=x,i[24]=R);let z;i[25]===R?z=i[26]:(z=(0,_.jsx)(s,{id:`description`,placeholder:`A brief description of this policy...`,...R}),i[25]=R,i[26]=z);let B;i[27]===C.description?B=i[28]:(B=(0,_.jsx)(p,{errors:[C.description]}),i[27]=C.description,i[28]=B);let V;i[29]!==z||i[30]!==B?(V=(0,_.jsxs)(m,{children:[L,z,B]}),i[29]=z,i[30]=B,i[31]=V):V=i[31];let H;i[32]===Symbol.for(`react.memo_cache_sentinel`)?(H=(0,_.jsx)(f,{children:(0,_.jsx)(`label`,{htmlFor:`policyDocument`,children:`Policy Document (JSON)`})}),i[32]=H):H=i[32];let U=!!C.policyDocument,W;i[33]===x?W=i[34]:(W=x(`policyDocument`),i[33]=x,i[34]=W);let G;i[35]!==U||i[36]!==W?(G=(0,_.jsx)(h,{"aria-invalid":U,className:`font-mono text-sm`,id:`policyDocument`,rows:15,...W}),i[35]=U,i[36]=W,i[37]=G):G=i[37];let K;i[38]===C.policyDocument?K=i[39]:(K=(0,_.jsx)(p,{errors:[C.policyDocument]}),i[38]=C.policyDocument,i[39]=K);let q;i[40]!==G||i[41]!==K?(q=(0,_.jsxs)(m,{children:[H,G,K]}),i[40]=G,i[41]=K,i[42]=q):q=i[42];let J;i[43]===S?J=i[44]:(J=ne(S),i[43]=S,i[44]=J);let Y;i[45]===J?Y=i[46]:(Y=(0,_.jsx)(n,{commands:J}),i[45]=J,i[46]=Y);let X;i[47]===a?X=i[48]:(X=async()=>await a({to:`/iam/list-policies`}),i[47]=a,i[48]=X);let Z;i[49]!==v.isPending||i[50]!==w||i[51]!==X?(Z=(0,_.jsx)(u,{isPending:v.isPending,isSubmitting:w,onCancel:X,pendingLabel:`Creating...`,submitLabel:`Create Policy`}),i[49]=v.isPending,i[50]=w,i[51]=X,i[52]=Z):Z=i[52];let Q;i[53]!==I||i[54]!==V||i[55]!==q||i[56]!==Y||i[57]!==Z||i[58]!==A?(Q=(0,_.jsxs)(`form`,{className:`max-w-4xl space-y-6`,onSubmit:A,children:[I,V,q,Y,Z]}),i[53]=I,i[54]=V,i[55]=q,i[56]=Y,i[57]=Z,i[58]=A,i[59]=Q):Q=i[59];let $;return i[60]!==Q||i[61]!==k?($=(0,_.jsxs)(_.Fragment,{children:[D,O,k,Q]}),i[60]=Q,i[61]=k,i[62]=$):$=i[62],$}function ne(e){let t=e(`policyName`),n=typeof t==`string`?t:``,r=e(`description`),i=typeof r==`string`?r:``,a=e(`policyDocument`),o=typeof a==`string`?a:``,s=[{type:`bin`,value:`AWS_PROFILE=spinifex aws iam create-policy`},{type:`flag`,value:` \\
+import{n as e,t}from"./compiler-runtime.js";import{t as n}from"./error-banner.js";import{t as r}from"./back-link.js";import{t as i}from"./cli-command-panel.js";import{o as a}from"./iam.js";import{Ir as o,Rn as s}from"./index.js";import{a as ee,i as te,t as ne}from"./zod.js";import{t as re}from"./form-actions.js";import{t as ie}from"./page-heading.js";import{a as c,r as l,t as u}from"./field.js";import{t as d}from"./textarea.js";import{r as f}from"./iam2.js";var ae=t(),p=e(),oe=JSON.stringify({Version:`2012-10-17`,Statement:[{Effect:`Allow`,Action:`*`,Resource:`*`}]},null,2);function m(){let e=(0,ae.c)(67),t=o(),m=a(),h;e[0]===Symbol.for(`react.memo_cache_sentinel`)?(h={resolver:ne(f),defaultValues:{policyDocument:oe}},e[0]=h):h=e[0];let{handleSubmit:g,register:_,control:v,formState:ce}=te(h),{errors:y,isSubmitting:b}=ce,x;e[1]===v?x=e[2]:(x={control:v},e[1]=v,e[2]=x);let S=ee(x),C;e[3]===S?C=e[4]:(C=e=>e?S[e]:void 0,e[3]=S,e[4]=C);let w=C,T;e[5]!==m||e[6]!==t?(T=async e=>{await m.mutateAsync(e),t({to:`/iam/list-policies`})},e[5]=m,e[6]=t,e[7]=T):T=e[7];let E=T,D,O;e[8]===Symbol.for(`react.memo_cache_sentinel`)?(D=(0,p.jsx)(r,{to:`/iam/list-policies`,children:`Back to policies`}),O=(0,p.jsx)(ie,{title:`Create Policy`}),e[8]=D,e[9]=O):(D=e[8],O=e[9]);let k;e[10]===m.error?k=e[11]:(k=m.error&&(0,p.jsx)(n,{error:m.error,msg:`Failed to create policy`}),e[10]=m.error,e[11]=k);let A;e[12]!==g||e[13]!==E?(A=g(E),e[12]=g,e[13]=E,e[14]=A):A=e[14];let j;e[15]===Symbol.for(`react.memo_cache_sentinel`)?(j=(0,p.jsx)(c,{children:(0,p.jsx)(`label`,{htmlFor:`policyName`,children:`Policy Name`})}),e[15]=j):j=e[15];let M=!!y.policyName,N;e[16]===_?N=e[17]:(N=_(`policyName`),e[16]=_,e[17]=N);let P;e[18]!==M||e[19]!==N?(P=(0,p.jsx)(s,{"aria-invalid":M,id:`policyName`,placeholder:`my-policy...`,...N}),e[18]=M,e[19]=N,e[20]=P):P=e[20];let F;e[21]===y.policyName?F=e[22]:(F=(0,p.jsx)(l,{errors:[y.policyName]}),e[21]=y.policyName,e[22]=F);let I;e[23]!==P||e[24]!==F?(I=(0,p.jsxs)(u,{children:[j,P,F]}),e[23]=P,e[24]=F,e[25]=I):I=e[25];let L;e[26]===Symbol.for(`react.memo_cache_sentinel`)?(L=(0,p.jsx)(c,{children:(0,p.jsx)(`label`,{htmlFor:`description`,children:`Description (optional)`})}),e[26]=L):L=e[26];let R;e[27]===_?R=e[28]:(R=_(`description`),e[27]=_,e[28]=R);let z;e[29]===R?z=e[30]:(z=(0,p.jsx)(s,{id:`description`,placeholder:`A brief description of this policy...`,...R}),e[29]=R,e[30]=z);let B;e[31]===y.description?B=e[32]:(B=(0,p.jsx)(l,{errors:[y.description]}),e[31]=y.description,e[32]=B);let V;e[33]!==z||e[34]!==B?(V=(0,p.jsxs)(u,{children:[L,z,B]}),e[33]=z,e[34]=B,e[35]=V):V=e[35];let H;e[36]===Symbol.for(`react.memo_cache_sentinel`)?(H=(0,p.jsx)(c,{children:(0,p.jsx)(`label`,{htmlFor:`policyDocument`,children:`Policy Document (JSON)`})}),e[36]=H):H=e[36];let U=!!y.policyDocument,W;e[37]===_?W=e[38]:(W=_(`policyDocument`),e[37]=_,e[38]=W);let G;e[39]!==U||e[40]!==W?(G=(0,p.jsx)(d,{"aria-invalid":U,className:`font-mono text-sm`,id:`policyDocument`,rows:15,...W}),e[39]=U,e[40]=W,e[41]=G):G=e[41];let K;e[42]===y.policyDocument?K=e[43]:(K=(0,p.jsx)(l,{errors:[y.policyDocument]}),e[42]=y.policyDocument,e[43]=K);let q;e[44]!==G||e[45]!==K?(q=(0,p.jsxs)(u,{children:[H,G,K]}),e[44]=G,e[45]=K,e[46]=q):q=e[46];let J;e[47]===w?J=e[48]:(J=se(w),e[47]=w,e[48]=J);let Y;e[49]===J?Y=e[50]:(Y=(0,p.jsx)(i,{commands:J}),e[49]=J,e[50]=Y);let X;e[51]===t?X=e[52]:(X=async()=>await t({to:`/iam/list-policies`}),e[51]=t,e[52]=X);let Z;e[53]!==m.isPending||e[54]!==b||e[55]!==X?(Z=(0,p.jsx)(re,{isPending:m.isPending,isSubmitting:b,onCancel:X,pendingLabel:`Creating...`,submitLabel:`Create Policy`}),e[53]=m.isPending,e[54]=b,e[55]=X,e[56]=Z):Z=e[56];let Q;e[57]!==I||e[58]!==V||e[59]!==q||e[60]!==Y||e[61]!==Z||e[62]!==A?(Q=(0,p.jsxs)(`form`,{className:`max-w-4xl space-y-6`,onSubmit:A,children:[I,V,q,Y,Z]}),e[57]=I,e[58]=V,e[59]=q,e[60]=Y,e[61]=Z,e[62]=A,e[63]=Q):Q=e[63];let $;return e[64]!==Q||e[65]!==k?($=(0,p.jsxs)(p.Fragment,{children:[D,O,k,Q]}),e[64]=Q,e[65]=k,e[66]=$):$=e[66],$}function se(e){let t=e(`policyName`),n=typeof t==`string`?t:``,r=e(`description`),i=typeof r==`string`?r:``,a=e(`policyDocument`),o=typeof a==`string`?a:``,s=[{type:`bin`,value:`AWS_PROFILE=spinifex aws iam create-policy`},{type:`flag`,value:` \\
--policy-name`},{type:`value`,value:` ${n||``}`}];return i&&s.push({type:`flag`,value:` \\
--description`},{type:`value`,value:` "${i}"`}),s.push({type:`flag`,value:` \\
- --policy-document`},{type:`value`,value:` '${o||``}'`}),[{label:`Create Policy`,parts:s}]}export{v as component};
\ No newline at end of file
+ --policy-document`},{type:`value`,value:` '${o||``}'`}),[{label:`Create Policy`,parts:s}]}export{m as component};
\ No newline at end of file
diff --git a/spinifex/services/spinifexui/frontend/dist/assets/create-role.js b/spinifex/services/spinifexui/frontend/dist/assets/create-role.js
new file mode 100644
index 000000000..775a39d7a
--- /dev/null
+++ b/spinifex/services/spinifexui/frontend/dist/assets/create-role.js
@@ -0,0 +1,4 @@
+import{n as e,t}from"./compiler-runtime.js";import{t as n}from"./error-banner.js";import{t as r}from"./back-link.js";import{t as i}from"./cli-command-panel.js";import{s as a}from"./iam.js";import{Ir as o,Rn as s}from"./index.js";import{a as ee,i as te,t as ne}from"./zod.js";import{t as re}from"./form-actions.js";import{t as ie}from"./page-heading.js";import{a as c,r as l,t as u}from"./field.js";import{t as d}from"./textarea.js";import{i as f,t as ae}from"./iam2.js";var oe=t(),p=e();function m(){let e=(0,oe.c)(67),t=o(),m=a(),h;e[0]===Symbol.for(`react.memo_cache_sentinel`)?(h={resolver:ne(f),defaultValues:{assumeRolePolicyDocument:ae}},e[0]=h):h=e[0];let{handleSubmit:g,register:_,control:v,formState:ce}=te(h),{errors:y,isSubmitting:b}=ce,x;e[1]===v?x=e[2]:(x={control:v},e[1]=v,e[2]=x);let S=ee(x),C;e[3]===S?C=e[4]:(C=e=>e?S[e]:void 0,e[3]=S,e[4]=C);let w=C,T;e[5]!==m||e[6]!==t?(T=async e=>{await m.mutateAsync(e),t({to:`/iam/list-roles`})},e[5]=m,e[6]=t,e[7]=T):T=e[7];let E=T,D,O;e[8]===Symbol.for(`react.memo_cache_sentinel`)?(D=(0,p.jsx)(r,{to:`/iam/list-roles`,children:`Back to roles`}),O=(0,p.jsx)(ie,{title:`Create Role`}),e[8]=D,e[9]=O):(D=e[8],O=e[9]);let k;e[10]===m.error?k=e[11]:(k=m.error&&(0,p.jsx)(n,{error:m.error,msg:`Failed to create role`}),e[10]=m.error,e[11]=k);let A;e[12]!==g||e[13]!==E?(A=g(E),e[12]=g,e[13]=E,e[14]=A):A=e[14];let j;e[15]===Symbol.for(`react.memo_cache_sentinel`)?(j=(0,p.jsx)(c,{children:(0,p.jsx)(`label`,{htmlFor:`roleName`,children:`Role Name`})}),e[15]=j):j=e[15];let M=!!y.roleName,N;e[16]===_?N=e[17]:(N=_(`roleName`),e[16]=_,e[17]=N);let P;e[18]!==M||e[19]!==N?(P=(0,p.jsx)(s,{"aria-invalid":M,id:`roleName`,placeholder:`my-role...`,...N}),e[18]=M,e[19]=N,e[20]=P):P=e[20];let F;e[21]===y.roleName?F=e[22]:(F=(0,p.jsx)(l,{errors:[y.roleName]}),e[21]=y.roleName,e[22]=F);let I;e[23]!==P||e[24]!==F?(I=(0,p.jsxs)(u,{children:[j,P,F]}),e[23]=P,e[24]=F,e[25]=I):I=e[25];let L;e[26]===Symbol.for(`react.memo_cache_sentinel`)?(L=(0,p.jsx)(c,{children:(0,p.jsx)(`label`,{htmlFor:`description`,children:`Description (optional)`})}),e[26]=L):L=e[26];let R;e[27]===_?R=e[28]:(R=_(`description`),e[27]=_,e[28]=R);let z;e[29]===R?z=e[30]:(z=(0,p.jsx)(s,{id:`description`,placeholder:`A brief description of this role...`,...R}),e[29]=R,e[30]=z);let B;e[31]===y.description?B=e[32]:(B=(0,p.jsx)(l,{errors:[y.description]}),e[31]=y.description,e[32]=B);let V;e[33]!==z||e[34]!==B?(V=(0,p.jsxs)(u,{children:[L,z,B]}),e[33]=z,e[34]=B,e[35]=V):V=e[35];let H;e[36]===Symbol.for(`react.memo_cache_sentinel`)?(H=(0,p.jsx)(c,{children:(0,p.jsx)(`label`,{htmlFor:`assumeRolePolicyDocument`,children:`Trust Policy (JSON)`})}),e[36]=H):H=e[36];let U=!!y.assumeRolePolicyDocument,W;e[37]===_?W=e[38]:(W=_(`assumeRolePolicyDocument`),e[37]=_,e[38]=W);let G;e[39]!==U||e[40]!==W?(G=(0,p.jsx)(d,{"aria-invalid":U,className:`font-mono text-sm`,id:`assumeRolePolicyDocument`,rows:15,...W}),e[39]=U,e[40]=W,e[41]=G):G=e[41];let K;e[42]===y.assumeRolePolicyDocument?K=e[43]:(K=(0,p.jsx)(l,{errors:[y.assumeRolePolicyDocument]}),e[42]=y.assumeRolePolicyDocument,e[43]=K);let q;e[44]!==G||e[45]!==K?(q=(0,p.jsxs)(u,{children:[H,G,K]}),e[44]=G,e[45]=K,e[46]=q):q=e[46];let J;e[47]===w?J=e[48]:(J=se(w),e[47]=w,e[48]=J);let Y;e[49]===J?Y=e[50]:(Y=(0,p.jsx)(i,{commands:J}),e[49]=J,e[50]=Y);let X;e[51]===t?X=e[52]:(X=async()=>await t({to:`/iam/list-roles`}),e[51]=t,e[52]=X);let Z;e[53]!==m.isPending||e[54]!==b||e[55]!==X?(Z=(0,p.jsx)(re,{isPending:m.isPending,isSubmitting:b,onCancel:X,pendingLabel:`Creating...`,submitLabel:`Create Role`}),e[53]=m.isPending,e[54]=b,e[55]=X,e[56]=Z):Z=e[56];let Q;e[57]!==I||e[58]!==V||e[59]!==q||e[60]!==Y||e[61]!==Z||e[62]!==A?(Q=(0,p.jsxs)(`form`,{className:`max-w-4xl space-y-6`,onSubmit:A,children:[I,V,q,Y,Z]}),e[57]=I,e[58]=V,e[59]=q,e[60]=Y,e[61]=Z,e[62]=A,e[63]=Q):Q=e[63];let $;return e[64]!==Q||e[65]!==k?($=(0,p.jsxs)(p.Fragment,{children:[D,O,k,Q]}),e[64]=Q,e[65]=k,e[66]=$):$=e[66],$}function se(e){let t=e(`roleName`),n=typeof t==`string`?t:``,r=e(`description`),i=typeof r==`string`?r:``,a=e(`assumeRolePolicyDocument`),o=typeof a==`string`?a:``,s=[{type:`bin`,value:`AWS_PROFILE=spinifex aws iam create-role`},{type:`flag`,value:` \\
+ --role-name`},{type:`value`,value:` ${n||``}`}];return i&&s.push({type:`flag`,value:` \\
+ --description`},{type:`value`,value:` "${i}"`}),s.push({type:`flag`,value:` \\
+ --assume-role-policy-document`},{type:`value`,value:` '${o||``}'`}),[{label:`Create Role`,parts:s}]}export{m as component};
\ No newline at end of file
diff --git a/spinifex/services/spinifexui/frontend/dist/assets/create-route-table.js b/spinifex/services/spinifexui/frontend/dist/assets/create-route-table.js
index 35346712d..de6c04793 100644
--- a/spinifex/services/spinifexui/frontend/dist/assets/create-route-table.js
+++ b/spinifex/services/spinifexui/frontend/dist/assets/create-route-table.js
@@ -1 +1 @@
-import{t as e}from"./error-banner.js";import{t}from"./useSuspenseQuery.js";import{t as n}from"./back-link.js";import{c as r,i,n as a,s as o,t as s}from"./select.js";import{g as c}from"./ec2.js";import{Fo as l,Ho as u,Ro as d,Xi as f,ao as p,ht as m}from"./index.js";import{i as h,n as g,t as _}from"./zod.js";import{t as v}from"./form-actions.js";import{t as y}from"./page-heading.js";import{a as b,r as x,t as S}from"./field.js";import{u as C}from"./ec22.js";var w=l(),T=u();function E(){let i=(0,w.c)(44),l=d(),{data:u}=t(m),p=c(),E;i[0]===u.Vpcs?E=i[1]:(E=u.Vpcs??[],i[0]=u.Vpcs,i[1]=E);let O=E,k;i[2]===Symbol.for(`react.memo_cache_sentinel`)?(k={resolver:_(C),defaultValues:{vpcId:``,name:``}},i[2]=k):k=i[2];let{control:A,handleSubmit:j,register:M,formState:N}=h(k),{errors:P,isSubmitting:F}=N,I;i[3]!==p||i[4]!==l?(I=async e=>{let t=(await p.mutateAsync({vpcId:e.vpcId,name:e.name?.trim()?e.name.trim():void 0})).RouteTable?.RouteTableId;l(t?{to:`/ec2/describe-route-tables/$id`,params:{id:t}}:{to:`/ec2/describe-route-tables`})},i[3]=p,i[4]=l,i[5]=I):I=i[5];let L=I,R,z;i[6]===Symbol.for(`react.memo_cache_sentinel`)?(R=(0,T.jsx)(n,{to:`/ec2/describe-route-tables`,children:`Back to Route Tables`}),z=(0,T.jsx)(y,{title:`Create Route Table`}),i[6]=R,i[7]=z):(R=i[6],z=i[7]);let B;i[8]===p.error?B=i[9]:(B=p.error&&(0,T.jsx)(e,{error:p.error,msg:`Failed to create Route Table`}),i[8]=p.error,i[9]=B);let V;i[10]!==j||i[11]!==L?(V=j(L),i[10]=j,i[11]=L,i[12]=V):V=i[12];let H;i[13]===Symbol.for(`react.memo_cache_sentinel`)?(H=(0,T.jsx)(b,{children:(0,T.jsx)(`label`,{htmlFor:`name`,children:`Name (optional)`})}),i[13]=H):H=i[13];let U;i[14]===M?U=i[15]:(U=M(`name`),i[14]=M,i[15]=U);let W;i[16]===U?W=i[17]:(W=(0,T.jsxs)(S,{children:[H,(0,T.jsx)(f,{id:`name`,placeholder:`my-route-table`,...U})]}),i[16]=U,i[17]=W);let G;i[18]===Symbol.for(`react.memo_cache_sentinel`)?(G=(0,T.jsx)(b,{children:(0,T.jsx)(`label`,{htmlFor:`vpcId`,children:`VPC`})}),i[18]=G):G=i[18];let K;i[19]!==P.vpcId||i[20]!==O?(K=e=>{let{field:t}=e;return(0,T.jsxs)(s,{onValueChange:e=>t.onChange(e),value:t.value??``,children:[(0,T.jsx)(o,{"aria-invalid":!!P.vpcId,className:`w-full`,id:`vpcId`,children:(0,T.jsx)(r,{placeholder:`Select a VPC`})}),(0,T.jsx)(a,{children:O.map(D)})]})},i[19]=P.vpcId,i[20]=O,i[21]=K):K=i[21];let q;i[22]!==A||i[23]!==K?(q=(0,T.jsx)(g,{control:A,name:`vpcId`,render:K}),i[22]=A,i[23]=K,i[24]=q):q=i[24];let J;i[25]===P.vpcId?J=i[26]:(J=(0,T.jsx)(x,{errors:[P.vpcId]}),i[25]=P.vpcId,i[26]=J);let Y;i[27]!==q||i[28]!==J?(Y=(0,T.jsxs)(S,{children:[G,q,J]}),i[27]=q,i[28]=J,i[29]=Y):Y=i[29];let X;i[30]===l?X=i[31]:(X=async()=>await l({to:`/ec2/describe-route-tables`}),i[30]=l,i[31]=X);let Z;i[32]!==p.isPending||i[33]!==F||i[34]!==X?(Z=(0,T.jsx)(v,{isPending:p.isPending,isSubmitting:F,onCancel:X,pendingLabel:`Creating…`,submitLabel:`Create Route Table`}),i[32]=p.isPending,i[33]=F,i[34]=X,i[35]=Z):Z=i[35];let Q;i[36]!==W||i[37]!==Y||i[38]!==Z||i[39]!==V?(Q=(0,T.jsxs)(`form`,{className:`max-w-4xl space-y-6`,onSubmit:V,children:[W,Y,Z]}),i[36]=W,i[37]=Y,i[38]=Z,i[39]=V,i[40]=Q):Q=i[40];let $;return i[41]!==Q||i[42]!==B?($=(0,T.jsxs)(T.Fragment,{children:[R,z,B,Q]}),i[41]=Q,i[42]=B,i[43]=$):$=i[43],$}function D(e){let t=p(e.Tags);return(0,T.jsx)(i,{value:e.VpcId??``,children:t?`${e.VpcId} (${t})`:`${e.VpcId} (${e.CidrBlock})`},e.VpcId)}export{E as component};
\ No newline at end of file
+import{n as e,t}from"./compiler-runtime.js";import{t as n}from"./error-banner.js";import{t as r}from"./useSuspenseQuery.js";import{A as i}from"./SignatureV4.js";import{t as a}from"./back-link.js";import{c as o,i as s,n as c,s as l,t as u}from"./select.js";import{_ as d}from"./ec2.js";import{Ir as f,Lt as p,Rn as m}from"./index.js";import{i as h,n as g,t as _}from"./zod.js";import{t as v}from"./form-actions.js";import{t as y}from"./page-heading.js";import{a as b,r as x,t as S}from"./field.js";import{u as C}from"./ec22.js";var w=t(),T=e();function E(){let e=(0,w.c)(44),t=f(),{data:i}=r(p),s=d(),E;e[0]===i.Vpcs?E=e[1]:(E=i.Vpcs??[],e[0]=i.Vpcs,e[1]=E);let O=E,k;e[2]===Symbol.for(`react.memo_cache_sentinel`)?(k={resolver:_(C),defaultValues:{vpcId:``,name:``}},e[2]=k):k=e[2];let{control:A,handleSubmit:j,register:M,formState:N}=h(k),{errors:P,isSubmitting:F}=N,I;e[3]!==s||e[4]!==t?(I=async e=>{let n=(await s.mutateAsync({vpcId:e.vpcId,name:e.name?.trim()?e.name.trim():void 0})).RouteTable?.RouteTableId;t(n?{to:`/ec2/describe-route-tables/$id`,params:{id:n}}:{to:`/ec2/describe-route-tables`})},e[3]=s,e[4]=t,e[5]=I):I=e[5];let L=I,R,z;e[6]===Symbol.for(`react.memo_cache_sentinel`)?(R=(0,T.jsx)(a,{to:`/ec2/describe-route-tables`,children:`Back to Route Tables`}),z=(0,T.jsx)(y,{title:`Create Route Table`}),e[6]=R,e[7]=z):(R=e[6],z=e[7]);let B;e[8]===s.error?B=e[9]:(B=s.error&&(0,T.jsx)(n,{error:s.error,msg:`Failed to create Route Table`}),e[8]=s.error,e[9]=B);let V;e[10]!==j||e[11]!==L?(V=j(L),e[10]=j,e[11]=L,e[12]=V):V=e[12];let H;e[13]===Symbol.for(`react.memo_cache_sentinel`)?(H=(0,T.jsx)(b,{children:(0,T.jsx)(`label`,{htmlFor:`name`,children:`Name (optional)`})}),e[13]=H):H=e[13];let U;e[14]===M?U=e[15]:(U=M(`name`),e[14]=M,e[15]=U);let W;e[16]===U?W=e[17]:(W=(0,T.jsxs)(S,{children:[H,(0,T.jsx)(m,{id:`name`,placeholder:`my-route-table`,...U})]}),e[16]=U,e[17]=W);let G;e[18]===Symbol.for(`react.memo_cache_sentinel`)?(G=(0,T.jsx)(b,{children:(0,T.jsx)(`label`,{htmlFor:`vpcId`,children:`VPC`})}),e[18]=G):G=e[18];let K;e[19]!==P.vpcId||e[20]!==O?(K=e=>{let{field:t}=e;return(0,T.jsxs)(u,{onValueChange:e=>t.onChange(e),value:t.value??``,children:[(0,T.jsx)(l,{"aria-invalid":!!P.vpcId,className:`w-full`,id:`vpcId`,children:(0,T.jsx)(o,{placeholder:`Select a VPC`})}),(0,T.jsx)(c,{children:O.map(D)})]})},e[19]=P.vpcId,e[20]=O,e[21]=K):K=e[21];let q;e[22]!==A||e[23]!==K?(q=(0,T.jsx)(g,{control:A,name:`vpcId`,render:K}),e[22]=A,e[23]=K,e[24]=q):q=e[24];let J;e[25]===P.vpcId?J=e[26]:(J=(0,T.jsx)(x,{errors:[P.vpcId]}),e[25]=P.vpcId,e[26]=J);let Y;e[27]!==q||e[28]!==J?(Y=(0,T.jsxs)(S,{children:[G,q,J]}),e[27]=q,e[28]=J,e[29]=Y):Y=e[29];let X;e[30]===t?X=e[31]:(X=async()=>await t({to:`/ec2/describe-route-tables`}),e[30]=t,e[31]=X);let Z;e[32]!==s.isPending||e[33]!==F||e[34]!==X?(Z=(0,T.jsx)(v,{isPending:s.isPending,isSubmitting:F,onCancel:X,pendingLabel:`Creating…`,submitLabel:`Create Route Table`}),e[32]=s.isPending,e[33]=F,e[34]=X,e[35]=Z):Z=e[35];let Q;e[36]!==W||e[37]!==Y||e[38]!==Z||e[39]!==V?(Q=(0,T.jsxs)(`form`,{className:`max-w-4xl space-y-6`,onSubmit:V,children:[W,Y,Z]}),e[36]=W,e[37]=Y,e[38]=Z,e[39]=V,e[40]=Q):Q=e[40];let $;return e[41]!==Q||e[42]!==B?($=(0,T.jsxs)(T.Fragment,{children:[R,z,B,Q]}),e[41]=Q,e[42]=B,e[43]=$):$=e[43],$}function D(e){let t=i(e.Tags);return(0,T.jsx)(s,{value:e.VpcId??``,children:t?`${e.VpcId} (${t})`:`${e.VpcId} (${e.CidrBlock})`},e.VpcId)}export{E as component};
\ No newline at end of file
diff --git a/spinifex/services/spinifexui/frontend/dist/assets/create-security-group.js b/spinifex/services/spinifexui/frontend/dist/assets/create-security-group.js
index 2f07aeab8..8cee6f577 100644
--- a/spinifex/services/spinifexui/frontend/dist/assets/create-security-group.js
+++ b/spinifex/services/spinifexui/frontend/dist/assets/create-security-group.js
@@ -1,4 +1,4 @@
-import{t as e}from"./error-banner.js";import{t}from"./useSuspenseQuery.js";import{t as ee}from"./back-link.js";import{c as n,i as r,n as i,s as a,t as o}from"./select.js";import{t as te}from"./cli-command-panel.js";import{_ as ne}from"./ec2.js";import{Fo as s,Ho as c,Ro as re,Xi as ie,ao as l,ht as ae}from"./index.js";import{i as oe,n as se,t as ce}from"./zod.js";import{t as le}from"./form-actions.js";import{t as ue}from"./page-heading.js";import{a as u,r as d,t as f}from"./field.js";import{d as de}from"./ec22.js";var fe=s(),p=c();function m(){let r=(0,fe.c)(69),s=re(),{data:c}=t(ae),l=ne(),m;r[0]===c.Vpcs?m=r[1]:(m=c.Vpcs??[],r[0]=c.Vpcs,r[1]=m);let h=m,g;r[2]===Symbol.for(`react.memo_cache_sentinel`)?(g=ce(de),r[2]=g):g=r[2];let _=h[0]?.VpcId??``,v;r[3]===_?v=r[4]:(v={resolver:g,defaultValues:{groupName:``,description:``,vpcId:_}},r[3]=_,r[4]=v);let{control:y,handleSubmit:b,register:x,watch:S,formState:he}=oe(v),{errors:C,isSubmitting:w}=he,T;r[5]!==l||r[6]!==s?(T=async e=>{let t=(await l.mutateAsync(e)).GroupId;s(t?{to:`/ec2/describe-security-groups/$id`,params:{id:t}}:{to:`/ec2/describe-security-groups`})},r[5]=l,r[6]=s,r[7]=T):T=r[7];let E=T,D,O;r[8]===Symbol.for(`react.memo_cache_sentinel`)?(D=(0,p.jsx)(ee,{to:`/ec2/describe-security-groups`,children:`Back to Security Groups`}),O=(0,p.jsx)(ue,{title:`Create Security Group`}),r[8]=D,r[9]=O):(D=r[8],O=r[9]);let k;r[10]===l.error?k=r[11]:(k=l.error&&(0,p.jsx)(e,{error:l.error,msg:`Failed to create security group`}),r[10]=l.error,r[11]=k);let A;r[12]!==b||r[13]!==E?(A=b(E),r[12]=b,r[13]=E,r[14]=A):A=r[14];let j;r[15]===Symbol.for(`react.memo_cache_sentinel`)?(j=(0,p.jsx)(u,{children:(0,p.jsx)(`label`,{htmlFor:`groupName`,children:`Group Name`})}),r[15]=j):j=r[15];let M=!!C.groupName,N;r[16]===x?N=r[17]:(N=x(`groupName`),r[16]=x,r[17]=N);let P;r[18]!==M||r[19]!==N?(P=(0,p.jsx)(ie,{"aria-invalid":M,id:`groupName`,placeholder:`my-security-group`,...N}),r[18]=M,r[19]=N,r[20]=P):P=r[20];let F;r[21]===C.groupName?F=r[22]:(F=(0,p.jsx)(d,{errors:[C.groupName]}),r[21]=C.groupName,r[22]=F);let I;r[23]!==P||r[24]!==F?(I=(0,p.jsxs)(f,{children:[j,P,F]}),r[23]=P,r[24]=F,r[25]=I):I=r[25];let L;r[26]===Symbol.for(`react.memo_cache_sentinel`)?(L=(0,p.jsx)(u,{children:(0,p.jsx)(`label`,{htmlFor:`description`,children:`Description`})}),r[26]=L):L=r[26];let R=!!C.description,z;r[27]===x?z=r[28]:(z=x(`description`),r[27]=x,r[28]=z);let B;r[29]!==R||r[30]!==z?(B=(0,p.jsx)(ie,{"aria-invalid":R,id:`description`,placeholder:`My security group description`,...z}),r[29]=R,r[30]=z,r[31]=B):B=r[31];let V;r[32]===C.description?V=r[33]:(V=(0,p.jsx)(d,{errors:[C.description]}),r[32]=C.description,r[33]=V);let H;r[34]!==B||r[35]!==V?(H=(0,p.jsxs)(f,{children:[L,B,V]}),r[34]=B,r[35]=V,r[36]=H):H=r[36];let U;r[37]===Symbol.for(`react.memo_cache_sentinel`)?(U=(0,p.jsx)(u,{children:(0,p.jsx)(`label`,{htmlFor:`vpcId`,children:`VPC`})}),r[37]=U):U=r[37];let W;r[38]!==C.vpcId||r[39]!==h?(W=e=>{let{field:t}=e;return(0,p.jsxs)(o,{onValueChange:e=>t.onChange(e),value:t.value??``,children:[(0,p.jsx)(a,{"aria-invalid":!!C.vpcId,className:`w-full`,id:`vpcId`,children:(0,p.jsx)(n,{})}),(0,p.jsx)(i,{children:h.map(pe)})]})},r[38]=C.vpcId,r[39]=h,r[40]=W):W=r[40];let G;r[41]!==y||r[42]!==W?(G=(0,p.jsx)(se,{control:y,name:`vpcId`,render:W}),r[41]=y,r[42]=W,r[43]=G):G=r[43];let K;r[44]===C.vpcId?K=r[45]:(K=(0,p.jsx)(d,{errors:[C.vpcId]}),r[44]=C.vpcId,r[45]=K);let q;r[46]!==G||r[47]!==K?(q=(0,p.jsxs)(f,{children:[U,G,K]}),r[46]=G,r[47]=K,r[48]=q):q=r[48];let J;r[49]===S?J=r[50]:(J=me(S),r[49]=S,r[50]=J);let Y;r[51]===J?Y=r[52]:(Y=(0,p.jsx)(te,{commands:J}),r[51]=J,r[52]=Y);let X;r[53]===s?X=r[54]:(X=async()=>await s({to:`/ec2/describe-security-groups`}),r[53]=s,r[54]=X);let Z;r[55]!==l.isPending||r[56]!==w||r[57]!==X?(Z=(0,p.jsx)(le,{isPending:l.isPending,isSubmitting:w,onCancel:X,pendingLabel:`Creating…`,submitLabel:`Create Security Group`}),r[55]=l.isPending,r[56]=w,r[57]=X,r[58]=Z):Z=r[58];let Q;r[59]!==I||r[60]!==H||r[61]!==q||r[62]!==Y||r[63]!==Z||r[64]!==A?(Q=(0,p.jsxs)(`form`,{className:`max-w-4xl space-y-6`,onSubmit:A,children:[I,H,q,Y,Z]}),r[59]=I,r[60]=H,r[61]=q,r[62]=Y,r[63]=Z,r[64]=A,r[65]=Q):Q=r[65];let $;return r[66]!==Q||r[67]!==k?($=(0,p.jsxs)(p.Fragment,{children:[D,O,k,Q]}),r[66]=Q,r[67]=k,r[68]=$):$=r[68],$}function pe(e){let t=l(e.Tags);return(0,p.jsx)(r,{value:e.VpcId??``,children:t?`${e.VpcId} (${t})`:`${e.VpcId} (${e.CidrBlock})`},e.VpcId)}function me(e){let t=e(`groupName`),ee=typeof t==`string`?t:``,n=e(`description`),r=typeof n==`string`?n:``,i=e(`vpcId`),a=typeof i==`string`?i:``,o=[{type:`bin`,value:`AWS_PROFILE=spinifex aws ec2 create-security-group`},{type:`flag`,value:` \\
- --group-name`},{type:`value`,value:` ${ee||``}`},{type:`flag`,value:` \\
- --description`},{type:`value`,value:` "${r||``}"`}];return a&&o.push({type:`flag`,value:` \\
- --vpc-id`},{type:`value`,value:` ${a}`}),[{label:`Create Security Group`,parts:o}]}export{m as component};
\ No newline at end of file
+import{n as e,t}from"./compiler-runtime.js";import{t as n}from"./error-banner.js";import{t as r}from"./useSuspenseQuery.js";import{A as i}from"./SignatureV4.js";import{t as a}from"./back-link.js";import{c as o,i as s,n as ee,s as te,t as ne}from"./select.js";import{t as re}from"./cli-command-panel.js";import{v as ie}from"./ec2.js";import{Ir as ae,Lt as oe,Rn as se}from"./index.js";import{a as ce,i as le,n as ue,t as de}from"./zod.js";import{t as fe}from"./form-actions.js";import{t as pe}from"./page-heading.js";import{a as c,r as l,t as u}from"./field.js";import{d as me}from"./ec22.js";var he=t(),d=e();function f(){let e=(0,he.c)(73),t=ae(),{data:i}=r(oe),s=ie(),f;e[0]===i.Vpcs?f=e[1]:(f=i.Vpcs??[],e[0]=i.Vpcs,e[1]=f);let p=f,m;e[2]===Symbol.for(`react.memo_cache_sentinel`)?(m=de(me),e[2]=m):m=e[2];let h=p[0]?.VpcId??``,g;e[3]===h?g=e[4]:(g={resolver:m,defaultValues:{groupName:``,description:``,vpcId:h}},e[3]=h,e[4]=g);let{control:_,handleSubmit:v,register:y,formState:ve}=le(g),{errors:b,isSubmitting:ye}=ve,x;e[5]===_?x=e[6]:(x={control:_},e[5]=_,e[6]=x);let S=ce(x),C;e[7]===S?C=e[8]:(C=e=>e?S[e]:void 0,e[7]=S,e[8]=C);let w=C,T;e[9]!==s||e[10]!==t?(T=async e=>{let n=(await s.mutateAsync(e)).GroupId;t(n?{to:`/ec2/describe-security-groups/$id`,params:{id:n}}:{to:`/ec2/describe-security-groups`})},e[9]=s,e[10]=t,e[11]=T):T=e[11];let E=T,D,O;e[12]===Symbol.for(`react.memo_cache_sentinel`)?(D=(0,d.jsx)(a,{to:`/ec2/describe-security-groups`,children:`Back to Security Groups`}),O=(0,d.jsx)(pe,{title:`Create Security Group`}),e[12]=D,e[13]=O):(D=e[12],O=e[13]);let k;e[14]===s.error?k=e[15]:(k=s.error&&(0,d.jsx)(n,{error:s.error,msg:`Failed to create security group`}),e[14]=s.error,e[15]=k);let A;e[16]!==v||e[17]!==E?(A=v(E),e[16]=v,e[17]=E,e[18]=A):A=e[18];let j;e[19]===Symbol.for(`react.memo_cache_sentinel`)?(j=(0,d.jsx)(c,{children:(0,d.jsx)(`label`,{htmlFor:`groupName`,children:`Group Name`})}),e[19]=j):j=e[19];let M=!!b.groupName,N;e[20]===y?N=e[21]:(N=y(`groupName`),e[20]=y,e[21]=N);let P;e[22]!==M||e[23]!==N?(P=(0,d.jsx)(se,{"aria-invalid":M,id:`groupName`,placeholder:`my-security-group`,...N}),e[22]=M,e[23]=N,e[24]=P):P=e[24];let F;e[25]===b.groupName?F=e[26]:(F=(0,d.jsx)(l,{errors:[b.groupName]}),e[25]=b.groupName,e[26]=F);let I;e[27]!==P||e[28]!==F?(I=(0,d.jsxs)(u,{children:[j,P,F]}),e[27]=P,e[28]=F,e[29]=I):I=e[29];let L;e[30]===Symbol.for(`react.memo_cache_sentinel`)?(L=(0,d.jsx)(c,{children:(0,d.jsx)(`label`,{htmlFor:`description`,children:`Description`})}),e[30]=L):L=e[30];let R=!!b.description,z;e[31]===y?z=e[32]:(z=y(`description`),e[31]=y,e[32]=z);let B;e[33]!==R||e[34]!==z?(B=(0,d.jsx)(se,{"aria-invalid":R,id:`description`,placeholder:`My security group description`,...z}),e[33]=R,e[34]=z,e[35]=B):B=e[35];let V;e[36]===b.description?V=e[37]:(V=(0,d.jsx)(l,{errors:[b.description]}),e[36]=b.description,e[37]=V);let H;e[38]!==B||e[39]!==V?(H=(0,d.jsxs)(u,{children:[L,B,V]}),e[38]=B,e[39]=V,e[40]=H):H=e[40];let U;e[41]===Symbol.for(`react.memo_cache_sentinel`)?(U=(0,d.jsx)(c,{children:(0,d.jsx)(`label`,{htmlFor:`vpcId`,children:`VPC`})}),e[41]=U):U=e[41];let W;e[42]!==b.vpcId||e[43]!==p?(W=e=>{let{field:t}=e;return(0,d.jsxs)(ne,{onValueChange:e=>t.onChange(e),value:t.value??``,children:[(0,d.jsx)(te,{"aria-invalid":!!b.vpcId,className:`w-full`,id:`vpcId`,children:(0,d.jsx)(o,{})}),(0,d.jsx)(ee,{children:p.map(ge)})]})},e[42]=b.vpcId,e[43]=p,e[44]=W):W=e[44];let G;e[45]!==_||e[46]!==W?(G=(0,d.jsx)(ue,{control:_,name:`vpcId`,render:W}),e[45]=_,e[46]=W,e[47]=G):G=e[47];let K;e[48]===b.vpcId?K=e[49]:(K=(0,d.jsx)(l,{errors:[b.vpcId]}),e[48]=b.vpcId,e[49]=K);let q;e[50]!==G||e[51]!==K?(q=(0,d.jsxs)(u,{children:[U,G,K]}),e[50]=G,e[51]=K,e[52]=q):q=e[52];let J;e[53]===w?J=e[54]:(J=_e(w),e[53]=w,e[54]=J);let Y;e[55]===J?Y=e[56]:(Y=(0,d.jsx)(re,{commands:J}),e[55]=J,e[56]=Y);let X;e[57]===t?X=e[58]:(X=async()=>await t({to:`/ec2/describe-security-groups`}),e[57]=t,e[58]=X);let Z;e[59]!==s.isPending||e[60]!==ye||e[61]!==X?(Z=(0,d.jsx)(fe,{isPending:s.isPending,isSubmitting:ye,onCancel:X,pendingLabel:`Creating…`,submitLabel:`Create Security Group`}),e[59]=s.isPending,e[60]=ye,e[61]=X,e[62]=Z):Z=e[62];let Q;e[63]!==A||e[64]!==I||e[65]!==H||e[66]!==q||e[67]!==Y||e[68]!==Z?(Q=(0,d.jsxs)(`form`,{className:`max-w-4xl space-y-6`,onSubmit:A,children:[I,H,q,Y,Z]}),e[63]=A,e[64]=I,e[65]=H,e[66]=q,e[67]=Y,e[68]=Z,e[69]=Q):Q=e[69];let $;return e[70]!==k||e[71]!==Q?($=(0,d.jsxs)(d.Fragment,{children:[D,O,k,Q]}),e[70]=k,e[71]=Q,e[72]=$):$=e[72],$}function ge(e){let t=i(e.Tags);return(0,d.jsx)(s,{value:e.VpcId??``,children:t?`${e.VpcId} (${t})`:`${e.VpcId} (${e.CidrBlock})`},e.VpcId)}function _e(e){let t=e(`groupName`),n=typeof t==`string`?t:``,r=e(`description`),i=typeof r==`string`?r:``,a=e(`vpcId`),o=typeof a==`string`?a:``,s=[{type:`bin`,value:`AWS_PROFILE=spinifex aws ec2 create-security-group`},{type:`flag`,value:` \\
+ --group-name`},{type:`value`,value:` ${n||``}`},{type:`flag`,value:` \\
+ --description`},{type:`value`,value:` "${i||``}"`}];return o&&s.push({type:`flag`,value:` \\
+ --vpc-id`},{type:`value`,value:` ${o}`}),[{label:`Create Security Group`,parts:s}]}export{f as component};
\ No newline at end of file
diff --git a/spinifex/services/spinifexui/frontend/dist/assets/create-snapshot.js b/spinifex/services/spinifexui/frontend/dist/assets/create-snapshot.js
index 1db8ed568..8b408aed5 100644
--- a/spinifex/services/spinifexui/frontend/dist/assets/create-snapshot.js
+++ b/spinifex/services/spinifexui/frontend/dist/assets/create-snapshot.js
@@ -1,3 +1,3 @@
-import{t as e}from"./error-banner.js";import{t}from"./useSuspenseQuery.js";import{t as n}from"./back-link.js";import{c as r,i,n as a,s as o,t as s}from"./select.js";import{t as c}from"./cli-command-panel.js";import{v as l}from"./ec2.js";import{Fo as u,Ho as d,R as f,Ro as p,pt as ee}from"./index.js";import{i as te,n as ne,t as re}from"./zod.js";import{t as ie}from"./form-actions.js";import{t as ae}from"./page-heading.js";import{a as m,r as oe,t as h}from"./field.js";import{f as g}from"./ec22.js";import{t as _}from"./textarea.js";var v=u(),y=d();function b(){let i=(0,v.c)(51),u=p(),{data:d}=t(ee),b=l(),x;i[0]===d.Volumes?x=i[1]:(x=d.Volumes??[],i[0]=d.Volumes,i[1]=x);let S=x,{volumeId:C}=f.useSearch(),w=C??S[0]?.VolumeId??``,T;i[2]===Symbol.for(`react.memo_cache_sentinel`)?(T=re(g),i[2]=T):T=i[2];let E;i[3]===w?E=i[4]:(E={resolver:T,defaultValues:{volumeId:w,description:``}},i[3]=w,i[4]=E);let{control:D,handleSubmit:O,register:k,watch:A,formState:j}=te(E),{errors:M,isSubmitting:N}=j,P;i[5]!==b||i[6]!==u?(P=async e=>{await b.mutateAsync(e),u({to:`/ec2/describe-snapshots`})},i[5]=b,i[6]=u,i[7]=P):P=i[7];let F=P,I,L;i[8]===Symbol.for(`react.memo_cache_sentinel`)?(I=(0,y.jsx)(n,{to:`/ec2/describe-snapshots`,children:`Back to snapshots`}),L=(0,y.jsx)(ae,{title:`Create Snapshot`}),i[8]=I,i[9]=L):(I=i[8],L=i[9]);let R;i[10]===b.error?R=i[11]:(R=b.error&&(0,y.jsx)(e,{error:b.error,msg:`Failed to create snapshot`}),i[10]=b.error,i[11]=R);let z;i[12]!==O||i[13]!==F?(z=O(F),i[12]=O,i[13]=F,i[14]=z):z=i[14];let B;i[15]===Symbol.for(`react.memo_cache_sentinel`)?(B=(0,y.jsx)(m,{children:(0,y.jsx)(`label`,{htmlFor:`volumeId`,children:`Volume`})}),i[15]=B):B=i[15];let V;i[16]!==M.volumeId||i[17]!==S?(V=e=>{let{field:t}=e;return(0,y.jsxs)(s,{onValueChange:e=>t.onChange(e),value:t.value??``,children:[(0,y.jsx)(o,{"aria-invalid":!!M.volumeId,className:`w-full`,id:`volumeId`,children:(0,y.jsx)(r,{})}),(0,y.jsx)(a,{children:S.map(se)})]})},i[16]=M.volumeId,i[17]=S,i[18]=V):V=i[18];let H;i[19]!==D||i[20]!==V?(H=(0,y.jsx)(ne,{control:D,name:`volumeId`,render:V}),i[19]=D,i[20]=V,i[21]=H):H=i[21];let U;i[22]===M.volumeId?U=i[23]:(U=(0,y.jsx)(oe,{errors:[M.volumeId]}),i[22]=M.volumeId,i[23]=U);let W;i[24]!==H||i[25]!==U?(W=(0,y.jsxs)(h,{children:[B,H,U]}),i[24]=H,i[25]=U,i[26]=W):W=i[26];let G;i[27]===Symbol.for(`react.memo_cache_sentinel`)?(G=(0,y.jsx)(m,{children:(0,y.jsx)(`label`,{htmlFor:`description`,children:`Description (optional)`})}),i[27]=G):G=i[27];let K;i[28]===k?K=i[29]:(K=k(`description`),i[28]=k,i[29]=K);let q;i[30]===K?q=i[31]:(q=(0,y.jsxs)(h,{children:[G,(0,y.jsx)(_,{id:`description`,placeholder:`Snapshot description`,...K})]}),i[30]=K,i[31]=q);let J;i[32]===A?J=i[33]:(J=ce(A),i[32]=A,i[33]=J);let Y;i[34]===J?Y=i[35]:(Y=(0,y.jsx)(c,{commands:J}),i[34]=J,i[35]=Y);let X;i[36]===u?X=i[37]:(X=async()=>await u({to:`/ec2/describe-snapshots`}),i[36]=u,i[37]=X);let Z;i[38]!==b.isPending||i[39]!==N||i[40]!==X?(Z=(0,y.jsx)(ie,{isPending:b.isPending,isSubmitting:N,onCancel:X,pendingLabel:`Creating…`,submitLabel:`Create Snapshot`}),i[38]=b.isPending,i[39]=N,i[40]=X,i[41]=Z):Z=i[41];let Q;i[42]!==W||i[43]!==q||i[44]!==Y||i[45]!==Z||i[46]!==z?(Q=(0,y.jsxs)(`form`,{className:`max-w-4xl space-y-6`,onSubmit:z,children:[W,q,Y,Z]}),i[42]=W,i[43]=q,i[44]=Y,i[45]=Z,i[46]=z,i[47]=Q):Q=i[47];let $;return i[48]!==Q||i[49]!==R?($=(0,y.jsxs)(y.Fragment,{children:[I,L,R,Q]}),i[48]=Q,i[49]=R,i[50]=$):$=i[50],$}function se(e){return(0,y.jsxs)(i,{value:e.VolumeId??``,children:[e.VolumeId,` (`,e.Size,` GiB)`]},e.VolumeId)}function ce(e){let t=e(`volumeId`),n=typeof t==`string`?t:``,r=e(`description`),i=typeof r==`string`?r:``,a=[{type:`bin`,value:`AWS_PROFILE=spinifex aws ec2 create-snapshot`},{type:`flag`,value:` \\
+import{n as e,t}from"./compiler-runtime.js";import{t as n}from"./error-banner.js";import{t as r}from"./useSuspenseQuery.js";import{t as i}from"./back-link.js";import{c as a,i as o,n as s,s as c,t as ee}from"./select.js";import{t as te}from"./cli-command-panel.js";import{y as ne}from"./ec2.js";import{Ft as re,Ir as ie,K as ae}from"./index.js";import{a as oe,i as se,n as ce,t as le}from"./zod.js";import{t as ue}from"./form-actions.js";import{t as l}from"./page-heading.js";import{a as u,r as d,t as f}from"./field.js";import{f as p}from"./ec22.js";import{t as m}from"./textarea.js";var h=t(),g=e();function _(){let e=(0,h.c)(55),t=ie(),{data:o}=r(re),_=ne(),v;e[0]===o.Volumes?v=e[1]:(v=o.Volumes??[],e[0]=o.Volumes,e[1]=v);let y=v,{volumeId:b}=ae.useSearch(),x=b??y[0]?.VolumeId??``,S;e[2]===Symbol.for(`react.memo_cache_sentinel`)?(S=le(p),e[2]=S):S=e[2];let C;e[3]===x?C=e[4]:(C={resolver:S,defaultValues:{volumeId:x,description:``}},e[3]=x,e[4]=C);let{control:w,handleSubmit:T,register:E,formState:D}=se(C),{errors:O,isSubmitting:k}=D,A;e[5]===w?A=e[6]:(A={control:w},e[5]=w,e[6]=A);let j=oe(A),M;e[7]===j?M=e[8]:(M=e=>e?j[e]:void 0,e[7]=j,e[8]=M);let N=M,P;e[9]!==_||e[10]!==t?(P=async e=>{await _.mutateAsync(e),t({to:`/ec2/describe-snapshots`})},e[9]=_,e[10]=t,e[11]=P):P=e[11];let F=P,I,L;e[12]===Symbol.for(`react.memo_cache_sentinel`)?(I=(0,g.jsx)(i,{to:`/ec2/describe-snapshots`,children:`Back to snapshots`}),L=(0,g.jsx)(l,{title:`Create Snapshot`}),e[12]=I,e[13]=L):(I=e[12],L=e[13]);let R;e[14]===_.error?R=e[15]:(R=_.error&&(0,g.jsx)(n,{error:_.error,msg:`Failed to create snapshot`}),e[14]=_.error,e[15]=R);let z;e[16]!==T||e[17]!==F?(z=T(F),e[16]=T,e[17]=F,e[18]=z):z=e[18];let B;e[19]===Symbol.for(`react.memo_cache_sentinel`)?(B=(0,g.jsx)(u,{children:(0,g.jsx)(`label`,{htmlFor:`volumeId`,children:`Volume`})}),e[19]=B):B=e[19];let V;e[20]!==O.volumeId||e[21]!==y?(V=e=>{let{field:t}=e;return(0,g.jsxs)(ee,{onValueChange:e=>t.onChange(e),value:t.value??``,children:[(0,g.jsx)(c,{"aria-invalid":!!O.volumeId,className:`w-full`,id:`volumeId`,children:(0,g.jsx)(a,{})}),(0,g.jsx)(s,{children:y.map(de)})]})},e[20]=O.volumeId,e[21]=y,e[22]=V):V=e[22];let H;e[23]!==w||e[24]!==V?(H=(0,g.jsx)(ce,{control:w,name:`volumeId`,render:V}),e[23]=w,e[24]=V,e[25]=H):H=e[25];let U;e[26]===O.volumeId?U=e[27]:(U=(0,g.jsx)(d,{errors:[O.volumeId]}),e[26]=O.volumeId,e[27]=U);let W;e[28]!==H||e[29]!==U?(W=(0,g.jsxs)(f,{children:[B,H,U]}),e[28]=H,e[29]=U,e[30]=W):W=e[30];let G;e[31]===Symbol.for(`react.memo_cache_sentinel`)?(G=(0,g.jsx)(u,{children:(0,g.jsx)(`label`,{htmlFor:`description`,children:`Description (optional)`})}),e[31]=G):G=e[31];let K;e[32]===E?K=e[33]:(K=E(`description`),e[32]=E,e[33]=K);let q;e[34]===K?q=e[35]:(q=(0,g.jsxs)(f,{children:[G,(0,g.jsx)(m,{id:`description`,placeholder:`Snapshot description`,...K})]}),e[34]=K,e[35]=q);let J;e[36]===N?J=e[37]:(J=fe(N),e[36]=N,e[37]=J);let Y;e[38]===J?Y=e[39]:(Y=(0,g.jsx)(te,{commands:J}),e[38]=J,e[39]=Y);let X;e[40]===t?X=e[41]:(X=async()=>await t({to:`/ec2/describe-snapshots`}),e[40]=t,e[41]=X);let Z;e[42]!==_.isPending||e[43]!==k||e[44]!==X?(Z=(0,g.jsx)(ue,{isPending:_.isPending,isSubmitting:k,onCancel:X,pendingLabel:`Creating…`,submitLabel:`Create Snapshot`}),e[42]=_.isPending,e[43]=k,e[44]=X,e[45]=Z):Z=e[45];let Q;e[46]!==z||e[47]!==W||e[48]!==q||e[49]!==Y||e[50]!==Z?(Q=(0,g.jsxs)(`form`,{className:`max-w-4xl space-y-6`,onSubmit:z,children:[W,q,Y,Z]}),e[46]=z,e[47]=W,e[48]=q,e[49]=Y,e[50]=Z,e[51]=Q):Q=e[51];let $;return e[52]!==Q||e[53]!==R?($=(0,g.jsxs)(g.Fragment,{children:[I,L,R,Q]}),e[52]=Q,e[53]=R,e[54]=$):$=e[54],$}function de(e){return(0,g.jsxs)(o,{value:e.VolumeId??``,children:[e.VolumeId,` (`,e.Size,` GiB)`]},e.VolumeId)}function fe(e){let t=e(`volumeId`),n=typeof t==`string`?t:``,r=e(`description`),i=typeof r==`string`?r:``,a=[{type:`bin`,value:`AWS_PROFILE=spinifex aws ec2 create-snapshot`},{type:`flag`,value:` \\
--volume-id`},{type:`value`,value:` ${n||``}`}];return i&&a.push({type:`flag`,value:` \\
- --description`},{type:`value`,value:` "${i}"`}),[{label:`Create Snapshot`,parts:a}]}export{b as component};
\ No newline at end of file
+ --description`},{type:`value`,value:` "${i}"`}),[{label:`Create Snapshot`,parts:a}]}export{_ as component};
\ No newline at end of file
diff --git a/spinifex/services/spinifexui/frontend/dist/assets/create-subnet.js b/spinifex/services/spinifexui/frontend/dist/assets/create-subnet.js
index 5b0c3778b..8fa0b6bd7 100644
--- a/spinifex/services/spinifexui/frontend/dist/assets/create-subnet.js
+++ b/spinifex/services/spinifexui/frontend/dist/assets/create-subnet.js
@@ -1,6 +1,6 @@
-import{t as e}from"./error-banner.js";import{t}from"./useSuspenseQuery.js";import{t as ee}from"./back-link.js";import{c as n,i as r,n as i,s as a,t as o}from"./select.js";import{t as te}from"./cli-command-panel.js";import{y as ne}from"./ec2.js";import{Fo as s,Ho as c,Ro as re,U as ie,Xi as ae,ao as l,ht as oe}from"./index.js";import{i as se,n as u,t as ce}from"./zod.js";import{t as le}from"./form-actions.js";import{t as ue}from"./page-heading.js";import{a as d,r as de,t as f}from"./field.js";import{p as fe}from"./ec22.js";var pe=s(),p=c();function m(){let s=(0,pe.c)(66),c=re(),{data:l}=t(oe),{data:m}=t(ie),h=ne(),g;s[0]===l.Vpcs?g=s[1]:(g=l.Vpcs??[],s[0]=l.Vpcs,s[1]=g);let _=g,v;s[2]===m.AvailabilityZones?v=s[3]:(v=m.AvailabilityZones??[],s[2]=m.AvailabilityZones,s[3]=v);let y=v,b;s[4]===Symbol.for(`react.memo_cache_sentinel`)?(b=ce(fe),s[4]=b):b=s[4];let x=_[0]?.VpcId??``,S;s[5]===x?S=s[6]:(S={resolver:b,defaultValues:{vpcId:x,cidrBlock:`10.0.1.0/24`,availabilityZone:void 0,mapPublicIpOnLaunch:!1}},s[5]=x,s[6]=S);let{control:C,handleSubmit:w,register:T,formState:ve}=se(S),{errors:E,isSubmitting:ye}=ve,D;s[7]!==h||s[8]!==c?(D=async e=>{let t=(await h.mutateAsync(e)).Subnet?.SubnetId;c(t?{to:`/ec2/describe-subnets/$id`,params:{id:t}}:{to:`/ec2/describe-subnets`})},s[7]=h,s[8]=c,s[9]=D):D=s[9];let O=D,k,A;s[10]===Symbol.for(`react.memo_cache_sentinel`)?(k=(0,p.jsx)(ee,{to:`/ec2/describe-subnets`,children:`Back to Subnets`}),A=(0,p.jsx)(ue,{title:`Create Subnet`}),s[10]=k,s[11]=A):(k=s[10],A=s[11]);let j;s[12]===h.error?j=s[13]:(j=h.error&&(0,p.jsx)(e,{error:h.error,msg:`Failed to create subnet`}),s[12]=h.error,s[13]=j);let M;s[14]!==w||s[15]!==O?(M=w(O),s[14]=w,s[15]=O,s[16]=M):M=s[16];let N;s[17]===Symbol.for(`react.memo_cache_sentinel`)?(N=(0,p.jsx)(d,{children:(0,p.jsx)(`label`,{htmlFor:`vpcId`,children:`VPC`})}),s[17]=N):N=s[17];let P;s[18]!==E.vpcId||s[19]!==_?(P=e=>{let{field:t}=e;return(0,p.jsxs)(o,{onValueChange:e=>t.onChange(e),value:t.value??``,children:[(0,p.jsx)(a,{"aria-invalid":!!E.vpcId,className:`w-full`,id:`vpcId`,children:(0,p.jsx)(n,{})}),(0,p.jsx)(i,{children:_.map(ge)})]})},s[18]=E.vpcId,s[19]=_,s[20]=P):P=s[20];let F;s[21]!==C||s[22]!==P?(F=(0,p.jsx)(u,{control:C,name:`vpcId`,render:P}),s[21]=C,s[22]=P,s[23]=F):F=s[23];let I;s[24]===E.vpcId?I=s[25]:(I=(0,p.jsx)(de,{errors:[E.vpcId]}),s[24]=E.vpcId,s[25]=I);let L;s[26]!==F||s[27]!==I?(L=(0,p.jsxs)(f,{children:[N,F,I]}),s[26]=F,s[27]=I,s[28]=L):L=s[28];let R;s[29]===Symbol.for(`react.memo_cache_sentinel`)?(R=(0,p.jsx)(d,{children:(0,p.jsx)(`label`,{htmlFor:`cidrBlock`,children:`CIDR Block`})}),s[29]=R):R=s[29];let z=!!E.cidrBlock,B;s[30]===T?B=s[31]:(B=T(`cidrBlock`),s[30]=T,s[31]=B);let V;s[32]!==z||s[33]!==B?(V=(0,p.jsx)(ae,{"aria-invalid":z,id:`cidrBlock`,placeholder:`10.0.1.0/24`,...B}),s[32]=z,s[33]=B,s[34]=V):V=s[34];let H;s[35]===E.cidrBlock?H=s[36]:(H=(0,p.jsx)(de,{errors:[E.cidrBlock]}),s[35]=E.cidrBlock,s[36]=H);let U;s[37]!==V||s[38]!==H?(U=(0,p.jsxs)(f,{children:[R,V,H]}),s[37]=V,s[38]=H,s[39]=U):U=s[39];let W;s[40]===Symbol.for(`react.memo_cache_sentinel`)?(W=(0,p.jsx)(d,{children:(0,p.jsx)(`label`,{htmlFor:`availabilityZone`,children:`Availability Zone (optional)`})}),s[40]=W):W=s[40];let G;s[41]===y?G=s[42]:(G=e=>{let{field:t}=e;return(0,p.jsxs)(o,{onValueChange:e=>t.onChange(e===`none`?void 0:e),value:t.value??`none`,children:[(0,p.jsx)(a,{className:`w-full`,id:`availabilityZone`,children:(0,p.jsx)(n,{})}),(0,p.jsxs)(i,{children:[(0,p.jsx)(r,{value:`none`,children:`none`}),y.map(he)]})]})},s[41]=y,s[42]=G);let K;s[43]!==C||s[44]!==G?(K=(0,p.jsxs)(f,{children:[W,(0,p.jsx)(u,{control:C,name:`availabilityZone`,render:G})]}),s[43]=C,s[44]=G,s[45]=K):K=s[45];let q;s[46]===Symbol.for(`react.memo_cache_sentinel`)?(q=(0,p.jsx)(d,{children:`Auto-assign public IPv4`}),s[46]=q):q=s[46];let J;s[47]===C?J=s[48]:(J=(0,p.jsxs)(f,{children:[q,(0,p.jsx)(u,{control:C,name:`mapPublicIpOnLaunch`,render:me})]}),s[47]=C,s[48]=J);let Y;s[49]===Symbol.for(`react.memo_cache_sentinel`)?(Y=(0,p.jsx)(te,{commands:_e(cliWatch)}),s[49]=Y):Y=s[49];let X;s[50]===c?X=s[51]:(X=async()=>await c({to:`/ec2/describe-subnets`}),s[50]=c,s[51]=X);let Z;s[52]!==h.isPending||s[53]!==ye||s[54]!==X?(Z=(0,p.jsx)(le,{isPending:h.isPending,isSubmitting:ye,onCancel:X,pendingLabel:`Creating…`,submitLabel:`Create Subnet`}),s[52]=h.isPending,s[53]=ye,s[54]=X,s[55]=Z):Z=s[55];let Q;s[56]!==M||s[57]!==L||s[58]!==U||s[59]!==K||s[60]!==J||s[61]!==Z?(Q=(0,p.jsxs)(`form`,{className:`max-w-4xl space-y-6`,onSubmit:M,children:[L,U,K,J,Y,Z]}),s[56]=M,s[57]=L,s[58]=U,s[59]=K,s[60]=J,s[61]=Z,s[62]=Q):Q=s[62];let $;return s[63]!==Q||s[64]!==j?($=(0,p.jsxs)(p.Fragment,{children:[k,A,j,Q]}),s[63]=Q,s[64]=j,s[65]=$):$=s[65],$}function me(e){let{field:t}=e;return(0,p.jsxs)(`label`,{className:`flex items-center gap-2 text-sm`,children:[(0,p.jsx)(`input`,{"aria-label":`Enable auto-assign public IPv4 address`,checked:t.value??!1,onChange:e=>t.onChange(e.target.checked),type:`checkbox`}),`Enable auto-assign public IPv4 address`]})}function he(e){return(0,p.jsx)(r,{value:e.ZoneName??``,children:e.ZoneName},e.ZoneName)}function ge(e){let t=l(e.Tags);return(0,p.jsx)(r,{value:e.VpcId??``,children:t?`${e.VpcId} (${t})`:`${e.VpcId} (${e.CidrBlock})`},e.VpcId)}function _e(e){let t=e(`vpcId`),ee=typeof t==`string`?t:``,n=e(`cidrBlock`),r=typeof n==`string`?n:``,i=e(`availabilityZone`),a=typeof i==`string`?i:``,o=[{type:`bin`,value:`AWS_PROFILE=spinifex aws ec2 create-subnet`},{type:`flag`,value:` \\
- --vpc-id`},{type:`value`,value:` ${ee||``}`},{type:`flag`,value:` \\
- --cidr-block`},{type:`value`,value:` ${r||``}`}];a&&o.push({type:`flag`,value:` \\
- --availability-zone`},{type:`value`,value:` ${a}`});let te=[{label:`Create Subnet`,parts:o}];return e(`mapPublicIpOnLaunch`)===!0&&te.push({label:`Enable auto-assign public IPv4`,parts:[{type:`bin`,value:`AWS_PROFILE=spinifex aws ec2 modify-subnet-attribute`},{type:`flag`,value:` \\
+import{n as e,t}from"./compiler-runtime.js";import{t as n}from"./error-banner.js";import{t as r}from"./useSuspenseQuery.js";import{A as i}from"./SignatureV4.js";import{t as a}from"./back-link.js";import{c as o,i as s,n as c,s as ee,t as te}from"./select.js";import{t as ne}from"./cli-command-panel.js";import{b as re}from"./ec2.js";import{Ir as ie,Lt as ae,Rn as oe,ut as se}from"./index.js";import{a as ce,i as le,n as ue,t as de}from"./zod.js";import{t as fe}from"./form-actions.js";import{t as pe}from"./page-heading.js";import{a as l,r as me,t as u}from"./field.js";import{p as he}from"./ec22.js";var ge=t(),d=e();function f(){let e=(0,ge.c)(74),t=ie(),{data:i}=r(ae),{data:f}=r(se),p=re(),m;e[0]===i.Vpcs?m=e[1]:(m=i.Vpcs??[],e[0]=i.Vpcs,e[1]=m);let h=m,g;e[2]===f.AvailabilityZones?g=e[3]:(g=f.AvailabilityZones??[],e[2]=f.AvailabilityZones,e[3]=g);let _=g,v;e[4]===Symbol.for(`react.memo_cache_sentinel`)?(v=de(he),e[4]=v):v=e[4];let y=h[0]?.VpcId??``,b;e[5]===y?b=e[6]:(b={resolver:v,defaultValues:{vpcId:y,cidrBlock:`10.0.1.0/24`,availabilityZone:void 0,mapPublicIpOnLaunch:!1}},e[5]=y,e[6]=b);let{control:x,handleSubmit:xe,register:Se,formState:Ce}=le(b),{errors:S,isSubmitting:we}=Ce,C;e[7]===x?C=e[8]:(C={control:x},e[7]=x,e[8]=C);let w=ce(C),T;e[9]===w?T=e[10]:(T=e=>e?w[e]:void 0,e[9]=w,e[10]=T);let E=T,D;e[11]!==p||e[12]!==t?(D=async e=>{let n=(await p.mutateAsync(e)).Subnet?.SubnetId;t(n?{to:`/ec2/describe-subnets/$id`,params:{id:n}}:{to:`/ec2/describe-subnets`})},e[11]=p,e[12]=t,e[13]=D):D=e[13];let O=D,k,A;e[14]===Symbol.for(`react.memo_cache_sentinel`)?(A=(0,d.jsx)(a,{to:`/ec2/describe-subnets`,children:`Back to Subnets`}),k=(0,d.jsx)(pe,{title:`Create Subnet`}),e[14]=k,e[15]=A):(k=e[14],A=e[15]);let j;e[16]===p.error?j=e[17]:(j=p.error&&(0,d.jsx)(n,{error:p.error,msg:`Failed to create subnet`}),e[16]=p.error,e[17]=j);let M;e[18]!==xe||e[19]!==O?(M=xe(O),e[18]=xe,e[19]=O,e[20]=M):M=e[20];let N;e[21]===Symbol.for(`react.memo_cache_sentinel`)?(N=(0,d.jsx)(l,{children:(0,d.jsx)(`label`,{htmlFor:`vpcId`,children:`VPC`})}),e[21]=N):N=e[21];let P;e[22]!==S.vpcId||e[23]!==h?(P=e=>{let{field:t}=e;return(0,d.jsxs)(te,{onValueChange:e=>t.onChange(e),value:t.value??``,children:[(0,d.jsx)(ee,{"aria-invalid":!!S.vpcId,className:`w-full`,id:`vpcId`,children:(0,d.jsx)(o,{})}),(0,d.jsx)(c,{children:h.map(ye)})]})},e[22]=S.vpcId,e[23]=h,e[24]=P):P=e[24];let F;e[25]!==x||e[26]!==P?(F=(0,d.jsx)(ue,{control:x,name:`vpcId`,render:P}),e[25]=x,e[26]=P,e[27]=F):F=e[27];let I;e[28]===S.vpcId?I=e[29]:(I=(0,d.jsx)(me,{errors:[S.vpcId]}),e[28]=S.vpcId,e[29]=I);let L;e[30]!==F||e[31]!==I?(L=(0,d.jsxs)(u,{children:[N,F,I]}),e[30]=F,e[31]=I,e[32]=L):L=e[32];let R;e[33]===Symbol.for(`react.memo_cache_sentinel`)?(R=(0,d.jsx)(l,{children:(0,d.jsx)(`label`,{htmlFor:`cidrBlock`,children:`CIDR Block`})}),e[33]=R):R=e[33];let Te=!!S.cidrBlock,z;e[34]===Se?z=e[35]:(z=Se(`cidrBlock`),e[34]=Se,e[35]=z);let B;e[36]!==Te||e[37]!==z?(B=(0,d.jsx)(oe,{"aria-invalid":Te,id:`cidrBlock`,placeholder:`10.0.1.0/24`,...z}),e[36]=Te,e[37]=z,e[38]=B):B=e[38];let V;e[39]===S.cidrBlock?V=e[40]:(V=(0,d.jsx)(me,{errors:[S.cidrBlock]}),e[39]=S.cidrBlock,e[40]=V);let H;e[41]!==B||e[42]!==V?(H=(0,d.jsxs)(u,{children:[R,B,V]}),e[41]=B,e[42]=V,e[43]=H):H=e[43];let U;e[44]===Symbol.for(`react.memo_cache_sentinel`)?(U=(0,d.jsx)(l,{children:(0,d.jsx)(`label`,{htmlFor:`availabilityZone`,children:`Availability Zone (optional)`})}),e[44]=U):U=e[44];let W;e[45]===_?W=e[46]:(W=e=>{let{field:t}=e;return(0,d.jsxs)(te,{onValueChange:e=>t.onChange(e===`none`?void 0:e),value:t.value??`none`,children:[(0,d.jsx)(ee,{className:`w-full`,id:`availabilityZone`,children:(0,d.jsx)(o,{})}),(0,d.jsxs)(c,{children:[(0,d.jsx)(s,{value:`none`,children:`none`}),_.map(ve)]})]})},e[45]=_,e[46]=W);let G;e[47]!==x||e[48]!==W?(G=(0,d.jsxs)(u,{children:[U,(0,d.jsx)(ue,{control:x,name:`availabilityZone`,render:W})]}),e[47]=x,e[48]=W,e[49]=G):G=e[49];let K;e[50]===Symbol.for(`react.memo_cache_sentinel`)?(K=(0,d.jsx)(l,{children:`Auto-assign public IPv4`}),e[50]=K):K=e[50];let q;e[51]===x?q=e[52]:(q=(0,d.jsxs)(u,{children:[K,(0,d.jsx)(ue,{control:x,name:`mapPublicIpOnLaunch`,render:_e})]}),e[51]=x,e[52]=q);let J;e[53]===E?J=e[54]:(J=be(E),e[53]=E,e[54]=J);let Y;e[55]===J?Y=e[56]:(Y=(0,d.jsx)(ne,{commands:J}),e[55]=J,e[56]=Y);let X;e[57]===t?X=e[58]:(X=async()=>await t({to:`/ec2/describe-subnets`}),e[57]=t,e[58]=X);let Z;e[59]!==p.isPending||e[60]!==we||e[61]!==X?(Z=(0,d.jsx)(fe,{isPending:p.isPending,isSubmitting:we,onCancel:X,pendingLabel:`Creating…`,submitLabel:`Create Subnet`}),e[59]=p.isPending,e[60]=we,e[61]=X,e[62]=Z):Z=e[62];let Q;e[63]!==M||e[64]!==L||e[65]!==H||e[66]!==G||e[67]!==q||e[68]!==Y||e[69]!==Z?(Q=(0,d.jsxs)(`form`,{className:`max-w-4xl space-y-6`,onSubmit:M,children:[L,H,G,q,Y,Z]}),e[63]=M,e[64]=L,e[65]=H,e[66]=G,e[67]=q,e[68]=Y,e[69]=Z,e[70]=Q):Q=e[70];let $;return e[71]!==j||e[72]!==Q?($=(0,d.jsxs)(d.Fragment,{children:[A,k,j,Q]}),e[71]=j,e[72]=Q,e[73]=$):$=e[73],$}function _e(e){let{field:t}=e;return(0,d.jsxs)(`label`,{className:`flex items-center gap-2 text-sm`,children:[(0,d.jsx)(`input`,{"aria-label":`Enable auto-assign public IPv4 address`,checked:t.value??!1,onChange:e=>t.onChange(e.target.checked),type:`checkbox`}),`Enable auto-assign public IPv4 address`]})}function ve(e){return(0,d.jsx)(s,{value:e.ZoneName??``,children:e.ZoneName},e.ZoneName)}function ye(e){let t=i(e.Tags);return(0,d.jsx)(s,{value:e.VpcId??``,children:t?`${e.VpcId} (${t})`:`${e.VpcId} (${e.CidrBlock})`},e.VpcId)}function be(e){let t=e(`vpcId`),n=typeof t==`string`?t:``,r=e(`cidrBlock`),i=typeof r==`string`?r:``,a=e(`availabilityZone`),o=typeof a==`string`?a:``,s=[{type:`bin`,value:`AWS_PROFILE=spinifex aws ec2 create-subnet`},{type:`flag`,value:` \\
+ --vpc-id`},{type:`value`,value:` ${n||``}`},{type:`flag`,value:` \\
+ --cidr-block`},{type:`value`,value:` ${i||``}`}];o&&s.push({type:`flag`,value:` \\
+ --availability-zone`},{type:`value`,value:` ${o}`});let c=[{label:`Create Subnet`,parts:s}];return e(`mapPublicIpOnLaunch`)===!0&&c.push({label:`Enable auto-assign public IPv4`,parts:[{type:`bin`,value:`AWS_PROFILE=spinifex aws ec2 modify-subnet-attribute`},{type:`flag`,value:` \\
--subnet-id`},{type:`value`,value:` `},{type:`flag`,value:` \\
- --map-public-ip-on-launch`}]}),te}export{m as component};
\ No newline at end of file
+ --map-public-ip-on-launch`}]}),c}export{f as component};
\ No newline at end of file
diff --git a/spinifex/services/spinifexui/frontend/dist/assets/create-target-group.js b/spinifex/services/spinifexui/frontend/dist/assets/create-target-group.js
index b3611bdc6..9cec08ea4 100644
--- a/spinifex/services/spinifexui/frontend/dist/assets/create-target-group.js
+++ b/spinifex/services/spinifexui/frontend/dist/assets/create-target-group.js
@@ -1,14 +1,14 @@
-import{t as e}from"./error-banner.js";import{t}from"./useSuspenseQuery.js";import{t as n}from"./back-link.js";import{t as r}from"./cli-command-panel.js";import{i}from"./elbv2.js";import{Ho as a,Ro as o,ht as s}from"./index.js";import{i as c,t as l}from"./zod.js";import{t as u}from"./form-actions.js";import{t as d}from"./page-heading.js";import{t as f}from"./target-group-form.js";import{i as p}from"./elbv22.js";var m=a();function h(){let a=o(),{data:h}=t(s),_=i(),v=h.Vpcs??[],y=c({resolver:l(p),defaultValues:{name:``,protocol:`HTTP`,port:80,vpcId:v[0]?.VpcId??``,healthCheck:{protocol:`HTTP`,path:`/`,port:`traffic-port`,intervalSeconds:30,timeoutSeconds:5,healthyThresholdCount:5,unhealthyThresholdCount:2,matcher:`200`},tags:[]}}),{handleSubmit:b,watch:x,formState:{isSubmitting:S}}=y;return(0,m.jsxs)(m.Fragment,{children:[(0,m.jsx)(n,{to:`/ec2/describe-target-groups`,children:`Back to target groups`}),(0,m.jsx)(d,{title:`Create Target Group`}),_.error&&(0,m.jsx)(e,{error:_.error,msg:`Failed to create target group`}),(0,m.jsxs)(`form`,{className:`max-w-4xl space-y-6`,onSubmit:b(async e=>{try{let t=(await _.mutateAsync(e)).TargetGroups?.[0]?.TargetGroupArn;a(t?{to:`/ec2/describe-target-groups/$id`,params:{id:encodeURIComponent(t)}}:{to:`/ec2/describe-target-groups`})}catch{}}),children:[(0,m.jsx)(f,{form:y,vpcs:v}),(0,m.jsx)(r,{commands:g(x)}),(0,m.jsx)(u,{isPending:_.isPending,isSubmitting:S,onCancel:async()=>await a({to:`/ec2/describe-target-groups`}),pendingLabel:`Creating…`,submitLabel:`Create Target Group`})]})]})}function g(e){let t=t=>{let n=e(t);return typeof n==`string`?n:``},n=t=>{let n=e(t);return typeof n==`number`&&Number.isFinite(n)?n:void 0},r=t(`name`),i=t(`protocol`)||`HTTP`,a=n(`port`),o=t(`vpcId`),s=t(`healthCheck.protocol`)||`HTTP`,c=t(`healthCheck.path`)||`/`,l=t(`healthCheck.port`)||`traffic-port`,u=n(`healthCheck.intervalSeconds`),d=n(`healthCheck.timeoutSeconds`),f=n(`healthCheck.healthyThresholdCount`),p=n(`healthCheck.unhealthyThresholdCount`),m=t(`healthCheck.matcher`)||`200`,h=[{type:`bin`,value:`AWS_PROFILE=spinifex aws elbv2 create-target-group`},{type:`flag`,value:` \\
+import{n as e}from"./compiler-runtime.js";import{t}from"./error-banner.js";import{t as n}from"./useSuspenseQuery.js";import{t as r}from"./back-link.js";import{t as i}from"./cli-command-panel.js";import{i as a}from"./elbv2.js";import{Ir as o,Lt as s}from"./index.js";import{i as c,t as l}from"./zod.js";import{t as u}from"./form-actions.js";import{t as d}from"./page-heading.js";import{t as f}from"./target-group-form.js";import{a as p}from"./elbv22.js";var m=e();function h(){let e=o(),{data:h}=n(s),_=a(),v=h.Vpcs??[],y=c({resolver:l(p),defaultValues:{name:``,protocol:`HTTP`,port:80,vpcId:v[0]?.VpcId??``,healthCheck:{protocol:`HTTP`,path:`/`,port:`traffic-port`,intervalSeconds:30,timeoutSeconds:5,healthyThresholdCount:5,unhealthyThresholdCount:2,matcher:`200`},tags:[]}}),{handleSubmit:b,watch:x,formState:{isSubmitting:S}}=y;return(0,m.jsxs)(m.Fragment,{children:[(0,m.jsx)(r,{to:`/ec2/describe-target-groups`,children:`Back to target groups`}),(0,m.jsx)(d,{title:`Create Target Group`}),_.error&&(0,m.jsx)(t,{error:_.error,msg:`Failed to create target group`}),(0,m.jsxs)(`form`,{className:`max-w-4xl space-y-6`,onSubmit:b(async t=>{try{let n=(await _.mutateAsync(t)).TargetGroups?.[0]?.TargetGroupArn;e(n?{to:`/ec2/describe-target-groups/$id`,params:{id:encodeURIComponent(n)}}:{to:`/ec2/describe-target-groups`})}catch{}}),children:[(0,m.jsx)(f,{form:y,vpcs:v}),(0,m.jsx)(i,{commands:g(x)}),(0,m.jsx)(u,{isPending:_.isPending,isSubmitting:S,onCancel:async()=>await e({to:`/ec2/describe-target-groups`}),pendingLabel:`Creating…`,submitLabel:`Create Target Group`})]})]})}function g(e){let t=t=>{let n=e(t);return typeof n==`string`?n:``},n=t=>{let n=e(t);return typeof n==`number`&&Number.isFinite(n)?n:void 0},r=t(`name`),i=t(`protocol`)||`HTTP`,a=n(`port`),o=t(`vpcId`),s=t(`healthCheck.protocol`)||`HTTP`,c=t(`healthCheck.path`)||`/`,l=t(`healthCheck.port`)||`traffic-port`,u=n(`healthCheck.intervalSeconds`),d=n(`healthCheck.timeoutSeconds`),f=n(`healthCheck.healthyThresholdCount`),p=n(`healthCheck.unhealthyThresholdCount`),m=t(`healthCheck.matcher`)||`200`,h=[{type:`bin`,value:`AWS_PROFILE=spinifex aws elbv2 create-target-group`},{type:`flag`,value:` \\
--name`},{type:`value`,value:` ${r||``}`},{type:`flag`,value:` \\
--protocol`},{type:`value`,value:` ${i}`},{type:`flag`,value:` \\
--port`},{type:`value`,value:` ${a??80}`},{type:`flag`,value:` \\
- --target-type`},{type:`value`,value:` instance`}];return o&&h.push({type:`flag`,value:` \\
- --vpc-id`},{type:`value`,value:` ${o}`}),h.push({type:`flag`,value:` \\
- --health-check-protocol`},{type:`value`,value:` ${s}`},{type:`flag`,value:` \\
- --health-check-path`},{type:`value`,value:` ${c}`},{type:`flag`,value:` \\
+ --target-type`},{type:`value`,value:` instance`}];o&&h.push({type:`flag`,value:` \\
+ --vpc-id`},{type:`value`,value:` ${o}`});let g=s===`HTTP`;return h.push({type:`flag`,value:` \\
+ --health-check-protocol`},{type:`value`,value:` ${s}`}),g&&h.push({type:`flag`,value:` \\
+ --health-check-path`},{type:`value`,value:` ${c}`}),h.push({type:`flag`,value:` \\
--health-check-port`},{type:`value`,value:` ${l}`},{type:`flag`,value:` \\
--health-check-interval-seconds`},{type:`value`,value:` ${u??30}`},{type:`flag`,value:` \\
--health-check-timeout-seconds`},{type:`value`,value:` ${d??5}`},{type:`flag`,value:` \\
--healthy-threshold-count`},{type:`value`,value:` ${f??5}`},{type:`flag`,value:` \\
- --unhealthy-threshold-count`},{type:`value`,value:` ${p??2}`},{type:`flag`,value:` \\
+ --unhealthy-threshold-count`},{type:`value`,value:` ${p??2}`}),g&&h.push({type:`flag`,value:` \\
--matcher`},{type:`value`,value:` HttpCode=${m}`}),[{label:`Create Target Group`,parts:h}]}var _=h;export{_ as component};
\ No newline at end of file
diff --git a/spinifex/services/spinifexui/frontend/dist/assets/create-user.js b/spinifex/services/spinifexui/frontend/dist/assets/create-user.js
index 17929f61c..c247d0468 100644
--- a/spinifex/services/spinifexui/frontend/dist/assets/create-user.js
+++ b/spinifex/services/spinifexui/frontend/dist/assets/create-user.js
@@ -1 +1 @@
-import{t as e}from"./error-banner.js";import{t}from"./back-link.js";import{t as n}from"./cli-command-panel.js";import{i as r}from"./iam.js";import{Fo as i,Ho as a,Ro as o,Xi as s}from"./index.js";import{i as c,t as l}from"./zod.js";import{t as u}from"./form-actions.js";import{t as d}from"./page-heading.js";import{a as f,r as p,t as m}from"./field.js";import{n as h}from"./iam2.js";var g=i(),_=a();function v(){let i=(0,g.c)(51),a=o(),v=r(),b;i[0]===Symbol.for(`react.memo_cache_sentinel`)?(b={resolver:l(h)},i[0]=b):b=i[0];let{handleSubmit:x,register:S,watch:C,formState:w}=c(b),{errors:T,isSubmitting:E}=w,D;i[1]!==v||i[2]!==a?(D=async e=>{await v.mutateAsync(e),a({to:`/iam/list-users`})},i[1]=v,i[2]=a,i[3]=D):D=i[3];let O=D,k,A;i[4]===Symbol.for(`react.memo_cache_sentinel`)?(k=(0,_.jsx)(t,{to:`/iam/list-users`,children:`Back to users`}),A=(0,_.jsx)(d,{title:`Create User`}),i[4]=k,i[5]=A):(k=i[4],A=i[5]);let j;i[6]===v.error?j=i[7]:(j=v.error&&(0,_.jsx)(e,{error:v.error,msg:`Failed to create user`}),i[6]=v.error,i[7]=j);let M;i[8]!==x||i[9]!==O?(M=x(O),i[8]=x,i[9]=O,i[10]=M):M=i[10];let N;i[11]===Symbol.for(`react.memo_cache_sentinel`)?(N=(0,_.jsx)(f,{children:(0,_.jsx)(`label`,{htmlFor:`userName`,children:`User Name`})}),i[11]=N):N=i[11];let P=!!T.userName,F;i[12]===S?F=i[13]:(F=S(`userName`),i[12]=S,i[13]=F);let I;i[14]!==P||i[15]!==F?(I=(0,_.jsx)(s,{"aria-invalid":P,id:`userName`,placeholder:`my-user...`,...F}),i[14]=P,i[15]=F,i[16]=I):I=i[16];let L;i[17]===T.userName?L=i[18]:(L=(0,_.jsx)(p,{errors:[T.userName]}),i[17]=T.userName,i[18]=L);let R;i[19]!==I||i[20]!==L?(R=(0,_.jsxs)(m,{children:[N,I,L]}),i[19]=I,i[20]=L,i[21]=R):R=i[21];let z;i[22]===Symbol.for(`react.memo_cache_sentinel`)?(z=(0,_.jsx)(f,{children:(0,_.jsx)(`label`,{htmlFor:`path`,children:`Path (optional)`})}),i[22]=z):z=i[22];let B;i[23]===S?B=i[24]:(B=S(`path`),i[23]=S,i[24]=B);let V;i[25]===B?V=i[26]:(V=(0,_.jsx)(s,{id:`path`,placeholder:`/`,...B}),i[25]=B,i[26]=V);let H;i[27]===T.path?H=i[28]:(H=(0,_.jsx)(p,{errors:[T.path]}),i[27]=T.path,i[28]=H);let U;i[29]!==V||i[30]!==H?(U=(0,_.jsxs)(m,{children:[z,V,H]}),i[29]=V,i[30]=H,i[31]=U):U=i[31];let W;i[32]===C?W=i[33]:(W=y(C),i[32]=C,i[33]=W);let G;i[34]===W?G=i[35]:(G=(0,_.jsx)(n,{commands:W}),i[34]=W,i[35]=G);let K;i[36]===a?K=i[37]:(K=async()=>await a({to:`/iam/list-users`}),i[36]=a,i[37]=K);let q;i[38]!==v.isPending||i[39]!==E||i[40]!==K?(q=(0,_.jsx)(u,{isPending:v.isPending,isSubmitting:E,onCancel:K,pendingLabel:`Creating...`,submitLabel:`Create User`}),i[38]=v.isPending,i[39]=E,i[40]=K,i[41]=q):q=i[41];let J;i[42]!==R||i[43]!==U||i[44]!==G||i[45]!==q||i[46]!==M?(J=(0,_.jsxs)(`form`,{className:`max-w-4xl space-y-6`,onSubmit:M,children:[R,U,G,q]}),i[42]=R,i[43]=U,i[44]=G,i[45]=q,i[46]=M,i[47]=J):J=i[47];let Y;return i[48]!==J||i[49]!==j?(Y=(0,_.jsxs)(_.Fragment,{children:[k,A,j,J]}),i[48]=J,i[49]=j,i[50]=Y):Y=i[50],Y}function y(e){let t=e(`userName`),n=typeof t==`string`?t:``,r=e(`path`),i=typeof r==`string`?r:``,a=[{type:`bin`,value:`AWS_PROFILE=spinifex aws iam create-user`},{type:`flag`,value:` --user-name`},{type:`value`,value:` ${n||``}`}];return i&&i!==`/`&&a.push({type:`flag`,value:` --path`},{type:`value`,value:` ${i}`}),[{label:`Create User`,parts:a}]}export{v as component};
\ No newline at end of file
+import{n as e,t}from"./compiler-runtime.js";import{t as n}from"./error-banner.js";import{t as r}from"./back-link.js";import{t as i}from"./cli-command-panel.js";import{c as a}from"./iam.js";import{Ir as o,Rn as s}from"./index.js";import{a as c,i as l,t as u}from"./zod.js";import{t as d}from"./form-actions.js";import{t as f}from"./page-heading.js";import{a as p,r as m,t as h}from"./field.js";import{a as g}from"./iam2.js";var _=t(),v=e();function y(){let e=(0,_.c)(55),t=o(),y=a(),b;e[0]===Symbol.for(`react.memo_cache_sentinel`)?(b={resolver:u(g)},e[0]=b):b=e[0];let{handleSubmit:x,register:S,control:C,formState:w}=l(b),{errors:T,isSubmitting:E}=w,D;e[1]===C?D=e[2]:(D={control:C},e[1]=C,e[2]=D);let O=c(D),k;e[3]===O?k=e[4]:(k=e=>e?O[e]:void 0,e[3]=O,e[4]=k);let A=k,j;e[5]!==y||e[6]!==t?(j=async e=>{await y.mutateAsync(e),t({to:`/iam/list-users`})},e[5]=y,e[6]=t,e[7]=j):j=e[7];let M=j,N,P;e[8]===Symbol.for(`react.memo_cache_sentinel`)?(N=(0,v.jsx)(r,{to:`/iam/list-users`,children:`Back to users`}),P=(0,v.jsx)(f,{title:`Create User`}),e[8]=N,e[9]=P):(N=e[8],P=e[9]);let F;e[10]===y.error?F=e[11]:(F=y.error&&(0,v.jsx)(n,{error:y.error,msg:`Failed to create user`}),e[10]=y.error,e[11]=F);let I;e[12]!==x||e[13]!==M?(I=x(M),e[12]=x,e[13]=M,e[14]=I):I=e[14];let L;e[15]===Symbol.for(`react.memo_cache_sentinel`)?(L=(0,v.jsx)(p,{children:(0,v.jsx)(`label`,{htmlFor:`userName`,children:`User Name`})}),e[15]=L):L=e[15];let R=!!T.userName,z;e[16]===S?z=e[17]:(z=S(`userName`),e[16]=S,e[17]=z);let B;e[18]!==R||e[19]!==z?(B=(0,v.jsx)(s,{"aria-invalid":R,id:`userName`,placeholder:`my-user...`,...z}),e[18]=R,e[19]=z,e[20]=B):B=e[20];let V;e[21]===T.userName?V=e[22]:(V=(0,v.jsx)(m,{errors:[T.userName]}),e[21]=T.userName,e[22]=V);let H;e[23]!==B||e[24]!==V?(H=(0,v.jsxs)(h,{children:[L,B,V]}),e[23]=B,e[24]=V,e[25]=H):H=e[25];let U;e[26]===Symbol.for(`react.memo_cache_sentinel`)?(U=(0,v.jsx)(p,{children:(0,v.jsx)(`label`,{htmlFor:`path`,children:`Path (optional)`})}),e[26]=U):U=e[26];let W;e[27]===S?W=e[28]:(W=S(`path`),e[27]=S,e[28]=W);let G;e[29]===W?G=e[30]:(G=(0,v.jsx)(s,{id:`path`,placeholder:`/`,...W}),e[29]=W,e[30]=G);let K;e[31]===T.path?K=e[32]:(K=(0,v.jsx)(m,{errors:[T.path]}),e[31]=T.path,e[32]=K);let q;e[33]!==G||e[34]!==K?(q=(0,v.jsxs)(h,{children:[U,G,K]}),e[33]=G,e[34]=K,e[35]=q):q=e[35];let J;e[36]===A?J=e[37]:(J=ee(A),e[36]=A,e[37]=J);let Y;e[38]===J?Y=e[39]:(Y=(0,v.jsx)(i,{commands:J}),e[38]=J,e[39]=Y);let X;e[40]===t?X=e[41]:(X=async()=>await t({to:`/iam/list-users`}),e[40]=t,e[41]=X);let Z;e[42]!==y.isPending||e[43]!==E||e[44]!==X?(Z=(0,v.jsx)(d,{isPending:y.isPending,isSubmitting:E,onCancel:X,pendingLabel:`Creating...`,submitLabel:`Create User`}),e[42]=y.isPending,e[43]=E,e[44]=X,e[45]=Z):Z=e[45];let Q;e[46]!==H||e[47]!==q||e[48]!==Y||e[49]!==Z||e[50]!==I?(Q=(0,v.jsxs)(`form`,{className:`max-w-4xl space-y-6`,onSubmit:I,children:[H,q,Y,Z]}),e[46]=H,e[47]=q,e[48]=Y,e[49]=Z,e[50]=I,e[51]=Q):Q=e[51];let $;return e[52]!==Q||e[53]!==F?($=(0,v.jsxs)(v.Fragment,{children:[N,P,F,Q]}),e[52]=Q,e[53]=F,e[54]=$):$=e[54],$}function ee(e){let t=e(`userName`),n=typeof t==`string`?t:``,r=e(`path`),i=typeof r==`string`?r:``,a=[{type:`bin`,value:`AWS_PROFILE=spinifex aws iam create-user`},{type:`flag`,value:` --user-name`},{type:`value`,value:` ${n||``}`}];return i&&i!==`/`&&a.push({type:`flag`,value:` --path`},{type:`value`,value:` ${i}`}),[{label:`Create User`,parts:a}]}export{y as component};
\ No newline at end of file
diff --git a/spinifex/services/spinifexui/frontend/dist/assets/create-volume.js b/spinifex/services/spinifexui/frontend/dist/assets/create-volume.js
index 10bab65e6..c500787f9 100644
--- a/spinifex/services/spinifexui/frontend/dist/assets/create-volume.js
+++ b/spinifex/services/spinifexui/frontend/dist/assets/create-volume.js
@@ -1,3 +1,3 @@
-import{t as e}from"./error-banner.js";import{t}from"./useSuspenseQuery.js";import{t as n}from"./back-link.js";import{c as r,i,n as ee,s as a,t as te}from"./select.js";import{t as o}from"./cli-command-panel.js";import{b as ne}from"./ec2.js";import{Fo as s,Ho as c,Ro as re,U as ie,Xi as l}from"./index.js";import{i as u,n as d,t as f}from"./zod.js";import{t as p}from"./form-actions.js";import{t as ae}from"./page-heading.js";import{a as m,r as h,t as g}from"./field.js";import{m as oe}from"./ec22.js";var se=s(),_=c();function v(){let i=(0,se.c)(57),s=re(),{data:c}=t(ie),v=ne(),y;i[0]===c.AvailabilityZones?y=i[1]:(y=c.AvailabilityZones??[],i[0]=c.AvailabilityZones,i[1]=y);let b=y,x=b[0]?.ZoneName??``,S;i[2]===Symbol.for(`react.memo_cache_sentinel`)?(S=f(oe),i[2]=S):S=i[2];let C;i[3]===x?C=i[4]:(C={resolver:S,defaultValues:{size:1,availabilityZone:x}},i[3]=x,i[4]=C);let{control:w,handleSubmit:T,register:E,watch:D,formState:O}=u(C),{errors:k,isSubmitting:A}=O,j;i[5]!==v||i[6]!==s?(j=async e=>{await v.mutateAsync(e),s({to:`/ec2/describe-volumes`})},i[5]=v,i[6]=s,i[7]=j):j=i[7];let M=j,N,P;i[8]===Symbol.for(`react.memo_cache_sentinel`)?(N=(0,_.jsx)(n,{to:`/ec2/describe-volumes`,children:`Back to volumes`}),P=(0,_.jsx)(ae,{title:`Create Volume`}),i[8]=N,i[9]=P):(N=i[8],P=i[9]);let F;i[10]===v.error?F=i[11]:(F=v.error&&(0,_.jsx)(e,{error:v.error,msg:`Failed to create volume`}),i[10]=v.error,i[11]=F);let I;i[12]!==T||i[13]!==M?(I=T(M),i[12]=T,i[13]=M,i[14]=I):I=i[14];let L;i[15]===Symbol.for(`react.memo_cache_sentinel`)?(L=(0,_.jsx)(m,{children:(0,_.jsx)(`label`,{htmlFor:`size`,children:`Size (GiB)`})}),i[15]=L):L=i[15];let R=!!k.size,z;i[16]===E?z=i[17]:(z=E(`size`,{valueAsNumber:!0}),i[16]=E,i[17]=z);let B;i[18]!==R||i[19]!==z?(B=(0,_.jsx)(l,{"aria-invalid":R,id:`size`,max:16384,min:1,type:`number`,...z}),i[18]=R,i[19]=z,i[20]=B):B=i[20];let V;i[21]===k.size?V=i[22]:(V=(0,_.jsx)(h,{errors:[k.size]}),i[21]=k.size,i[22]=V);let H;i[23]!==B||i[24]!==V?(H=(0,_.jsxs)(g,{children:[L,B,V]}),i[23]=B,i[24]=V,i[25]=H):H=i[25];let U;i[26]===Symbol.for(`react.memo_cache_sentinel`)?(U=(0,_.jsx)(m,{children:(0,_.jsx)(`label`,{htmlFor:`availabilityZone`,children:`Availability Zone`})}),i[26]=U):U=i[26];let W;i[27]!==b||i[28]!==k.availabilityZone?(W=e=>{let{field:t}=e;return(0,_.jsxs)(te,{onValueChange:e=>t.onChange(e),value:t.value??``,children:[(0,_.jsx)(a,{"aria-invalid":!!k.availabilityZone,className:`w-full`,id:`availabilityZone`,children:(0,_.jsx)(r,{})}),(0,_.jsx)(ee,{children:b.map(ce)})]})},i[27]=b,i[28]=k.availabilityZone,i[29]=W):W=i[29];let G;i[30]!==w||i[31]!==W?(G=(0,_.jsx)(d,{control:w,name:`availabilityZone`,render:W}),i[30]=w,i[31]=W,i[32]=G):G=i[32];let K;i[33]===k.availabilityZone?K=i[34]:(K=(0,_.jsx)(h,{errors:[k.availabilityZone]}),i[33]=k.availabilityZone,i[34]=K);let q;i[35]!==G||i[36]!==K?(q=(0,_.jsxs)(g,{children:[U,G,K]}),i[35]=G,i[36]=K,i[37]=q):q=i[37];let J;i[38]===D?J=i[39]:(J=le(D),i[38]=D,i[39]=J);let Y;i[40]===J?Y=i[41]:(Y=(0,_.jsx)(o,{commands:J}),i[40]=J,i[41]=Y);let X;i[42]===s?X=i[43]:(X=async()=>await s({to:`/ec2/describe-volumes`}),i[42]=s,i[43]=X);let Z;i[44]!==v.isPending||i[45]!==A||i[46]!==X?(Z=(0,_.jsx)(p,{isPending:v.isPending,isSubmitting:A,onCancel:X,pendingLabel:`Creating…`,submitLabel:`Create Volume`}),i[44]=v.isPending,i[45]=A,i[46]=X,i[47]=Z):Z=i[47];let Q;i[48]!==H||i[49]!==q||i[50]!==Y||i[51]!==Z||i[52]!==I?(Q=(0,_.jsxs)(`form`,{className:`max-w-4xl space-y-6`,onSubmit:I,children:[H,q,Y,Z]}),i[48]=H,i[49]=q,i[50]=Y,i[51]=Z,i[52]=I,i[53]=Q):Q=i[53];let $;return i[54]!==Q||i[55]!==F?($=(0,_.jsxs)(_.Fragment,{children:[N,P,F,Q]}),i[54]=Q,i[55]=F,i[56]=$):$=i[56],$}function ce(e){return(0,_.jsx)(i,{value:e.ZoneName??``,children:e.ZoneName},e.ZoneName)}function le(e){let t=e(`size`),n=typeof t==`number`?t:0,r=e(`availabilityZone`),i=typeof r==`string`?r:``;return[{label:`Create Volume`,parts:[{type:`bin`,value:`AWS_PROFILE=spinifex aws ec2 create-volume`},{type:`flag`,value:` \\
+import{n as e,t}from"./compiler-runtime.js";import{t as n}from"./error-banner.js";import{t as r}from"./useSuspenseQuery.js";import{t as i}from"./back-link.js";import{c as a,i as o,n as s,s as c,t as l}from"./select.js";import{t as u}from"./cli-command-panel.js";import{x as ee}from"./ec2.js";import{Ir as te,Rn as ne,ut as re}from"./index.js";import{i as ie,n as ae,t as oe}from"./zod.js";import{t as se}from"./form-actions.js";import{t as ce}from"./page-heading.js";import{a as d,r as f,t as p}from"./field.js";import{m}from"./ec22.js";var h=t(),g=e();function _(){let e=(0,h.c)(57),t=te(),{data:o}=r(re),_=ee(),y;e[0]===o.AvailabilityZones?y=e[1]:(y=o.AvailabilityZones??[],e[0]=o.AvailabilityZones,e[1]=y);let b=y,x=b[0]?.ZoneName??``,S;e[2]===Symbol.for(`react.memo_cache_sentinel`)?(S=oe(m),e[2]=S):S=e[2];let C;e[3]===x?C=e[4]:(C={resolver:S,defaultValues:{size:1,availabilityZone:x}},e[3]=x,e[4]=C);let{control:w,handleSubmit:T,register:E,watch:D,formState:O}=ie(C),{errors:k,isSubmitting:A}=O,j;e[5]!==_||e[6]!==t?(j=async e=>{await _.mutateAsync(e),t({to:`/ec2/describe-volumes`})},e[5]=_,e[6]=t,e[7]=j):j=e[7];let M=j,N,P;e[8]===Symbol.for(`react.memo_cache_sentinel`)?(N=(0,g.jsx)(i,{to:`/ec2/describe-volumes`,children:`Back to volumes`}),P=(0,g.jsx)(ce,{title:`Create Volume`}),e[8]=N,e[9]=P):(N=e[8],P=e[9]);let F;e[10]===_.error?F=e[11]:(F=_.error&&(0,g.jsx)(n,{error:_.error,msg:`Failed to create volume`}),e[10]=_.error,e[11]=F);let I;e[12]!==T||e[13]!==M?(I=T(M),e[12]=T,e[13]=M,e[14]=I):I=e[14];let L;e[15]===Symbol.for(`react.memo_cache_sentinel`)?(L=(0,g.jsx)(d,{children:(0,g.jsx)(`label`,{htmlFor:`size`,children:`Size (GiB)`})}),e[15]=L):L=e[15];let R=!!k.size,z;e[16]===E?z=e[17]:(z=E(`size`,{valueAsNumber:!0}),e[16]=E,e[17]=z);let B;e[18]!==R||e[19]!==z?(B=(0,g.jsx)(ne,{"aria-invalid":R,id:`size`,max:16384,min:1,type:`number`,...z}),e[18]=R,e[19]=z,e[20]=B):B=e[20];let V;e[21]===k.size?V=e[22]:(V=(0,g.jsx)(f,{errors:[k.size]}),e[21]=k.size,e[22]=V);let H;e[23]!==B||e[24]!==V?(H=(0,g.jsxs)(p,{children:[L,B,V]}),e[23]=B,e[24]=V,e[25]=H):H=e[25];let U;e[26]===Symbol.for(`react.memo_cache_sentinel`)?(U=(0,g.jsx)(d,{children:(0,g.jsx)(`label`,{htmlFor:`availabilityZone`,children:`Availability Zone`})}),e[26]=U):U=e[26];let W;e[27]!==b||e[28]!==k.availabilityZone?(W=e=>{let{field:t}=e;return(0,g.jsxs)(l,{onValueChange:e=>t.onChange(e),value:t.value??``,children:[(0,g.jsx)(c,{"aria-invalid":!!k.availabilityZone,className:`w-full`,id:`availabilityZone`,children:(0,g.jsx)(a,{})}),(0,g.jsx)(s,{children:b.map(le)})]})},e[27]=b,e[28]=k.availabilityZone,e[29]=W):W=e[29];let G;e[30]!==w||e[31]!==W?(G=(0,g.jsx)(ae,{control:w,name:`availabilityZone`,render:W}),e[30]=w,e[31]=W,e[32]=G):G=e[32];let K;e[33]===k.availabilityZone?K=e[34]:(K=(0,g.jsx)(f,{errors:[k.availabilityZone]}),e[33]=k.availabilityZone,e[34]=K);let q;e[35]!==G||e[36]!==K?(q=(0,g.jsxs)(p,{children:[U,G,K]}),e[35]=G,e[36]=K,e[37]=q):q=e[37];let J;e[38]===D?J=e[39]:(J=v(D),e[38]=D,e[39]=J);let Y;e[40]===J?Y=e[41]:(Y=(0,g.jsx)(u,{commands:J}),e[40]=J,e[41]=Y);let X;e[42]===t?X=e[43]:(X=async()=>await t({to:`/ec2/describe-volumes`}),e[42]=t,e[43]=X);let Z;e[44]!==_.isPending||e[45]!==A||e[46]!==X?(Z=(0,g.jsx)(se,{isPending:_.isPending,isSubmitting:A,onCancel:X,pendingLabel:`Creating…`,submitLabel:`Create Volume`}),e[44]=_.isPending,e[45]=A,e[46]=X,e[47]=Z):Z=e[47];let Q;e[48]!==H||e[49]!==q||e[50]!==Y||e[51]!==Z||e[52]!==I?(Q=(0,g.jsxs)(`form`,{className:`max-w-4xl space-y-6`,onSubmit:I,children:[H,q,Y,Z]}),e[48]=H,e[49]=q,e[50]=Y,e[51]=Z,e[52]=I,e[53]=Q):Q=e[53];let $;return e[54]!==Q||e[55]!==F?($=(0,g.jsxs)(g.Fragment,{children:[N,P,F,Q]}),e[54]=Q,e[55]=F,e[56]=$):$=e[56],$}function le(e){return(0,g.jsx)(o,{value:e.ZoneName??``,children:e.ZoneName},e.ZoneName)}function v(e){let t=e(`size`),n=typeof t==`number`?t:0,r=e(`availabilityZone`),i=typeof r==`string`?r:``;return[{label:`Create Volume`,parts:[{type:`bin`,value:`AWS_PROFILE=spinifex aws ec2 create-volume`},{type:`flag`,value:` \\
--size`},{type:`value`,value:` ${n||1}`},{type:`flag`,value:` \\
- --availability-zone`},{type:`value`,value:` ${i||``}`}]}]}export{v as component};
\ No newline at end of file
+ --availability-zone`},{type:`value`,value:` ${i||``}`}]}]}export{_ as component};
\ No newline at end of file
diff --git a/spinifex/services/spinifexui/frontend/dist/assets/create-vpc.js b/spinifex/services/spinifexui/frontend/dist/assets/create-vpc.js
index 6fd562bcd..fa4cc4db3 100644
--- a/spinifex/services/spinifexui/frontend/dist/assets/create-vpc.js
+++ b/spinifex/services/spinifexui/frontend/dist/assets/create-vpc.js
@@ -1,5 +1,5 @@
-import{t as e}from"./error-banner.js";import{t}from"./back-link.js";import{t as n}from"./chevron-down.js";import{t as r}from"./chevron-right.js";import{c as i,i as a,n as o,s,t as c}from"./select.js";import{t as l}from"./cli-command-panel.js";import{S as u,Y as d,x as f}from"./ec2.js";import{Fo as p,Ho as m,Ro as h,Uo as g,Xa as _,Xi as v,cs as y}from"./index.js";import{i as b,n as x,t as S}from"./zod.js";import{t as C}from"./form-actions.js";import{t as w}from"./page-heading.js";import{a as T,r as E,t as D}from"./field.js";import{h as O}from"./ec22.js";var k=p(),A=y(g(),1),j=m();function M(e){let t=(0,k.c)(18),{form:i,type:a,count:o,defaults:s}=e,[c,l]=(0,A.useState)(!1),u=a===`public`?`publicSubnetCidrs`:`privateSubnetCidrs`;if(o===0)return null;let d;t[0]===c?d=t[1]:(d=()=>l(!c),t[0]=c,t[1]=d);let f;t[2]===c?f=t[3]:(f=c?(0,j.jsx)(n,{className:`size-3`}):(0,j.jsx)(r,{className:`size-3`}),t[2]=c,t[3]=f);let p;t[4]!==d||t[5]!==f||t[6]!==a?(p=(0,j.jsxs)(_,{className:`gap-1 px-0 text-xs text-muted-foreground`,onClick:d,type:`button`,variant:`link`,children:[f,`Customize `,a,` subnet CIDR blocks`]}),t[4]=d,t[5]=f,t[6]=a,t[7]=p):p=t[7];let m;t[8]!==o||t[9]!==s||t[10]!==c||t[11]!==u||t[12]!==i||t[13]!==a?(m=c&&(0,j.jsx)(`div`,{className:`mt-2 space-y-2`,children:Array.from({length:o},(e,t)=>(0,j.jsxs)(`div`,{className:`flex items-center gap-2`,children:[(0,j.jsxs)(`span`,{className:`w-32 text-xs text-muted-foreground`,children:[a===`public`?`Public`:`Private`,` subnet `,t+1]}),(0,j.jsx)(v,{defaultValue:s[t],placeholder:s[t],...i.register(`${u}.${t}`)})]},t))}),t[8]=o,t[9]=s,t[10]=c,t[11]=u,t[12]=i,t[13]=a,t[14]=m):m=t[14];let h;return t[15]!==p||t[16]!==m?(h=(0,j.jsxs)(`div`,{className:`mt-2`,children:[p,m]}),t[15]=p,t[16]=m,t[17]=h):h=t[17],h}function N(e){let t=(0,k.c)(8),{label:n,cidr:r,variant:i}=e,a=`rounded-md border px-3 py-2 text-xs ${i===`public`?`border-emerald-500/30 bg-emerald-500/10`:`border-blue-500/30 bg-blue-500/10`}`,o;t[0]===n?o=t[1]:(o=(0,j.jsx)(`div`,{className:`font-medium`,children:n}),t[0]=n,t[1]=o);let s;t[2]===r?s=t[3]:(s=(0,j.jsx)(`div`,{className:`text-muted-foreground`,children:r}),t[2]=r,t[3]=s);let c;return t[4]!==a||t[5]!==o||t[6]!==s?(c=(0,j.jsxs)(`div`,{className:a,children:[o,s]}),t[4]=a,t[5]=o,t[6]=s,t[7]=c):c=t[7],c}function P(e){let t=(0,k.c)(14),{mode:n,vpcCidr:r,publicSubnets:i,privateSubnets:a,hasIgw:o}=e,s;t[0]===Symbol.for(`react.memo_cache_sentinel`)?(s=(0,j.jsx)(`h3`,{className:`mb-3 text-xs font-semibold tracking-wider text-muted-foreground uppercase`,children:`Preview`}),t[0]=s):s=t[0];let c;t[1]===r?c=t[2]:(c=(0,j.jsxs)(`div`,{className:`mb-3 text-xs font-medium`,children:[`VPC `,(0,j.jsxs)(`span`,{className:`text-muted-foreground`,children:[`(`,r,`)`]})]}),t[1]=r,t[2]=c);let l;t[3]!==o||t[4]!==n||t[5]!==a||t[6]!==i?(l=n===`vpc-and-more`&&(0,j.jsxs)(`div`,{className:`grid gap-4 md:grid-cols-3`,children:[(0,j.jsxs)(`div`,{className:`space-y-2`,children:[(0,j.jsx)(`div`,{className:`text-xs font-medium text-muted-foreground`,children:`Subnets`}),i.map(I),a.map(F),i.length===0&&a.length===0&&(0,j.jsx)(`div`,{className:`text-xs text-muted-foreground`,children:`No subnets`})]}),(0,j.jsxs)(`div`,{className:`space-y-2`,children:[(0,j.jsx)(`div`,{className:`text-xs font-medium text-muted-foreground`,children:`Route Tables`}),i.length>0&&(0,j.jsxs)(`div`,{className:`rounded-md border border-foreground/10 bg-background px-3 py-2 text-xs`,children:[(0,j.jsx)(`div`,{className:`font-medium`,children:`Public Route Table`}),(0,j.jsx)(`div`,{className:`mt-1 text-muted-foreground`,children:`0.0.0.0/0 → IGW`}),(0,j.jsx)(`div`,{className:`text-muted-foreground`,children:`local`})]}),a.length>0&&(0,j.jsxs)(`div`,{className:`rounded-md border border-foreground/10 bg-background px-3 py-2 text-xs`,children:[(0,j.jsx)(`div`,{className:`font-medium`,children:`Private Route Table`}),(0,j.jsx)(`div`,{className:`mt-1 text-muted-foreground`,children:`local`})]})]}),(0,j.jsxs)(`div`,{className:`space-y-2`,children:[(0,j.jsx)(`div`,{className:`text-xs font-medium text-muted-foreground`,children:`Network Connections`}),o&&(0,j.jsxs)(`div`,{className:`rounded-md border border-amber-500/30 bg-amber-500/10 px-3 py-2 text-xs`,children:[(0,j.jsx)(`div`,{className:`font-medium`,children:`Internet Gateway`}),(0,j.jsx)(`div`,{className:`text-muted-foreground`,children:`Attached to VPC`})]}),!o&&(0,j.jsx)(`div`,{className:`text-xs text-muted-foreground`,children:`No internet gateway`})]})]}),t[3]=o,t[4]=n,t[5]=a,t[6]=i,t[7]=l):l=t[7];let u;t[8]===n?u=t[9]:(u=n===`vpc-only`&&(0,j.jsx)(`div`,{className:`text-xs text-muted-foreground`,children:`VPC only — no additional resources will be created.`}),t[8]=n,t[9]=u);let d;return t[10]!==c||t[11]!==l||t[12]!==u?(d=(0,j.jsxs)(`div`,{className:`rounded-lg border bg-muted/30 p-4`,children:[s,(0,j.jsxs)(`div`,{className:`rounded-lg border-2 border-dashed border-foreground/20 p-4`,children:[c,l,u]})]}),t[10]=c,t[11]=l,t[12]=u,t[13]=d):d=t[13],d}function F(e){return(0,j.jsx)(N,{cidr:e.cidr,label:e.label,variant:`private`},e.cidr)}function I(e){return(0,j.jsx)(N,{cidr:e.cidr,label:e.label,variant:`public`},e.cidr)}var L=[0,1],R=[0,1,2];function z(){let n=h(),r=f(),p=u(),[m,g]=(0,A.useState)(null),y=b({resolver:S(O),defaultValues:{mode:`vpc-only`,namePrefix:``,autoGenerateNames:!0,cidrBlock:`10.0.0.0/16`,tenancy:`default`,natGateway:`none`,publicSubnetCount:1,privateSubnetCount:1,publicSubnetCidrs:[],privateSubnetCidrs:[],tags:[]}}),{handleSubmit:k,register:N,watch:F,control:I,formState:{errors:z,isSubmitting:V}}=y,H=F(`mode`),U=F(`cidrBlock`),W=F(`publicSubnetCount`),G=F(`privateSubnetCount`),K=d(U||`10.0.0.0/16`,H===`vpc-and-more`?W:0,H===`vpc-and-more`?G:0),[q,J]=(0,A.useState)(``),Y=async e=>{if(g(null),e.mode===`vpc-only`){let t=e.autoGenerateNames&&e.namePrefix?`${e.namePrefix}-vpc`:e.namePrefix,i=(await r.mutateAsync({cidrBlock:e.cidrBlock,name:t||void 0})).Vpc?.VpcId;n(i?{to:`/ec2/describe-vpcs/$id`,params:{id:i}}:{to:`/ec2/describe-vpcs`});return}J(`Creating VPC and resources...`);let t=await p.mutateAsync(e);g(t),!t.error&&t.vpcId&&n({to:`/ec2/describe-vpcs/$id`,params:{id:t.vpcId}}),J(``)},X=r.error??p.error,Z=r.isPending||p.isPending;return(0,j.jsxs)(j.Fragment,{children:[(0,j.jsx)(t,{to:`/ec2/describe-vpcs`,children:`Back to VPCs`}),(0,j.jsx)(w,{title:`Create VPC`}),X&&(0,j.jsx)(e,{error:X,msg:`Failed to create VPC`}),m?.error&&(0,j.jsxs)(`div`,{className:`mb-6 max-w-4xl rounded-md border border-destructive bg-destructive/10 p-4`,children:[(0,j.jsxs)(`h2`,{className:`text-sm font-medium text-destructive`,children:[`Wizard failed: `,m.failedStep]}),(0,j.jsx)(`p`,{className:`mt-1 text-sm text-destructive`,children:m.error.message}),m.created.length>0&&(0,j.jsxs)(`div`,{className:`mt-3`,children:[(0,j.jsx)(`p`,{className:`text-xs font-medium text-destructive`,children:`Successfully created resources:`}),(0,j.jsx)(`ul`,{className:`mt-1 list-inside list-disc text-xs text-destructive`,children:m.created.map((e,t)=>(0,j.jsxs)(`li`,{children:[e.type,`: `,e.id]},t))})]})]}),q&&(0,j.jsx)(`div`,{className:`mb-4 max-w-4xl text-sm text-muted-foreground`,children:q}),(0,j.jsxs)(`div`,{className:`flex max-w-6xl gap-8`,children:[(0,j.jsxs)(`form`,{className:`min-w-0 flex-1 space-y-6`,onSubmit:k(Y),children:[(0,j.jsxs)(D,{children:[(0,j.jsx)(T,{children:`Resources to create`}),(0,j.jsx)(x,{control:I,name:`mode`,render:({field:e})=>(0,j.jsxs)(`div`,{className:`flex gap-4`,children:[(0,j.jsxs)(`label`,{className:`flex items-center gap-2 text-xs`,children:[(0,j.jsx)(`input`,{"aria-label":`VPC only`,checked:e.value===`vpc-only`,name:`mode`,onChange:()=>e.onChange(`vpc-only`),type:`radio`}),`VPC only`]}),(0,j.jsxs)(`label`,{className:`flex items-center gap-2 text-xs`,children:[(0,j.jsx)(`input`,{"aria-label":`VPC and more`,checked:e.value===`vpc-and-more`,name:`mode`,onChange:()=>e.onChange(`vpc-and-more`),type:`radio`}),`VPC and more`]})]})})]}),(0,j.jsxs)(D,{children:[(0,j.jsx)(T,{children:(0,j.jsx)(`label`,{htmlFor:`namePrefix`,children:`Name tag auto-generation`})}),(0,j.jsxs)(`div`,{className:`flex items-center gap-3`,children:[(0,j.jsx)(v,{id:`namePrefix`,placeholder:`project`,...N(`namePrefix`)}),(0,j.jsx)(x,{control:I,name:`autoGenerateNames`,render:({field:e})=>(0,j.jsxs)(`label`,{className:`flex shrink-0 items-center gap-2 text-xs`,children:[(0,j.jsx)(`input`,{"aria-label":`Auto-generate name tags`,checked:e.value,onChange:t=>e.onChange(t.target.checked),type:`checkbox`}),`Auto-generate`]})})]})]}),(0,j.jsxs)(D,{children:[(0,j.jsx)(T,{children:(0,j.jsx)(`label`,{htmlFor:`cidrBlock`,children:`IPv4 CIDR block`})}),(0,j.jsx)(v,{"aria-invalid":!!z.cidrBlock,id:`cidrBlock`,placeholder:`10.0.0.0/16`,...N(`cidrBlock`)}),(0,j.jsx)(E,{errors:[z.cidrBlock]})]}),(0,j.jsxs)(D,{children:[(0,j.jsx)(T,{children:`Tenancy`}),(0,j.jsx)(x,{control:I,name:`tenancy`,render:({field:e})=>(0,j.jsxs)(c,{onValueChange:e.onChange,value:e.value,children:[(0,j.jsx)(s,{id:`tenancy`,children:(0,j.jsx)(i,{})}),(0,j.jsxs)(o,{children:[(0,j.jsx)(a,{value:`default`,children:`default`}),(0,j.jsx)(a,{value:`dedicated`,children:`dedicated`})]})]})})]}),H===`vpc-and-more`&&(0,j.jsxs)(j.Fragment,{children:[(0,j.jsxs)(D,{children:[(0,j.jsx)(T,{children:`Number of public subnets`}),(0,j.jsx)(x,{control:I,name:`publicSubnetCount`,render:({field:e})=>(0,j.jsx)(`div`,{className:`flex gap-1`,children:L.map(t=>(0,j.jsx)(_,{onClick:()=>e.onChange(t),size:`sm`,type:`button`,variant:e.value===t?`default`:`outline`,children:t},t))})}),(0,j.jsx)(M,{count:W,defaults:K.publicSubnets.map(e=>e.cidr),form:y,type:`public`})]}),(0,j.jsxs)(D,{children:[(0,j.jsx)(T,{children:`Number of private subnets`}),(0,j.jsx)(x,{control:I,name:`privateSubnetCount`,render:({field:e})=>(0,j.jsx)(`div`,{className:`flex gap-1`,children:R.map(t=>(0,j.jsx)(_,{onClick:()=>e.onChange(t),size:`sm`,type:`button`,variant:e.value===t?`default`:`outline`,children:t},t))})}),(0,j.jsx)(M,{count:G,defaults:K.privateSubnets.map(e=>e.cidr),form:y,type:`private`})]}),W>0&&G>0&&(0,j.jsxs)(D,{children:[(0,j.jsx)(T,{children:`NAT gateways`}),(0,j.jsx)(x,{control:I,name:`natGateway`,render:({field:e})=>(0,j.jsxs)(c,{onValueChange:e.onChange,value:e.value??`none`,children:[(0,j.jsx)(s,{id:`natGateway`,children:(0,j.jsx)(i,{})}),(0,j.jsxs)(o,{children:[(0,j.jsx)(a,{value:`none`,children:`None`}),(0,j.jsx)(a,{value:`single`,children:`In 1 AZ`})]})]})}),(0,j.jsx)(`p`,{className:`mt-1 text-xs text-muted-foreground`,children:`Creates an Elastic IP + NAT gateway in the public subnet and routes private subnet egress (0.0.0.0/0) through it.`})]})]}),(0,j.jsx)(l,{commands:B(F,K)}),(0,j.jsx)(C,{isPending:Z,isSubmitting:V,onCancel:async()=>await n({to:`/ec2/describe-vpcs`}),pendingLabel:`Creating…`,submitLabel:`Create VPC`})]}),(0,j.jsx)(`div`,{className:`hidden min-w-0 flex-1 lg:block`,children:(0,j.jsx)(P,{hasIgw:H===`vpc-and-more`&&W>0,mode:H,privateSubnets:K.privateSubnets,publicSubnets:K.publicSubnets,vpcCidr:U||`10.0.0.0/16`})})]})]})}function B(e,t){let n=e(`mode`),r=typeof n==`string`?n:``,i=e(`cidrBlock`),a=typeof i==`string`?i:``,o=e(`tenancy`),s=typeof o==`string`?o:``,c=e(`natGateway`),l=typeof c==`string`?c:``;if(r===`vpc-only`){let e=[{type:`bin`,value:`AWS_PROFILE=spinifex aws ec2 create-vpc`},{type:`flag`,value:` \\
- --cidr-block`},{type:`value`,value:` ${a||`10.0.0.0/16`}`}];return s&&s!==`default`&&e.push({type:`flag`,value:` \\
+import{n as e,o as t,r as n,t as r}from"./compiler-runtime.js";import{t as i}from"./error-banner.js";import{t as a}from"./back-link.js";import{t as o}from"./chevron-down.js";import{t as s}from"./chevron-right.js";import{c,i as l,n as u,s as d,t as f}from"./select.js";import{t as p}from"./cli-command-panel.js";import{t as m}from"./button.js";import{C as h,S as g}from"./ec2.js";import{Ir as _,Rn as v}from"./index.js";import{i as y,n as b,t as x}from"./zod.js";import{t as S}from"./form-actions.js";import{t as C}from"./page-heading.js";import{a as w,r as T,t as E}from"./field.js";import{t as D}from"./subnet-calculator.js";import{h as O}from"./ec22.js";var k=r(),A=t(n(),1),j=e();function M(e){let t=(0,k.c)(18),{form:n,type:r,count:i,defaults:a}=e,[c,l]=(0,A.useState)(!1),u=r===`public`?`publicSubnetCidrs`:`privateSubnetCidrs`;if(i===0)return null;let d;t[0]===c?d=t[1]:(d=()=>l(!c),t[0]=c,t[1]=d);let f;t[2]===c?f=t[3]:(f=c?(0,j.jsx)(o,{className:`size-3`}):(0,j.jsx)(s,{className:`size-3`}),t[2]=c,t[3]=f);let p;t[4]!==d||t[5]!==f||t[6]!==r?(p=(0,j.jsxs)(m,{className:`gap-1 px-0 text-xs text-muted-foreground`,onClick:d,type:`button`,variant:`link`,children:[f,`Customize `,r,` subnet CIDR blocks`]}),t[4]=d,t[5]=f,t[6]=r,t[7]=p):p=t[7];let h;t[8]!==i||t[9]!==a||t[10]!==c||t[11]!==u||t[12]!==n||t[13]!==r?(h=c&&(0,j.jsx)(`div`,{className:`mt-2 space-y-2`,children:Array.from({length:i},(e,t)=>(0,j.jsxs)(`div`,{className:`flex items-center gap-2`,children:[(0,j.jsxs)(`span`,{className:`w-32 text-xs text-muted-foreground`,children:[r===`public`?`Public`:`Private`,` subnet `,t+1]}),(0,j.jsx)(v,{defaultValue:a[t],placeholder:a[t],...n.register(`${u}.${t}`)})]},t))}),t[8]=i,t[9]=a,t[10]=c,t[11]=u,t[12]=n,t[13]=r,t[14]=h):h=t[14];let g;return t[15]!==p||t[16]!==h?(g=(0,j.jsxs)(`div`,{className:`mt-2`,children:[p,h]}),t[15]=p,t[16]=h,t[17]=g):g=t[17],g}function N(e){let t=(0,k.c)(8),{label:n,cidr:r,variant:i}=e,a=`rounded-md border px-3 py-2 text-xs ${i===`public`?`border-emerald-500/30 bg-emerald-500/10`:`border-blue-500/30 bg-blue-500/10`}`,o;t[0]===n?o=t[1]:(o=(0,j.jsx)(`div`,{className:`font-medium`,children:n}),t[0]=n,t[1]=o);let s;t[2]===r?s=t[3]:(s=(0,j.jsx)(`div`,{className:`text-muted-foreground`,children:r}),t[2]=r,t[3]=s);let c;return t[4]!==a||t[5]!==o||t[6]!==s?(c=(0,j.jsxs)(`div`,{className:a,children:[o,s]}),t[4]=a,t[5]=o,t[6]=s,t[7]=c):c=t[7],c}function P(e){let t=(0,k.c)(14),{mode:n,vpcCidr:r,publicSubnets:i,privateSubnets:a,hasIgw:o}=e,s;t[0]===Symbol.for(`react.memo_cache_sentinel`)?(s=(0,j.jsx)(`h3`,{className:`mb-3 text-xs font-semibold tracking-wider text-muted-foreground uppercase`,children:`Preview`}),t[0]=s):s=t[0];let c;t[1]===r?c=t[2]:(c=(0,j.jsxs)(`div`,{className:`mb-3 text-xs font-medium`,children:[`VPC `,(0,j.jsxs)(`span`,{className:`text-muted-foreground`,children:[`(`,r,`)`]})]}),t[1]=r,t[2]=c);let l;t[3]!==o||t[4]!==n||t[5]!==a||t[6]!==i?(l=n===`vpc-and-more`&&(0,j.jsxs)(`div`,{className:`grid gap-4 md:grid-cols-3`,children:[(0,j.jsxs)(`div`,{className:`space-y-2`,children:[(0,j.jsx)(`div`,{className:`text-xs font-medium text-muted-foreground`,children:`Subnets`}),i.map(I),a.map(F),i.length===0&&a.length===0&&(0,j.jsx)(`div`,{className:`text-xs text-muted-foreground`,children:`No subnets`})]}),(0,j.jsxs)(`div`,{className:`space-y-2`,children:[(0,j.jsx)(`div`,{className:`text-xs font-medium text-muted-foreground`,children:`Route Tables`}),i.length>0&&(0,j.jsxs)(`div`,{className:`rounded-md border border-foreground/10 bg-background px-3 py-2 text-xs`,children:[(0,j.jsx)(`div`,{className:`font-medium`,children:`Public Route Table`}),(0,j.jsx)(`div`,{className:`mt-1 text-muted-foreground`,children:`0.0.0.0/0 → IGW`}),(0,j.jsx)(`div`,{className:`text-muted-foreground`,children:`local`})]}),a.length>0&&(0,j.jsxs)(`div`,{className:`rounded-md border border-foreground/10 bg-background px-3 py-2 text-xs`,children:[(0,j.jsx)(`div`,{className:`font-medium`,children:`Private Route Table`}),(0,j.jsx)(`div`,{className:`mt-1 text-muted-foreground`,children:`local`})]})]}),(0,j.jsxs)(`div`,{className:`space-y-2`,children:[(0,j.jsx)(`div`,{className:`text-xs font-medium text-muted-foreground`,children:`Network Connections`}),o&&(0,j.jsxs)(`div`,{className:`rounded-md border border-amber-500/30 bg-amber-500/10 px-3 py-2 text-xs`,children:[(0,j.jsx)(`div`,{className:`font-medium`,children:`Internet Gateway`}),(0,j.jsx)(`div`,{className:`text-muted-foreground`,children:`Attached to VPC`})]}),!o&&(0,j.jsx)(`div`,{className:`text-xs text-muted-foreground`,children:`No internet gateway`})]})]}),t[3]=o,t[4]=n,t[5]=a,t[6]=i,t[7]=l):l=t[7];let u;t[8]===n?u=t[9]:(u=n===`vpc-only`&&(0,j.jsx)(`div`,{className:`text-xs text-muted-foreground`,children:`VPC only — no additional resources will be created.`}),t[8]=n,t[9]=u);let d;return t[10]!==c||t[11]!==l||t[12]!==u?(d=(0,j.jsxs)(`div`,{className:`rounded-lg border bg-muted/30 p-4`,children:[s,(0,j.jsxs)(`div`,{className:`rounded-lg border-2 border-dashed border-foreground/20 p-4`,children:[c,l,u]})]}),t[10]=c,t[11]=l,t[12]=u,t[13]=d):d=t[13],d}function F(e){return(0,j.jsx)(N,{cidr:e.cidr,label:e.label,variant:`private`},e.cidr)}function I(e){return(0,j.jsx)(N,{cidr:e.cidr,label:e.label,variant:`public`},e.cidr)}var L=[0,1],R=[0,1,2];function z(){let e=_(),t=g(),n=h(),[r,o]=(0,A.useState)(null),s=y({resolver:x(O),defaultValues:{mode:`vpc-only`,namePrefix:``,autoGenerateNames:!0,cidrBlock:`10.0.0.0/16`,tenancy:`default`,natGateway:`none`,publicSubnetCount:1,privateSubnetCount:1,publicSubnetCidrs:[],privateSubnetCidrs:[],tags:[]}}),{handleSubmit:k,register:N,watch:F,control:I,formState:{errors:z,isSubmitting:V}}=s,H=F(`mode`),U=F(`cidrBlock`),W=F(`publicSubnetCount`),G=F(`privateSubnetCount`),K=D(U||`10.0.0.0/16`,H===`vpc-and-more`?W:0,H===`vpc-and-more`?G:0),[q,J]=(0,A.useState)(``),Y=async r=>{if(o(null),r.mode===`vpc-only`){let n=r.autoGenerateNames&&r.namePrefix?`${r.namePrefix}-vpc`:r.namePrefix,i=(await t.mutateAsync({cidrBlock:r.cidrBlock,name:n||void 0})).Vpc?.VpcId;e(i?{to:`/ec2/describe-vpcs/$id`,params:{id:i}}:{to:`/ec2/describe-vpcs`});return}J(`Creating VPC and resources...`);let i=await n.mutateAsync(r);o(i),!i.error&&i.vpcId&&e({to:`/ec2/describe-vpcs/$id`,params:{id:i.vpcId}}),J(``)},X=t.error??n.error,Z=t.isPending||n.isPending;return(0,j.jsxs)(j.Fragment,{children:[(0,j.jsx)(a,{to:`/ec2/describe-vpcs`,children:`Back to VPCs`}),(0,j.jsx)(C,{title:`Create VPC`}),X&&(0,j.jsx)(i,{error:X,msg:`Failed to create VPC`}),r?.error&&(0,j.jsxs)(`div`,{className:`mb-6 max-w-4xl rounded-md border border-destructive bg-destructive/10 p-4`,children:[(0,j.jsxs)(`h2`,{className:`text-sm font-medium text-destructive`,children:[`Wizard failed: `,r.failedStep]}),(0,j.jsx)(`p`,{className:`mt-1 text-sm text-destructive`,children:r.error.message}),r.created.length>0&&(0,j.jsxs)(`div`,{className:`mt-3`,children:[(0,j.jsx)(`p`,{className:`text-xs font-medium text-destructive`,children:`Successfully created resources:`}),(0,j.jsx)(`ul`,{className:`mt-1 list-inside list-disc text-xs text-destructive`,children:r.created.map((e,t)=>(0,j.jsxs)(`li`,{children:[e.type,`: `,e.id]},t))})]})]}),q&&(0,j.jsx)(`div`,{className:`mb-4 max-w-4xl text-sm text-muted-foreground`,children:q}),(0,j.jsxs)(`div`,{className:`flex max-w-6xl gap-8`,children:[(0,j.jsxs)(`form`,{className:`min-w-0 flex-1 space-y-6`,onSubmit:k(Y),children:[(0,j.jsxs)(E,{children:[(0,j.jsx)(w,{children:`Resources to create`}),(0,j.jsx)(b,{control:I,name:`mode`,render:({field:e})=>(0,j.jsxs)(`div`,{className:`flex gap-4`,children:[(0,j.jsxs)(`label`,{className:`flex items-center gap-2 text-xs`,children:[(0,j.jsx)(`input`,{"aria-label":`VPC only`,checked:e.value===`vpc-only`,name:`mode`,onChange:()=>e.onChange(`vpc-only`),type:`radio`}),`VPC only`]}),(0,j.jsxs)(`label`,{className:`flex items-center gap-2 text-xs`,children:[(0,j.jsx)(`input`,{"aria-label":`VPC and more`,checked:e.value===`vpc-and-more`,name:`mode`,onChange:()=>e.onChange(`vpc-and-more`),type:`radio`}),`VPC and more`]})]})})]}),(0,j.jsxs)(E,{children:[(0,j.jsx)(w,{children:(0,j.jsx)(`label`,{htmlFor:`namePrefix`,children:`Name tag auto-generation`})}),(0,j.jsxs)(`div`,{className:`flex items-center gap-3`,children:[(0,j.jsx)(v,{id:`namePrefix`,placeholder:`project`,...N(`namePrefix`)}),(0,j.jsx)(b,{control:I,name:`autoGenerateNames`,render:({field:e})=>(0,j.jsxs)(`label`,{className:`flex shrink-0 items-center gap-2 text-xs`,children:[(0,j.jsx)(`input`,{"aria-label":`Auto-generate name tags`,checked:e.value,onChange:t=>e.onChange(t.target.checked),type:`checkbox`}),`Auto-generate`]})})]})]}),(0,j.jsxs)(E,{children:[(0,j.jsx)(w,{children:(0,j.jsx)(`label`,{htmlFor:`cidrBlock`,children:`IPv4 CIDR block`})}),(0,j.jsx)(v,{"aria-invalid":!!z.cidrBlock,id:`cidrBlock`,placeholder:`10.0.0.0/16`,...N(`cidrBlock`)}),(0,j.jsx)(T,{errors:[z.cidrBlock]})]}),(0,j.jsxs)(E,{children:[(0,j.jsx)(w,{children:`Tenancy`}),(0,j.jsx)(b,{control:I,name:`tenancy`,render:({field:e})=>(0,j.jsxs)(f,{onValueChange:e.onChange,value:e.value,children:[(0,j.jsx)(d,{id:`tenancy`,children:(0,j.jsx)(c,{})}),(0,j.jsxs)(u,{children:[(0,j.jsx)(l,{value:`default`,children:`default`}),(0,j.jsx)(l,{value:`dedicated`,children:`dedicated`})]})]})})]}),H===`vpc-and-more`&&(0,j.jsxs)(j.Fragment,{children:[(0,j.jsxs)(E,{children:[(0,j.jsx)(w,{children:`Number of public subnets`}),(0,j.jsx)(b,{control:I,name:`publicSubnetCount`,render:({field:e})=>(0,j.jsx)(`div`,{className:`flex gap-1`,children:L.map(t=>(0,j.jsx)(m,{onClick:()=>e.onChange(t),size:`sm`,type:`button`,variant:e.value===t?`default`:`outline`,children:t},t))})}),(0,j.jsx)(M,{count:W,defaults:K.publicSubnets.map(e=>e.cidr),form:s,type:`public`})]}),(0,j.jsxs)(E,{children:[(0,j.jsx)(w,{children:`Number of private subnets`}),(0,j.jsx)(b,{control:I,name:`privateSubnetCount`,render:({field:e})=>(0,j.jsx)(`div`,{className:`flex gap-1`,children:R.map(t=>(0,j.jsx)(m,{onClick:()=>e.onChange(t),size:`sm`,type:`button`,variant:e.value===t?`default`:`outline`,children:t},t))})}),(0,j.jsx)(M,{count:G,defaults:K.privateSubnets.map(e=>e.cidr),form:s,type:`private`})]}),W>0&&G>0&&(0,j.jsxs)(E,{children:[(0,j.jsx)(w,{children:`NAT gateways`}),(0,j.jsx)(b,{control:I,name:`natGateway`,render:({field:e})=>(0,j.jsxs)(f,{onValueChange:e.onChange,value:e.value??`none`,children:[(0,j.jsx)(d,{id:`natGateway`,children:(0,j.jsx)(c,{})}),(0,j.jsxs)(u,{children:[(0,j.jsx)(l,{value:`none`,children:`None`}),(0,j.jsx)(l,{value:`single`,children:`In 1 AZ`})]})]})}),(0,j.jsx)(`p`,{className:`mt-1 text-xs text-muted-foreground`,children:`Creates an Elastic IP + NAT gateway in the public subnet and routes private subnet egress (0.0.0.0/0) through it.`})]})]}),(0,j.jsx)(p,{commands:B(F,K)}),(0,j.jsx)(S,{isPending:Z,isSubmitting:V,onCancel:async()=>await e({to:`/ec2/describe-vpcs`}),pendingLabel:`Creating…`,submitLabel:`Create VPC`})]}),(0,j.jsx)(`div`,{className:`hidden min-w-0 flex-1 lg:block`,children:(0,j.jsx)(P,{hasIgw:H===`vpc-and-more`&&W>0,mode:H,privateSubnets:K.privateSubnets,publicSubnets:K.publicSubnets,vpcCidr:U||`10.0.0.0/16`})})]})]})}function B(e,t){let n=e(`mode`),r=typeof n==`string`?n:``,i=e(`cidrBlock`),a=typeof i==`string`?i:``,o=e(`tenancy`),s=typeof o==`string`?o:``,c=e(`natGateway`),l=typeof c==`string`?c:``;if(r===`vpc-only`){let e=[{type:`bin`,value:`AWS_PROFILE=spinifex aws ec2 create-vpc`},{type:`flag`,value:` \\
+ --cidr-block`},{type:`value`,value:` ${a||`10.0.0.0/16`}`}];return s&&s!=="default"&&e.push({type:`flag`,value:` \\
--instance-tenancy`},{type:`value`,value:` ${s}`}),[{label:`Create VPC`,parts:e}]}let u=[],d=[...t.publicSubnets.map((e,t)=>({...e,name:`PUBLIC_SUBNET_${t+1}`})),...t.privateSubnets.map((e,t)=>({...e,name:`PRIVATE_SUBNET_${t+1}`}))];u.push({label:`Create VPC`,parts:[{type:`comment`,value:`# Create VPC with subnets, internet gateway, and routing
`},{type:`variable`,value:`VPC_ID=`},{type:`bin`,value:`$(AWS_PROFILE=spinifex aws ec2 create-vpc`},{type:`flag`,value:` \\
diff --git a/spinifex/services/spinifexui/frontend/dist/assets/createBaseUIEventDetails.js b/spinifex/services/spinifexui/frontend/dist/assets/createBaseUIEventDetails.js
new file mode 100644
index 000000000..397e004d6
--- /dev/null
+++ b/spinifex/services/spinifexui/frontend/dist/assets/createBaseUIEventDetails.js
@@ -0,0 +1 @@
+import{o as e,r as t}from"./compiler-runtime.js";import{a as n}from"./dist.js";import{c as r,w as i}from"./button.js";var a=e(t()),o=0;function s(e,t=`mui`){let[n,r]=a.useState(e),i=e||n;return a.useEffect(()=>{n??(o+=1,r(`${t}-${o}`))},[n,t]),i}var c=r.useId;function l(e,t){if(c!==void 0){let n=c();return e??(t?`${t}-${n}`:n)}return s(e,t)}function u(e){return l(e,`base-ui`)}function d(e){return e?.ownerDocument||document}function f(e){let t=e.activeElement;for(;t?.shadowRoot?.activeElement!=null;)t=t.shadowRoot.activeElement;return t}function p(e,t){if(!e||!t)return!1;let n=t.getRootNode?.();if(e.contains(t))return!0;if(n&&i(n)){let n=t;for(;n;){if(e===n)return!0;n=n.parentNode||n.host}}return!1}function m(e){return`composedPath`in e?e.composedPath()[0]:e.target}var h=`none`,g=`trigger-press`,_=`trigger-hover`,v=`trigger-focus`,y=`outside-press`,b=`item-press`,x=`close-press`,S=`focus-out`,C=`escape-key`,w=`list-navigation`,T=`cancel-open`,E=`disabled`,D=`missing`,O=`initial`,k=`imperative-action`,A=`window-resize`;function j(e,t,r,i){let a=!1,o=!1,s=i??n;return{reason:e,event:t??new Event(`base-ui`),cancel(){a=!0},allowPropagation(){o=!0},get isCanceled(){return a},get isPropagationAllowed(){return o},trigger:r,...s}}export{l as C,u as S,A as _,C as a,m as b,O as c,D as d,h as f,g,_ as h,E as i,b as l,v as m,T as n,S as o,y as p,x as r,k as s,j as t,w as u,f as v,d as x,p as y};
\ No newline at end of file
diff --git a/spinifex/services/spinifexui/frontend/dist/assets/delete-confirmation-dialog.js b/spinifex/services/spinifexui/frontend/dist/assets/delete-confirmation-dialog.js
index 35fd39eca..947a6e4d9 100644
--- a/spinifex/services/spinifexui/frontend/dist/assets/delete-confirmation-dialog.js
+++ b/spinifex/services/spinifexui/frontend/dist/assets/delete-confirmation-dialog.js
@@ -1 +1 @@
-import{Fo as e,Ho as t}from"./index.js";import{a as n,i as r,l as i,n as a,o,r as s,s as c,t as l}from"./alert-dialog.js";var u=e(),d=t();function f(e){let t=(0,u.c)(19),{open:f,onOpenChange:p,title:m,description:h,isPending:g,onConfirm:_,confirmLabel:v,pendingLabel:y}=e,b=v===void 0?`Delete`:v,x=y===void 0?`Deleting…`:y,S;t[0]===m?S=t[1]:(S=(0,d.jsx)(i,{children:m}),t[0]=m,t[1]=S);let C;t[2]===h?C=t[3]:(C=(0,d.jsx)(n,{children:h}),t[2]=h,t[3]=C);let w;t[4]!==S||t[5]!==C?(w=(0,d.jsxs)(c,{children:[S,C]}),t[4]=S,t[5]=C,t[6]=w):w=t[6];let T;t[7]===Symbol.for(`react.memo_cache_sentinel`)?(T=(0,d.jsx)(s,{children:`Cancel`}),t[7]=T):T=t[7];let E=g?x:b,D;t[8]!==g||t[9]!==_||t[10]!==E?(D=(0,d.jsxs)(o,{children:[T,(0,d.jsx)(a,{disabled:g,onClick:_,children:E})]}),t[8]=g,t[9]=_,t[10]=E,t[11]=D):D=t[11];let O;t[12]!==w||t[13]!==D?(O=(0,d.jsxs)(r,{children:[w,D]}),t[12]=w,t[13]=D,t[14]=O):O=t[14];let k;return t[15]!==p||t[16]!==f||t[17]!==O?(k=(0,d.jsx)(l,{onOpenChange:p,open:f,children:O}),t[15]=p,t[16]=f,t[17]=O,t[18]=k):k=t[18],k}export{f as t};
\ No newline at end of file
+import{n as e,t}from"./compiler-runtime.js";import{a as n,i as r,l as i,n as a,o,r as s,s as c,t as l}from"./alert-dialog.js";var u=t(),d=e();function f(e){let t=(0,u.c)(19),{open:f,onOpenChange:p,title:m,description:h,isPending:g,onConfirm:_,confirmLabel:v,pendingLabel:y}=e,b=v===void 0?`Delete`:v,x=y===void 0?`Deleting…`:y,S;t[0]===m?S=t[1]:(S=(0,d.jsx)(i,{children:m}),t[0]=m,t[1]=S);let C;t[2]===h?C=t[3]:(C=(0,d.jsx)(n,{children:h}),t[2]=h,t[3]=C);let w;t[4]!==S||t[5]!==C?(w=(0,d.jsxs)(c,{children:[S,C]}),t[4]=S,t[5]=C,t[6]=w):w=t[6];let T;t[7]===Symbol.for(`react.memo_cache_sentinel`)?(T=(0,d.jsx)(s,{children:`Cancel`}),t[7]=T):T=t[7];let E=g?x:b,D;t[8]!==g||t[9]!==_||t[10]!==E?(D=(0,d.jsxs)(o,{children:[T,(0,d.jsx)(a,{disabled:g,onClick:_,children:E})]}),t[8]=g,t[9]=_,t[10]=E,t[11]=D):D=t[11];let O;t[12]!==w||t[13]!==D?(O=(0,d.jsxs)(r,{children:[w,D]}),t[12]=w,t[13]=D,t[14]=O):O=t[14];let k;return t[15]!==p||t[16]!==f||t[17]!==O?(k=(0,d.jsx)(l,{onOpenChange:p,open:f,children:O}),t[15]=p,t[16]=f,t[17]=O,t[18]=k):k=t[18],k}export{f as t};
\ No newline at end of file
diff --git a/spinifex/services/spinifexui/frontend/dist/assets/describe-addresses.js b/spinifex/services/spinifexui/frontend/dist/assets/describe-addresses.js
index 3bd943efc..6f4d61101 100644
--- a/spinifex/services/spinifexui/frontend/dist/assets/describe-addresses.js
+++ b/spinifex/services/spinifexui/frontend/dist/assets/describe-addresses.js
@@ -1 +1 @@
-import{t as e}from"./useSuspenseQuery.js";import{Fo as t,H as n,Ho as r,Lo as i,Xa as a,ao as o}from"./index.js";import{t as s}from"./page-heading.js";import{t as c}from"./list-card.js";var l=t(),u=r();function d(){let t=(0,l.c)(5),{data:r}=e(n),o;t[0]===r.Addresses?o=t[1]:(o=r.Addresses??[],t[0]=r.Addresses,t[1]=o);let c=o,d;t[2]===Symbol.for(`react.memo_cache_sentinel`)?(d=(0,u.jsx)(s,{actions:(0,u.jsx)(i,{to:`/ec2/allocate-address`,children:(0,u.jsx)(a,{children:`Allocate Elastic IP`})}),title:`Elastic IPs`}),t[2]=d):d=t[2];let p;return t[3]===c?p=t[4]:(p=(0,u.jsxs)(u.Fragment,{children:[d,c.length>0?(0,u.jsx)(`div`,{className:`space-y-4`,children:c.map(f)}):(0,u.jsx)(`p`,{className:`text-muted-foreground`,children:`No Elastic IPs found.`})]}),t[3]=c,t[4]=p),p}function f(e){if(!e.AllocationId)return null;let t=o(e.Tags),n=e.AssociationId?`${e.PublicIp} • associated`:`${e.PublicIp} • available`;return(0,u.jsx)(c,{params:{id:e.AllocationId},subtitle:n,title:t?`${e.AllocationId} (${t})`:e.AllocationId??``,to:`/ec2/describe-addresses/$id`},e.AllocationId)}export{d as component};
\ No newline at end of file
+import{n as e,t}from"./compiler-runtime.js";import{t as n}from"./useSuspenseQuery.js";import{Or as r}from"./awsClient.js";import{A as i}from"./SignatureV4.js";import{t as a}from"./button.js";import{lt as o}from"./index.js";import{t as s}from"./page-heading.js";import{t as c}from"./list-card.js";var l=t(),u=e();function d(){let e=(0,l.c)(5),{data:t}=n(o),i;e[0]===t.Addresses?i=e[1]:(i=t.Addresses??[],e[0]=t.Addresses,e[1]=i);let c=i,d;e[2]===Symbol.for(`react.memo_cache_sentinel`)?(d=(0,u.jsx)(s,{actions:(0,u.jsx)(r,{to:`/ec2/allocate-address`,children:(0,u.jsx)(a,{children:`Allocate Elastic IP`})}),title:`Elastic IPs`}),e[2]=d):d=e[2];let p;return e[3]===c?p=e[4]:(p=(0,u.jsxs)(u.Fragment,{children:[d,c.length>0?(0,u.jsx)(`div`,{className:`space-y-4`,children:c.map(f)}):(0,u.jsx)(`p`,{className:`text-muted-foreground`,children:`No Elastic IPs found.`})]}),e[3]=c,e[4]=p),p}function f(e){if(!e.AllocationId)return null;let t=i(e.Tags),n=e.AssociationId?`${e.PublicIp} • associated`:`${e.PublicIp} • available`;return(0,u.jsx)(c,{params:{id:e.AllocationId},subtitle:n,title:t?`${e.AllocationId} (${t})`:e.AllocationId??``,to:`/ec2/describe-addresses/$id`},e.AllocationId)}export{d as component};
\ No newline at end of file
diff --git a/spinifex/services/spinifexui/frontend/dist/assets/describe-images.js b/spinifex/services/spinifexui/frontend/dist/assets/describe-images.js
index 67527c711..0a25bee42 100644
--- a/spinifex/services/spinifexui/frontend/dist/assets/describe-images.js
+++ b/spinifex/services/spinifexui/frontend/dist/assets/describe-images.js
@@ -1 +1 @@
-import{t as e}from"./useSuspenseQuery.js";import{Fo as t,G as n,Ho as r}from"./index.js";import{t as i}from"./page-heading.js";import{t as a}from"./list-card.js";import{t as o}from"./state-badge.js";import{t as s}from"./system-managed.js";var c=t(),l=r();function u(){let t=(0,c.c)(3),{data:r}=e(n),a;if(t[0]!==r.Images){let e=(r.Images??[]).filter(f),n;t[2]===Symbol.for(`react.memo_cache_sentinel`)?(n=(0,l.jsx)(i,{title:`Images`}),t[2]=n):n=t[2],a=(0,l.jsxs)(l.Fragment,{children:[n,e.length>0?(0,l.jsx)(`div`,{className:`space-y-4`,children:e.map(d)}):(0,l.jsx)(`p`,{className:`text-muted-foreground`,children:`No images found.`})]}),t[0]=r.Images,t[1]=a}else a=t[1];return a}function d(e){return e.ImageId?(0,l.jsx)(a,{badge:(0,l.jsx)(o,{state:e.State}),params:{id:e.ImageId},subtitle:e.ImageId??``,title:e.Name??e.ImageId??``,to:`/ec2/describe-images/$id`},e.ImageId):null}function f(e){return!s(e)}export{u as component};
\ No newline at end of file
+import{n as e,t}from"./compiler-runtime.js";import{t as n}from"./useSuspenseQuery.js";import{pt as r}from"./index.js";import{t as i}from"./page-heading.js";import{t as a}from"./list-card.js";import{t as o}from"./state-badge.js";import{n as s}from"./system-managed.js";var c=t(),l=e();function u(){let e=(0,c.c)(3),{data:t}=n(r),a;if(e[0]!==t.Images){let n=(t.Images??[]).filter(f),r;e[2]===Symbol.for(`react.memo_cache_sentinel`)?(r=(0,l.jsx)(i,{title:`Images`}),e[2]=r):r=e[2],a=(0,l.jsxs)(l.Fragment,{children:[r,n.length>0?(0,l.jsx)(`div`,{className:`space-y-4`,children:n.map(d)}):(0,l.jsx)(`p`,{className:`text-muted-foreground`,children:`No images found.`})]}),e[0]=t.Images,e[1]=a}else a=e[1];return a}function d(e){return e.ImageId?(0,l.jsx)(a,{badge:(0,l.jsx)(o,{state:e.State}),params:{id:e.ImageId},subtitle:e.ImageId??``,title:e.Name??e.ImageId??``,to:`/ec2/describe-images/$id`},e.ImageId):null}function f(e){return!s(e)}export{u as component};
\ No newline at end of file
diff --git a/spinifex/services/spinifexui/frontend/dist/assets/describe-instances.js b/spinifex/services/spinifexui/frontend/dist/assets/describe-instances.js
index 5a4addc9a..3ffa51af6 100644
--- a/spinifex/services/spinifexui/frontend/dist/assets/describe-instances.js
+++ b/spinifex/services/spinifexui/frontend/dist/assets/describe-instances.js
@@ -1 +1 @@
-import{t as e}from"./useSuspenseQuery.js";import{Fo as t,Ho as n,J as r,Lo as i,Xa as a}from"./index.js";import{t as o}from"./page-heading.js";import{t as s}from"./list-card.js";import{t as c}from"./state-badge.js";var l=t(),u=n(),d={running:0,pending:1,stopping:2,"shutting-down":3,stopped:4,terminated:5};function f(){let t=(0,l.c)(3),{data:n}=e(r),s;if(t[0]!==n.Reservations){let e=(n.Reservations?.flatMap(h)??[]).toSorted(m),r;t[2]===Symbol.for(`react.memo_cache_sentinel`)?(r=(0,u.jsx)(o,{actions:(0,u.jsx)(i,{to:`/ec2/run-instances`,children:(0,u.jsx)(a,{children:`Run Instances`})}),title:`Instances`}),t[2]=r):r=t[2],s=(0,u.jsxs)(u.Fragment,{children:[r,e.length>0?(0,u.jsx)(`div`,{className:`space-y-4`,children:e.map(p)}):(0,u.jsx)(`p`,{className:`text-muted-foreground`,children:`No instances found.`})]}),t[0]=n.Reservations,t[1]=s}else s=t[1];return s}function p(e){return e.InstanceId?(0,u.jsx)(s,{badge:(0,u.jsx)(c,{state:e.State?.Name}),params:{id:e.InstanceId},subtitle:`${e.InstanceType}`,title:e.InstanceId,to:`/ec2/describe-instances/$id`},e.InstanceId):null}function m(e,t){let n=d[e.State?.Name??``]??6,r=d[t.State?.Name??``]??6;return n===r?(e.InstanceId??``).localeCompare(t.InstanceId??``):n-r}function h(e){return e.Instances??[]}export{f as component};
\ No newline at end of file
+import{n as e,t}from"./compiler-runtime.js";import{t as n}from"./useSuspenseQuery.js";import{Or as r}from"./awsClient.js";import{t as i}from"./button.js";import{gt as a}from"./index.js";import{t as o}from"./page-heading.js";import{t as s}from"./list-card.js";import{t as c}from"./state-badge.js";var l=t(),u=e(),d={running:0,pending:1,stopping:2,"shutting-down":3,stopped:4,terminated:5};function f(){let e=(0,l.c)(3),{data:t}=n(a),s;if(e[0]!==t.Reservations){let n=(t.Reservations?.flatMap(h)??[]).toSorted(m),a;e[2]===Symbol.for(`react.memo_cache_sentinel`)?(a=(0,u.jsx)(o,{actions:(0,u.jsx)(r,{to:`/ec2/run-instances`,children:(0,u.jsx)(i,{children:`Run Instances`})}),title:`Instances`}),e[2]=a):a=e[2],s=(0,u.jsxs)(u.Fragment,{children:[a,n.length>0?(0,u.jsx)(`div`,{className:`space-y-4`,children:n.map(p)}):(0,u.jsx)(`p`,{className:`text-muted-foreground`,children:`No instances found.`})]}),e[0]=t.Reservations,e[1]=s}else s=e[1];return s}function p(e){return e.InstanceId?(0,u.jsx)(s,{badge:(0,u.jsx)(c,{state:e.State?.Name}),params:{id:e.InstanceId},subtitle:`${e.InstanceType}`,title:e.InstanceId,to:`/ec2/describe-instances/$id`},e.InstanceId):null}function m(e,t){let n=d[e.State?.Name??``]??6,r=d[t.State?.Name??``]??6;return n===r?(e.InstanceId??``).localeCompare(t.InstanceId??``):n-r}function h(e){return e.Instances??[]}export{f as component};
\ No newline at end of file
diff --git a/spinifex/services/spinifexui/frontend/dist/assets/describe-internet-gateways.js b/spinifex/services/spinifexui/frontend/dist/assets/describe-internet-gateways.js
index c50a77b00..da57dc6fb 100644
--- a/spinifex/services/spinifexui/frontend/dist/assets/describe-internet-gateways.js
+++ b/spinifex/services/spinifexui/frontend/dist/assets/describe-internet-gateways.js
@@ -1 +1 @@
-import{t as e}from"./useSuspenseQuery.js";import{Fo as t,Ho as n,Lo as r,X as i,Xa as a,ao as o}from"./index.js";import{t as s}from"./page-heading.js";import{t as c}from"./list-card.js";var l=t(),u=n();function d(){let t=(0,l.c)(5),{data:n}=e(i),o;t[0]===n.InternetGateways?o=t[1]:(o=n.InternetGateways??[],t[0]=n.InternetGateways,t[1]=o);let c=o,d;t[2]===Symbol.for(`react.memo_cache_sentinel`)?(d=(0,u.jsx)(s,{actions:(0,u.jsx)(r,{to:`/ec2/create-internet-gateway`,children:(0,u.jsx)(a,{children:`Create Internet Gateway`})}),title:`Internet Gateways`}),t[2]=d):d=t[2];let p;return t[3]===c?p=t[4]:(p=(0,u.jsxs)(u.Fragment,{children:[d,c.length>0?(0,u.jsx)(`div`,{className:`space-y-4`,children:c.map(f)}):(0,u.jsx)(`p`,{className:`text-muted-foreground`,children:`No Internet Gateways found.`})]}),t[3]=c,t[4]=p),p}function f(e){if(!e.InternetGatewayId)return null;let t=o(e.Tags),n=e.Attachments?.[0]?.VpcId;return(0,u.jsx)(c,{params:{id:e.InternetGatewayId},subtitle:n?`attached to ${n}`:`detached`,title:t?`${e.InternetGatewayId} (${t})`:e.InternetGatewayId,to:`/ec2/describe-internet-gateways/$id`},e.InternetGatewayId)}export{d as component};
\ No newline at end of file
+import{n as e,t}from"./compiler-runtime.js";import{t as n}from"./useSuspenseQuery.js";import{Or as r}from"./awsClient.js";import{A as i}from"./SignatureV4.js";import{t as a}from"./button.js";import{vt as o}from"./index.js";import{t as s}from"./page-heading.js";import{t as c}from"./list-card.js";var l=t(),u=e();function d(){let e=(0,l.c)(5),{data:t}=n(o),i;e[0]===t.InternetGateways?i=e[1]:(i=t.InternetGateways??[],e[0]=t.InternetGateways,e[1]=i);let c=i,d;e[2]===Symbol.for(`react.memo_cache_sentinel`)?(d=(0,u.jsx)(s,{actions:(0,u.jsx)(r,{to:`/ec2/create-internet-gateway`,children:(0,u.jsx)(a,{children:`Create Internet Gateway`})}),title:`Internet Gateways`}),e[2]=d):d=e[2];let p;return e[3]===c?p=e[4]:(p=(0,u.jsxs)(u.Fragment,{children:[d,c.length>0?(0,u.jsx)(`div`,{className:`space-y-4`,children:c.map(f)}):(0,u.jsx)(`p`,{className:`text-muted-foreground`,children:`No Internet Gateways found.`})]}),e[3]=c,e[4]=p),p}function f(e){if(!e.InternetGatewayId)return null;let t=i(e.Tags),n=e.Attachments?.[0]?.VpcId;return(0,u.jsx)(c,{params:{id:e.InternetGatewayId},subtitle:n?`attached to ${n}`:`detached`,title:t?`${e.InternetGatewayId} (${t})`:e.InternetGatewayId,to:`/ec2/describe-internet-gateways/$id`},e.InternetGatewayId)}export{d as component};
\ No newline at end of file
diff --git a/spinifex/services/spinifexui/frontend/dist/assets/describe-key-pairs.js b/spinifex/services/spinifexui/frontend/dist/assets/describe-key-pairs.js
index c31782aff..59fd709ec 100644
--- a/spinifex/services/spinifexui/frontend/dist/assets/describe-key-pairs.js
+++ b/spinifex/services/spinifexui/frontend/dist/assets/describe-key-pairs.js
@@ -1 +1 @@
-import{t as e}from"./useSuspenseQuery.js";import{Fo as t,Ho as n,Lo as r,Q as i,Xa as a}from"./index.js";import{t as o}from"./page-heading.js";import{t as s}from"./list-card.js";var c=t(),l=n();function u(){let t=(0,c.c)(4),{data:n}=e(i),s;if(t[0]!==n.KeyPairs){let e=(n.KeyPairs??[]).toSorted(f),i;t[2]===Symbol.for(`react.memo_cache_sentinel`)?(i=(0,l.jsx)(r,{to:`/ec2/import-key-pair`,children:(0,l.jsx)(a,{variant:`outline`,children:`Import Key Pair`})}),t[2]=i):i=t[2];let c;t[3]===Symbol.for(`react.memo_cache_sentinel`)?(c=(0,l.jsx)(o,{actions:(0,l.jsxs)(`div`,{className:`flex gap-2`,children:[i,(0,l.jsx)(r,{to:`/ec2/create-key-pair`,children:(0,l.jsx)(a,{children:`Create Key Pair`})})]}),title:`Key Pairs`}),t[3]=c):c=t[3],s=(0,l.jsxs)(l.Fragment,{children:[c,e.length>0?(0,l.jsx)(`div`,{className:`space-y-4`,children:e.map(d)}):(0,l.jsx)(`p`,{className:`text-muted-foreground`,children:`No key pairs found.`})]}),t[0]=n.KeyPairs,t[1]=s}else s=t[1];return s}function d(e){return e.KeyPairId?(0,l.jsx)(s,{params:{id:e.KeyPairId},subtitle:e.KeyPairId??``,title:e.KeyName??``,to:`/ec2/describe-key-pairs/$id`},e.KeyPairId):null}function f(e,t){let n=e.KeyName?.toLowerCase()??``,r=t.KeyName?.toLowerCase()??``;return n.localeCompare(r)}export{u as component};
\ No newline at end of file
+import{n as e,t}from"./compiler-runtime.js";import{t as n}from"./useSuspenseQuery.js";import{Or as r}from"./awsClient.js";import{t as i}from"./button.js";import{bt as a}from"./index.js";import{t as o}from"./page-heading.js";import{t as s}from"./list-card.js";var c=t(),l=e();function u(){let e=(0,c.c)(4),{data:t}=n(a),s;if(e[0]!==t.KeyPairs){let n=(t.KeyPairs??[]).toSorted(f),a;e[2]===Symbol.for(`react.memo_cache_sentinel`)?(a=(0,l.jsx)(r,{to:`/ec2/import-key-pair`,children:(0,l.jsx)(i,{variant:`outline`,children:`Import Key Pair`})}),e[2]=a):a=e[2];let c;e[3]===Symbol.for(`react.memo_cache_sentinel`)?(c=(0,l.jsx)(o,{actions:(0,l.jsxs)(`div`,{className:`flex gap-2`,children:[a,(0,l.jsx)(r,{to:`/ec2/create-key-pair`,children:(0,l.jsx)(i,{children:`Create Key Pair`})})]}),title:`Key Pairs`}),e[3]=c):c=e[3],s=(0,l.jsxs)(l.Fragment,{children:[c,n.length>0?(0,l.jsx)(`div`,{className:`space-y-4`,children:n.map(d)}):(0,l.jsx)(`p`,{className:`text-muted-foreground`,children:`No key pairs found.`})]}),e[0]=t.KeyPairs,e[1]=s}else s=e[1];return s}function d(e){return e.KeyPairId?(0,l.jsx)(s,{params:{id:e.KeyPairId},subtitle:e.KeyPairId??``,title:e.KeyName??``,to:`/ec2/describe-key-pairs/$id`},e.KeyPairId):null}function f(e,t){let n=e.KeyName?.toLowerCase()??``,r=t.KeyName?.toLowerCase()??``;return n.localeCompare(r)}export{u as component};
\ No newline at end of file
diff --git a/spinifex/services/spinifexui/frontend/dist/assets/describe-load-balancers.js b/spinifex/services/spinifexui/frontend/dist/assets/describe-load-balancers.js
index e82fa9461..d1b7831ba 100644
--- a/spinifex/services/spinifexui/frontend/dist/assets/describe-load-balancers.js
+++ b/spinifex/services/spinifexui/frontend/dist/assets/describe-load-balancers.js
@@ -1 +1 @@
-import{t as e}from"./useSuspenseQuery.js";import{Fo as t,Ho as n,Lo as r,M as i,Ro as a,Xa as o,no as s}from"./index.js";import{t as c}from"./page-heading.js";import{t as l}from"./state-badge.js";var u=t(),d=n();function f(){let t=(0,u.c)(10),n=a(),{data:f}=e(i),m;t[0]===f.LoadBalancers?m=t[1]:(m=f.LoadBalancers??[],t[0]=f.LoadBalancers,t[1]=m);let h=m,g;t[2]===n?g=t[3]:(g=(0,d.jsx)(c,{actions:(0,d.jsx)(o,{onClick:async()=>await n({to:`/ec2/create-load-balancer`}),children:`Create Load Balancer`}),title:`Load Balancers`}),t[2]=n,t[3]=g);let _;t[4]!==h||t[5]!==n?(_=h.length>0?(0,d.jsx)(`div`,{className:`overflow-x-auto rounded-lg border bg-card`,children:(0,d.jsxs)(`table`,{className:`w-full text-sm`,children:[(0,d.jsx)(`thead`,{children:(0,d.jsxs)(`tr`,{className:`border-b text-left text-muted-foreground`,children:[(0,d.jsx)(`th`,{className:`px-4 py-2 font-medium`,children:`Name`}),(0,d.jsx)(`th`,{className:`px-4 py-2 font-medium`,children:`DNS Name`}),(0,d.jsx)(`th`,{className:`px-4 py-2 font-medium`,children:`State`}),(0,d.jsx)(`th`,{className:`px-4 py-2 font-medium`,children:`Type`}),(0,d.jsx)(`th`,{className:`px-4 py-2 font-medium`,children:`Scheme`}),(0,d.jsx)(`th`,{className:`px-4 py-2 font-medium`,children:`VPC`}),(0,d.jsx)(`th`,{className:`px-4 py-2 font-medium`,children:`Created`})]})}),(0,d.jsx)(`tbody`,{children:h.map(e=>{if(!e.LoadBalancerArn)return null;let t=e.LoadBalancerArn;return(0,d.jsxs)(`tr`,{className:`cursor-pointer border-b transition-colors last:border-0 hover:bg-accent`,onClick:async()=>await n({to:`/ec2/describe-load-balancers/$id`,params:{id:encodeURIComponent(t)}}),children:[(0,d.jsx)(`td`,{className:`px-4 py-2 font-medium`,children:(0,d.jsx)(r,{className:`text-primary hover:underline`,onClick:p,params:{id:encodeURIComponent(t)},to:`/ec2/describe-load-balancers/$id`,children:e.LoadBalancerName??t})}),(0,d.jsx)(`td`,{className:`px-4 py-2 font-mono text-xs`,children:e.DNSName??``}),(0,d.jsx)(`td`,{className:`px-4 py-2`,children:(0,d.jsx)(l,{state:e.State?.Code})}),(0,d.jsx)(`td`,{className:`px-4 py-2`,children:e.Type??``}),(0,d.jsx)(`td`,{className:`px-4 py-2`,children:e.Scheme??``}),(0,d.jsx)(`td`,{className:`px-4 py-2 font-mono text-xs`,children:e.VpcId??``}),(0,d.jsx)(`td`,{className:`px-4 py-2 text-xs`,children:s(e.CreatedTime)})]},t)})})]})}):(0,d.jsx)(`p`,{className:`text-muted-foreground`,children:`No load balancers found.`}),t[4]=h,t[5]=n,t[6]=_):_=t[6];let v;return t[7]!==g||t[8]!==_?(v=(0,d.jsxs)(d.Fragment,{children:[g,_]}),t[7]=g,t[8]=_,t[9]=v):v=t[9],v}function p(e){return e.stopPropagation()}var m=f;export{m as component};
\ No newline at end of file
+import{n as e,t}from"./compiler-runtime.js";import{t as n}from"./useSuspenseQuery.js";import{Or as r}from"./awsClient.js";import{D as i}from"./SignatureV4.js";import{t as a}from"./button.js";import{Ir as o,R as s}from"./index.js";import{t as c}from"./page-heading.js";import{t as l}from"./state-badge.js";var u=t(),d=e();function f(){let e=(0,u.c)(10),t=o(),{data:f}=n(s),m;e[0]===f.LoadBalancers?m=e[1]:(m=f.LoadBalancers??[],e[0]=f.LoadBalancers,e[1]=m);let h=m,g;e[2]===t?g=e[3]:(g=(0,d.jsx)(c,{actions:(0,d.jsx)(a,{onClick:async()=>await t({to:`/ec2/create-load-balancer`}),children:`Create Load Balancer`}),title:`Load Balancers`}),e[2]=t,e[3]=g);let _;e[4]!==h||e[5]!==t?(_=h.length>0?(0,d.jsx)(`div`,{className:`overflow-x-auto rounded-lg border bg-card`,children:(0,d.jsxs)(`table`,{className:`w-full text-sm`,children:[(0,d.jsx)(`thead`,{children:(0,d.jsxs)(`tr`,{className:`border-b text-left text-muted-foreground`,children:[(0,d.jsx)(`th`,{className:`px-4 py-2 font-medium`,children:`Name`}),(0,d.jsx)(`th`,{className:`px-4 py-2 font-medium`,children:`DNS Name`}),(0,d.jsx)(`th`,{className:`px-4 py-2 font-medium`,children:`State`}),(0,d.jsx)(`th`,{className:`px-4 py-2 font-medium`,children:`Type`}),(0,d.jsx)(`th`,{className:`px-4 py-2 font-medium`,children:`Scheme`}),(0,d.jsx)(`th`,{className:`px-4 py-2 font-medium`,children:`VPC`}),(0,d.jsx)(`th`,{className:`px-4 py-2 font-medium`,children:`Created`})]})}),(0,d.jsx)(`tbody`,{children:h.map(e=>{if(!e.LoadBalancerArn)return null;let n=e.LoadBalancerArn;return(0,d.jsxs)(`tr`,{className:`cursor-pointer border-b transition-colors last:border-0 hover:bg-accent`,onClick:async()=>await t({to:`/ec2/describe-load-balancers/$id`,params:{id:encodeURIComponent(n)}}),children:[(0,d.jsx)(`td`,{className:`px-4 py-2 font-medium`,children:(0,d.jsx)(r,{className:`text-primary hover:underline`,onClick:p,params:{id:encodeURIComponent(n)},to:`/ec2/describe-load-balancers/$id`,children:e.LoadBalancerName??n})}),(0,d.jsx)(`td`,{className:`px-4 py-2 font-mono text-xs`,children:e.DNSName??``}),(0,d.jsx)(`td`,{className:`px-4 py-2`,children:(0,d.jsx)(l,{state:e.State?.Code})}),(0,d.jsx)(`td`,{className:`px-4 py-2`,children:e.Type??``}),(0,d.jsx)(`td`,{className:`px-4 py-2`,children:e.Scheme??``}),(0,d.jsx)(`td`,{className:`px-4 py-2 font-mono text-xs`,children:e.VpcId??``}),(0,d.jsx)(`td`,{className:`px-4 py-2 text-xs`,children:i(e.CreatedTime)})]},n)})})]})}):(0,d.jsx)(`p`,{className:`text-muted-foreground`,children:`No load balancers found.`}),e[4]=h,e[5]=t,e[6]=_):_=e[6];let v;return e[7]!==g||e[8]!==_?(v=(0,d.jsxs)(d.Fragment,{children:[g,_]}),e[7]=g,e[8]=_,e[9]=v):v=e[9],v}function p(e){return e.stopPropagation()}var m=f;export{m as component};
\ No newline at end of file
diff --git a/spinifex/services/spinifexui/frontend/dist/assets/describe-nat-gateways.js b/spinifex/services/spinifexui/frontend/dist/assets/describe-nat-gateways.js
index 9c4ceb0dd..9fb479870 100644
--- a/spinifex/services/spinifexui/frontend/dist/assets/describe-nat-gateways.js
+++ b/spinifex/services/spinifexui/frontend/dist/assets/describe-nat-gateways.js
@@ -1 +1 @@
-import{t as e}from"./useSuspenseQuery.js";import{Fo as t,Ho as n,Lo as r,Xa as i,ao as a,et as o}from"./index.js";import{t as s}from"./page-heading.js";import{t as c}from"./list-card.js";import{t as l}from"./state-badge.js";var u=t(),d=n();function f(){let t=(0,u.c)(5),{data:n}=e(o),a;t[0]===n.NatGateways?a=t[1]:(a=n.NatGateways??[],t[0]=n.NatGateways,t[1]=a);let c=a,l;t[2]===Symbol.for(`react.memo_cache_sentinel`)?(l=(0,d.jsx)(s,{actions:(0,d.jsx)(r,{to:`/ec2/create-nat-gateway`,children:(0,d.jsx)(i,{children:`Create NAT Gateway`})}),title:`NAT Gateways`}),t[2]=l):l=t[2];let f;return t[3]===c?f=t[4]:(f=(0,d.jsxs)(d.Fragment,{children:[l,c.length>0?(0,d.jsx)(`div`,{className:`space-y-4`,children:c.map(p)}):(0,d.jsx)(`p`,{className:`text-muted-foreground`,children:`No NAT Gateways found.`})]}),t[3]=c,t[4]=f),f}function p(e){if(!e.NatGatewayId)return null;let t=a(e.Tags);return(0,d.jsx)(c,{badge:(0,d.jsx)(l,{state:e.State}),params:{id:e.NatGatewayId},subtitle:e.SubnetId??``,title:t?`${e.NatGatewayId} (${t})`:e.NatGatewayId,to:`/ec2/describe-nat-gateways/$id`},e.NatGatewayId)}export{f as component};
\ No newline at end of file
+import{n as e,t}from"./compiler-runtime.js";import{t as n}from"./useSuspenseQuery.js";import{Or as r}from"./awsClient.js";import{A as i}from"./SignatureV4.js";import{t as a}from"./button.js";import{St as o}from"./index.js";import{t as s}from"./page-heading.js";import{t as c}from"./list-card.js";import{t as l}from"./state-badge.js";var u=t(),d=e();function f(){let e=(0,u.c)(5),{data:t}=n(o),i;e[0]===t.NatGateways?i=e[1]:(i=t.NatGateways??[],e[0]=t.NatGateways,e[1]=i);let c=i,l;e[2]===Symbol.for(`react.memo_cache_sentinel`)?(l=(0,d.jsx)(s,{actions:(0,d.jsx)(r,{to:`/ec2/create-nat-gateway`,children:(0,d.jsx)(a,{children:`Create NAT Gateway`})}),title:`NAT Gateways`}),e[2]=l):l=e[2];let f;return e[3]===c?f=e[4]:(f=(0,d.jsxs)(d.Fragment,{children:[l,c.length>0?(0,d.jsx)(`div`,{className:`space-y-4`,children:c.map(p)}):(0,d.jsx)(`p`,{className:`text-muted-foreground`,children:`No NAT Gateways found.`})]}),e[3]=c,e[4]=f),f}function p(e){if(!e.NatGatewayId)return null;let t=i(e.Tags);return(0,d.jsx)(c,{badge:(0,d.jsx)(l,{state:e.State}),params:{id:e.NatGatewayId},subtitle:e.SubnetId??``,title:t?`${e.NatGatewayId} (${t})`:e.NatGatewayId,to:`/ec2/describe-nat-gateways/$id`},e.NatGatewayId)}export{f as component};
\ No newline at end of file
diff --git a/spinifex/services/spinifexui/frontend/dist/assets/describe-placement-groups.js b/spinifex/services/spinifexui/frontend/dist/assets/describe-placement-groups.js
index 30d7320ae..0a290835b 100644
--- a/spinifex/services/spinifexui/frontend/dist/assets/describe-placement-groups.js
+++ b/spinifex/services/spinifexui/frontend/dist/assets/describe-placement-groups.js
@@ -1 +1 @@
-import{t as e}from"./useSuspenseQuery.js";import{Fo as t,Ho as n,Lo as r,Xa as i,nt as a}from"./index.js";import{t as o}from"./page-heading.js";import{t as s}from"./list-card.js";import{t as c}from"./state-badge.js";var l=t(),u=n();function d(){let t=(0,l.c)(5),{data:n}=e(a),s;t[0]===n.PlacementGroups?s=t[1]:(s=n.PlacementGroups??[],t[0]=n.PlacementGroups,t[1]=s);let c=s,d;t[2]===Symbol.for(`react.memo_cache_sentinel`)?(d=(0,u.jsx)(o,{actions:(0,u.jsx)(r,{to:`/ec2/create-placement-group`,children:(0,u.jsx)(i,{children:`Create Placement Group`})}),title:`Placement Groups`}),t[2]=d):d=t[2];let p;return t[3]===c?p=t[4]:(p=(0,u.jsxs)(u.Fragment,{children:[d,c.length>0?(0,u.jsx)(`div`,{className:`space-y-4`,children:c.map(f)}):(0,u.jsx)(`p`,{className:`text-muted-foreground`,children:`No placement groups found.`})]}),t[3]=c,t[4]=p),p}function f(e){return e.GroupId?(0,u.jsx)(s,{badge:(0,u.jsx)(c,{state:e.State}),params:{id:e.GroupId},subtitle:`${e.Strategy} \u2022 ${e.GroupId}`,title:e.GroupName??e.GroupId,to:`/ec2/describe-placement-groups/$id`},e.GroupId):null}export{d as component};
\ No newline at end of file
+import{n as e,t}from"./compiler-runtime.js";import{t as n}from"./useSuspenseQuery.js";import{Or as r}from"./awsClient.js";import{t as i}from"./button.js";import{wt as a}from"./index.js";import{t as o}from"./page-heading.js";import{t as s}from"./list-card.js";import{t as c}from"./state-badge.js";var l=t(),u=e();function d(){let e=(0,l.c)(5),{data:t}=n(a),s;e[0]===t.PlacementGroups?s=e[1]:(s=t.PlacementGroups??[],e[0]=t.PlacementGroups,e[1]=s);let c=s,d;e[2]===Symbol.for(`react.memo_cache_sentinel`)?(d=(0,u.jsx)(o,{actions:(0,u.jsx)(r,{to:`/ec2/create-placement-group`,children:(0,u.jsx)(i,{children:`Create Placement Group`})}),title:`Placement Groups`}),e[2]=d):d=e[2];let p;return e[3]===c?p=e[4]:(p=(0,u.jsxs)(u.Fragment,{children:[d,c.length>0?(0,u.jsx)(`div`,{className:`space-y-4`,children:c.map(f)}):(0,u.jsx)(`p`,{className:`text-muted-foreground`,children:`No placement groups found.`})]}),e[3]=c,e[4]=p),p}function f(e){return e.GroupId?(0,u.jsx)(s,{badge:(0,u.jsx)(c,{state:e.State}),params:{id:e.GroupId},subtitle:`${e.Strategy} \u2022 ${e.GroupId}`,title:e.GroupName??e.GroupId,to:`/ec2/describe-placement-groups/$id`},e.GroupId):null}export{d as component};
\ No newline at end of file
diff --git a/spinifex/services/spinifexui/frontend/dist/assets/describe-route-tables.js b/spinifex/services/spinifexui/frontend/dist/assets/describe-route-tables.js
index 378784f81..f8187b859 100644
--- a/spinifex/services/spinifexui/frontend/dist/assets/describe-route-tables.js
+++ b/spinifex/services/spinifexui/frontend/dist/assets/describe-route-tables.js
@@ -1 +1 @@
-import{t as e}from"./useSuspenseQuery.js";import{Fo as t,Ho as n,Lo as r,Xa as i,ao as a,at as o}from"./index.js";import{t as s}from"./page-heading.js";import{t as c}from"./list-card.js";import{t as l}from"./badge.js";var u=t(),d=n();function f(){let t=(0,u.c)(5),{data:n}=e(o),a;t[0]===n.RouteTables?a=t[1]:(a=n.RouteTables??[],t[0]=n.RouteTables,t[1]=a);let c=a,l;t[2]===Symbol.for(`react.memo_cache_sentinel`)?(l=(0,d.jsx)(s,{actions:(0,d.jsx)(r,{to:`/ec2/create-route-table`,children:(0,d.jsx)(i,{children:`Create Route Table`})}),title:`Route Tables`}),t[2]=l):l=t[2];let f;return t[3]===c?f=t[4]:(f=(0,d.jsxs)(d.Fragment,{children:[l,c.length>0?(0,d.jsx)(`div`,{className:`space-y-4`,children:c.map(p)}):(0,d.jsx)(`p`,{className:`text-muted-foreground`,children:`No Route Tables found.`})]}),t[3]=c,t[4]=f),f}function p(e){if(!e.RouteTableId)return null;let t=a(e.Tags),n=e.Associations?.some(h)??!1,r=e.Associations?.filter(m).length??0;return(0,d.jsx)(c,{badge:n?(0,d.jsx)(l,{variant:`secondary`,children:`Main`}):null,params:{id:e.RouteTableId},subtitle:`${e.VpcId??``} · ${r} subnet${r===1?``:`s`}`,title:t?`${e.RouteTableId} (${t})`:e.RouteTableId,to:`/ec2/describe-route-tables/$id`},e.RouteTableId)}function m(e){return e.SubnetId}function h(e){return e.Main}export{f as component};
\ No newline at end of file
+import{n as e,t}from"./compiler-runtime.js";import{t as n}from"./useSuspenseQuery.js";import{Or as r}from"./awsClient.js";import{A as i}from"./SignatureV4.js";import{t as a}from"./button.js";import{Dt as o}from"./index.js";import{t as s}from"./page-heading.js";import{t as c}from"./list-card.js";import{t as l}from"./badge.js";var u=t(),d=e();function f(){let e=(0,u.c)(5),{data:t}=n(o),i;e[0]===t.RouteTables?i=e[1]:(i=t.RouteTables??[],e[0]=t.RouteTables,e[1]=i);let c=i,l;e[2]===Symbol.for(`react.memo_cache_sentinel`)?(l=(0,d.jsx)(s,{actions:(0,d.jsx)(r,{to:`/ec2/create-route-table`,children:(0,d.jsx)(a,{children:`Create Route Table`})}),title:`Route Tables`}),e[2]=l):l=e[2];let f;return e[3]===c?f=e[4]:(f=(0,d.jsxs)(d.Fragment,{children:[l,c.length>0?(0,d.jsx)(`div`,{className:`space-y-4`,children:c.map(p)}):(0,d.jsx)(`p`,{className:`text-muted-foreground`,children:`No Route Tables found.`})]}),e[3]=c,e[4]=f),f}function p(e){if(!e.RouteTableId)return null;let t=i(e.Tags),n=e.Associations?.some(h)??!1,r=e.Associations?.filter(m).length??0;return(0,d.jsx)(c,{badge:n?(0,d.jsx)(l,{variant:`secondary`,children:`Main`}):null,params:{id:e.RouteTableId},subtitle:`${e.VpcId??``} · ${r} subnet${r===1?``:`s`}`,title:t?`${e.RouteTableId} (${t})`:e.RouteTableId,to:`/ec2/describe-route-tables/$id`},e.RouteTableId)}function m(e){return e.SubnetId}function h(e){return e.Main}export{f as component};
\ No newline at end of file
diff --git a/spinifex/services/spinifexui/frontend/dist/assets/describe-security-groups.js b/spinifex/services/spinifexui/frontend/dist/assets/describe-security-groups.js
index c7ae4cd81..de4a29294 100644
--- a/spinifex/services/spinifexui/frontend/dist/assets/describe-security-groups.js
+++ b/spinifex/services/spinifexui/frontend/dist/assets/describe-security-groups.js
@@ -1 +1 @@
-import{t as e}from"./useSuspenseQuery.js";import{Fo as t,Ho as n,Lo as r,Xa as i,st as a}from"./index.js";import{t as o}from"./page-heading.js";import{t as s}from"./list-card.js";var c=t(),l=n();function u(){let t=(0,c.c)(5),{data:n}=e(a),s;t[0]===n.SecurityGroups?s=t[1]:(s=n.SecurityGroups??[],t[0]=n.SecurityGroups,t[1]=s);let u=s,f;t[2]===Symbol.for(`react.memo_cache_sentinel`)?(f=(0,l.jsx)(o,{actions:(0,l.jsx)(r,{to:`/ec2/create-security-group`,children:(0,l.jsx)(i,{children:`Create Security Group`})}),title:`Security Groups`}),t[2]=f):f=t[2];let p;return t[3]===u?p=t[4]:(p=(0,l.jsxs)(l.Fragment,{children:[f,u.length>0?(0,l.jsx)(`div`,{className:`space-y-4`,children:u.map(d)}):(0,l.jsx)(`p`,{className:`text-muted-foreground`,children:`No security groups found.`})]}),t[3]=u,t[4]=p),p}function d(e){return e.GroupId?(0,l.jsx)(s,{params:{id:e.GroupId},subtitle:e.VpcId??``,title:e.GroupName?`${e.GroupId} (${e.GroupName})`:e.GroupId,to:`/ec2/describe-security-groups/$id`},e.GroupId):null}export{u as component};
\ No newline at end of file
+import{n as e,t}from"./compiler-runtime.js";import{t as n}from"./useSuspenseQuery.js";import{Or as r}from"./awsClient.js";import{t as i}from"./button.js";import{kt as a}from"./index.js";import{t as o}from"./page-heading.js";import{t as s}from"./list-card.js";var c=t(),l=e();function u(){let e=(0,c.c)(5),{data:t}=n(a),s;e[0]===t.SecurityGroups?s=e[1]:(s=t.SecurityGroups??[],e[0]=t.SecurityGroups,e[1]=s);let u=s,f;e[2]===Symbol.for(`react.memo_cache_sentinel`)?(f=(0,l.jsx)(o,{actions:(0,l.jsx)(r,{to:`/ec2/create-security-group`,children:(0,l.jsx)(i,{children:`Create Security Group`})}),title:`Security Groups`}),e[2]=f):f=e[2];let p;return e[3]===u?p=e[4]:(p=(0,l.jsxs)(l.Fragment,{children:[f,u.length>0?(0,l.jsx)(`div`,{className:`space-y-4`,children:u.map(d)}):(0,l.jsx)(`p`,{className:`text-muted-foreground`,children:`No security groups found.`})]}),e[3]=u,e[4]=p),p}function d(e){return e.GroupId?(0,l.jsx)(s,{params:{id:e.GroupId},subtitle:e.VpcId??``,title:e.GroupName?`${e.GroupId} (${e.GroupName})`:e.GroupId,to:`/ec2/describe-security-groups/$id`},e.GroupId):null}export{u as component};
\ No newline at end of file
diff --git a/spinifex/services/spinifexui/frontend/dist/assets/describe-snapshots.js b/spinifex/services/spinifexui/frontend/dist/assets/describe-snapshots.js
index 1c5606a44..76e721908 100644
--- a/spinifex/services/spinifexui/frontend/dist/assets/describe-snapshots.js
+++ b/spinifex/services/spinifexui/frontend/dist/assets/describe-snapshots.js
@@ -1 +1 @@
-import{t as e}from"./useSuspenseQuery.js";import{Fo as t,Ho as n,Lo as r,Xa as i,lt as a}from"./index.js";import{t as o}from"./page-heading.js";import{t as s}from"./list-card.js";import{t as c}from"./state-badge.js";var l=t(),u=n();function d(){let t=(0,l.c)(5),{data:n}=e(a),s;t[0]===n.Snapshots?s=t[1]:(s=n.Snapshots??[],t[0]=n.Snapshots,t[1]=s);let c=s,d;t[2]===Symbol.for(`react.memo_cache_sentinel`)?(d=(0,u.jsx)(o,{actions:(0,u.jsx)(r,{to:`/ec2/create-snapshot`,children:(0,u.jsx)(i,{children:`Create Snapshot`})}),title:`Snapshots`}),t[2]=d):d=t[2];let p;return t[3]===c?p=t[4]:(p=(0,u.jsxs)(u.Fragment,{children:[d,c.length>0?(0,u.jsx)(`div`,{className:`space-y-4`,children:c.map(f)}):(0,u.jsx)(`p`,{className:`text-muted-foreground`,children:`No snapshots found.`})]}),t[3]=c,t[4]=p),p}function f(e){return e.SnapshotId?(0,u.jsx)(s,{badge:(0,u.jsx)(c,{state:e.State}),params:{id:e.SnapshotId},subtitle:`${e.VolumeSize} GiB \u2022 ${e.VolumeId}`,title:e.SnapshotId,to:`/ec2/describe-snapshots/$id`},e.SnapshotId):null}export{d as component};
\ No newline at end of file
+import{n as e,t}from"./compiler-runtime.js";import{t as n}from"./useSuspenseQuery.js";import{Or as r}from"./awsClient.js";import{t as i}from"./button.js";import{jt as a}from"./index.js";import{t as o}from"./page-heading.js";import{t as s}from"./list-card.js";import{t as c}from"./state-badge.js";var l=t(),u=e();function d(){let e=(0,l.c)(5),{data:t}=n(a),s;e[0]===t.Snapshots?s=e[1]:(s=t.Snapshots??[],e[0]=t.Snapshots,e[1]=s);let c=s,d;e[2]===Symbol.for(`react.memo_cache_sentinel`)?(d=(0,u.jsx)(o,{actions:(0,u.jsx)(r,{to:`/ec2/create-snapshot`,children:(0,u.jsx)(i,{children:`Create Snapshot`})}),title:`Snapshots`}),e[2]=d):d=e[2];let p;return e[3]===c?p=e[4]:(p=(0,u.jsxs)(u.Fragment,{children:[d,c.length>0?(0,u.jsx)(`div`,{className:`space-y-4`,children:c.map(f)}):(0,u.jsx)(`p`,{className:`text-muted-foreground`,children:`No snapshots found.`})]}),e[3]=c,e[4]=p),p}function f(e){return e.SnapshotId?(0,u.jsx)(s,{badge:(0,u.jsx)(c,{state:e.State}),params:{id:e.SnapshotId},subtitle:`${e.VolumeSize} GiB \u2022 ${e.VolumeId}`,title:e.SnapshotId,to:`/ec2/describe-snapshots/$id`},e.SnapshotId):null}export{d as component};
\ No newline at end of file
diff --git a/spinifex/services/spinifexui/frontend/dist/assets/describe-subnets.js b/spinifex/services/spinifexui/frontend/dist/assets/describe-subnets.js
index 1bb08537d..e25a0f4d6 100644
--- a/spinifex/services/spinifexui/frontend/dist/assets/describe-subnets.js
+++ b/spinifex/services/spinifexui/frontend/dist/assets/describe-subnets.js
@@ -1 +1 @@
-import{t as e}from"./useSuspenseQuery.js";import{Fo as t,Ho as n,Lo as r,Xa as i,ao as a,dt as o}from"./index.js";import{t as s}from"./page-heading.js";import{t as c}from"./list-card.js";import{t as l}from"./state-badge.js";var u=t(),d=n();function f(){let t=(0,u.c)(5),{data:n}=e(o),a;t[0]===n.Subnets?a=t[1]:(a=n.Subnets??[],t[0]=n.Subnets,t[1]=a);let c=a,l;t[2]===Symbol.for(`react.memo_cache_sentinel`)?(l=(0,d.jsx)(s,{actions:(0,d.jsx)(r,{to:`/ec2/create-subnet`,children:(0,d.jsx)(i,{children:`Create Subnet`})}),title:`Subnets`}),t[2]=l):l=t[2];let f;return t[3]===c?f=t[4]:(f=(0,d.jsxs)(d.Fragment,{children:[l,c.length>0?(0,d.jsx)(`div`,{className:`space-y-4`,children:c.map(p)}):(0,d.jsx)(`p`,{className:`text-muted-foreground`,children:`No subnets found.`})]}),t[3]=c,t[4]=f),f}function p(e){if(!e.SubnetId)return null;let t=a(e.Tags);return(0,d.jsx)(c,{badge:(0,d.jsx)(l,{state:e.State}),params:{id:e.SubnetId},subtitle:`${e.CidrBlock??``} \u2022 ${e.AvailabilityZone??``}`,title:t?`${e.SubnetId} (${t})`:e.SubnetId,to:`/ec2/describe-subnets/$id`},e.SubnetId)}export{f as component};
\ No newline at end of file
+import{n as e,t}from"./compiler-runtime.js";import{t as n}from"./useSuspenseQuery.js";import{Or as r}from"./awsClient.js";import{A as i}from"./SignatureV4.js";import{t as a}from"./button.js";import{Nt as o}from"./index.js";import{t as s}from"./page-heading.js";import{t as c}from"./list-card.js";import{t as l}from"./state-badge.js";var u=t(),d=e();function f(){let e=(0,u.c)(5),{data:t}=n(o),i;e[0]===t.Subnets?i=e[1]:(i=t.Subnets??[],e[0]=t.Subnets,e[1]=i);let c=i,l;e[2]===Symbol.for(`react.memo_cache_sentinel`)?(l=(0,d.jsx)(s,{actions:(0,d.jsx)(r,{to:`/ec2/create-subnet`,children:(0,d.jsx)(a,{children:`Create Subnet`})}),title:`Subnets`}),e[2]=l):l=e[2];let f;return e[3]===c?f=e[4]:(f=(0,d.jsxs)(d.Fragment,{children:[l,c.length>0?(0,d.jsx)(`div`,{className:`space-y-4`,children:c.map(p)}):(0,d.jsx)(`p`,{className:`text-muted-foreground`,children:`No subnets found.`})]}),e[3]=c,e[4]=f),f}function p(e){if(!e.SubnetId)return null;let t=i(e.Tags);return(0,d.jsx)(c,{badge:(0,d.jsx)(l,{state:e.State}),params:{id:e.SubnetId},subtitle:`${e.CidrBlock??``} \u2022 ${e.AvailabilityZone??``}`,title:t?`${e.SubnetId} (${t})`:e.SubnetId,to:`/ec2/describe-subnets/$id`},e.SubnetId)}export{f as component};
\ No newline at end of file
diff --git a/spinifex/services/spinifexui/frontend/dist/assets/describe-target-groups.js b/spinifex/services/spinifexui/frontend/dist/assets/describe-target-groups.js
index eec1b40ce..6e93a5959 100644
--- a/spinifex/services/spinifexui/frontend/dist/assets/describe-target-groups.js
+++ b/spinifex/services/spinifexui/frontend/dist/assets/describe-target-groups.js
@@ -1 +1 @@
-import{t as e}from"./useQuery.js";import{t}from"./useSuspenseQuery.js";import{Fo as n,Ho as r,I as i,L as a,Lo as o,Ro as s,Xa as c}from"./index.js";import{t as l}from"./page-heading.js";var u=n(),d=r();function f(){let e=(0,u.c)(6),n=s(),{data:r}=t(i),a;e[0]===r.TargetGroups?a=e[1]:(a=r.TargetGroups??[],e[0]=r.TargetGroups,e[1]=a);let f=a,h;e[2]===Symbol.for(`react.memo_cache_sentinel`)?(h=(0,d.jsx)(l,{actions:(0,d.jsx)(o,{to:`/ec2/create-target-group`,children:(0,d.jsx)(c,{children:`Create Target Group`})}),title:`Target Groups`}),e[2]=h):h=e[2];let g;return e[3]!==n||e[4]!==f?(g=(0,d.jsxs)(d.Fragment,{children:[h,f.length>0?(0,d.jsx)(`div`,{className:`overflow-x-auto rounded-lg border bg-card`,children:(0,d.jsxs)(`table`,{className:`w-full text-sm`,children:[(0,d.jsx)(`thead`,{children:(0,d.jsxs)(`tr`,{className:`border-b text-left text-muted-foreground`,children:[(0,d.jsx)(`th`,{className:`px-4 py-2 font-medium`,children:`Name`}),(0,d.jsx)(`th`,{className:`px-4 py-2 font-medium`,children:`Protocol`}),(0,d.jsx)(`th`,{className:`px-4 py-2 font-medium`,children:`Port`}),(0,d.jsx)(`th`,{className:`px-4 py-2 font-medium`,children:`VPC`}),(0,d.jsx)(`th`,{className:`px-4 py-2 font-medium`,children:`Target Type`}),(0,d.jsx)(`th`,{className:`px-4 py-2 font-medium`,children:`Targets`}),(0,d.jsx)(`th`,{className:`px-4 py-2 font-medium`,children:`Health`})]})}),(0,d.jsx)(`tbody`,{children:f.map(e=>{if(!e.TargetGroupArn)return null;let t=e.TargetGroupArn;return(0,d.jsxs)(`tr`,{className:`cursor-pointer border-b transition-colors last:border-0 hover:bg-accent`,onClick:async()=>await n({to:`/ec2/describe-target-groups/$id`,params:{id:encodeURIComponent(t)}}),children:[(0,d.jsx)(`td`,{className:`px-4 py-2 font-medium`,children:(0,d.jsx)(o,{className:`text-primary hover:underline`,onClick:p,params:{id:encodeURIComponent(t)},to:`/ec2/describe-target-groups/$id`,children:e.TargetGroupName??t})}),(0,d.jsx)(`td`,{className:`px-4 py-2`,children:e.Protocol??``}),(0,d.jsx)(`td`,{className:`px-4 py-2`,children:e.Port??``}),(0,d.jsx)(`td`,{className:`px-4 py-2 font-mono text-xs`,children:e.VpcId??``}),(0,d.jsx)(`td`,{className:`px-4 py-2`,children:e.TargetType??``}),(0,d.jsx)(m,{arn:t})]},t)})})]})}):(0,d.jsx)(`p`,{className:`text-muted-foreground`,children:`No target groups found.`})]}),e[3]=n,e[4]=f,e[5]=g):g=e[5],g}function p(e){return e.stopPropagation()}function m(t){let n=(0,u.c)(13),{arn:r}=t,i;n[0]===r?i=n[1]:(i=a(r),n[0]=r,n[1]=i);let{data:o,isLoading:s}=e(i);if(s){let e;return n[2]===Symbol.for(`react.memo_cache_sentinel`)?(e=(0,d.jsxs)(d.Fragment,{children:[(0,d.jsx)(`td`,{className:`px-4 py-2 text-muted-foreground`,children:`…`}),(0,d.jsx)(`td`,{className:`px-4 py-2 text-muted-foreground`,children:`…`})]}),n[2]=e):e=n[2],e}let c,l;if(n[3]!==o?.TargetHealthDescriptions){c=o?.TargetHealthDescriptions??[];let e={};for(let t of c){let n=t.TargetHealth?.State??`unknown`;e[n]=(e[n]??0)+1}l=Object.entries(e).map(h).join(`, `)||`—`,n[3]=o?.TargetHealthDescriptions,n[4]=c,n[5]=l}else c=n[4],l=n[5];let f=l,p;n[6]===c.length?p=n[7]:(p=(0,d.jsx)(`td`,{className:`px-4 py-2`,children:c.length}),n[6]=c.length,n[7]=p);let m;n[8]===f?m=n[9]:(m=(0,d.jsx)(`td`,{className:`px-4 py-2 text-xs`,children:f}),n[8]=f,n[9]=m);let g;return n[10]!==p||n[11]!==m?(g=(0,d.jsxs)(d.Fragment,{children:[p,m]}),n[10]=p,n[11]=m,n[12]=g):g=n[12],g}function h(e){let[t,n]=e;return`${n} ${t}`}var g=f;export{g as component};
\ No newline at end of file
+import{n as e,t}from"./compiler-runtime.js";import{t as n}from"./useQuery.js";import{t as r}from"./useSuspenseQuery.js";import{Or as i}from"./awsClient.js";import{t as a}from"./button.js";import{Ir as o,U as s,W as c}from"./index.js";import{t as l}from"./page-heading.js";var u=t(),d=e();function f(){let e=(0,u.c)(6),t=o(),{data:n}=r(s),c;e[0]===n.TargetGroups?c=e[1]:(c=n.TargetGroups??[],e[0]=n.TargetGroups,e[1]=c);let f=c,h;e[2]===Symbol.for(`react.memo_cache_sentinel`)?(h=(0,d.jsx)(l,{actions:(0,d.jsx)(i,{to:`/ec2/create-target-group`,children:(0,d.jsx)(a,{children:`Create Target Group`})}),title:`Target Groups`}),e[2]=h):h=e[2];let g;return e[3]!==t||e[4]!==f?(g=(0,d.jsxs)(d.Fragment,{children:[h,f.length>0?(0,d.jsx)(`div`,{className:`overflow-x-auto rounded-lg border bg-card`,children:(0,d.jsxs)(`table`,{className:`w-full text-sm`,children:[(0,d.jsx)(`thead`,{children:(0,d.jsxs)(`tr`,{className:`border-b text-left text-muted-foreground`,children:[(0,d.jsx)(`th`,{className:`px-4 py-2 font-medium`,children:`Name`}),(0,d.jsx)(`th`,{className:`px-4 py-2 font-medium`,children:`Protocol`}),(0,d.jsx)(`th`,{className:`px-4 py-2 font-medium`,children:`Port`}),(0,d.jsx)(`th`,{className:`px-4 py-2 font-medium`,children:`VPC`}),(0,d.jsx)(`th`,{className:`px-4 py-2 font-medium`,children:`Target Type`}),(0,d.jsx)(`th`,{className:`px-4 py-2 font-medium`,children:`Targets`}),(0,d.jsx)(`th`,{className:`px-4 py-2 font-medium`,children:`Health`})]})}),(0,d.jsx)(`tbody`,{children:f.map(e=>{if(!e.TargetGroupArn)return null;let n=e.TargetGroupArn;return(0,d.jsxs)(`tr`,{className:`cursor-pointer border-b transition-colors last:border-0 hover:bg-accent`,onClick:async()=>await t({to:`/ec2/describe-target-groups/$id`,params:{id:encodeURIComponent(n)}}),children:[(0,d.jsx)(`td`,{className:`px-4 py-2 font-medium`,children:(0,d.jsx)(i,{className:`text-primary hover:underline`,onClick:p,params:{id:encodeURIComponent(n)},to:`/ec2/describe-target-groups/$id`,children:e.TargetGroupName??n})}),(0,d.jsx)(`td`,{className:`px-4 py-2`,children:e.Protocol??``}),(0,d.jsx)(`td`,{className:`px-4 py-2`,children:e.Port??``}),(0,d.jsx)(`td`,{className:`px-4 py-2 font-mono text-xs`,children:e.VpcId??``}),(0,d.jsx)(`td`,{className:`px-4 py-2`,children:e.TargetType??``}),(0,d.jsx)(m,{arn:n})]},n)})})]})}):(0,d.jsx)(`p`,{className:`text-muted-foreground`,children:`No target groups found.`})]}),e[3]=t,e[4]=f,e[5]=g):g=e[5],g}function p(e){return e.stopPropagation()}function m(e){let t=(0,u.c)(13),{arn:r}=e,i;t[0]===r?i=t[1]:(i=c(r),t[0]=r,t[1]=i);let{data:a,isLoading:o}=n(i);if(o){let e;return t[2]===Symbol.for(`react.memo_cache_sentinel`)?(e=(0,d.jsxs)(d.Fragment,{children:[(0,d.jsx)(`td`,{className:`px-4 py-2 text-muted-foreground`,children:`…`}),(0,d.jsx)(`td`,{className:`px-4 py-2 text-muted-foreground`,children:`…`})]}),t[2]=e):e=t[2],e}let s,l;if(t[3]!==a?.TargetHealthDescriptions){s=a?.TargetHealthDescriptions??[];let e={};for(let t of s){let n=t.TargetHealth?.State??`unknown`;e[n]=(e[n]??0)+1}l=Object.entries(e).map(h).join(`, `)||`—`,t[3]=a?.TargetHealthDescriptions,t[4]=s,t[5]=l}else s=t[4],l=t[5];let f=l,p;t[6]===s.length?p=t[7]:(p=(0,d.jsx)(`td`,{className:`px-4 py-2`,children:s.length}),t[6]=s.length,t[7]=p);let m;t[8]===f?m=t[9]:(m=(0,d.jsx)(`td`,{className:`px-4 py-2 text-xs`,children:f}),t[8]=f,t[9]=m);let g;return t[10]!==p||t[11]!==m?(g=(0,d.jsxs)(d.Fragment,{children:[p,m]}),t[10]=p,t[11]=m,t[12]=g):g=t[12],g}function h(e){let[t,n]=e;return`${n} ${t}`}var g=f;export{g as component};
\ No newline at end of file
diff --git a/spinifex/services/spinifexui/frontend/dist/assets/describe-volumes.js b/spinifex/services/spinifexui/frontend/dist/assets/describe-volumes.js
index 7d90520f5..86684fb4e 100644
--- a/spinifex/services/spinifexui/frontend/dist/assets/describe-volumes.js
+++ b/spinifex/services/spinifexui/frontend/dist/assets/describe-volumes.js
@@ -1 +1 @@
-import{t as e}from"./useSuspenseQuery.js";import{Fo as t,Ho as n,Lo as r,Xa as i,pt as a}from"./index.js";import{t as o}from"./page-heading.js";import{t as s}from"./list-card.js";import{t as c}from"./state-badge.js";var l=t(),u=n();function d(){let t=(0,l.c)(5),{data:n}=e(a),s;t[0]===n.Volumes?s=t[1]:(s=n.Volumes??[],t[0]=n.Volumes,t[1]=s);let c=s,d;t[2]===Symbol.for(`react.memo_cache_sentinel`)?(d=(0,u.jsx)(o,{actions:(0,u.jsx)(r,{to:`/ec2/create-volume`,children:(0,u.jsx)(i,{children:`Create Volume`})}),title:`Volumes`}),t[2]=d):d=t[2];let p;return t[3]===c?p=t[4]:(p=(0,u.jsxs)(u.Fragment,{children:[d,c.length>0?(0,u.jsx)(`div`,{className:`space-y-4`,children:c.map(f)}):(0,u.jsx)(`p`,{className:`text-muted-foreground`,children:`No volumes found.`})]}),t[3]=c,t[4]=p),p}function f(e){return e.VolumeId?(0,u.jsx)(s,{badge:(0,u.jsx)(c,{state:e.State}),params:{id:e.VolumeId},subtitle:`${e.Size} GiB`,title:e.VolumeId,to:`/ec2/describe-volumes/$id`},e.VolumeId):null}export{d as component};
\ No newline at end of file
+import{n as e,t}from"./compiler-runtime.js";import{t as n}from"./useSuspenseQuery.js";import{Or as r}from"./awsClient.js";import{t as i}from"./button.js";import{Ft as a}from"./index.js";import{t as o}from"./page-heading.js";import{t as s}from"./list-card.js";import{t as c}from"./state-badge.js";var l=t(),u=e();function d(){let e=(0,l.c)(5),{data:t}=n(a),s;e[0]===t.Volumes?s=e[1]:(s=t.Volumes??[],e[0]=t.Volumes,e[1]=s);let c=s,d;e[2]===Symbol.for(`react.memo_cache_sentinel`)?(d=(0,u.jsx)(o,{actions:(0,u.jsx)(r,{to:`/ec2/create-volume`,children:(0,u.jsx)(i,{children:`Create Volume`})}),title:`Volumes`}),e[2]=d):d=e[2];let p;return e[3]===c?p=e[4]:(p=(0,u.jsxs)(u.Fragment,{children:[d,c.length>0?(0,u.jsx)(`div`,{className:`space-y-4`,children:c.map(f)}):(0,u.jsx)(`p`,{className:`text-muted-foreground`,children:`No volumes found.`})]}),e[3]=c,e[4]=p),p}function f(e){return e.VolumeId?(0,u.jsx)(s,{badge:(0,u.jsx)(c,{state:e.State}),params:{id:e.VolumeId},subtitle:`${e.Size} GiB`,title:e.VolumeId,to:`/ec2/describe-volumes/$id`},e.VolumeId):null}export{d as component};
\ No newline at end of file
diff --git a/spinifex/services/spinifexui/frontend/dist/assets/describe-vpcs.js b/spinifex/services/spinifexui/frontend/dist/assets/describe-vpcs.js
index 1666d16e1..730f90493 100644
--- a/spinifex/services/spinifexui/frontend/dist/assets/describe-vpcs.js
+++ b/spinifex/services/spinifexui/frontend/dist/assets/describe-vpcs.js
@@ -1 +1 @@
-import{t as e}from"./useSuspenseQuery.js";import{Fo as t,Ho as n,Lo as r,Xa as i,ao as a,ht as o}from"./index.js";import{t as s}from"./page-heading.js";import{t as c}from"./list-card.js";import{t as l}from"./state-badge.js";var u=t(),d=n();function f(){let t=(0,u.c)(5),{data:n}=e(o),a;t[0]===n.Vpcs?a=t[1]:(a=n.Vpcs??[],t[0]=n.Vpcs,t[1]=a);let c=a,l;t[2]===Symbol.for(`react.memo_cache_sentinel`)?(l=(0,d.jsx)(s,{actions:(0,d.jsx)(r,{to:`/ec2/create-vpc`,children:(0,d.jsx)(i,{children:`Create VPC`})}),title:`VPCs`}),t[2]=l):l=t[2];let f;return t[3]===c?f=t[4]:(f=(0,d.jsxs)(d.Fragment,{children:[l,c.length>0?(0,d.jsx)(`div`,{className:`space-y-4`,children:c.map(p)}):(0,d.jsx)(`p`,{className:`text-muted-foreground`,children:`No VPCs found.`})]}),t[3]=c,t[4]=f),f}function p(e){if(!e.VpcId)return null;let t=a(e.Tags);return(0,d.jsx)(c,{badge:(0,d.jsx)(l,{state:e.State}),params:{id:e.VpcId},subtitle:e.CidrBlock??``,title:t?`${e.VpcId} (${t})`:e.VpcId,to:`/ec2/describe-vpcs/$id`},e.VpcId)}export{f as component};
\ No newline at end of file
+import{n as e,t}from"./compiler-runtime.js";import{t as n}from"./useSuspenseQuery.js";import{Or as r}from"./awsClient.js";import{A as i}from"./SignatureV4.js";import{t as a}from"./button.js";import{Lt as o}from"./index.js";import{t as s}from"./page-heading.js";import{t as c}from"./list-card.js";import{t as l}from"./state-badge.js";var u=t(),d=e();function f(){let e=(0,u.c)(5),{data:t}=n(o),i;e[0]===t.Vpcs?i=e[1]:(i=t.Vpcs??[],e[0]=t.Vpcs,e[1]=i);let c=i,l;e[2]===Symbol.for(`react.memo_cache_sentinel`)?(l=(0,d.jsx)(s,{actions:(0,d.jsx)(r,{to:`/ec2/create-vpc`,children:(0,d.jsx)(a,{children:`Create VPC`})}),title:`VPCs`}),e[2]=l):l=e[2];let f;return e[3]===c?f=e[4]:(f=(0,d.jsxs)(d.Fragment,{children:[l,c.length>0?(0,d.jsx)(`div`,{className:`space-y-4`,children:c.map(p)}):(0,d.jsx)(`p`,{className:`text-muted-foreground`,children:`No VPCs found.`})]}),e[3]=c,e[4]=f),f}function p(e){if(!e.VpcId)return null;let t=i(e.Tags);return(0,d.jsx)(c,{badge:(0,d.jsx)(l,{state:e.State}),params:{id:e.VpcId},subtitle:e.CidrBlock??``,title:t?`${e.VpcId} (${t})`:e.VpcId,to:`/ec2/describe-vpcs/$id`},e.VpcId)}export{f as component};
\ No newline at end of file
diff --git a/spinifex/services/spinifexui/frontend/dist/assets/detail-row.js b/spinifex/services/spinifexui/frontend/dist/assets/detail-row.js
index 005aac22c..942dfe228 100644
--- a/spinifex/services/spinifexui/frontend/dist/assets/detail-row.js
+++ b/spinifex/services/spinifexui/frontend/dist/assets/detail-row.js
@@ -1 +1 @@
-import{Fo as e,Ho as t}from"./index.js";var n=e(),r=t();function i(e){let t=(0,n.c)(2),{children:i}=e,a;return t[0]===i?a=t[1]:(a=(0,r.jsx)(`div`,{className:`rounded-lg border bg-card`,children:i}),t[0]=i,t[1]=a),a}function a(e){let t=(0,n.c)(2),{children:i}=e,a;return t[0]===i?a=t[1]:(a=(0,r.jsx)(`div`,{className:`border-b p-4`,children:(0,r.jsx)(`h2`,{className:`font-semibold`,children:i})}),t[0]=i,t[1]=a),a}function o(e){let t=(0,n.c)(2),{children:i}=e,a;return t[0]===i?a=t[1]:(a=(0,r.jsx)(`div`,{className:`grid gap-4 p-4 sm:grid-cols-2`,children:i}),t[0]=i,t[1]=a),a}var s=Object.assign(i,{Header:a,Content:o});function c(e){let t=(0,n.c)(9),{label:i,value:a}=e,o;t[0]===i?o=t[1]:(o=(0,r.jsx)(`dt`,{className:`text-sm text-muted-foreground`,children:i}),t[0]=i,t[1]=o);let s;t[2]===a?s=t[3]:(s=a||(0,r.jsx)(`span`,{className:`text-muted-foreground`,children:`—`}),t[2]=a,t[3]=s);let c;t[4]===s?c=t[5]:(c=(0,r.jsx)(`dd`,{className:`mt-1 font-mono text-sm`,children:s}),t[4]=s,t[5]=c);let l;return t[6]!==o||t[7]!==c?(l=(0,r.jsxs)(`div`,{children:[o,c]}),t[6]=o,t[7]=c,t[8]=l):l=t[8],l}export{s as n,c as t};
\ No newline at end of file
+import{n as e,t}from"./compiler-runtime.js";var n=t(),r=e();function i(e){let t=(0,n.c)(2),{children:i}=e,a;return t[0]===i?a=t[1]:(a=(0,r.jsx)(`div`,{className:`rounded-lg border bg-card`,children:i}),t[0]=i,t[1]=a),a}function a(e){let t=(0,n.c)(2),{children:i}=e,a;return t[0]===i?a=t[1]:(a=(0,r.jsx)(`div`,{className:`border-b p-4`,children:(0,r.jsx)(`h2`,{className:`font-semibold`,children:i})}),t[0]=i,t[1]=a),a}function o(e){let t=(0,n.c)(2),{children:i}=e,a;return t[0]===i?a=t[1]:(a=(0,r.jsx)(`div`,{className:`grid gap-4 p-4 sm:grid-cols-2`,children:i}),t[0]=i,t[1]=a),a}var s=Object.assign(i,{Header:a,Content:o});function c(e){let t=(0,n.c)(9),{label:i,value:a}=e,o;t[0]===i?o=t[1]:(o=(0,r.jsx)(`dt`,{className:`text-sm text-muted-foreground`,children:i}),t[0]=i,t[1]=o);let s;t[2]===a?s=t[3]:(s=a||(0,r.jsx)(`span`,{className:`text-muted-foreground`,children:`—`}),t[2]=a,t[3]=s);let c;t[4]===s?c=t[5]:(c=(0,r.jsx)(`dd`,{className:`mt-1 font-mono text-sm`,children:s}),t[4]=s,t[5]=c);let l;return t[6]!==o||t[7]!==c?(l=(0,r.jsxs)(`div`,{children:[o,c]}),t[6]=o,t[7]=c,t[8]=l):l=t[8],l}export{s as n,c as t};
\ No newline at end of file
diff --git a/spinifex/services/spinifexui/frontend/dist/assets/dist.js b/spinifex/services/spinifexui/frontend/dist/assets/dist.js
new file mode 100644
index 000000000..355493ed3
--- /dev/null
+++ b/spinifex/services/spinifexui/frontend/dist/assets/dist.js
@@ -0,0 +1 @@
+import{o as e,r as t}from"./compiler-runtime.js";import{M as n}from"./SignatureV4.js";function r(e,t){if(e&&!t)return e;if(!e&&t)return t;if(e||t)return{...e,...t}}var i={};function a(e,t,n,r,i){if(!n&&!r&&!i&&!e)return s(t);let a=s(e);return t&&(a=c(a,t)),n&&(a=c(a,n)),r&&(a=c(a,r)),i&&(a=c(a,i)),a}function o(e){if(e.length===0)return i;if(e.length===1)return s(e[0]);let t=s(e[0]);for(let n=1;n=65&&i<=90&&(typeof t==`function`||t===void 0)}function f(e){return typeof e==`function`}function p(e,t){return f(e)?e(t):e??i}function m(e,t){return t?e?(...n)=>{let r=n[0];if(v(r)){let i=r;g(i);let a=t(...n);return i.baseUIHandlerPrevented||e?.(...n),a}let i=t(...n);return e?.(...n),i}:h(t):e}function h(e){return e&&((...t)=>{let n=t[0];return v(n)&&g(n),e(...t)})}function g(e){return e.preventBaseUIHandler=()=>{e.baseUIHandlerPrevented=!0},e}function _(e,t){return t?e?t+` `+e:t:e}function v(e){return typeof e==`object`&&!!e&&`nativeEvent`in e}function y(e,t){return function(n,...r){let i=new URL(e);return i.searchParams.set(`code`,n.toString()),r.forEach(e=>i.searchParams.append(`args[]`,e)),`${t} error #${n}; visit ${i} for the full message.`}}var b=y(`https://base-ui.com/production-error`,`Base UI`),x=e(t()),S={};function C(e,t){let n=x.useRef(S);return n.current===S&&(n.current=e(t)),n}function w(e,t,n,r){let i=C(E).current;return D(i,e,t,n,r)&&k(i,[e,t,n,r]),i.callback}function T(e){let t=C(E).current;return O(t,e)&&k(t,e),t.callback}function E(){return{callback:null,cleanup:null,refs:[]}}function D(e,t,n,r,i){return e.refs[0]!==t||e.refs[1]!==n||e.refs[2]!==r||e.refs[3]!==i}function O(e,t){return e.refs.length!==t.length||e.refs.some((e,n)=>e!==t[n])}function k(e,t){if(e.refs=t,t.every(e=>e==null)){e.callback=null;return}e.callback=n=>{if(e.cleanup&&=(e.cleanup(),null),n!=null){let r=Array(t.length).fill(null);for(let e=0;e{for(let e=0;e=e}function M(e){if(!x.isValidElement(e))return null;let t=e,n=t.props;return(j(19)?n?.ref:t.ref)??null}function N(){}var P=Object.freeze([]),F=Object.freeze({});function I(e,t){let n={};for(let r in e){let i=e[r];if(t?.hasOwnProperty(r)){let e=t[r](i);e!=null&&Object.assign(n,e);continue}i===!0?n[`data-${r.toLowerCase()}`]=``:i&&(n[`data-${r.toLowerCase()}`]=i.toString())}return n}function L(e,t){return typeof e==`function`?e(t):e}function R(e,t){return typeof e==`function`?e(t):e}function z(e,t,n={}){let r=t.render,i=B(t,n);return n.enabled===!1?null:U(e,r,i,n.state??F)}function B(e,t={}){let{className:n,style:i,render:a}=e,{state:o=F,ref:s,props:c,stateAttributesMapping:l,enabled:u=!0}=t,d=u?L(n,o):void 0,f=u?R(i,o):void 0,p=u?I(o,l):F,m=u&&c?V(c):void 0,h=u?r(p,m)??{}:F;return typeof document<`u`&&(u?Array.isArray(s)?h.ref=T([h.ref,M(a),...s]):h.ref=w(h.ref,M(a),s):w(null,null)),u?(d!==void 0&&(h.className=_(h.className,d)),f!==void 0&&(h.style=r(h.style,f)),h):F}function V(e){return Array.isArray(e)?o(e):a(void 0,e)}var H=Symbol.for(`react.lazy`);function U(e,t,n,r){if(t){if(typeof t==`function`)return t(n,r);let e=a(n,t.props);e.ref=n.ref;let i=t;return i?.$$typeof===H&&(i=x.Children.toArray(t)[0]),x.cloneElement(i,e)}if(e&&typeof e==`string`)return W(e,n);throw Error(b(8))}function W(e,t){return e===`button`?(0,x.createElement)(`button`,{type:`button`,...t,key:t.key}):e===`img`?(0,x.createElement)(`img`,{alt:``,...t,key:t.key}):x.createElement(e,t)}var G=e=>typeof e==`boolean`?`${e}`:e===0?`0`:e,K=n,q=(e,t)=>n=>{if(t?.variants==null)return K(e,n?.class,n?.className);let{variants:r,defaultVariants:i}=t,a=Object.keys(r).map(e=>{let t=n?.[e],a=i?.[e];if(t===null)return null;let o=G(t)||G(a);return r[e][o]}),o=n&&Object.entries(n).reduce((e,t)=>{let[n,r]=t;return r===void 0||(e[n]=r),e},{});return K(e,a,t?.compoundVariants?.reduce((e,t)=>{let{class:n,className:r,...a}=t;return Object.entries(a).every(e=>{let[t,n]=e;return Array.isArray(n)?n.includes({...i,...o}[t]):{...i,...o}[t]===n})?[...e,n,r]:e},[]),n?.class,n?.className)};export{F as a,w as c,g as d,a as f,P as i,C as l,z as n,N as o,R as r,j as s,q as t,b as u};
\ No newline at end of file
diff --git a/spinifex/services/spinifexui/frontend/dist/assets/download.js b/spinifex/services/spinifexui/frontend/dist/assets/download.js
index 17a4663cc..d91050ecd 100644
--- a/spinifex/services/spinifexui/frontend/dist/assets/download.js
+++ b/spinifex/services/spinifexui/frontend/dist/assets/download.js
@@ -1 +1 @@
-import{wo as e}from"./index.js";var t=e(`download`,[[`path`,{d:`M12 15V3`,key:`m9g1x1`}],[`path`,{d:`M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4`,key:`ih7n3h`}],[`path`,{d:`m7 10 5 5 5-5`,key:`brsn70`}]]);export{t};
\ No newline at end of file
+import{Nr as e}from"./index.js";var t=e(`download`,[[`path`,{d:`M12 15V3`,key:`m9g1x1`}],[`path`,{d:`M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4`,key:`ih7n3h`}],[`path`,{d:`m7 10 5 5 5-5`,key:`brsn70`}]]);export{t};
\ No newline at end of file
diff --git a/spinifex/services/spinifexui/frontend/dist/assets/ec2.js b/spinifex/services/spinifexui/frontend/dist/assets/ec2.js
index 877bdcea6..86c59621f 100644
--- a/spinifex/services/spinifexui/frontend/dist/assets/ec2.js
+++ b/spinifex/services/spinifexui/frontend/dist/assets/ec2.js
@@ -1 +1 @@
-import{n as e}from"./error-banner.js";import{$n as t,An as n,Bn as r,Cn as i,Dn as a,En as o,Fn as s,Fo as c,Gn as l,Hn as u,In as d,Jn as f,Kn as p,Ln as ee,Mn as te,Nn as ne,On as m,Pn as h,Q as g,Qn as re,Rn as ie,Sn as ae,Tn as oe,Un as se,Vn as ce,Vo as _,Wn as le,Xn as ue,Yn as de,Zn as fe,_n as pe,bn as me,cr as he,dn as ge,er as _e,fn as ve,fr as ye,gn as be,hn as xe,ir as v,jn as Se,kn as Ce,lr as y,mn as b,nr as x,or as S,pn as C,pr as w,qn as T,rr as E,sr as D,tr as O,un as k,vn as A,vr as we,vt as j,wn as Te,wr as M,xn as Ee,yn as De,zn as Oe}from"./index.js";var N=class extends S.classBuilder().ep(v).m(function(e,t,n,r){return[D(n,e.getEndpointParameterInstructions())]}).s(`AmazonEC2`,`AllocateAddress`,{}).n(`EC2Client`,`AllocateAddressCommand`).sc(k).build(){},ke=class extends S.classBuilder().ep(v).m(function(e,t,n,r){return[D(n,e.getEndpointParameterInstructions())]}).s(`AmazonEC2`,`AssociateAddress`,{}).n(`EC2Client`,`AssociateAddressCommand`).sc(ge).build(){},P=class extends S.classBuilder().ep(v).m(function(e,t,n,r){return[D(n,e.getEndpointParameterInstructions())]}).s(`AmazonEC2`,`AssociateRouteTable`,{}).n(`EC2Client`,`AssociateRouteTableCommand`).sc(ve).build(){},F=class extends S.classBuilder().ep(v).m(function(e,t,n,r){return[D(n,e.getEndpointParameterInstructions())]}).s(`AmazonEC2`,`AttachInternetGateway`,{}).n(`EC2Client`,`AttachInternetGatewayCommand`).sc(C).build(){},Ae=class extends S.classBuilder().ep(v).m(function(e,t,n,r){return[D(n,e.getEndpointParameterInstructions())]}).s(`AmazonEC2`,`AttachVolume`,{}).n(`EC2Client`,`AttachVolumeCommand`).sc(b).build(){},je=class extends S.classBuilder().ep(v).m(function(e,t,n,r){return[D(n,e.getEndpointParameterInstructions())]}).s(`AmazonEC2`,`AuthorizeSecurityGroupEgress`,{}).n(`EC2Client`,`AuthorizeSecurityGroupEgressCommand`).sc(xe).build(){},I=class extends S.classBuilder().ep(v).m(function(e,t,n,r){return[D(n,e.getEndpointParameterInstructions())]}).s(`AmazonEC2`,`AuthorizeSecurityGroupIngress`,{}).n(`EC2Client`,`AuthorizeSecurityGroupIngressCommand`).sc(be).build(){};function Me(e){let{port:t,query:n}=e,{protocol:r,path:i,hostname:a}=e;r&&r.slice(-1)!==`:`&&(r+=`:`),t&&(a+=`:${t}`),i&&i.charAt(0)!==`/`&&(i=`/${i}`);let o=n?w(n):``;o&&o[0]!==`?`&&(o=`?${o}`);let s=``;(e.username!=null||e.password!=null)&&(s=`${e.username??``}:${e.password??``}@`);let c=``;return e.fragment&&(c=`#${e.fragment}`),`${r}//${s}${a}${i}${o}${c}`}var Ne=`2016-11-15`;function Pe(e){return(t,n)=>async n=>{let{input:r}=n;if(!r.PresignedUrl){let t=await e.region(),i=await he(r,{getEndpointParameterInstructions(){return{UseFIPS:{type:`builtInParams`,name:`useFipsEndpoint`},Endpoint:{type:`builtInParams`,name:`endpoint`},Region:{type:`builtInParams`,name:`region`},UseDualStack:{type:`builtInParams`,name:`useDualstackEndpoint`}}}},{...e,region:r.SourceRegion}),a=typeof e.endpoint==`function`?await e.endpoint():y(i),o=new M({...a,protocol:`https`,headers:{host:a.hostname},query:{...Object.entries(r).reduce((e,[t,n])=>(e[t]=String(n??``),e),{}),Action:`CopySnapshot`,Version:Ne,DestinationRegion:t}}),s=await new we({credentials:e.credentials,region:r.SourceRegion,service:`ec2`,sha256:e.sha256,uriEscapePath:e.signingEscapePath}).presign(o,{expiresIn:3600});if(n={...n,input:{...n.input,DestinationRegion:t,PresignedUrl:Me(s)}},M.isInstance(n.request)){let{request:e}=n;(e.body??``).includes(`DestinationRegion=`)||(e.body+=`&DestinationRegion=${t}`),(e.body??``).includes(`PresignedUrl=`)||(e.body+=`&PresignedUrl=${ye(n.input.PresignedUrl)}`)}}return t(n)}}var Fe={step:`serialize`,tags:[`CROSS_REGION_PRESIGNED_URL`],name:`crossRegionPresignedUrlMiddleware`,override:!0,relation:`after`,toMiddleware:`endpointV2Middleware`},Ie=e=>({applyToStack:t=>{t.add(Pe(e),Fe)}}),Le=class extends S.classBuilder().ep(v).m(function(e,t,n,r){return[D(n,e.getEndpointParameterInstructions()),Ie(n)]}).s(`AmazonEC2`,`CopySnapshot`,{}).n(`EC2Client`,`CopySnapshotCommand`).sc(pe).build(){},Re=class extends S.classBuilder().ep(v).m(function(e,t,n,r){return[D(n,e.getEndpointParameterInstructions())]}).s(`AmazonEC2`,`CreateImage`,{}).n(`EC2Client`,`CreateImageCommand`).sc(A).build(){},L=class extends S.classBuilder().ep(v).m(function(e,t,n,r){return[D(n,e.getEndpointParameterInstructions())]}).s(`AmazonEC2`,`CreateInternetGateway`,{}).n(`EC2Client`,`CreateInternetGatewayCommand`).sc(De).build(){},ze=class extends S.classBuilder().ep(v).m(function(e,t,n,r){return[D(n,e.getEndpointParameterInstructions())]}).s(`AmazonEC2`,`CreateKeyPair`,{}).n(`EC2Client`,`CreateKeyPairCommand`).sc(me).build(){},R=class extends S.classBuilder().ep(v).m(function(e,t,n,r){return[D(n,e.getEndpointParameterInstructions())]}).s(`AmazonEC2`,`CreateNatGateway`,{}).n(`EC2Client`,`CreateNatGatewayCommand`).sc(Ee).build(){},Be=class extends S.classBuilder().ep(v).m(function(e,t,n,r){return[D(n,e.getEndpointParameterInstructions())]}).s(`AmazonEC2`,`CreatePlacementGroup`,{}).n(`EC2Client`,`CreatePlacementGroupCommand`).sc(ae).build(){},z=class extends S.classBuilder().ep(v).m(function(e,t,n,r){return[D(n,e.getEndpointParameterInstructions())]}).s(`AmazonEC2`,`CreateRoute`,{}).n(`EC2Client`,`CreateRouteCommand`).sc(i).build(){},B=class extends S.classBuilder().ep(v).m(function(e,t,n,r){return[D(n,e.getEndpointParameterInstructions())]}).s(`AmazonEC2`,`CreateRouteTable`,{}).n(`EC2Client`,`CreateRouteTableCommand`).sc(Te).build(){},V=class extends S.classBuilder().ep(v).m(function(e,t,n,r){return[D(n,e.getEndpointParameterInstructions())]}).s(`AmazonEC2`,`CreateSecurityGroup`,{}).n(`EC2Client`,`CreateSecurityGroupCommand`).sc(oe).build(){},Ve=class extends S.classBuilder().ep(v).m(function(e,t,n,r){return[D(n,e.getEndpointParameterInstructions())]}).s(`AmazonEC2`,`CreateSnapshot`,{}).n(`EC2Client`,`CreateSnapshotCommand`).sc(o).build(){},H=class extends S.classBuilder().ep(v).m(function(e,t,n,r){return[D(n,e.getEndpointParameterInstructions())]}).s(`AmazonEC2`,`CreateSubnet`,{}).n(`EC2Client`,`CreateSubnetCommand`).sc(a).build(){},He=class extends S.classBuilder().ep(v).m(function(e,t,n,r){return[D(n,e.getEndpointParameterInstructions())]}).s(`AmazonEC2`,`CreateVolume`,{}).n(`EC2Client`,`CreateVolumeCommand`).sc(m).build(){},U=class extends S.classBuilder().ep(v).m(function(e,t,n,r){return[D(n,e.getEndpointParameterInstructions())]}).s(`AmazonEC2`,`CreateVpc`,{}).n(`EC2Client`,`CreateVpcCommand`).sc(Ce).build(){},Ue=class extends S.classBuilder().ep(v).m(function(e,t,n,r){return[D(n,e.getEndpointParameterInstructions())]}).s(`AmazonEC2`,`DeleteInternetGateway`,{}).n(`EC2Client`,`DeleteInternetGatewayCommand`).sc(n).build(){},We=class extends S.classBuilder().ep(v).m(function(e,t,n,r){return[D(n,e.getEndpointParameterInstructions())]}).s(`AmazonEC2`,`DeleteKeyPair`,{}).n(`EC2Client`,`DeleteKeyPairCommand`).sc(Se).build(){},Ge=class extends S.classBuilder().ep(v).m(function(e,t,n,r){return[D(n,e.getEndpointParameterInstructions())]}).s(`AmazonEC2`,`DeleteNatGateway`,{}).n(`EC2Client`,`DeleteNatGatewayCommand`).sc(te).build(){},Ke=class extends S.classBuilder().ep(v).m(function(e,t,n,r){return[D(n,e.getEndpointParameterInstructions())]}).s(`AmazonEC2`,`DeletePlacementGroup`,{}).n(`EC2Client`,`DeletePlacementGroupCommand`).sc(ne).build(){},qe=class extends S.classBuilder().ep(v).m(function(e,t,n,r){return[D(n,e.getEndpointParameterInstructions())]}).s(`AmazonEC2`,`DeleteRoute`,{}).n(`EC2Client`,`DeleteRouteCommand`).sc(h).build(){},Je=class extends S.classBuilder().ep(v).m(function(e,t,n,r){return[D(n,e.getEndpointParameterInstructions())]}).s(`AmazonEC2`,`DeleteRouteTable`,{}).n(`EC2Client`,`DeleteRouteTableCommand`).sc(s).build(){},Ye=class extends S.classBuilder().ep(v).m(function(e,t,n,r){return[D(n,e.getEndpointParameterInstructions())]}).s(`AmazonEC2`,`DeleteSecurityGroup`,{}).n(`EC2Client`,`DeleteSecurityGroupCommand`).sc(d).build(){},Xe=class extends S.classBuilder().ep(v).m(function(e,t,n,r){return[D(n,e.getEndpointParameterInstructions())]}).s(`AmazonEC2`,`DeleteSnapshot`,{}).n(`EC2Client`,`DeleteSnapshotCommand`).sc(ee).build(){},Ze=class extends S.classBuilder().ep(v).m(function(e,t,n,r){return[D(n,e.getEndpointParameterInstructions())]}).s(`AmazonEC2`,`DeleteSubnet`,{}).n(`EC2Client`,`DeleteSubnetCommand`).sc(ie).build(){},Qe=class extends S.classBuilder().ep(v).m(function(e,t,n,r){return[D(n,e.getEndpointParameterInstructions())]}).s(`AmazonEC2`,`DeleteVolume`,{}).n(`EC2Client`,`DeleteVolumeCommand`).sc(Oe).build(){},$e=class extends S.classBuilder().ep(v).m(function(e,t,n,r){return[D(n,e.getEndpointParameterInstructions())]}).s(`AmazonEC2`,`DeleteVpc`,{}).n(`EC2Client`,`DeleteVpcCommand`).sc(r).build(){},et=class extends S.classBuilder().ep(v).m(function(e,t,n,r){return[D(n,e.getEndpointParameterInstructions())]}).s(`AmazonEC2`,`DetachInternetGateway`,{}).n(`EC2Client`,`DetachInternetGatewayCommand`).sc(ce).build(){},tt=class extends S.classBuilder().ep(v).m(function(e,t,n,r){return[D(n,e.getEndpointParameterInstructions())]}).s(`AmazonEC2`,`DetachVolume`,{}).n(`EC2Client`,`DetachVolumeCommand`).sc(u).build(){},nt=class extends S.classBuilder().ep(v).m(function(e,t,n,r){return[D(n,e.getEndpointParameterInstructions())]}).s(`AmazonEC2`,`DisassociateAddress`,{}).n(`EC2Client`,`DisassociateAddressCommand`).sc(se).build(){},rt=class extends S.classBuilder().ep(v).m(function(e,t,n,r){return[D(n,e.getEndpointParameterInstructions())]}).s(`AmazonEC2`,`DisassociateRouteTable`,{}).n(`EC2Client`,`DisassociateRouteTableCommand`).sc(le).build(){},it=class extends S.classBuilder().ep(v).m(function(e,t,n,r){return[D(n,e.getEndpointParameterInstructions())]}).s(`AmazonEC2`,`GetConsoleOutput`,{}).n(`EC2Client`,`GetConsoleOutputCommand`).sc(l).build(){},at=class extends S.classBuilder().ep(v).m(function(e,t,n,r){return[D(n,e.getEndpointParameterInstructions())]}).s(`AmazonEC2`,`ImportKeyPair`,{}).n(`EC2Client`,`ImportKeyPairCommand`).sc(p).build(){},ot=class extends S.classBuilder().ep(v).m(function(e,t,n,r){return[D(n,e.getEndpointParameterInstructions())]}).s(`AmazonEC2`,`ModifyInstanceAttribute`,{}).n(`EC2Client`,`ModifyInstanceAttributeCommand`).sc(T).build(){},W=class extends S.classBuilder().ep(v).m(function(e,t,n,r){return[D(n,e.getEndpointParameterInstructions())]}).s(`AmazonEC2`,`ModifySubnetAttribute`,{}).n(`EC2Client`,`ModifySubnetAttributeCommand`).sc(f).build(){},st=class extends S.classBuilder().ep(v).m(function(e,t,n,r){return[D(n,e.getEndpointParameterInstructions())]}).s(`AmazonEC2`,`ModifyVolume`,{}).n(`EC2Client`,`ModifyVolumeCommand`).sc(de).build(){},ct=class extends S.classBuilder().ep(v).m(function(e,t,n,r){return[D(n,e.getEndpointParameterInstructions())]}).s(`AmazonEC2`,`RebootInstances`,{}).n(`EC2Client`,`RebootInstancesCommand`).sc(ue).build(){},lt=class extends S.classBuilder().ep(v).m(function(e,t,n,r){return[D(n,e.getEndpointParameterInstructions())]}).s(`AmazonEC2`,`ReleaseAddress`,{}).n(`EC2Client`,`ReleaseAddressCommand`).sc(fe).build(){},ut=class extends S.classBuilder().ep(v).m(function(e,t,n,r){return[D(n,e.getEndpointParameterInstructions())]}).s(`AmazonEC2`,`RevokeSecurityGroupEgress`,{}).n(`EC2Client`,`RevokeSecurityGroupEgressCommand`).sc(re).build(){},dt=class extends S.classBuilder().ep(v).m(function(e,t,n,r){return[D(n,e.getEndpointParameterInstructions())]}).s(`AmazonEC2`,`RevokeSecurityGroupIngress`,{}).n(`EC2Client`,`RevokeSecurityGroupIngressCommand`).sc(t).build(){},ft=class extends S.classBuilder().ep(v).m(function(e,t,n,r){return[D(n,e.getEndpointParameterInstructions())]}).s(`AmazonEC2`,`RunInstances`,{}).n(`EC2Client`,`RunInstancesCommand`).sc(_e).build(){},pt=class extends S.classBuilder().ep(v).m(function(e,t,n,r){return[D(n,e.getEndpointParameterInstructions())]}).s(`AmazonEC2`,`StartInstances`,{}).n(`EC2Client`,`StartInstancesCommand`).sc(O).build(){},mt=class extends S.classBuilder().ep(v).m(function(e,t,n,r){return[D(n,e.getEndpointParameterInstructions())]}).s(`AmazonEC2`,`StopInstances`,{}).n(`EC2Client`,`StopInstancesCommand`).sc(x).build(){},ht=class extends S.classBuilder().ep(v).m(function(e,t,n,r){return[D(n,e.getEndpointParameterInstructions())]}).s(`AmazonEC2`,`TerminateInstances`,{}).n(`EC2Client`,`TerminateInstancesCommand`).sc(E).build(){},G={capacity_block:`capacity-block`,capacity_manager_data_export:`capacity-manager-data-export`,capacity_reservation:`capacity-reservation`,capacity_reservation_fleet:`capacity-reservation-fleet`,carrier_gateway:`carrier-gateway`,client_vpn_endpoint:`client-vpn-endpoint`,coip_pool:`coip-pool`,customer_gateway:`customer-gateway`,declarative_policies_report:`declarative-policies-report`,dedicated_host:`dedicated-host`,dhcp_options:`dhcp-options`,egress_only_internet_gateway:`egress-only-internet-gateway`,elastic_gpu:`elastic-gpu`,elastic_ip:`elastic-ip`,export_image_task:`export-image-task`,export_instance_task:`export-instance-task`,fleet:`fleet`,fpga_image:`fpga-image`,host_reservation:`host-reservation`,image:`image`,image_usage_report:`image-usage-report`,import_image_task:`import-image-task`,import_snapshot_task:`import-snapshot-task`,instance:`instance`,instance_connect_endpoint:`instance-connect-endpoint`,instance_event_window:`instance-event-window`,internet_gateway:`internet-gateway`,ipam:`ipam`,ipam_external_resource_verification_token:`ipam-external-resource-verification-token`,ipam_policy:`ipam-policy`,ipam_pool:`ipam-pool`,ipam_prefix_list_resolver:`ipam-prefix-list-resolver`,ipam_prefix_list_resolver_target:`ipam-prefix-list-resolver-target`,ipam_resource_discovery:`ipam-resource-discovery`,ipam_resource_discovery_association:`ipam-resource-discovery-association`,ipam_scope:`ipam-scope`,ipv4pool_ec2:`ipv4pool-ec2`,ipv6pool_ec2:`ipv6pool-ec2`,key_pair:`key-pair`,launch_template:`launch-template`,local_gateway:`local-gateway`,local_gateway_route_table:`local-gateway-route-table`,local_gateway_route_table_virtual_interface_group_association:`local-gateway-route-table-virtual-interface-group-association`,local_gateway_route_table_vpc_association:`local-gateway-route-table-vpc-association`,local_gateway_virtual_interface:`local-gateway-virtual-interface`,local_gateway_virtual_interface_group:`local-gateway-virtual-interface-group`,mac_modification_task:`mac-modification-task`,natgateway:`natgateway`,network_acl:`network-acl`,network_insights_access_scope:`network-insights-access-scope`,network_insights_access_scope_analysis:`network-insights-access-scope-analysis`,network_insights_analysis:`network-insights-analysis`,network_insights_path:`network-insights-path`,network_interface:`network-interface`,outpost_lag:`outpost-lag`,placement_group:`placement-group`,prefix_list:`prefix-list`,replace_root_volume_task:`replace-root-volume-task`,reserved_instances:`reserved-instances`,route_server:`route-server`,route_server_endpoint:`route-server-endpoint`,route_server_peer:`route-server-peer`,route_table:`route-table`,secondary_interface:`secondary-interface`,secondary_network:`secondary-network`,secondary_subnet:`secondary-subnet`,security_group:`security-group`,security_group_rule:`security-group-rule`,service_link_virtual_interface:`service-link-virtual-interface`,snapshot:`snapshot`,spot_fleet_request:`spot-fleet-request`,spot_instances_request:`spot-instances-request`,subnet:`subnet`,subnet_cidr_reservation:`subnet-cidr-reservation`,traffic_mirror_filter:`traffic-mirror-filter`,traffic_mirror_filter_rule:`traffic-mirror-filter-rule`,traffic_mirror_session:`traffic-mirror-session`,traffic_mirror_target:`traffic-mirror-target`,transit_gateway:`transit-gateway`,transit_gateway_attachment:`transit-gateway-attachment`,transit_gateway_connect_peer:`transit-gateway-connect-peer`,transit_gateway_metering_policy:`transit-gateway-metering-policy`,transit_gateway_multicast_domain:`transit-gateway-multicast-domain`,transit_gateway_policy_table:`transit-gateway-policy-table`,transit_gateway_route_table:`transit-gateway-route-table`,transit_gateway_route_table_announcement:`transit-gateway-route-table-announcement`,verified_access_endpoint:`verified-access-endpoint`,verified_access_endpoint_target:`verified-access-endpoint-target`,verified_access_group:`verified-access-group`,verified_access_instance:`verified-access-instance`,verified_access_policy:`verified-access-policy`,verified_access_trust_provider:`verified-access-trust-provider`,volume:`volume`,vpc:`vpc`,vpc_block_public_access_exclusion:`vpc-block-public-access-exclusion`,vpc_encryption_control:`vpc-encryption-control`,vpc_endpoint:`vpc-endpoint`,vpc_endpoint_connection:`vpc-endpoint-connection`,vpc_endpoint_connection_device_type:`vpc-endpoint-connection-device-type`,vpc_endpoint_service:`vpc-endpoint-service`,vpc_endpoint_service_permission:`vpc-endpoint-service-permission`,vpc_flow_log:`vpc-flow-log`,vpc_peering_connection:`vpc-peering-connection`,vpn_concentrator:`vpn-concentrator`,vpn_connection:`vpn-connection`,vpn_connection_device_type:`vpn-connection-device-type`,vpn_gateway:`vpn-gateway`},K=c();function q(e){let t=e.split(`/`),n=t[0]??``,r=t[1]??`0`,i=Number.parseInt(r,10),a=n.split(`.`).map(e=>Number.parseInt(e,10)),o=a[0]??0,s=a[1]??0,c=a[2]??0,l=a[3]??0;return{network:((o<<24|s<<16|c<<8|l)>>>0&(i===0?0:4294967295<<32-i>>>0))>>>0,prefix:i}}function J(e){return`${e>>>24&255}.${e>>>16&255}.${e>>>8&255}.${e&255}`}function Y(e){return 2**(32-e)}function gt(e){let t=e+4;return Math.min(t,28)}function X(e,t,n){let{network:r,prefix:i}=q(e),a=gt(i),o=Y(a),s=Y(i)/2,c=[];for(let e=0;e>>0;c.push({cidr:`${J(t)}/${a}`,label:`Public Subnet ${e+1}`})}let l=[];for(let e=0;e>>0;l.push({cidr:`${J(t)}/${a}`,label:`Private Subnet ${e+1}`})}return{publicSubnets:c,privateSubnets:l}}function _t(e,t){let n=q(e),r=q(t),i=n.network+Y(n.prefix)-1>>>0,a=r.network+Y(r.prefix)-1>>>0;return n.network<=a&&r.network<=i}function vt(e,t){let n=q(e),r=q(t),i=n.network+Y(n.prefix)-1>>>0,a=r.network+Y(r.prefix)-1>>>0;return r.network>=n.network&&a<=i}function yt(e,t=16,n=28){let r=/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})\/(\d{1,2})$/.exec(e);if(!r||[Number.parseInt(r[1]??`0`,10),Number.parseInt(r[2]??`0`,10),Number.parseInt(r[3]??`0`,10),Number.parseInt(r[4]??`0`,10)].some(e=>e>255))return!1;let i=Number.parseInt(r[5]??`0`,10);return i>=t&&i<=n}var bt=/\s+/;function xt(){let t=(0,K.c)(2),n=_(),r;return t[0]===n?r=t[1]:(r={mutationFn:St,onSuccess:()=>{n.invalidateQueries({queryKey:[`ec2`,`instances`]})}},t[0]=n,t[1]=r),e(r)}async function St(e){let t=new pt({InstanceIds:[e]});return await j().send(t)}function Ct(){let t=(0,K.c)(2),n=_(),r;return t[0]===n?r=t[1]:(r={mutationFn:wt,onSuccess:()=>{n.invalidateQueries({queryKey:[`ec2`,`instances`]})}},t[0]=n,t[1]=r),e(r)}async function wt(e){let t=new mt({InstanceIds:[e]});return await j().send(t)}function Tt(){let t=(0,K.c)(2),n=_(),r;return t[0]===n?r=t[1]:(r={mutationFn:Et,onSuccess:()=>{n.invalidateQueries({queryKey:[`ec2`,`instances`]})}},t[0]=n,t[1]=r),e(r)}async function Et(e){let t=new ht({InstanceIds:[e]});return await j().send(t)}function Dt(){let t=(0,K.c)(2),n=_(),r;return t[0]===n?r=t[1]:(r={mutationFn:Ot,onSuccess:()=>{n.invalidateQueries({queryKey:[`ec2`,`instances`]})}},t[0]=n,t[1]=r),e(r)}async function Ot(e){let t=new ct({InstanceIds:[e]});return await j().send(t)}function kt(){let t=(0,K.c)(2),n=_(),r;return t[0]===n?r=t[1]:(r={mutationFn:At,onSuccess:()=>{n.invalidateQueries({queryKey:[`ec2`,`instances`]}),n.invalidateQueries({queryKey:[`ec2`,`securityGroups`]})}},t[0]=n,t[1]=r),e(r)}async function At(e){let t=j(),n=await jt(t,e),r=new ft({ImageId:e.imageId,InstanceType:e.instanceType,KeyName:e.keyName,MinCount:e.count,MaxCount:e.count,SubnetId:e.subnetId||void 0,SecurityGroupIds:n.length>0?n:void 0,Placement:e.placementGroupName?{GroupName:e.placementGroupName}:void 0,BlockDeviceMappings:Mt(e)});return await t.send(r)}async function jt(e,t){if(t.securityGroupMode!==`create`)return t.securityGroupIds??[];let n=(await e.send(new V({GroupName:t.newSgName,Description:t.newSgDescription||`Created by launch wizard`,VpcId:t.resolvedVpcId}))).GroupId;if(!n)throw Error(`Security group was created but no ID was returned`);let r=[...t.allowSsh?[22]:[],...t.allowHttp?[80]:[],...t.allowHttps?[443]:[]];if(r.length>0){let i=t.ruleSource===`custom`&&t.customCidr?t.customCidr:`0.0.0.0/0`;await e.send(new I({GroupId:n,IpPermissions:r.map(e=>({IpProtocol:`tcp`,FromPort:e,ToPort:e,IpRanges:[{CidrIp:i}]}))}))}return[n]}function Mt(e){let{rootDeviceName:t,rootVolumeSize:n,rootVolumeType:r,rootDeleteOnTermination:i}=e;return n!==void 0||r!==void 0||i!==void 0?[{DeviceName:t,Ebs:{...n!==void 0&&{VolumeSize:n},...r!==void 0&&{VolumeType:r},...i!==void 0&&{DeleteOnTermination:i}}}]:void 0}function Nt(){let t=(0,K.c)(2),n=_(),r;return t[0]===n?r=t[1]:(r={mutationFn:Pt,onSuccess:()=>{n.invalidateQueries(g)}},t[0]=n,t[1]=r),e(r)}async function Pt(e){let t=new ze({KeyName:e.keyName,KeyType:`rsa`}),n=await j().send(t);if(!n.KeyMaterial)throw Error(`Key pair was created but the API returned no private key. The key cannot be recovered — delete it and try again.`);return n}function Ft(){let t=(0,K.c)(2),n=_(),r;return t[0]===n?r=t[1]:(r={mutationFn:It,onSuccess:()=>{n.invalidateQueries(g)}},t[0]=n,t[1]=r),e(r)}async function It(e){let t=e.publicKeyMaterial.trim().split(bt).slice(0,2).join(` `),n=new at({KeyName:e.keyName,PublicKeyMaterial:new TextEncoder().encode(t)});return await j().send(n)}function Lt(){let t=(0,K.c)(2),n=_(),r;return t[0]===n?r=t[1]:(r={mutationFn:Rt,onSuccess:()=>{n.invalidateQueries(g)}},t[0]=n,t[1]=r),e(r)}async function Rt(e){let t=new We({KeyPairId:e});return await j().send(t)}function zt(){let t=(0,K.c)(2),n=_(),r;return t[0]===n?r=t[1]:(r={mutationFn:Bt,onSuccess:()=>{n.invalidateQueries({queryKey:[`ec2`,`volumes`]})}},t[0]=n,t[1]=r),e(r)}async function Bt(e){let t=new He({Size:e.size,AvailabilityZone:e.availabilityZone,VolumeType:`gp3`});return await j().send(t)}function Vt(){let t=(0,K.c)(2),n=_(),r;return t[0]===n?r=t[1]:(r={mutationFn:Ht,onSuccess:()=>{n.invalidateQueries({queryKey:[`ec2`,`volumes`]})}},t[0]=n,t[1]=r),e(r)}async function Ht(e){let t=new st({VolumeId:e.volumeId,Size:e.size});return await j().send(t)}function Ut(){let t=(0,K.c)(2),n=_(),r;return t[0]===n?r=t[1]:(r={mutationFn:Wt,onSuccess:()=>{n.invalidateQueries({queryKey:[`ec2`,`volumes`]})}},t[0]=n,t[1]=r),e(r)}async function Wt(e){let t=new Qe({VolumeId:e});return await j().send(t)}function Gt(){let t=(0,K.c)(2),n=_(),r;return t[0]===n?r=t[1]:(r={mutationFn:Kt,onSuccess:()=>{n.invalidateQueries({queryKey:[`ec2`,`snapshots`]})}},t[0]=n,t[1]=r),e(r)}async function Kt(e){let t=new Ve({VolumeId:e.volumeId,Description:e.description||void 0});return await j().send(t)}function qt(){let t=(0,K.c)(2),n=_(),r;return t[0]===n?r=t[1]:(r={mutationFn:Jt,onSuccess:()=>{n.invalidateQueries({queryKey:[`ec2`,`snapshots`]})}},t[0]=n,t[1]=r),e(r)}async function Jt(e){let t=new Xe({SnapshotId:e});return await j().send(t)}function Yt(){let t=(0,K.c)(2),n=_(),r;return t[0]===n?r=t[1]:(r={mutationFn:Xt,onSuccess:()=>{n.invalidateQueries({queryKey:[`ec2`,`snapshots`]})}},t[0]=n,t[1]=r),e(r)}async function Xt(e){let t=new Le({SourceSnapshotId:e.sourceSnapshotId,SourceRegion:e.sourceRegion,Description:e.description||void 0});return await j().send(t)}function Zt(){let t=(0,K.c)(2),n=_(),r;return t[0]===n?r=t[1]:(r={mutationFn:Qt,onSuccess:()=>{n.invalidateQueries({queryKey:[`ec2`,`volumes`]}),n.invalidateQueries({queryKey:[`ec2`,`instances`]})}},t[0]=n,t[1]=r),e(r)}async function Qt(e){let t=new Ae({VolumeId:e.volumeId,InstanceId:e.instanceId,Device:e.device||void 0});return await j().send(t)}function $t(){let t=(0,K.c)(2),n=_(),r;return t[0]===n?r=t[1]:(r={mutationFn:en,onSuccess:()=>{n.invalidateQueries({queryKey:[`ec2`,`volumes`]}),n.invalidateQueries({queryKey:[`ec2`,`instances`]})}},t[0]=n,t[1]=r),e(r)}async function en(e){let t=new tt({VolumeId:e.volumeId,InstanceId:e.instanceId||void 0,Force:e.force});return await j().send(t)}function tn(){let t=(0,K.c)(2),n=_(),r;return t[0]===n?r=t[1]:(r={mutationFn:nn,onSuccess:()=>{n.invalidateQueries({queryKey:[`ec2`,`instances`]})}},t[0]=n,t[1]=r),e(r)}async function nn(e){let{instanceId:t,...n}=e,r=new ot({InstanceId:t,InstanceType:{Value:n.instanceType}});return await j().send(r)}function rn(){let t=(0,K.c)(1),n;return t[0]===Symbol.for(`react.memo_cache_sentinel`)?(n={mutationFn:an},t[0]=n):n=t[0],e(n)}async function an(e){let t=new it({InstanceId:e});return await j().send(t)}function on(){let t=(0,K.c)(2),n=_(),r;return t[0]===n?r=t[1]:(r={mutationFn:sn,onSuccess:()=>{n.invalidateQueries({queryKey:[`ec2`,`images`]})}},t[0]=n,t[1]=r),e(r)}async function sn(e){let t=new Re({InstanceId:e.instanceId,Name:e.name,Description:e.description||void 0});return await j().send(t)}function cn(){let t=(0,K.c)(2),n=_(),r;return t[0]===n?r=t[1]:(r={mutationFn:ln,onSuccess:()=>{n.invalidateQueries({queryKey:[`ec2`,`vpcs`]})}},t[0]=n,t[1]=r),e(r)}async function ln(e){let t=new U({CidrBlock:e.cidrBlock,TagSpecifications:e.name?[{ResourceType:`vpc`,Tags:[{Key:`Name`,Value:e.name}]}]:void 0});return await j().send(t)}function un(){let t=(0,K.c)(2),n=_(),r;return t[0]===n?r=t[1]:(r={mutationFn:dn,onSuccess:()=>{n.invalidateQueries({queryKey:[`ec2`,`vpcs`]})}},t[0]=n,t[1]=r),e(r)}async function dn(e){let t=new $e({VpcId:e});return await j().send(t)}function fn(){let t=(0,K.c)(2),n=_(),r;return t[0]===n?r=t[1]:(r={mutationFn:pn,onSuccess:()=>{n.invalidateQueries({queryKey:[`ec2`,`subnets`]})}},t[0]=n,t[1]=r),e(r)}async function pn(e){let t=j(),n=await t.send(new H({VpcId:e.vpcId,CidrBlock:e.cidrBlock,AvailabilityZone:e.availabilityZone||void 0})),r=n.Subnet?.SubnetId;return e.mapPublicIpOnLaunch&&r&&await t.send(new W({SubnetId:r,MapPublicIpOnLaunch:{Value:!0}})),n}function mn(){let t=(0,K.c)(2),n=_(),r;return t[0]===n?r=t[1]:(r={mutationFn:hn,onSuccess:()=>{n.invalidateQueries({queryKey:[`ec2`,`subnets`]})}},t[0]=n,t[1]=r),e(r)}async function hn(e){let t=new Ze({SubnetId:e});return await j().send(t)}function gn(){let t=(0,K.c)(2),n=_(),r;return t[0]===n?r=t[1]:(r={mutationFn:_n,onSuccess:()=>{n.invalidateQueries({queryKey:[`ec2`,`placementGroups`]})}},t[0]=n,t[1]=r),e(r)}async function _n(e){let t=new Be({GroupName:e.groupName,Strategy:e.strategy});return await j().send(t)}function vn(){let t=(0,K.c)(2),n=_(),r;return t[0]===n?r=t[1]:(r={mutationFn:yn,onSuccess:()=>{n.invalidateQueries({queryKey:[`ec2`,`placementGroups`]})}},t[0]=n,t[1]=r),e(r)}async function yn(e){let t=new Ke({GroupName:e});return await j().send(t)}function bn(){let t=(0,K.c)(2),n=_(),r;return t[0]===n?r=t[1]:(r={mutationFn:xn,onSuccess:()=>{n.invalidateQueries({queryKey:[`ec2`,`securityGroups`]})}},t[0]=n,t[1]=r),e(r)}async function xn(e){let t=new V({GroupName:e.groupName,Description:e.description,VpcId:e.vpcId});return await j().send(t)}function Sn(){let t=(0,K.c)(2),n=_(),r;return t[0]===n?r=t[1]:(r={mutationFn:Cn,onSuccess:()=>{n.invalidateQueries({queryKey:[`ec2`,`securityGroups`]})}},t[0]=n,t[1]=r),e(r)}async function Cn(e){let t=new Ye({GroupId:e});return await j().send(t)}function wn(){let t=(0,K.c)(2),n=_(),r;return t[0]===n?r=t[1]:(r={mutationFn:Tn,onSuccess:(e,t)=>{n.invalidateQueries({queryKey:[`ec2`,`securityGroups`]}),n.invalidateQueries({queryKey:[`ec2`,`securityGroups`,t.groupId]})}},t[0]=n,t[1]=r),e(r)}async function Tn(e){let t=new I({GroupId:e.groupId,IpPermissions:[{IpProtocol:e.ipProtocol,FromPort:e.fromPort,ToPort:e.toPort,IpRanges:[{CidrIp:e.cidrIp}]}]});return await j().send(t)}function En(){let t=(0,K.c)(2),n=_(),r;return t[0]===n?r=t[1]:(r={mutationFn:Dn,onSuccess:(e,t)=>{n.invalidateQueries({queryKey:[`ec2`,`securityGroups`]}),n.invalidateQueries({queryKey:[`ec2`,`securityGroups`,t.groupId]})}},t[0]=n,t[1]=r),e(r)}async function Dn(e){let t=new je({GroupId:e.groupId,IpPermissions:[{IpProtocol:e.ipProtocol,FromPort:e.fromPort,ToPort:e.toPort,IpRanges:[{CidrIp:e.cidrIp}]}]});return await j().send(t)}function Z(e){return{IpProtocol:e.ipProtocol,FromPort:e.fromPort,ToPort:e.toPort,...e.sourceGroupId?{UserIdGroupPairs:[{GroupId:e.sourceGroupId}]}:{IpRanges:[{CidrIp:e.cidrIp??``}]}}}function On(){let t=(0,K.c)(2),n=_(),r;return t[0]===n?r=t[1]:(r={mutationFn:kn,onSuccess:(e,t)=>{n.invalidateQueries({queryKey:[`ec2`,`securityGroups`]}),n.invalidateQueries({queryKey:[`ec2`,`securityGroups`,t.groupId]})}},t[0]=n,t[1]=r),e(r)}async function kn(e){let t=new dt({GroupId:e.groupId,IpPermissions:[Z(e)]});return await j().send(t)}function An(){let t=(0,K.c)(2),n=_(),r;return t[0]===n?r=t[1]:(r={mutationFn:jn,onSuccess:(e,t)=>{n.invalidateQueries({queryKey:[`ec2`,`securityGroups`]}),n.invalidateQueries({queryKey:[`ec2`,`securityGroups`,t.groupId]})}},t[0]=n,t[1]=r),e(r)}async function jn(e){let t=new ut({GroupId:e.groupId,IpPermissions:[Z(e)]});return await j().send(t)}function Mn(){let t=(0,K.c)(2),n=_(),r;return t[0]===n?r=t[1]:(r={mutationFn:Nn,onSuccess:()=>{n.invalidateQueries({queryKey:[`ec2`,`internetGateways`]})}},t[0]=n,t[1]=r),e(r)}async function Nn(e){let t=new L({TagSpecifications:$(G.internet_gateway,e?.name,[])});return await j().send(t)}function Pn(){let t=(0,K.c)(2),n=_(),r;return t[0]===n?r=t[1]:(r={mutationFn:Fn,onSuccess:()=>{n.invalidateQueries({queryKey:[`ec2`,`internetGateways`]})}},t[0]=n,t[1]=r),e(r)}async function Fn(e){let t=new Ue({InternetGatewayId:e});return await j().send(t)}function In(){let t=(0,K.c)(2),n=_(),r;return t[0]===n?r=t[1]:(r={mutationFn:Ln,onSuccess:()=>{n.invalidateQueries({queryKey:[`ec2`,`internetGateways`]})}},t[0]=n,t[1]=r),e(r)}async function Ln(e){let t=new F({InternetGatewayId:e.internetGatewayId,VpcId:e.vpcId});return await j().send(t)}function Rn(){let t=(0,K.c)(2),n=_(),r;return t[0]===n?r=t[1]:(r={mutationFn:zn,onSuccess:()=>{n.invalidateQueries({queryKey:[`ec2`,`internetGateways`]})}},t[0]=n,t[1]=r),e(r)}async function zn(e){let t=new et({InternetGatewayId:e.internetGatewayId,VpcId:e.vpcId});return await j().send(t)}function Bn(){let t=(0,K.c)(2),n=_(),r;return t[0]===n?r=t[1]:(r={mutationFn:Vn,onSuccess:()=>{n.invalidateQueries({queryKey:[`ec2`,`addresses`]})}},t[0]=n,t[1]=r),e(r)}async function Vn(e){let t=new N({Domain:`vpc`,TagSpecifications:$(G.elastic_ip,e?.name,[])});return await j().send(t)}function Hn(){let t=(0,K.c)(2),n=_(),r;return t[0]===n?r=t[1]:(r={mutationFn:Un,onSuccess:()=>{n.invalidateQueries({queryKey:[`ec2`,`addresses`]})}},t[0]=n,t[1]=r),e(r)}async function Un(e){let t=new lt({AllocationId:e});return await j().send(t)}function Wn(){let t=(0,K.c)(2),n=_(),r;return t[0]===n?r=t[1]:(r={mutationFn:Gn,onSuccess:()=>{n.invalidateQueries({queryKey:[`ec2`,`addresses`]}),n.invalidateQueries({queryKey:[`ec2`,`instances`]})}},t[0]=n,t[1]=r),e(r)}async function Gn(e){let t=new ke({AllocationId:e.allocationId,InstanceId:e.instanceId,NetworkInterfaceId:e.networkInterfaceId});return await j().send(t)}function Kn(){let t=(0,K.c)(2),n=_(),r;return t[0]===n?r=t[1]:(r={mutationFn:qn,onSuccess:()=>{n.invalidateQueries({queryKey:[`ec2`,`addresses`]}),n.invalidateQueries({queryKey:[`ec2`,`instances`]})}},t[0]=n,t[1]=r),e(r)}async function qn(e){let t=new nt({AssociationId:e});return await j().send(t)}function Jn(){let t=(0,K.c)(2),n=_(),r;return t[0]===n?r=t[1]:(r={mutationFn:Q,onSuccess:()=>{n.invalidateQueries({queryKey:[`ec2`,`natGateways`]}),n.invalidateQueries({queryKey:[`ec2`,`addresses`]})}},t[0]=n,t[1]=r),e(r)}async function Q(e){let t=new R({SubnetId:e.subnetId,AllocationId:e.allocationId,TagSpecifications:$(G.natgateway,e.name,[])});return await j().send(t)}function Yn(){let t=(0,K.c)(2),n=_(),r;return t[0]===n?r=t[1]:(r={mutationFn:Xn,onSuccess:()=>{n.invalidateQueries({queryKey:[`ec2`,`natGateways`]})}},t[0]=n,t[1]=r),e(r)}async function Xn(e){let t=new Ge({NatGatewayId:e});return await j().send(t)}function Zn(){let t=(0,K.c)(2),n=_(),r;return t[0]===n?r=t[1]:(r={mutationFn:Qn,onSuccess:()=>{n.invalidateQueries({queryKey:[`ec2`,`routeTables`]})}},t[0]=n,t[1]=r),e(r)}async function Qn(e){let t=new B({VpcId:e.vpcId,TagSpecifications:$(G.route_table,e.name,[])});return await j().send(t)}function $n(){let t=(0,K.c)(2),n=_(),r;return t[0]===n?r=t[1]:(r={mutationFn:er,onSuccess:()=>{n.invalidateQueries({queryKey:[`ec2`,`routeTables`]})}},t[0]=n,t[1]=r),e(r)}async function er(e){let t=new Je({RouteTableId:e});return await j().send(t)}function tr(){let t=(0,K.c)(2),n=_(),r;return t[0]===n?r=t[1]:(r={mutationFn:nr,onSuccess:()=>{n.invalidateQueries({queryKey:[`ec2`,`routeTables`]})}},t[0]=n,t[1]=r),e(r)}async function nr(e){let t=new z({RouteTableId:e.routeTableId,DestinationCidrBlock:e.destinationCidrBlock,GatewayId:e.gatewayId,NatGatewayId:e.natGatewayId});return await j().send(t)}function rr(){let t=(0,K.c)(2),n=_(),r;return t[0]===n?r=t[1]:(r={mutationFn:ir,onSuccess:()=>{n.invalidateQueries({queryKey:[`ec2`,`routeTables`]})}},t[0]=n,t[1]=r),e(r)}async function ir(e){let t=new qe({RouteTableId:e.routeTableId,DestinationCidrBlock:e.destinationCidrBlock});return await j().send(t)}function ar(){let t=(0,K.c)(2),n=_(),r;return t[0]===n?r=t[1]:(r={mutationFn:or,onSuccess:()=>{n.invalidateQueries({queryKey:[`ec2`,`routeTables`]})}},t[0]=n,t[1]=r),e(r)}async function or(e){let t=new P({RouteTableId:e.routeTableId,SubnetId:e.subnetId});return await j().send(t)}function sr(){let t=(0,K.c)(2),n=_(),r;return t[0]===n?r=t[1]:(r={mutationFn:cr,onSuccess:()=>{n.invalidateQueries({queryKey:[`ec2`,`routeTables`]})}},t[0]=n,t[1]=r),e(r)}async function cr(e){let t=new rt({AssociationId:e});return await j().send(t)}function lr(e,t){let n=[];return e&&n.push({Key:`Name`,Value:e}),n.push(...t),n}function $(e,t,n){let r=lr(t,n);if(r.length!==0)return[{ResourceType:e,Tags:r}]}function ur(){let t=_();return e({mutationFn:async e=>{let t=j(),n=[],r=e.autoGenerateNames?e.namePrefix:void 0,i=e.tags.map(e=>({Key:e.key,Value:e.value})),a=`creating VPC`;try{let o=r?`${r}-vpc`:e.namePrefix,s=e.tenancy,c=(await t.send(new U({CidrBlock:e.cidrBlock,InstanceTenancy:s,TagSpecifications:$(G.vpc,o,i)}))).Vpc?.VpcId;if(!c)throw Error(`VPC was created but no VPC ID was returned by the API`);if(n.push({type:`VPC`,id:c}),e.mode===`vpc-only`)return{vpcId:c,created:n};let l=X(e.cidrBlock,e.publicSubnetCount,e.privateSubnetCount),u=e.publicSubnetCidrs.length>0?e.publicSubnetCidrs:l.publicSubnets.map(e=>e.cidr),d=e.privateSubnetCidrs.length>0?e.privateSubnetCidrs:l.privateSubnets.map(e=>e.cidr);a=`creating public subnets`;let f=[];for(let a=0;a0){a=`creating internet gateway`;let e=r?`${r}-igw`:void 0,o=(await t.send(new L({TagSpecifications:$(G.internet_gateway,e,i)}))).InternetGateway?.InternetGatewayId;if(!o)throw Error(`Internet gateway was created but no ID was returned`);n.push({type:`Internet Gateway`,id:o}),a=`attaching internet gateway to VPC`,await t.send(new F({InternetGatewayId:o,VpcId:c})),a=`creating public route table`;let s=r?`${r}-rtb-public`:void 0,l=(await t.send(new B({VpcId:c,TagSpecifications:$(G.route_table,s,i)}))).RouteTable?.RouteTableId;if(!l)throw Error(`Public route table was created but no ID was returned`);n.push({type:`Public Route Table`,id:l}),a=`creating default route to internet gateway`,await t.send(new z({RouteTableId:l,DestinationCidrBlock:`0.0.0.0/0`,GatewayId:o})),a=`associating public subnets with route table`;for(let e of f)await t.send(new P({RouteTableId:l,SubnetId:e}))}if(e.privateSubnetCount>0){a=`creating private route table`;let o=r?`${r}-rtb-private`:void 0,s=(await t.send(new B({VpcId:c,TagSpecifications:$(G.route_table,o,i)}))).RouteTable?.RouteTableId;if(!s)throw Error(`Private route table was created but no ID was returned`);n.push({type:`Private Route Table`,id:s}),a=`associating private subnets with route table`;for(let e of p)await t.send(new P({RouteTableId:s,SubnetId:e}));if(e.natGateway===`single`&&f[0]){a=`allocating Elastic IP for NAT gateway`;let e=(await t.send(new N({Domain:`vpc`,TagSpecifications:$(G.elastic_ip,r?`${r}-eip-nat`:void 0,i)}))).AllocationId;if(!e)throw Error(`Elastic IP was allocated but no allocation ID was returned`);n.push({type:`Elastic IP`,id:e}),a=`creating NAT gateway`;let o=(await t.send(new R({SubnetId:f[0],AllocationId:e,TagSpecifications:$(G.natgateway,r?`${r}-nat`:void 0,i)}))).NatGateway?.NatGatewayId;if(!o)throw Error(`NAT gateway was created but no ID was returned`);n.push({type:`NAT Gateway`,id:o}),a=`creating default route to NAT gateway`,await t.send(new z({RouteTableId:s,DestinationCidrBlock:`0.0.0.0/0`,NatGatewayId:o}))}}return{vpcId:c,created:n}}catch(e){return{vpcId:n.find(e=>e.type===`VPC`)?.id,created:n,error:e instanceof Error?e:Error(String(e)),failedStep:`Failed while ${a}`}}},onSuccess:()=>{t.invalidateQueries({queryKey:[`ec2`,`vpcs`]}),t.invalidateQueries({queryKey:[`ec2`,`subnets`]}),t.invalidateQueries({queryKey:[`ec2`,`internetGateways`]}),t.invalidateQueries({queryKey:[`ec2`,`routeTables`]}),t.invalidateQueries({queryKey:[`ec2`,`natGateways`]}),t.invalidateQueries({queryKey:[`ec2`,`addresses`]})}})}export{qt as A,tn as B,Pn as C,rr as D,vn as E,$t as F,On as G,Dt as H,Kn as I,Tt as J,xt as K,sr as L,Ut as M,un as N,$n as O,Rn as P,yt as Q,rn as R,ur as S,Yn as T,Hn as U,Vt as V,An as W,vt as X,X as Y,_t as Z,bn as _,Zt as a,zt as b,Yt as c,Mn as d,Nt as f,Zn as g,tr as h,In as i,mn as j,Sn as k,on as l,gn as m,Wn as n,En as o,Jn as p,Ct as q,ar as r,wn as s,Bn as t,kt as u,Gt as v,Lt as w,cn as x,fn as y,Ft as z};
\ No newline at end of file
+import{t as e}from"./compiler-runtime.js";import{n as t}from"./error-banner.js";import{$n as n,An as r,Cn as i,Cr as a,Dn as o,En as s,Er as c,Mn as l,On as u,Qn as d,Sn as f,Sr as p,Tn as m,Tr as h,Xn as g,Zn as ee,_n as te,an as ne,ar as re,bn as ie,cn as ae,cr as oe,dn as se,dr as ce,er as le,fn as ue,fr as de,gn as fe,hn as pe,in as me,ir as he,jn as ge,kn as _e,ln as ve,lr as ye,mn as be,mr as _,nn as v,nr as y,on as b,or as x,pn as S,pr as C,r as w,rn as T,rr as E,sn as D,sr as O,tn as k,tr as A,un as j,ur as M,vn as N,wn as xe,xn as Se,xr as Ce,yn as we,yr as Te}from"./awsClient.js";import{i as P,t as Ee}from"./SignatureV4.js";import{Lr as F,bt as I}from"./index.js";import{t as De}from"./subnet-calculator.js";var L=class extends h.classBuilder().ep(_).m(function(e,t,n,r){return[a(n,e.getEndpointParameterInstructions())]}).s(`AmazonEC2`,`AllocateAddress`,{}).n(`EC2Client`,`AllocateAddressCommand`).sc(k).build(){},Oe=class extends h.classBuilder().ep(_).m(function(e,t,n,r){return[a(n,e.getEndpointParameterInstructions())]}).s(`AmazonEC2`,`AssociateAddress`,{}).n(`EC2Client`,`AssociateAddressCommand`).sc(v).build(){},ke=class extends h.classBuilder().ep(_).m(function(e,t,n,r){return[a(n,e.getEndpointParameterInstructions())]}).s(`AmazonEC2`,`AssociateIamInstanceProfile`,{}).n(`EC2Client`,`AssociateIamInstanceProfileCommand`).sc(T).build(){},R=class extends h.classBuilder().ep(_).m(function(e,t,n,r){return[a(n,e.getEndpointParameterInstructions())]}).s(`AmazonEC2`,`AssociateRouteTable`,{}).n(`EC2Client`,`AssociateRouteTableCommand`).sc(me).build(){},z=class extends h.classBuilder().ep(_).m(function(e,t,n,r){return[a(n,e.getEndpointParameterInstructions())]}).s(`AmazonEC2`,`AttachInternetGateway`,{}).n(`EC2Client`,`AttachInternetGatewayCommand`).sc(ne).build(){},Ae=class extends h.classBuilder().ep(_).m(function(e,t,n,r){return[a(n,e.getEndpointParameterInstructions())]}).s(`AmazonEC2`,`AttachVolume`,{}).n(`EC2Client`,`AttachVolumeCommand`).sc(b).build(){},je=class extends h.classBuilder().ep(_).m(function(e,t,n,r){return[a(n,e.getEndpointParameterInstructions())]}).s(`AmazonEC2`,`AuthorizeSecurityGroupEgress`,{}).n(`EC2Client`,`AuthorizeSecurityGroupEgressCommand`).sc(D).build(){},B=class extends h.classBuilder().ep(_).m(function(e,t,n,r){return[a(n,e.getEndpointParameterInstructions())]}).s(`AmazonEC2`,`AuthorizeSecurityGroupIngress`,{}).n(`EC2Client`,`AuthorizeSecurityGroupIngressCommand`).sc(ae).build(){};function Me(e){let{port:t,query:n}=e,{protocol:r,path:i,hostname:a}=e;r&&r.slice(-1)!==`:`&&(r+=`:`),t&&(a+=`:${t}`),i&&i.charAt(0)!==`/`&&(i=`/${i}`);let o=n?Te(n):``;o&&o[0]!==`?`&&(o=`?${o}`);let s=``;(e.username!=null||e.password!=null)&&(s=`${e.username??``}:${e.password??``}@`);let c=``;return e.fragment&&(c=`#${e.fragment}`),`${r}//${s}${a}${i}${o}${c}`}var Ne=`2016-11-15`;function Pe(e){return(t,n)=>async n=>{let{input:r}=n;if(!r.PresignedUrl){let t=await e.region(),i=await p(r,{getEndpointParameterInstructions(){return{UseFIPS:{type:`builtInParams`,name:`useFipsEndpoint`},Endpoint:{type:`builtInParams`,name:`endpoint`},Region:{type:`builtInParams`,name:`region`},UseDualStack:{type:`builtInParams`,name:`useDualstackEndpoint`}}}},{...e,region:r.SourceRegion}),a=typeof e.endpoint==`function`?await e.endpoint():c(i),o=new P({...a,protocol:`https`,headers:{host:a.hostname},query:{...Object.entries(r).reduce((e,[t,n])=>(e[t]=String(n??``),e),{}),Action:`CopySnapshot`,Version:Ne,DestinationRegion:t}}),s=await new Ee({credentials:e.credentials,region:r.SourceRegion,service:`ec2`,sha256:e.sha256,uriEscapePath:e.signingEscapePath}).presign(o,{expiresIn:3600});if(n={...n,input:{...n.input,DestinationRegion:t,PresignedUrl:Me(s)}},P.isInstance(n.request)){let{request:e}=n;(e.body??``).includes(`DestinationRegion=`)||(e.body+=`&DestinationRegion=${t}`),(e.body??``).includes(`PresignedUrl=`)||(e.body+=`&PresignedUrl=${Ce(n.input.PresignedUrl)}`)}}return t(n)}}var Fe={step:`serialize`,tags:[`CROSS_REGION_PRESIGNED_URL`],name:`crossRegionPresignedUrlMiddleware`,override:!0,relation:`after`,toMiddleware:`endpointV2Middleware`},Ie=e=>({applyToStack:t=>{t.add(Pe(e),Fe)}}),Le=class extends h.classBuilder().ep(_).m(function(e,t,n,r){return[a(n,e.getEndpointParameterInstructions()),Ie(n)]}).s(`AmazonEC2`,`CopySnapshot`,{}).n(`EC2Client`,`CopySnapshotCommand`).sc(ve).build(){},Re=class extends h.classBuilder().ep(_).m(function(e,t,n,r){return[a(n,e.getEndpointParameterInstructions())]}).s(`AmazonEC2`,`CreateImage`,{}).n(`EC2Client`,`CreateImageCommand`).sc(j).build(){},V=class extends h.classBuilder().ep(_).m(function(e,t,n,r){return[a(n,e.getEndpointParameterInstructions())]}).s(`AmazonEC2`,`CreateInternetGateway`,{}).n(`EC2Client`,`CreateInternetGatewayCommand`).sc(se).build(){},ze=class extends h.classBuilder().ep(_).m(function(e,t,n,r){return[a(n,e.getEndpointParameterInstructions())]}).s(`AmazonEC2`,`CreateKeyPair`,{}).n(`EC2Client`,`CreateKeyPairCommand`).sc(ue).build(){},H=class extends h.classBuilder().ep(_).m(function(e,t,n,r){return[a(n,e.getEndpointParameterInstructions())]}).s(`AmazonEC2`,`CreateNatGateway`,{}).n(`EC2Client`,`CreateNatGatewayCommand`).sc(S).build(){},Be=class extends h.classBuilder().ep(_).m(function(e,t,n,r){return[a(n,e.getEndpointParameterInstructions())]}).s(`AmazonEC2`,`CreatePlacementGroup`,{}).n(`EC2Client`,`CreatePlacementGroupCommand`).sc(be).build(){},U=class extends h.classBuilder().ep(_).m(function(e,t,n,r){return[a(n,e.getEndpointParameterInstructions())]}).s(`AmazonEC2`,`CreateRoute`,{}).n(`EC2Client`,`CreateRouteCommand`).sc(pe).build(){},W=class extends h.classBuilder().ep(_).m(function(e,t,n,r){return[a(n,e.getEndpointParameterInstructions())]}).s(`AmazonEC2`,`CreateRouteTable`,{}).n(`EC2Client`,`CreateRouteTableCommand`).sc(fe).build(){},G=class extends h.classBuilder().ep(_).m(function(e,t,n,r){return[a(n,e.getEndpointParameterInstructions())]}).s(`AmazonEC2`,`CreateSecurityGroup`,{}).n(`EC2Client`,`CreateSecurityGroupCommand`).sc(te).build(){},Ve=class extends h.classBuilder().ep(_).m(function(e,t,n,r){return[a(n,e.getEndpointParameterInstructions())]}).s(`AmazonEC2`,`CreateSnapshot`,{}).n(`EC2Client`,`CreateSnapshotCommand`).sc(N).build(){},K=class extends h.classBuilder().ep(_).m(function(e,t,n,r){return[a(n,e.getEndpointParameterInstructions())]}).s(`AmazonEC2`,`CreateSubnet`,{}).n(`EC2Client`,`CreateSubnetCommand`).sc(we).build(){},He=class extends h.classBuilder().ep(_).m(function(e,t,n,r){return[a(n,e.getEndpointParameterInstructions())]}).s(`AmazonEC2`,`CreateVolume`,{}).n(`EC2Client`,`CreateVolumeCommand`).sc(ie).build(){},q=class extends h.classBuilder().ep(_).m(function(e,t,n,r){return[a(n,e.getEndpointParameterInstructions())]}).s(`AmazonEC2`,`CreateVpc`,{}).n(`EC2Client`,`CreateVpcCommand`).sc(Se).build(){},Ue=class extends h.classBuilder().ep(_).m(function(e,t,n,r){return[a(n,e.getEndpointParameterInstructions())]}).s(`AmazonEC2`,`DeleteInternetGateway`,{}).n(`EC2Client`,`DeleteInternetGatewayCommand`).sc(f).build(){},We=class extends h.classBuilder().ep(_).m(function(e,t,n,r){return[a(n,e.getEndpointParameterInstructions())]}).s(`AmazonEC2`,`DeleteKeyPair`,{}).n(`EC2Client`,`DeleteKeyPairCommand`).sc(i).build(){},Ge=class extends h.classBuilder().ep(_).m(function(e,t,n,r){return[a(n,e.getEndpointParameterInstructions())]}).s(`AmazonEC2`,`DeleteNatGateway`,{}).n(`EC2Client`,`DeleteNatGatewayCommand`).sc(xe).build(){},Ke=class extends h.classBuilder().ep(_).m(function(e,t,n,r){return[a(n,e.getEndpointParameterInstructions())]}).s(`AmazonEC2`,`DeletePlacementGroup`,{}).n(`EC2Client`,`DeletePlacementGroupCommand`).sc(m).build(){},qe=class extends h.classBuilder().ep(_).m(function(e,t,n,r){return[a(n,e.getEndpointParameterInstructions())]}).s(`AmazonEC2`,`DeleteRoute`,{}).n(`EC2Client`,`DeleteRouteCommand`).sc(s).build(){},Je=class extends h.classBuilder().ep(_).m(function(e,t,n,r){return[a(n,e.getEndpointParameterInstructions())]}).s(`AmazonEC2`,`DeleteRouteTable`,{}).n(`EC2Client`,`DeleteRouteTableCommand`).sc(o).build(){},Ye=class extends h.classBuilder().ep(_).m(function(e,t,n,r){return[a(n,e.getEndpointParameterInstructions())]}).s(`AmazonEC2`,`DeleteSecurityGroup`,{}).n(`EC2Client`,`DeleteSecurityGroupCommand`).sc(u).build(){},Xe=class extends h.classBuilder().ep(_).m(function(e,t,n,r){return[a(n,e.getEndpointParameterInstructions())]}).s(`AmazonEC2`,`DeleteSnapshot`,{}).n(`EC2Client`,`DeleteSnapshotCommand`).sc(_e).build(){},Ze=class extends h.classBuilder().ep(_).m(function(e,t,n,r){return[a(n,e.getEndpointParameterInstructions())]}).s(`AmazonEC2`,`DeleteSubnet`,{}).n(`EC2Client`,`DeleteSubnetCommand`).sc(r).build(){},Qe=class extends h.classBuilder().ep(_).m(function(e,t,n,r){return[a(n,e.getEndpointParameterInstructions())]}).s(`AmazonEC2`,`DeleteVolume`,{}).n(`EC2Client`,`DeleteVolumeCommand`).sc(ge).build(){},$e=class extends h.classBuilder().ep(_).m(function(e,t,n,r){return[a(n,e.getEndpointParameterInstructions())]}).s(`AmazonEC2`,`DeleteVpc`,{}).n(`EC2Client`,`DeleteVpcCommand`).sc(l).build(){},et=class extends h.classBuilder().ep(_).m(function(e,t,n,r){return[a(n,e.getEndpointParameterInstructions())]}).s(`AmazonEC2`,`DetachInternetGateway`,{}).n(`EC2Client`,`DetachInternetGatewayCommand`).sc(g).build(){},tt=class extends h.classBuilder().ep(_).m(function(e,t,n,r){return[a(n,e.getEndpointParameterInstructions())]}).s(`AmazonEC2`,`DetachVolume`,{}).n(`EC2Client`,`DetachVolumeCommand`).sc(ee).build(){},nt=class extends h.classBuilder().ep(_).m(function(e,t,n,r){return[a(n,e.getEndpointParameterInstructions())]}).s(`AmazonEC2`,`DisassociateAddress`,{}).n(`EC2Client`,`DisassociateAddressCommand`).sc(d).build(){},rt=class extends h.classBuilder().ep(_).m(function(e,t,n,r){return[a(n,e.getEndpointParameterInstructions())]}).s(`AmazonEC2`,`DisassociateIamInstanceProfile`,{}).n(`EC2Client`,`DisassociateIamInstanceProfileCommand`).sc(n).build(){},it=class extends h.classBuilder().ep(_).m(function(e,t,n,r){return[a(n,e.getEndpointParameterInstructions())]}).s(`AmazonEC2`,`DisassociateRouteTable`,{}).n(`EC2Client`,`DisassociateRouteTableCommand`).sc(le).build(){},at=class extends h.classBuilder().ep(_).m(function(e,t,n,r){return[a(n,e.getEndpointParameterInstructions())]}).s(`AmazonEC2`,`GetConsoleOutput`,{}).n(`EC2Client`,`GetConsoleOutputCommand`).sc(A).build(){},ot=class extends h.classBuilder().ep(_).m(function(e,t,n,r){return[a(n,e.getEndpointParameterInstructions())]}).s(`AmazonEC2`,`ImportKeyPair`,{}).n(`EC2Client`,`ImportKeyPairCommand`).sc(y).build(){},st=class extends h.classBuilder().ep(_).m(function(e,t,n,r){return[a(n,e.getEndpointParameterInstructions())]}).s(`AmazonEC2`,`ModifyInstanceAttribute`,{}).n(`EC2Client`,`ModifyInstanceAttributeCommand`).sc(E).build(){},J=class extends h.classBuilder().ep(_).m(function(e,t,n,r){return[a(n,e.getEndpointParameterInstructions())]}).s(`AmazonEC2`,`ModifySubnetAttribute`,{}).n(`EC2Client`,`ModifySubnetAttributeCommand`).sc(he).build(){},ct=class extends h.classBuilder().ep(_).m(function(e,t,n,r){return[a(n,e.getEndpointParameterInstructions())]}).s(`AmazonEC2`,`ModifyVolume`,{}).n(`EC2Client`,`ModifyVolumeCommand`).sc(re).build(){},lt=class extends h.classBuilder().ep(_).m(function(e,t,n,r){return[a(n,e.getEndpointParameterInstructions())]}).s(`AmazonEC2`,`RebootInstances`,{}).n(`EC2Client`,`RebootInstancesCommand`).sc(x).build(){},ut=class extends h.classBuilder().ep(_).m(function(e,t,n,r){return[a(n,e.getEndpointParameterInstructions())]}).s(`AmazonEC2`,`ReleaseAddress`,{}).n(`EC2Client`,`ReleaseAddressCommand`).sc(O).build(){},dt=class extends h.classBuilder().ep(_).m(function(e,t,n,r){return[a(n,e.getEndpointParameterInstructions())]}).s(`AmazonEC2`,`RevokeSecurityGroupEgress`,{}).n(`EC2Client`,`RevokeSecurityGroupEgressCommand`).sc(oe).build(){},ft=class extends h.classBuilder().ep(_).m(function(e,t,n,r){return[a(n,e.getEndpointParameterInstructions())]}).s(`AmazonEC2`,`RevokeSecurityGroupIngress`,{}).n(`EC2Client`,`RevokeSecurityGroupIngressCommand`).sc(ye).build(){},pt=class extends h.classBuilder().ep(_).m(function(e,t,n,r){return[a(n,e.getEndpointParameterInstructions())]}).s(`AmazonEC2`,`RunInstances`,{}).n(`EC2Client`,`RunInstancesCommand`).sc(M).build(){},mt=class extends h.classBuilder().ep(_).m(function(e,t,n,r){return[a(n,e.getEndpointParameterInstructions())]}).s(`AmazonEC2`,`StartInstances`,{}).n(`EC2Client`,`StartInstancesCommand`).sc(ce).build(){},ht=class extends h.classBuilder().ep(_).m(function(e,t,n,r){return[a(n,e.getEndpointParameterInstructions())]}).s(`AmazonEC2`,`StopInstances`,{}).n(`EC2Client`,`StopInstancesCommand`).sc(de).build(){},gt=class extends h.classBuilder().ep(_).m(function(e,t,n,r){return[a(n,e.getEndpointParameterInstructions())]}).s(`AmazonEC2`,`TerminateInstances`,{}).n(`EC2Client`,`TerminateInstancesCommand`).sc(C).build(){},Y={capacity_block:`capacity-block`,capacity_manager_data_export:`capacity-manager-data-export`,capacity_reservation:`capacity-reservation`,capacity_reservation_cancellation_quote:`capacity-reservation-cancellation-quote`,capacity_reservation_fleet:`capacity-reservation-fleet`,carrier_gateway:`carrier-gateway`,client_vpn_endpoint:`client-vpn-endpoint`,coip_pool:`coip-pool`,customer_gateway:`customer-gateway`,declarative_policies_report:`declarative-policies-report`,dedicated_host:`dedicated-host`,dhcp_options:`dhcp-options`,egress_only_internet_gateway:`egress-only-internet-gateway`,elastic_gpu:`elastic-gpu`,elastic_ip:`elastic-ip`,export_image_task:`export-image-task`,export_instance_task:`export-instance-task`,fleet:`fleet`,fpga_image:`fpga-image`,host_reservation:`host-reservation`,image:`image`,image_usage_report:`image-usage-report`,import_image_task:`import-image-task`,import_snapshot_task:`import-snapshot-task`,instance:`instance`,instance_connect_endpoint:`instance-connect-endpoint`,instance_event_window:`instance-event-window`,internet_gateway:`internet-gateway`,ipam:`ipam`,ipam_external_resource_verification_token:`ipam-external-resource-verification-token`,ipam_policy:`ipam-policy`,ipam_pool:`ipam-pool`,ipam_pool_allocation:`ipam-pool-allocation`,ipam_prefix_list_resolver:`ipam-prefix-list-resolver`,ipam_prefix_list_resolver_target:`ipam-prefix-list-resolver-target`,ipam_resource_discovery:`ipam-resource-discovery`,ipam_resource_discovery_association:`ipam-resource-discovery-association`,ipam_scope:`ipam-scope`,ipv4pool_ec2:`ipv4pool-ec2`,ipv6pool_ec2:`ipv6pool-ec2`,key_pair:`key-pair`,launch_template:`launch-template`,local_gateway:`local-gateway`,local_gateway_route_table:`local-gateway-route-table`,local_gateway_route_table_virtual_interface_group_association:`local-gateway-route-table-virtual-interface-group-association`,local_gateway_route_table_vpc_association:`local-gateway-route-table-vpc-association`,local_gateway_virtual_interface:`local-gateway-virtual-interface`,local_gateway_virtual_interface_group:`local-gateway-virtual-interface-group`,mac_modification_task:`mac-modification-task`,natgateway:`natgateway`,network_acl:`network-acl`,network_insights_access_scope:`network-insights-access-scope`,network_insights_access_scope_analysis:`network-insights-access-scope-analysis`,network_insights_analysis:`network-insights-analysis`,network_insights_path:`network-insights-path`,network_interface:`network-interface`,outpost_lag:`outpost-lag`,placement_group:`placement-group`,prefix_list:`prefix-list`,replace_root_volume_task:`replace-root-volume-task`,reserved_instances:`reserved-instances`,route_server:`route-server`,route_server_endpoint:`route-server-endpoint`,route_server_peer:`route-server-peer`,route_table:`route-table`,secondary_interface:`secondary-interface`,secondary_network:`secondary-network`,secondary_subnet:`secondary-subnet`,security_group:`security-group`,security_group_rule:`security-group-rule`,service_link_virtual_interface:`service-link-virtual-interface`,snapshot:`snapshot`,spot_fleet_request:`spot-fleet-request`,spot_instances_request:`spot-instances-request`,subnet:`subnet`,subnet_cidr_reservation:`subnet-cidr-reservation`,traffic_mirror_filter:`traffic-mirror-filter`,traffic_mirror_filter_rule:`traffic-mirror-filter-rule`,traffic_mirror_session:`traffic-mirror-session`,traffic_mirror_target:`traffic-mirror-target`,transit_gateway:`transit-gateway`,transit_gateway_attachment:`transit-gateway-attachment`,transit_gateway_connect_peer:`transit-gateway-connect-peer`,transit_gateway_metering_policy:`transit-gateway-metering-policy`,transit_gateway_multicast_domain:`transit-gateway-multicast-domain`,transit_gateway_policy_table:`transit-gateway-policy-table`,transit_gateway_route_table:`transit-gateway-route-table`,transit_gateway_route_table_announcement:`transit-gateway-route-table-announcement`,verified_access_endpoint:`verified-access-endpoint`,verified_access_endpoint_target:`verified-access-endpoint-target`,verified_access_group:`verified-access-group`,verified_access_instance:`verified-access-instance`,verified_access_policy:`verified-access-policy`,verified_access_trust_provider:`verified-access-trust-provider`,volume:`volume`,vpc:`vpc`,vpc_block_public_access_exclusion:`vpc-block-public-access-exclusion`,vpc_encryption_control:`vpc-encryption-control`,vpc_endpoint:`vpc-endpoint`,vpc_endpoint_connection:`vpc-endpoint-connection`,vpc_endpoint_connection_device_type:`vpc-endpoint-connection-device-type`,vpc_endpoint_service:`vpc-endpoint-service`,vpc_endpoint_service_permission:`vpc-endpoint-service-permission`,vpc_flow_log:`vpc-flow-log`,vpc_peering_connection:`vpc-peering-connection`,vpn_concentrator:`vpn-concentrator`,vpn_connection:`vpn-connection`,vpn_connection_device_type:`vpn-connection-device-type`,vpn_gateway:`vpn-gateway`},X=e(),_t=/\s+/;function vt(){let e=(0,X.c)(2),n=F(),r;return e[0]===n?r=e[1]:(r={mutationFn:yt,onSuccess:()=>{n.invalidateQueries({queryKey:[`ec2`,`instances`]})}},e[0]=n,e[1]=r),t(r)}async function yt(e){let t=new mt({InstanceIds:[e]});return await w().send(t)}function bt(){let e=(0,X.c)(2),n=F(),r;return e[0]===n?r=e[1]:(r={mutationFn:xt,onSuccess:()=>{n.invalidateQueries({queryKey:[`ec2`,`instances`]})}},e[0]=n,e[1]=r),t(r)}async function xt(e){let t=new ht({InstanceIds:[e]});return await w().send(t)}function St(){let e=(0,X.c)(2),n=F(),r;return e[0]===n?r=e[1]:(r={mutationFn:Ct,onSuccess:()=>{n.invalidateQueries({queryKey:[`ec2`,`instances`]})}},e[0]=n,e[1]=r),t(r)}async function Ct(e){let t=new gt({InstanceIds:[e]});return await w().send(t)}function wt(){let e=(0,X.c)(2),n=F(),r;return e[0]===n?r=e[1]:(r={mutationFn:Tt,onSuccess:()=>{n.invalidateQueries({queryKey:[`ec2`,`instances`]})}},e[0]=n,e[1]=r),t(r)}async function Tt(e){let t=new lt({InstanceIds:[e]});return await w().send(t)}function Et(){let e=(0,X.c)(2),n=F(),r;return e[0]===n?r=e[1]:(r={mutationFn:Dt,onSuccess:()=>{n.invalidateQueries({queryKey:[`ec2`,`instances`]}),n.invalidateQueries({queryKey:[`ec2`,`securityGroups`]})}},e[0]=n,e[1]=r),t(r)}async function Dt(e){let t=w(),n=await Ot(t,e),r=new pt({ImageId:e.imageId,InstanceType:e.instanceType,KeyName:e.keyName,MinCount:e.count,MaxCount:e.count,SubnetId:e.subnetId||void 0,SecurityGroupIds:n.length>0?n:void 0,Placement:e.placementGroupName?{GroupName:e.placementGroupName}:void 0,BlockDeviceMappings:kt(e)});return await t.send(r)}async function Ot(e,t){if(t.securityGroupMode!==`create`)return t.securityGroupIds??[];let n=(await e.send(new G({GroupName:t.newSgName,Description:t.newSgDescription||`Created by launch wizard`,VpcId:t.resolvedVpcId}))).GroupId;if(!n)throw Error(`Security group was created but no ID was returned`);let r=[...t.allowSsh?[22]:[],...t.allowHttp?[80]:[],...t.allowHttps?[443]:[]];if(r.length>0){let i=t.ruleSource===`custom`&&t.customCidr?t.customCidr:`0.0.0.0/0`;await e.send(new B({GroupId:n,IpPermissions:r.map(e=>({IpProtocol:`tcp`,FromPort:e,ToPort:e,IpRanges:[{CidrIp:i}]}))}))}return[n]}function kt(e){let{rootDeviceName:t,rootVolumeSize:n,rootVolumeType:r,rootDeleteOnTermination:i}=e;return n!==void 0||r!==void 0||i!==void 0?[{DeviceName:t,Ebs:{...n!==void 0&&{VolumeSize:n},...r!==void 0&&{VolumeType:r},...i!==void 0&&{DeleteOnTermination:i}}}]:void 0}function At(){let e=(0,X.c)(2),n=F(),r;return e[0]===n?r=e[1]:(r={mutationFn:jt,onSuccess:()=>{n.invalidateQueries(I)}},e[0]=n,e[1]=r),t(r)}async function jt(e){let t=new ze({KeyName:e.keyName,KeyType:`rsa`}),n=await w().send(t);if(!n.KeyMaterial)throw Error(`Key pair was created but the API returned no private key. The key cannot be recovered — delete it and try again.`);return n}function Mt(){let e=(0,X.c)(2),n=F(),r;return e[0]===n?r=e[1]:(r={mutationFn:Nt,onSuccess:()=>{n.invalidateQueries(I)}},e[0]=n,e[1]=r),t(r)}async function Nt(e){let t=e.publicKeyMaterial.trim().split(_t).slice(0,2).join(` `),n=new ot({KeyName:e.keyName,PublicKeyMaterial:new TextEncoder().encode(t)});return await w().send(n)}function Pt(){let e=(0,X.c)(2),n=F(),r;return e[0]===n?r=e[1]:(r={mutationFn:Ft,onSuccess:()=>{n.invalidateQueries(I)}},e[0]=n,e[1]=r),t(r)}async function Ft(e){let t=new We({KeyPairId:e});return await w().send(t)}function It(){let e=(0,X.c)(2),n=F(),r;return e[0]===n?r=e[1]:(r={mutationFn:Lt,onSuccess:()=>{n.invalidateQueries({queryKey:[`ec2`,`volumes`]})}},e[0]=n,e[1]=r),t(r)}async function Lt(e){let t=new He({Size:e.size,AvailabilityZone:e.availabilityZone,VolumeType:`gp3`});return await w().send(t)}function Rt(){let e=(0,X.c)(2),n=F(),r;return e[0]===n?r=e[1]:(r={mutationFn:zt,onSuccess:()=>{n.invalidateQueries({queryKey:[`ec2`,`volumes`]})}},e[0]=n,e[1]=r),t(r)}async function zt(e){let t=new ct({VolumeId:e.volumeId,Size:e.size});return await w().send(t)}function Bt(){let e=(0,X.c)(2),n=F(),r;return e[0]===n?r=e[1]:(r={mutationFn:Vt,onSuccess:()=>{n.invalidateQueries({queryKey:[`ec2`,`volumes`]})}},e[0]=n,e[1]=r),t(r)}async function Vt(e){let t=new Qe({VolumeId:e});return await w().send(t)}function Ht(){let e=(0,X.c)(2),n=F(),r;return e[0]===n?r=e[1]:(r={mutationFn:Ut,onSuccess:()=>{n.invalidateQueries({queryKey:[`ec2`,`snapshots`]})}},e[0]=n,e[1]=r),t(r)}async function Ut(e){let t=new Ve({VolumeId:e.volumeId,Description:e.description||void 0});return await w().send(t)}function Wt(){let e=(0,X.c)(2),n=F(),r;return e[0]===n?r=e[1]:(r={mutationFn:Gt,onSuccess:()=>{n.invalidateQueries({queryKey:[`ec2`,`snapshots`]})}},e[0]=n,e[1]=r),t(r)}async function Gt(e){let t=new Xe({SnapshotId:e});return await w().send(t)}function Kt(){let e=(0,X.c)(2),n=F(),r;return e[0]===n?r=e[1]:(r={mutationFn:qt,onSuccess:()=>{n.invalidateQueries({queryKey:[`ec2`,`snapshots`]})}},e[0]=n,e[1]=r),t(r)}async function qt(e){let t=new Le({SourceSnapshotId:e.sourceSnapshotId,SourceRegion:e.sourceRegion,Description:e.description||void 0});return await w().send(t)}function Jt(){let e=(0,X.c)(2),n=F(),r;return e[0]===n?r=e[1]:(r={mutationFn:Yt,onSuccess:()=>{n.invalidateQueries({queryKey:[`ec2`,`volumes`]}),n.invalidateQueries({queryKey:[`ec2`,`instances`]})}},e[0]=n,e[1]=r),t(r)}async function Yt(e){let t=new Ae({VolumeId:e.volumeId,InstanceId:e.instanceId,Device:e.device||void 0});return await w().send(t)}function Xt(){let e=(0,X.c)(2),n=F(),r;return e[0]===n?r=e[1]:(r={mutationFn:Zt,onSuccess:()=>{n.invalidateQueries({queryKey:[`ec2`,`volumes`]}),n.invalidateQueries({queryKey:[`ec2`,`instances`]})}},e[0]=n,e[1]=r),t(r)}async function Zt(e){let t=new tt({VolumeId:e.volumeId,InstanceId:e.instanceId||void 0,Force:e.force});return await w().send(t)}function Qt(){let e=(0,X.c)(2),n=F(),r;return e[0]===n?r=e[1]:(r={mutationFn:$t,onSuccess:()=>{n.invalidateQueries({queryKey:[`ec2`,`instances`]})}},e[0]=n,e[1]=r),t(r)}async function $t(e){let{instanceId:t,...n}=e,r=new st({InstanceId:t,InstanceType:{Value:n.instanceType}});return await w().send(r)}function en(){let e=(0,X.c)(1),n;return e[0]===Symbol.for(`react.memo_cache_sentinel`)?(n={mutationFn:tn},e[0]=n):n=e[0],t(n)}async function tn(e){let t=new at({InstanceId:e});return await w().send(t)}function nn(){let e=(0,X.c)(2),n=F(),r;return e[0]===n?r=e[1]:(r={mutationFn:rn,onSuccess:()=>{n.invalidateQueries({queryKey:[`ec2`,`images`]})}},e[0]=n,e[1]=r),t(r)}async function rn(e){let t=new Re({InstanceId:e.instanceId,Name:e.name,Description:e.description||void 0});return await w().send(t)}function an(){let e=(0,X.c)(2),n=F(),r;return e[0]===n?r=e[1]:(r={mutationFn:on,onSuccess:()=>{n.invalidateQueries({queryKey:[`ec2`,`vpcs`]})}},e[0]=n,e[1]=r),t(r)}async function on(e){let t=new q({CidrBlock:e.cidrBlock,TagSpecifications:e.name?[{ResourceType:`vpc`,Tags:[{Key:`Name`,Value:e.name}]}]:void 0});return await w().send(t)}function sn(){let e=(0,X.c)(2),n=F(),r;return e[0]===n?r=e[1]:(r={mutationFn:cn,onSuccess:()=>{n.invalidateQueries({queryKey:[`ec2`,`vpcs`]})}},e[0]=n,e[1]=r),t(r)}async function cn(e){let t=new $e({VpcId:e});return await w().send(t)}function ln(){let e=(0,X.c)(2),n=F(),r;return e[0]===n?r=e[1]:(r={mutationFn:un,onSuccess:()=>{n.invalidateQueries({queryKey:[`ec2`,`subnets`]})}},e[0]=n,e[1]=r),t(r)}async function un(e){let t=w(),n=await t.send(new K({VpcId:e.vpcId,CidrBlock:e.cidrBlock,AvailabilityZone:e.availabilityZone||void 0})),r=n.Subnet?.SubnetId;return e.mapPublicIpOnLaunch&&r&&await t.send(new J({SubnetId:r,MapPublicIpOnLaunch:{Value:!0}})),n}function dn(){let e=(0,X.c)(2),n=F(),r;return e[0]===n?r=e[1]:(r={mutationFn:fn,onSuccess:()=>{n.invalidateQueries({queryKey:[`ec2`,`subnets`]})}},e[0]=n,e[1]=r),t(r)}async function fn(e){let t=new Ze({SubnetId:e});return await w().send(t)}function pn(){let e=(0,X.c)(2),n=F(),r;return e[0]===n?r=e[1]:(r={mutationFn:mn,onSuccess:()=>{n.invalidateQueries({queryKey:[`ec2`,`placementGroups`]})}},e[0]=n,e[1]=r),t(r)}async function mn(e){let t=new Be({GroupName:e.groupName,Strategy:e.strategy});return await w().send(t)}function hn(){let e=(0,X.c)(2),n=F(),r;return e[0]===n?r=e[1]:(r={mutationFn:gn,onSuccess:()=>{n.invalidateQueries({queryKey:[`ec2`,`placementGroups`]})}},e[0]=n,e[1]=r),t(r)}async function gn(e){let t=new Ke({GroupName:e});return await w().send(t)}function _n(){let e=(0,X.c)(2),n=F(),r;return e[0]===n?r=e[1]:(r={mutationFn:vn,onSuccess:()=>{n.invalidateQueries({queryKey:[`ec2`,`securityGroups`]})}},e[0]=n,e[1]=r),t(r)}async function vn(e){let t=new G({GroupName:e.groupName,Description:e.description,VpcId:e.vpcId});return await w().send(t)}function yn(){let e=(0,X.c)(2),n=F(),r;return e[0]===n?r=e[1]:(r={mutationFn:bn,onSuccess:()=>{n.invalidateQueries({queryKey:[`ec2`,`securityGroups`]})}},e[0]=n,e[1]=r),t(r)}async function bn(e){let t=new Ye({GroupId:e});return await w().send(t)}function xn(){let e=(0,X.c)(2),n=F(),r;return e[0]===n?r=e[1]:(r={mutationFn:Sn,onSuccess:(e,t)=>{n.invalidateQueries({queryKey:[`ec2`,`securityGroups`]}),n.invalidateQueries({queryKey:[`ec2`,`securityGroups`,t.groupId]})}},e[0]=n,e[1]=r),t(r)}async function Sn(e){let t=new B({GroupId:e.groupId,IpPermissions:[{IpProtocol:e.ipProtocol,FromPort:e.fromPort,ToPort:e.toPort,IpRanges:[{CidrIp:e.cidrIp}]}]});return await w().send(t)}function Cn(){let e=(0,X.c)(2),n=F(),r;return e[0]===n?r=e[1]:(r={mutationFn:wn,onSuccess:(e,t)=>{n.invalidateQueries({queryKey:[`ec2`,`securityGroups`]}),n.invalidateQueries({queryKey:[`ec2`,`securityGroups`,t.groupId]})}},e[0]=n,e[1]=r),t(r)}async function wn(e){let t=new je({GroupId:e.groupId,IpPermissions:[{IpProtocol:e.ipProtocol,FromPort:e.fromPort,ToPort:e.toPort,IpRanges:[{CidrIp:e.cidrIp}]}]});return await w().send(t)}function Z(e){return{IpProtocol:e.ipProtocol,FromPort:e.fromPort,ToPort:e.toPort,...e.sourceGroupId?{UserIdGroupPairs:[{GroupId:e.sourceGroupId}]}:{IpRanges:[{CidrIp:e.cidrIp??``}]}}}function Tn(){let e=(0,X.c)(2),n=F(),r;return e[0]===n?r=e[1]:(r={mutationFn:En,onSuccess:(e,t)=>{n.invalidateQueries({queryKey:[`ec2`,`securityGroups`]}),n.invalidateQueries({queryKey:[`ec2`,`securityGroups`,t.groupId]})}},e[0]=n,e[1]=r),t(r)}async function En(e){let t=new ft({GroupId:e.groupId,IpPermissions:[Z(e)]});return await w().send(t)}function Dn(){let e=(0,X.c)(2),n=F(),r;return e[0]===n?r=e[1]:(r={mutationFn:On,onSuccess:(e,t)=>{n.invalidateQueries({queryKey:[`ec2`,`securityGroups`]}),n.invalidateQueries({queryKey:[`ec2`,`securityGroups`,t.groupId]})}},e[0]=n,e[1]=r),t(r)}async function On(e){let t=new dt({GroupId:e.groupId,IpPermissions:[Z(e)]});return await w().send(t)}function kn(){let e=(0,X.c)(2),n=F(),r;return e[0]===n?r=e[1]:(r={mutationFn:An,onSuccess:()=>{n.invalidateQueries({queryKey:[`ec2`,`internetGateways`]})}},e[0]=n,e[1]=r),t(r)}async function An(e){let t=new V({TagSpecifications:$(Y.internet_gateway,e?.name,[])});return await w().send(t)}function jn(){let e=(0,X.c)(2),n=F(),r;return e[0]===n?r=e[1]:(r={mutationFn:Mn,onSuccess:()=>{n.invalidateQueries({queryKey:[`ec2`,`internetGateways`]})}},e[0]=n,e[1]=r),t(r)}async function Mn(e){let t=new Ue({InternetGatewayId:e});return await w().send(t)}function Nn(){let e=(0,X.c)(2),n=F(),r;return e[0]===n?r=e[1]:(r={mutationFn:Pn,onSuccess:()=>{n.invalidateQueries({queryKey:[`ec2`,`internetGateways`]})}},e[0]=n,e[1]=r),t(r)}async function Pn(e){let t=new z({InternetGatewayId:e.internetGatewayId,VpcId:e.vpcId});return await w().send(t)}function Fn(){let e=(0,X.c)(2),n=F(),r;return e[0]===n?r=e[1]:(r={mutationFn:In,onSuccess:()=>{n.invalidateQueries({queryKey:[`ec2`,`internetGateways`]})}},e[0]=n,e[1]=r),t(r)}async function In(e){let t=new et({InternetGatewayId:e.internetGatewayId,VpcId:e.vpcId});return await w().send(t)}function Ln(){let e=(0,X.c)(2),n=F(),r;return e[0]===n?r=e[1]:(r={mutationFn:Rn,onSuccess:()=>{n.invalidateQueries({queryKey:[`ec2`,`addresses`]})}},e[0]=n,e[1]=r),t(r)}async function Rn(e){let t=new L({Domain:`vpc`,TagSpecifications:$(Y.elastic_ip,e?.name,[])});return await w().send(t)}function zn(){let e=(0,X.c)(2),n=F(),r;return e[0]===n?r=e[1]:(r={mutationFn:Bn,onSuccess:()=>{n.invalidateQueries({queryKey:[`ec2`,`addresses`]})}},e[0]=n,e[1]=r),t(r)}async function Bn(e){let t=new ut({AllocationId:e});return await w().send(t)}function Vn(){let e=(0,X.c)(2),n=F(),r;return e[0]===n?r=e[1]:(r={mutationFn:Hn,onSuccess:()=>{n.invalidateQueries({queryKey:[`ec2`,`addresses`]}),n.invalidateQueries({queryKey:[`ec2`,`instances`]})}},e[0]=n,e[1]=r),t(r)}async function Hn(e){let t=new Oe({AllocationId:e.allocationId,InstanceId:e.instanceId,NetworkInterfaceId:e.networkInterfaceId});return await w().send(t)}function Un(){let e=(0,X.c)(2),n=F(),r;return e[0]===n?r=e[1]:(r={mutationFn:Wn,onSuccess:()=>{n.invalidateQueries({queryKey:[`ec2`,`addresses`]}),n.invalidateQueries({queryKey:[`ec2`,`instances`]})}},e[0]=n,e[1]=r),t(r)}async function Wn(e){let t=new nt({AssociationId:e});return await w().send(t)}function Gn(){let e=(0,X.c)(2),n=F(),r;return e[0]===n?r=e[1]:(r={mutationFn:Kn,onSuccess:(e,t)=>{n.invalidateQueries({queryKey:[`ec2`,`iam-instance-profile-associations`,t.instanceId]}),n.invalidateQueries({queryKey:[`ec2`,`instances`]})}},e[0]=n,e[1]=r),t(r)}async function Kn(e){let t=new ke({InstanceId:e.instanceId,IamInstanceProfile:{Name:e.instanceProfileName}});return await w().send(t)}function qn(){let e=(0,X.c)(2),n=F(),r;return e[0]===n?r=e[1]:(r={mutationFn:Jn,onSuccess:(e,t)=>{n.invalidateQueries({queryKey:[`ec2`,`iam-instance-profile-associations`,t.instanceId]}),n.invalidateQueries({queryKey:[`ec2`,`instances`]})}},e[0]=n,e[1]=r),t(r)}async function Jn(e){let t=new rt({AssociationId:e.associationId});return await w().send(t)}function Q(){let e=(0,X.c)(2),n=F(),r;return e[0]===n?r=e[1]:(r={mutationFn:Yn,onSuccess:()=>{n.invalidateQueries({queryKey:[`ec2`,`natGateways`]}),n.invalidateQueries({queryKey:[`ec2`,`addresses`]})}},e[0]=n,e[1]=r),t(r)}async function Yn(e){let t=new H({SubnetId:e.subnetId,AllocationId:e.allocationId,TagSpecifications:$(Y.natgateway,e.name,[])});return await w().send(t)}function Xn(){let e=(0,X.c)(2),n=F(),r;return e[0]===n?r=e[1]:(r={mutationFn:Zn,onSuccess:()=>{n.invalidateQueries({queryKey:[`ec2`,`natGateways`]})}},e[0]=n,e[1]=r),t(r)}async function Zn(e){let t=new Ge({NatGatewayId:e});return await w().send(t)}function Qn(){let e=(0,X.c)(2),n=F(),r;return e[0]===n?r=e[1]:(r={mutationFn:$n,onSuccess:()=>{n.invalidateQueries({queryKey:[`ec2`,`routeTables`]})}},e[0]=n,e[1]=r),t(r)}async function $n(e){let t=new W({VpcId:e.vpcId,TagSpecifications:$(Y.route_table,e.name,[])});return await w().send(t)}function er(){let e=(0,X.c)(2),n=F(),r;return e[0]===n?r=e[1]:(r={mutationFn:tr,onSuccess:()=>{n.invalidateQueries({queryKey:[`ec2`,`routeTables`]})}},e[0]=n,e[1]=r),t(r)}async function tr(e){let t=new Je({RouteTableId:e});return await w().send(t)}function nr(){let e=(0,X.c)(2),n=F(),r;return e[0]===n?r=e[1]:(r={mutationFn:rr,onSuccess:()=>{n.invalidateQueries({queryKey:[`ec2`,`routeTables`]})}},e[0]=n,e[1]=r),t(r)}async function rr(e){let t=new U({RouteTableId:e.routeTableId,DestinationCidrBlock:e.destinationCidrBlock,GatewayId:e.gatewayId,NatGatewayId:e.natGatewayId});return await w().send(t)}function ir(){let e=(0,X.c)(2),n=F(),r;return e[0]===n?r=e[1]:(r={mutationFn:ar,onSuccess:()=>{n.invalidateQueries({queryKey:[`ec2`,`routeTables`]})}},e[0]=n,e[1]=r),t(r)}async function ar(e){let t=new qe({RouteTableId:e.routeTableId,DestinationCidrBlock:e.destinationCidrBlock});return await w().send(t)}function or(){let e=(0,X.c)(2),n=F(),r;return e[0]===n?r=e[1]:(r={mutationFn:sr,onSuccess:()=>{n.invalidateQueries({queryKey:[`ec2`,`routeTables`]})}},e[0]=n,e[1]=r),t(r)}async function sr(e){let t=new R({RouteTableId:e.routeTableId,SubnetId:e.subnetId});return await w().send(t)}function cr(){let e=(0,X.c)(2),n=F(),r;return e[0]===n?r=e[1]:(r={mutationFn:lr,onSuccess:()=>{n.invalidateQueries({queryKey:[`ec2`,`routeTables`]})}},e[0]=n,e[1]=r),t(r)}async function lr(e){let t=new it({AssociationId:e});return await w().send(t)}function ur(e,t){let n=[];return e&&n.push({Key:`Name`,Value:e}),n.push(...t),n}function $(e,t,n){let r=ur(t,n);if(r.length!==0)return[{ResourceType:e,Tags:r}]}function dr(){let e=F();return t({mutationFn:async e=>{let t=w(),n=[],r=e.autoGenerateNames?e.namePrefix:void 0,i=e.tags.map(e=>({Key:e.key,Value:e.value})),a=`creating VPC`;try{let o=r?`${r}-vpc`:e.namePrefix,s=e.tenancy,c=(await t.send(new q({CidrBlock:e.cidrBlock,InstanceTenancy:s,TagSpecifications:$(Y.vpc,o,i)}))).Vpc?.VpcId;if(!c)throw Error(`VPC was created but no VPC ID was returned by the API`);if(n.push({type:`VPC`,id:c}),e.mode===`vpc-only`)return{vpcId:c,created:n};let l=De(e.cidrBlock,e.publicSubnetCount,e.privateSubnetCount),u=e.publicSubnetCidrs.length>0?e.publicSubnetCidrs:l.publicSubnets.map(e=>e.cidr),d=e.privateSubnetCidrs.length>0?e.privateSubnetCidrs:l.privateSubnets.map(e=>e.cidr);a=`creating public subnets`;let f=[];for(let a=0;a0){a=`creating internet gateway`;let e=r?`${r}-igw`:void 0,o=(await t.send(new V({TagSpecifications:$(Y.internet_gateway,e,i)}))).InternetGateway?.InternetGatewayId;if(!o)throw Error(`Internet gateway was created but no ID was returned`);n.push({type:`Internet Gateway`,id:o}),a=`attaching internet gateway to VPC`,await t.send(new z({InternetGatewayId:o,VpcId:c})),a=`creating public route table`;let s=r?`${r}-rtb-public`:void 0,l=(await t.send(new W({VpcId:c,TagSpecifications:$(Y.route_table,s,i)}))).RouteTable?.RouteTableId;if(!l)throw Error(`Public route table was created but no ID was returned`);n.push({type:`Public Route Table`,id:l}),a=`creating default route to internet gateway`,await t.send(new U({RouteTableId:l,DestinationCidrBlock:`0.0.0.0/0`,GatewayId:o})),a=`associating public subnets with route table`;for(let e of f)await t.send(new R({RouteTableId:l,SubnetId:e}))}if(e.privateSubnetCount>0){a=`creating private route table`;let o=r?`${r}-rtb-private`:void 0,s=(await t.send(new W({VpcId:c,TagSpecifications:$(Y.route_table,o,i)}))).RouteTable?.RouteTableId;if(!s)throw Error(`Private route table was created but no ID was returned`);n.push({type:`Private Route Table`,id:s}),a=`associating private subnets with route table`;for(let e of p)await t.send(new R({RouteTableId:s,SubnetId:e}));if(e.natGateway===`single`&&f[0]){a=`allocating Elastic IP for NAT gateway`;let e=(await t.send(new L({Domain:`vpc`,TagSpecifications:$(Y.elastic_ip,r?`${r}-eip-nat`:void 0,i)}))).AllocationId;if(!e)throw Error(`Elastic IP was allocated but no allocation ID was returned`);n.push({type:`Elastic IP`,id:e}),a=`creating NAT gateway`;let o=(await t.send(new H({SubnetId:f[0],AllocationId:e,TagSpecifications:$(Y.natgateway,r?`${r}-nat`:void 0,i)}))).NatGateway?.NatGatewayId;if(!o)throw Error(`NAT gateway was created but no ID was returned`);n.push({type:`NAT Gateway`,id:o}),a=`creating default route to NAT gateway`,await t.send(new U({RouteTableId:s,DestinationCidrBlock:`0.0.0.0/0`,NatGatewayId:o}))}}return{vpcId:c,created:n}}catch(e){return{vpcId:n.find(e=>e.type===`VPC`)?.id,created:n,error:e instanceof Error?e:Error(String(e)),failedStep:`Failed while ${a}`}}},onSuccess:()=>{e.invalidateQueries({queryKey:[`ec2`,`vpcs`]}),e.invalidateQueries({queryKey:[`ec2`,`subnets`]}),e.invalidateQueries({queryKey:[`ec2`,`internetGateways`]}),e.invalidateQueries({queryKey:[`ec2`,`routeTables`]}),e.invalidateQueries({queryKey:[`ec2`,`natGateways`]}),e.invalidateQueries({queryKey:[`ec2`,`addresses`]})}})}export{yn as A,en as B,dr as C,hn as D,Xn as E,Fn as F,zn as G,Qt as H,Xt as I,vt as J,Dn as K,Un as L,dn as M,Bt as N,ir as O,sn as P,qn as R,an as S,Pt as T,Rt as U,Mt as V,wt as W,St as X,bt as Y,Qn as _,Nn as a,ln as b,xn as c,Et as d,kn as f,nr as g,pn as h,or as i,Wt as j,er as k,Kt as l,Q as m,Vn as n,Jt as o,At as p,Tn as q,Gn as r,Cn as s,Ln as t,nn as u,_n as v,jn as w,It as x,Ht as y,cr as z};
\ No newline at end of file
diff --git a/spinifex/services/spinifexui/frontend/dist/assets/ec22.js b/spinifex/services/spinifexui/frontend/dist/assets/ec22.js
index 7c5b9cfcb..f7893103e 100644
--- a/spinifex/services/spinifexui/frontend/dist/assets/ec22.js
+++ b/spinifex/services/spinifexui/frontend/dist/assets/ec22.js
@@ -1 +1 @@
-import{Q as e,X as t,Z as n}from"./ec2.js";import{Br as r,Ir as i,Lr as a,Rr as o,Ur as s,Vr as c,zr as l}from"./index.js";var u=/^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}\/\d{1,2}$/,d=s().min(1,`Key name is required`).max(255,`Key name must be 255 characters or less`).regex(/^[\w\s._\-:/()#,@[\]+=&;{}!$*]+$/,`Key name contains invalid characters`),f=[`gp3`],p=`launch-wizard-`,m=c({imageId:s(`Please select an Image`),instanceType:s(`Please select an instance type`),keyName:s(`Please select a key pair`).min(1,`Key pair is required`),subnetId:s().optional(),placementGroupName:s().optional(),count:l(`Instance count must be a whole number`).min(1,`Instance count must be at least 1`),rootDeviceName:s().optional(),rootVolumeSize:r().int(`Volume size must be a whole number`).min(1,`Volume size must be at least 1 GiB`).max(16384,`Volume size must be at most 16384 GiB`).optional(),rootVolumeType:i(f).optional(),rootDeleteOnTermination:o().optional(),securityGroupMode:i([`create`,`existing`]).optional(),securityGroupIds:a(s()).optional(),newSgName:s().optional(),newSgDescription:s().optional(),allowSsh:o().optional(),allowHttp:o().optional(),allowHttps:o().optional(),ruleSource:i([`anywhere`,`custom`]).optional(),customCidr:s().optional()}).superRefine((t,n)=>{if(t.securityGroupMode){if(t.securityGroupMode===`existing`){(t.securityGroupIds??[]).length===0&&n.addIssue({code:`custom`,message:`Select at least one security group`,path:[`securityGroupIds`]});return}(!t.newSgName||t.newSgName.trim().length===0)&&n.addIssue({code:`custom`,message:`Security group name is required`,path:[`newSgName`]}),t.ruleSource===`custom`&&(!t.customCidr||!e(t.customCidr))&&n.addIssue({code:`custom`,message:`Enter a valid CIDR (e.g. 203.0.113.0/24)`,path:[`customCidr`]})}});function h(e){let t=0,n=RegExp(`^${p}(\\d+)$`);for(let r of e){let e=n.exec(r);if(e){let n=Number(e[1]);n>t&&(t=n)}}return`${p}${t+1}`}var g=c({keyName:d}),_=c({keyName:d,publicKeyMaterial:s().min(1,`Public key is required`).refine(e=>e.trim().length>0,`Public key cannot be empty`)}),v=c({size:r().int(`Size must be a whole number`).min(1,`Size must be at least 1 GiB`).max(16384,`Size must be at most 16384 GiB`),availabilityZone:s().min(1,`Availability zone is required`)}),y=c({size:r().int(`Size must be a whole number`).min(1,`Size must be at least 1 GiB`)}),b=c({volumeId:s().min(1,`Volume is required`),description:s().optional()});c({sourceSnapshotId:s().min(1,`Source snapshot is required`),sourceRegion:s().min(1,`Source region is required`),description:s().optional()}),c({volumeId:s().min(1,`Volume is required`),instanceId:s().min(1,`Instance is required`),device:s().optional()}),c({volumeId:s().min(1,`Volume is required`),instanceId:s().optional(),force:o().optional()}),c({instanceType:s().min(1,`Instance type is required`)}),c({name:s().min(1,`Name is required`),description:s().optional()});var x=c({vpcId:s().min(1,`VPC is required`),cidrBlock:s().min(1,`CIDR block is required`).regex(u,`Must be a valid CIDR block (e.g. 10.0.1.0/24)`),availabilityZone:s().optional(),mapPublicIpOnLaunch:o().optional()}),S=c({name:s().optional()}),C=c({subnetId:s().min(1,`Subnet is required`),allocationId:s().min(1,`Elastic IP is required`),name:s().optional()}),w=c({name:s().optional()}),T=c({vpcId:s().min(1,`VPC is required`),name:s().optional()}),E=c({destinationCidrBlock:s().min(1,`Destination is required`).regex(u,`Must be a valid CIDR block (e.g. 0.0.0.0/0)`),targetType:i([`igw`,`nat`]),targetId:s().min(1,`Target is required`)}),D=c({subnetId:s().min(1,`Subnet is required`)});c({cidrBlock:s().min(1,`CIDR block is required`).regex(u,`Must be a valid CIDR block (e.g. 10.0.0.0/16)`),name:s().optional()});var O=c({key:s().min(1,`Key is required`),value:s()}),k=c({mode:i([`vpc-only`,`vpc-and-more`]),namePrefix:s().optional(),autoGenerateNames:o(),cidrBlock:s().min(1,`CIDR block is required`).regex(u,`Must be a valid CIDR block (e.g. 10.0.0.0/16)`).refine(t=>e(t),`CIDR has invalid octets or prefix length (must be /16 to /28)`),tenancy:i([`default`,`dedicated`]),natGateway:i([`none`,`single`]).optional(),publicSubnetCount:r().int().min(0).max(1),privateSubnetCount:r().int().min(0).max(2),publicSubnetCidrs:a(s()),privateSubnetCidrs:a(s()),tags:a(O)}).superRefine((r,i)=>{if(r.mode!==`vpc-and-more`)return;let a=[];for(let[n,o]of r.publicSubnetCidrs.entries())e(o,16,28)?(t(r.cidrBlock,o)||i.addIssue({code:`custom`,message:`Subnet CIDR must be within the VPC CIDR range`,path:[`publicSubnetCidrs`,n]}),a.push({cidr:o,field:`publicSubnetCidrs`,index:n})):i.addIssue({code:`custom`,message:`Invalid subnet CIDR format or prefix length`,path:[`publicSubnetCidrs`,n]});for(let[n,o]of r.privateSubnetCidrs.entries())e(o,16,28)?(t(r.cidrBlock,o)||i.addIssue({code:`custom`,message:`Subnet CIDR must be within the VPC CIDR range`,path:[`privateSubnetCidrs`,n]}),a.push({cidr:o,field:`privateSubnetCidrs`,index:n})):i.addIssue({code:`custom`,message:`Invalid subnet CIDR format or prefix length`,path:[`privateSubnetCidrs`,n]});for(let e=0;e{if(e.securityGroupMode){if(e.securityGroupMode===`existing`){(e.securityGroupIds??[]).length===0&&t.addIssue({code:`custom`,message:`Select at least one security group`,path:[`securityGroupIds`]});return}(!e.newSgName||e.newSgName.trim().length===0)&&t.addIssue({code:`custom`,message:`Security group name is required`,path:[`newSgName`]}),e.ruleSource===`custom`&&(!e.customCidr||!s(e.customCidr))&&t.addIssue({code:`custom`,message:`Enter a valid CIDR (e.g. 203.0.113.0/24)`,path:[`customCidr`]})}});function h(e){let t=0,n=RegExp(`^${p}(\\d+)$`);for(let r of e){let e=n.exec(r);if(e){let n=Number(e[1]);n>t&&(t=n)}}return`${p}${t+1}`}var g=e({keyName:d}),_=e({keyName:d,publicKeyMaterial:o().min(1,`Public key is required`).refine(e=>e.trim().length>0,`Public key cannot be empty`)}),v=e({size:n().int(`Size must be a whole number`).min(1,`Size must be at least 1 GiB`).max(16384,`Size must be at most 16384 GiB`),availabilityZone:o().min(1,`Availability zone is required`)}),y=e({size:n().int(`Size must be a whole number`).min(1,`Size must be at least 1 GiB`)}),b=e({volumeId:o().min(1,`Volume is required`),description:o().optional()});e({sourceSnapshotId:o().min(1,`Source snapshot is required`),sourceRegion:o().min(1,`Source region is required`),description:o().optional()}),e({volumeId:o().min(1,`Volume is required`),instanceId:o().min(1,`Instance is required`),device:o().optional()}),e({volumeId:o().min(1,`Volume is required`),instanceId:o().optional(),force:i().optional()}),e({instanceType:o().min(1,`Instance type is required`)}),e({name:o().min(1,`Name is required`),description:o().optional()});var x=e({vpcId:o().min(1,`VPC is required`),cidrBlock:o().min(1,`CIDR block is required`).regex(u,`Must be a valid CIDR block (e.g. 10.0.1.0/24)`),availabilityZone:o().optional(),mapPublicIpOnLaunch:i().optional()}),S=e({name:o().optional()}),C=e({subnetId:o().min(1,`Subnet is required`),allocationId:o().min(1,`Elastic IP is required`),name:o().optional()}),w=e({name:o().optional()}),T=e({vpcId:o().min(1,`VPC is required`),name:o().optional()}),E=e({destinationCidrBlock:o().min(1,`Destination is required`).regex(u,`Must be a valid CIDR block (e.g. 0.0.0.0/0)`),targetType:t([`igw`,`nat`]),targetId:o().min(1,`Target is required`)}),D=e({subnetId:o().min(1,`Subnet is required`)});e({cidrBlock:o().min(1,`CIDR block is required`).regex(u,`Must be a valid CIDR block (e.g. 10.0.0.0/16)`),name:o().optional()});var O=e({key:o().min(1,`Key is required`),value:o()}),k=e({mode:t([`vpc-only`,`vpc-and-more`]),namePrefix:o().optional(),autoGenerateNames:i(),cidrBlock:o().min(1,`CIDR block is required`).regex(u,`Must be a valid CIDR block (e.g. 10.0.0.0/16)`).refine(e=>s(e),`CIDR has invalid octets or prefix length (must be /16 to /28)`),tenancy:t([`default`,`dedicated`]),natGateway:t([`none`,`single`]).optional(),publicSubnetCount:n().int().min(0).max(1),privateSubnetCount:n().int().min(0).max(2),publicSubnetCidrs:a(o()),privateSubnetCidrs:a(o()),tags:a(O)}).superRefine((e,t)=>{if(e.mode!==`vpc-and-more`)return;let n=[];for(let[r,i]of e.publicSubnetCidrs.entries())s(i,16,28)?(c(e.cidrBlock,i)||t.addIssue({code:`custom`,message:`Subnet CIDR must be within the VPC CIDR range`,path:[`publicSubnetCidrs`,r]}),n.push({cidr:i,field:`publicSubnetCidrs`,index:r})):t.addIssue({code:`custom`,message:`Invalid subnet CIDR format or prefix length`,path:[`publicSubnetCidrs`,r]});for(let[r,i]of e.privateSubnetCidrs.entries())s(i,16,28)?(c(e.cidrBlock,i)||t.addIssue({code:`custom`,message:`Subnet CIDR must be within the VPC CIDR range`,path:[`privateSubnetCidrs`,r]}),n.push({cidr:i,field:`privateSubnetCidrs`,index:r})):t.addIssue({code:`custom`,message:`Invalid subnet CIDR format or prefix length`,path:[`privateSubnetCidrs`,r]});for(let e=0;e{n.invalidateQueries({queryKey:[`eks`,`clusters`]})}},e[0]=n,e[1]=r),t(r)}async function R(e){let t=new E({name:e.name,version:e.version,roleArn:e.roleArn,resourcesVpcConfig:{subnetIds:e.subnetIds,endpointPublicAccess:e.endpointPublicAccess,endpointPrivateAccess:e.endpointPrivateAccess,publicAccessCidrs:e.endpointPublicAccess?e.publicAccessCidrs:void 0},accessConfig:{authenticationMode:`API`,bootstrapClusterCreatorAdminPermissions:e.bootstrapClusterCreatorAdminPermissions}});return await m().send(t)}function z(){let e=(0,I.c)(2),n=x(),r;return e[0]===n?r=e[1]:(r={mutationFn:B,onSuccess:()=>{n.invalidateQueries({queryKey:[`eks`,`clusters`]})}},e[0]=n,e[1]=r),t(r)}async function B(e){let t=new A({name:e});return await m().send(t)}function V(){let e=(0,I.c)(2),n=x(),r;return e[0]===n?r=e[1]:(r={mutationFn:H,onSuccess:(e,t)=>{n.invalidateQueries({queryKey:[`eks`,`clusters`,t.clusterName,`nodegroups`]})}},e[0]=n,e[1]=r),t(r)}async function H(e){let t=new D({clusterName:e.clusterName,nodegroupName:e.nodegroupName,nodeRole:e.nodeRole,subnets:e.subnetIds,instanceTypes:e.instanceTypes,amiType:e.amiType,capacityType:e.capacityType,diskSize:e.diskSize,scalingConfig:{minSize:e.minSize,maxSize:e.maxSize,desiredSize:e.desiredSize}});return await m().send(t)}function U(){let e=(0,I.c)(2),n=x(),r;return e[0]===n?r=e[1]:(r={mutationFn:ie,onSuccess:(e,t)=>{n.invalidateQueries({queryKey:[`eks`,`clusters`,t.clusterName,`nodegroups`]})}},e[0]=n,e[1]=r),t(r)}async function ie(e){let t=new P({clusterName:e.clusterName,nodegroupName:e.nodegroupName,scalingConfig:{minSize:e.minSize,maxSize:e.maxSize,desiredSize:e.desiredSize}});return await m().send(t)}function W(){let e=(0,I.c)(2),n=x(),r;return e[0]===n?r=e[1]:(r={mutationFn:G,onSuccess:(e,t)=>{n.invalidateQueries({queryKey:[`eks`,`clusters`,t.clusterName,`nodegroups`]})}},e[0]=n,e[1]=r),t(r)}async function G(e){let t=new F({clusterName:e.clusterName,nodegroupName:e.nodegroupName,version:e.version});return await m().send(t)}function K(){let e=(0,I.c)(2),n=x(),r;return e[0]===n?r=e[1]:(r={mutationFn:q,onSuccess:(e,t)=>{n.invalidateQueries({queryKey:[`eks`,`clusters`,t.clusterName,`nodegroups`]})}},e[0]=n,e[1]=r),t(r)}async function q(e){let t=new j({clusterName:e.clusterName,nodegroupName:e.nodegroupName});return await m().send(t)}function J(){let e=(0,I.c)(2),n=x(),r;return e[0]===n?r=e[1]:(r={mutationFn:Y,onSuccess:(e,t)=>{n.invalidateQueries({queryKey:[`eks`,`clusters`,t.clusterName,`addons`]})}},e[0]=n,e[1]=r),t(r)}async function Y(e){let t=new T({clusterName:e.clusterName,addonName:e.addonName,addonVersion:e.addonVersion,serviceAccountRoleArn:e.serviceAccountRoleArn,configurationValues:e.configurationValues});return await m().send(t)}function ae(){let e=(0,I.c)(2),n=x(),r;return e[0]===n?r=e[1]:(r={mutationFn:oe,onSuccess:(e,t)=>{n.invalidateQueries({queryKey:[`eks`,`clusters`,t.clusterName,`addons`]})}},e[0]=n,e[1]=r),t(r)}async function oe(e){let t=new N({clusterName:e.clusterName,addonName:e.addonName,addonVersion:e.addonVersion,serviceAccountRoleArn:e.serviceAccountRoleArn,configurationValues:e.configurationValues});return await m().send(t)}function se(){let e=(0,I.c)(2),n=x(),r;return e[0]===n?r=e[1]:(r={mutationFn:ce,onSuccess:(e,t)=>{n.invalidateQueries({queryKey:[`eks`,`clusters`,t.clusterName,`addons`]})}},e[0]=n,e[1]=r),t(r)}async function ce(e){let t=new k({clusterName:e.clusterName,addonName:e.addonName});return await m().send(t)}function le(){let e=(0,I.c)(2),n=x(),r;return e[0]===n?r=e[1]:(r={mutationFn:ue,onSuccess:(e,t)=>{n.invalidateQueries({queryKey:[`eks`,`clusters`,t.clusterName,`access-entries`]})}},e[0]=n,e[1]=r),t(r)}async function ue(e){let t=new w({clusterName:e.clusterName,principalArn:e.principalArn,kubernetesGroups:e.kubernetesGroups,type:e.type});return await m().send(t)}function de(){let e=(0,I.c)(2),n=x(),r;return e[0]===n?r=e[1]:(r={mutationFn:fe,onSuccess:(e,t)=>{n.invalidateQueries({queryKey:[`eks`,`clusters`,t.clusterName,`access-entries`]})}},e[0]=n,e[1]=r),t(r)}async function fe(e){let t=new O({clusterName:e.clusterName,principalArn:e.principalArn});return await m().send(t)}function pe(){let e=(0,I.c)(2),n=x(),r;return e[0]===n?r=e[1]:(r={mutationFn:me,onSuccess:(e,t)=>{n.invalidateQueries({queryKey:[`eks`,`clusters`,t.clusterName,`access-entries`,t.principalArn,`policies`]})}},e[0]=n,e[1]=r),t(r)}async function me(e){let t=new C({clusterName:e.clusterName,principalArn:e.principalArn,policyArn:e.policyArn,accessScope:{type:e.accessScopeType,namespaces:e.namespaces}});return await m().send(t)}function he(){let e=(0,I.c)(2),n=x(),r;return e[0]===n?r=e[1]:(r={mutationFn:ge,onSuccess:(e,t)=>{n.invalidateQueries({queryKey:[`eks`,`clusters`,t.clusterName,`access-entries`,t.principalArn,`policies`]})}},e[0]=n,e[1]=r),t(r)}async function ge(e){let t=new M({clusterName:e.clusterName,principalArn:e.principalArn,policyArn:e.policyArn});return await m().send(t)}var X=[`1.32`,`1.31`,`1.30`],_e=[{arn:`arn:aws:eks::aws:cluster-access-policy/AmazonEKSClusterAdminPolicy`,name:`AmazonEKSClusterAdminPolicy`},{arn:`arn:aws:eks::aws:cluster-access-policy/AmazonEKSAdminPolicy`,name:`AmazonEKSAdminPolicy`},{arn:`arn:aws:eks::aws:cluster-access-policy/AmazonEKSEditPolicy`,name:`AmazonEKSEditPolicy`},{arn:`arn:aws:eks::aws:cluster-access-policy/AmazonEKSViewPolicy`,name:`AmazonEKSViewPolicy`}],Z=[`AL2_x86_64`,`AL2_ARM_64`],Q=[`ON_DEMAND`,`SPOT`],$=b().min(1,`Name is required`).max(100,`Name must be 100 characters or fewer`).regex(/^[0-9A-Za-z][A-Za-z0-9\-_]*$/,`Must start with a letter or digit and contain only letters, digits, hyphens, or underscores`),ve=h({name:$,version:b().min(1,`Version is required`),roleArn:b().min(1,`Cluster IAM role is required`),vpcId:b().min(1,`VPC is required`),subnetIds:y(b()).min(1,`At least 1 subnet is required`),bootstrapClusterCreatorAdminPermissions:v(),endpointPublicAccess:v(),endpointPrivateAccess:v(),publicAccessCidrs:y(b())}).refine(e=>e.endpointPublicAccess||e.endpointPrivateAccess,{message:`Enable public access, private access, or both`,path:[`endpointPublicAccess`]}).refine(e=>!e.endpointPublicAccess||e.publicAccessCidrs.every(e=>S(e,0,32)),{message:`Each public access CIDR must be valid (e.g. 203.0.113.0/24)`,path:[`publicAccessCidrs`]}),ye=h({nodegroupName:$,nodeRole:b().min(1,`Node IAM role is required`),subnetIds:y(b()).min(1,`At least 1 subnet is required`),instanceTypes:b().min(1,`At least 1 instance type is required`),amiType:g(Z),capacityType:g(Q),diskSize:_().int().min(1),minSize:_().int().min(0),desiredSize:_().int().min(0),maxSize:_().int().min(1)}).refine(e=>e.minSize<=e.desiredSize&&e.desiredSize<=e.maxSize,{message:`Sizes must satisfy min ≤ desired ≤ max`,path:[`desiredSize`]}),be=h({addonName:b().min(1,`Add-on is required`),addonVersion:b().min(1,`Version is required`),serviceAccountRoleArn:b(),configurationValues:b().refine(e=>{if(e.trim()===``)return!0;try{return JSON.parse(e),!0}catch{return!1}},{message:`Configuration must be valid JSON`})});function xe(e){switch(e??``){case`ACTIVE`:return`default`;case`CREATE_FAILED`:case`DELETE_FAILED`:case`UPDATE_FAILED`:case`DEGRADED`:return`destructive`;default:return`secondary`}}var Se=h({principalArn:b().min(1,`Principal ARN is required`),kubernetesGroups:b()});export{W as S,z as _,xe as a,U as b,ve as c,le as d,J as f,se as g,de as h,X as i,ye as l,V as m,Z as n,Se as o,L as p,Q as r,be as s,_e as t,pe as u,K as v,ae as x,he as y};
\ No newline at end of file
diff --git a/spinifex/services/spinifexui/frontend/dist/assets/elbv2.js b/spinifex/services/spinifexui/frontend/dist/assets/elbv2.js
index 23a95c6f8..c1156ee3b 100644
--- a/spinifex/services/spinifexui/frontend/dist/assets/elbv2.js
+++ b/spinifex/services/spinifexui/frontend/dist/assets/elbv2.js
@@ -1 +1 @@
-import{n as e}from"./error-banner.js";import{$t as t,Fo as n,Jt as r,Qt as i,Vo as a,Xt as o,Yt as s,Zt as ee,an as c,cn as l,en as u,in as d,nn as f,on as te,or as p,rn as ne,sn as m,sr as h,tn as g,yt as _}from"./index.js";var v=class extends p.classBuilder().ep(l).m(function(e,t,n,r){return[h(n,e.getEndpointParameterInstructions())]}).s(`ElasticLoadBalancing_v10`,`CreateListener`,{}).n(`ElasticLoadBalancingV2Client`,`CreateListenerCommand`).sc(r).build(){},y=class extends p.classBuilder().ep(l).m(function(e,t,n,r){return[h(n,e.getEndpointParameterInstructions())]}).s(`ElasticLoadBalancing_v10`,`CreateLoadBalancer`,{}).n(`ElasticLoadBalancingV2Client`,`CreateLoadBalancerCommand`).sc(s).build(){},b=class extends p.classBuilder().ep(l).m(function(e,t,n,r){return[h(n,e.getEndpointParameterInstructions())]}).s(`ElasticLoadBalancing_v10`,`CreateRule`,{}).n(`ElasticLoadBalancingV2Client`,`CreateRuleCommand`).sc(o).build(){},x=class extends p.classBuilder().ep(l).m(function(e,t,n,r){return[h(n,e.getEndpointParameterInstructions())]}).s(`ElasticLoadBalancing_v10`,`CreateTargetGroup`,{}).n(`ElasticLoadBalancingV2Client`,`CreateTargetGroupCommand`).sc(ee).build(){},S=class extends p.classBuilder().ep(l).m(function(e,t,n,r){return[h(n,e.getEndpointParameterInstructions())]}).s(`ElasticLoadBalancing_v10`,`DeleteListener`,{}).n(`ElasticLoadBalancingV2Client`,`DeleteListenerCommand`).sc(i).build(){},C=class extends p.classBuilder().ep(l).m(function(e,t,n,r){return[h(n,e.getEndpointParameterInstructions())]}).s(`ElasticLoadBalancing_v10`,`DeleteLoadBalancer`,{}).n(`ElasticLoadBalancingV2Client`,`DeleteLoadBalancerCommand`).sc(t).build(){},w=class extends p.classBuilder().ep(l).m(function(e,t,n,r){return[h(n,e.getEndpointParameterInstructions())]}).s(`ElasticLoadBalancing_v10`,`DeleteRule`,{}).n(`ElasticLoadBalancingV2Client`,`DeleteRuleCommand`).sc(u).build(){},T=class extends p.classBuilder().ep(l).m(function(e,t,n,r){return[h(n,e.getEndpointParameterInstructions())]}).s(`ElasticLoadBalancing_v10`,`DeleteTargetGroup`,{}).n(`ElasticLoadBalancingV2Client`,`DeleteTargetGroupCommand`).sc(g).build(){},E=class extends p.classBuilder().ep(l).m(function(e,t,n,r){return[h(n,e.getEndpointParameterInstructions())]}).s(`ElasticLoadBalancing_v10`,`DeregisterTargets`,{}).n(`ElasticLoadBalancingV2Client`,`DeregisterTargetsCommand`).sc(f).build(){},D=class extends p.classBuilder().ep(l).m(function(e,t,n,r){return[h(n,e.getEndpointParameterInstructions())]}).s(`ElasticLoadBalancing_v10`,`ModifyListener`,{}).n(`ElasticLoadBalancingV2Client`,`ModifyListenerCommand`).sc(ne).build(){},O=class extends p.classBuilder().ep(l).m(function(e,t,n,r){return[h(n,e.getEndpointParameterInstructions())]}).s(`ElasticLoadBalancing_v10`,`ModifyLoadBalancerAttributes`,{}).n(`ElasticLoadBalancingV2Client`,`ModifyLoadBalancerAttributesCommand`).sc(d).build(){},k=class extends p.classBuilder().ep(l).m(function(e,t,n,r){return[h(n,e.getEndpointParameterInstructions())]}).s(`ElasticLoadBalancing_v10`,`ModifyRule`,{}).n(`ElasticLoadBalancingV2Client`,`ModifyRuleCommand`).sc(c).build(){},A=class extends p.classBuilder().ep(l).m(function(e,t,n,r){return[h(n,e.getEndpointParameterInstructions())]}).s(`ElasticLoadBalancing_v10`,`ModifyTargetGroupAttributes`,{}).n(`ElasticLoadBalancingV2Client`,`ModifyTargetGroupAttributesCommand`).sc(te).build(){},j=class extends p.classBuilder().ep(l).m(function(e,t,n,r){return[h(n,e.getEndpointParameterInstructions())]}).s(`ElasticLoadBalancing_v10`,`RegisterTargets`,{}).n(`ElasticLoadBalancingV2Client`,`RegisterTargetsCommand`).sc(m).build(){},M=n();function N(){let t=(0,M.c)(2),n=a(),r;return t[0]===n?r=t[1]:(r={mutationFn:P,onSuccess:()=>{n.invalidateQueries({queryKey:[`elbv2`,`loadBalancers`]})}},t[0]=n,t[1]=r),e(r)}async function P(e){let t=new C({LoadBalancerArn:e});return await _().send(t)}function F(){let t=(0,M.c)(2),n=a(),r;return t[0]===n?r=t[1]:(r={mutationFn:I,onSuccess:(e,t)=>{n.invalidateQueries({queryKey:[`elbv2`,`loadBalancers`,t.loadBalancerArn,`attributes`]})}},t[0]=n,t[1]=r),e(r)}async function I(e){let t=new O({LoadBalancerArn:e.loadBalancerArn,Attributes:e.attributes.map(L)});return await _().send(t)}function L(e){return{Key:e.key,Value:e.value}}function re(){let t=(0,M.c)(2),n=a(),r;return t[0]===n?r=t[1]:(r={mutationFn:R,onSuccess:()=>{n.invalidateQueries({queryKey:[`elbv2`,`targetGroups`]})}},t[0]=n,t[1]=r),e(r)}async function R(e){let t=e.tags.filter(B).map(z),n=new x({Name:e.name,Protocol:e.protocol,Port:e.port,VpcId:e.vpcId,TargetType:`instance`,HealthCheckProtocol:e.healthCheck.protocol,HealthCheckPath:e.healthCheck.path,HealthCheckPort:e.healthCheck.port,HealthCheckIntervalSeconds:e.healthCheck.intervalSeconds,HealthCheckTimeoutSeconds:e.healthCheck.timeoutSeconds,HealthyThresholdCount:e.healthCheck.healthyThresholdCount,UnhealthyThresholdCount:e.healthCheck.unhealthyThresholdCount,Matcher:{HttpCode:e.healthCheck.matcher},Tags:t.length>0?t:void 0});return await _().send(n)}function z(e){return{Key:e.key,Value:e.value}}function B(e){return e.key.length>0}function V(){let t=(0,M.c)(2),n=a(),r;return t[0]===n?r=t[1]:(r={mutationFn:H,onSuccess:()=>{n.invalidateQueries({queryKey:[`elbv2`,`targetGroups`]})}},t[0]=n,t[1]=r),e(r)}async function H(e){let t=new T({TargetGroupArn:e});return await _().send(t)}function U(){let t=(0,M.c)(2),n=a(),r;return t[0]===n?r=t[1]:(r={mutationFn:W,onSuccess:(e,t)=>{n.invalidateQueries({queryKey:[`elbv2`,`targetGroups`,t.targetGroupArn,`attributes`]})}},t[0]=n,t[1]=r),e(r)}async function W(e){let t=new A({TargetGroupArn:e.targetGroupArn,Attributes:e.attributes.map(G)});return await _().send(t)}function G(e){return{Key:e.key,Value:e.value}}function K(){let t=(0,M.c)(2),n=a(),r;return t[0]===n?r=t[1]:(r={mutationFn:q,onSuccess:(e,t)=>{n.invalidateQueries({queryKey:[`elbv2`,`listeners`,t.loadBalancerArn]})}},t[0]=n,t[1]=r),e(r)}async function q(e){let t=[{Type:`forward`,TargetGroupArn:e.defaultTargetGroupArn}],n=new v({LoadBalancerArn:e.loadBalancerArn,Protocol:e.protocol,Port:e.port,DefaultActions:t});return await _().send(n)}function J(){let t=(0,M.c)(2),n=a(),r;return t[0]===n?r=t[1]:(r={mutationFn:Y,onSuccess:()=>{n.invalidateQueries({queryKey:[`elbv2`,`listeners`]})}},t[0]=n,t[1]=r),e(r)}async function Y(e){let t=new S({ListenerArn:e});return await _().send(t)}function X(){let t=(0,M.c)(2),n=a(),r;return t[0]===n?r=t[1]:(r={mutationFn:ie,onSuccess:(e,t)=>{n.invalidateQueries({queryKey:[`elbv2`,`listeners`,t.loadBalancerArn]})}},t[0]=n,t[1]=r),e(r)}async function ie(e){let t=[{Type:`forward`,TargetGroupArn:e.defaultTargetGroupArn}],n=new D({ListenerArn:e.listenerArn,Protocol:e.protocol,Port:e.port,DefaultActions:t});return await _().send(n)}function ae(){let t=a();return e({mutationFn:async e=>{let t=_(),n=[],r=``;try{let i=e.listener.existingTargetGroupArn;if(e.listener.targetGroupMode===`new`){let a=e.listener.newTargetGroup;if(!a)throw Error(`internal error: new target group requested but no data supplied`);r=`creating target group`;let o=a.tags.filter(e=>e.key.length>0).map(e=>({Key:e.key,Value:e.value}));if(i=(await t.send(new x({Name:a.name,Protocol:a.protocol,Port:a.port,VpcId:a.vpcId,TargetType:`instance`,HealthCheckProtocol:a.healthCheck.protocol,HealthCheckPath:a.healthCheck.path,HealthCheckPort:a.healthCheck.port,HealthCheckIntervalSeconds:a.healthCheck.intervalSeconds,HealthCheckTimeoutSeconds:a.healthCheck.timeoutSeconds,HealthyThresholdCount:a.healthCheck.healthyThresholdCount,UnhealthyThresholdCount:a.healthCheck.unhealthyThresholdCount,Matcher:{HttpCode:a.healthCheck.matcher},Tags:o.length>0?o:void 0}))).TargetGroups?.[0]?.TargetGroupArn,!i)throw Error(`Target group was created but no ARN was returned by the API`);n.push({type:`Target Group`,id:i})}if(!i)throw Error(`Target group selection is required`);r=`creating load balancer`;let a=e.lb.tags.filter(e=>e.key.length>0).map(e=>({Key:e.key,Value:e.value})),o=(await t.send(new y({Name:e.lb.name,Scheme:e.lb.scheme,Type:`application`,IpAddressType:`ipv4`,Subnets:e.lb.subnetIds,SecurityGroups:e.lb.securityGroupIds.length>0?e.lb.securityGroupIds:void 0,Tags:a.length>0?a:void 0}))).LoadBalancers?.[0]?.LoadBalancerArn;if(!o)throw Error(`Load balancer was created but no ARN was returned by the API`);return n.push({type:`Load Balancer`,id:o}),r=`creating listener`,await t.send(new v({LoadBalancerArn:o,Protocol:e.listener.protocol,Port:e.listener.port,DefaultActions:[{Type:`forward`,TargetGroupArn:i}]})),n.push({type:`Listener`,id:void 0}),{loadBalancerArn:o,created:n}}catch(e){return{loadBalancerArn:void 0,created:n,error:e instanceof Error?e:Error(String(e)),failedStep:r}}},onSettled:()=>{t.invalidateQueries({queryKey:[`elbv2`]})}})}function Z(e){return e.map(e=>({Id:e.id,Port:e.port}))}function oe(){let t=(0,M.c)(2),n=a(),r;return t[0]===n?r=t[1]:(r={mutationFn:se,onSuccess:(e,t)=>{n.invalidateQueries({queryKey:[`elbv2`,`targetGroups`,t.targetGroupArn,`health`]})}},t[0]=n,t[1]=r),e(r)}async function se(e){let t=new j({TargetGroupArn:e.targetGroupArn,Targets:Z(e.targets)});return await _().send(t)}function Q(){let t=(0,M.c)(2),n=a(),r;return t[0]===n?r=t[1]:(r={mutationFn:ce,onSuccess:(e,t)=>{n.invalidateQueries({queryKey:[`elbv2`,`targetGroups`,t.targetGroupArn,`health`]})}},t[0]=n,t[1]=r),e(r)}async function ce(e){let t=new E({TargetGroupArn:e.targetGroupArn,Targets:Z(e.targets)});return await _().send(t)}function $(e){return e.map(e=>{switch(e.field){case`host-header`:return{Field:e.field,HostHeaderConfig:{Values:e.values}};case`path-pattern`:return{Field:e.field,PathPatternConfig:{Values:e.values}};case`http-header`:return{Field:e.field,HttpHeaderConfig:{HttpHeaderName:e.httpHeaderName??``,Values:e.values}};case`http-request-method`:return{Field:e.field,HttpRequestMethodConfig:{Values:e.values}};case`source-ip`:return{Field:e.field,SourceIpConfig:{Values:e.values}};case`query-string`:return{Field:e.field,QueryStringConfig:{Values:(e.queryStringPairs??[]).map(e=>({Key:e.key&&e.key.length>0?e.key:void 0,Value:e.value}))}};default:return{Field:e.field,Values:e.values}}})}function le(){let t=(0,M.c)(2),n=a(),r;return t[0]===n?r=t[1]:(r={mutationFn:ue,onSuccess:(e,t)=>{n.invalidateQueries({queryKey:[`elbv2`,`listeners`,t.listenerArn,`rules`]})}},t[0]=n,t[1]=r),e(r)}async function ue(e){let t=[{Type:`forward`,TargetGroupArn:e.forwardTargetGroupArn}],n=new b({ListenerArn:e.listenerArn,Priority:e.priority,Conditions:$(e.conditions),Actions:t});return await _().send(n)}function de(){let t=(0,M.c)(2),n=a(),r;return t[0]===n?r=t[1]:(r={mutationFn:fe,onSuccess:(e,t)=>{n.invalidateQueries({queryKey:[`elbv2`,`listeners`,t.listenerArn,`rules`]})}},t[0]=n,t[1]=r),e(r)}async function fe(e){let t=new k({RuleArn:e.ruleArn,Conditions:e.conditions?$(e.conditions):void 0,Actions:e.forwardTargetGroupArn?[{Type:`forward`,TargetGroupArn:e.forwardTargetGroupArn}]:void 0});return await _().send(t)}function pe(){let t=(0,M.c)(2),n=a(),r;return t[0]===n?r=t[1]:(r={mutationFn:me,onSuccess:(e,t)=>{n.invalidateQueries({queryKey:[`elbv2`,`listeners`,t.listenerArn,`rules`]})}},t[0]=n,t[1]=r),e(r)}async function me(e){let t=new w({RuleArn:e.ruleArn});return await _().send(t)}export{J as a,V as c,F as d,de as f,re as i,Q as l,oe as m,ae as n,N as o,U as p,le as r,pe as s,K as t,X as u};
\ No newline at end of file
+import{t as e}from"./compiler-runtime.js";import{n as t}from"./error-banner.js";import{Cr as n,Ct as r,Dt as i,Et as a,Ot as o,St as s,Tr as c,Tt as ee,a as l,at as u,ct as d,dt as f,it as p,kt as m,lt as te,nt as ne,ot as re,rt as ie,st as h,ut as g,wt as _,xt as v}from"./awsClient.js";import{Lr as y}from"./index.js";var b=class extends c.classBuilder().ep(m).m(function(e,t,r,i){return[n(r,e.getEndpointParameterInstructions())]}).s(`ElasticLoadBalancing_v10`,`AddTags`,{}).n(`ElasticLoadBalancingV2Client`,`AddTagsCommand`).sc(ne).build(){},x=class extends c.classBuilder().ep(m).m(function(e,t,r,i){return[n(r,e.getEndpointParameterInstructions())]}).s(`ElasticLoadBalancing_v10`,`CreateListener`,{}).n(`ElasticLoadBalancingV2Client`,`CreateListenerCommand`).sc(ie).build(){},S=class extends c.classBuilder().ep(m).m(function(e,t,r,i){return[n(r,e.getEndpointParameterInstructions())]}).s(`ElasticLoadBalancing_v10`,`CreateLoadBalancer`,{}).n(`ElasticLoadBalancingV2Client`,`CreateLoadBalancerCommand`).sc(p).build(){},C=class extends c.classBuilder().ep(m).m(function(e,t,r,i){return[n(r,e.getEndpointParameterInstructions())]}).s(`ElasticLoadBalancing_v10`,`CreateRule`,{}).n(`ElasticLoadBalancingV2Client`,`CreateRuleCommand`).sc(u).build(){},w=class extends c.classBuilder().ep(m).m(function(e,t,r,i){return[n(r,e.getEndpointParameterInstructions())]}).s(`ElasticLoadBalancing_v10`,`CreateTargetGroup`,{}).n(`ElasticLoadBalancingV2Client`,`CreateTargetGroupCommand`).sc(re).build(){},T=class extends c.classBuilder().ep(m).m(function(e,t,r,i){return[n(r,e.getEndpointParameterInstructions())]}).s(`ElasticLoadBalancing_v10`,`DeleteListener`,{}).n(`ElasticLoadBalancingV2Client`,`DeleteListenerCommand`).sc(h).build(){},E=class extends c.classBuilder().ep(m).m(function(e,t,r,i){return[n(r,e.getEndpointParameterInstructions())]}).s(`ElasticLoadBalancing_v10`,`DeleteLoadBalancer`,{}).n(`ElasticLoadBalancingV2Client`,`DeleteLoadBalancerCommand`).sc(d).build(){},D=class extends c.classBuilder().ep(m).m(function(e,t,r,i){return[n(r,e.getEndpointParameterInstructions())]}).s(`ElasticLoadBalancing_v10`,`DeleteRule`,{}).n(`ElasticLoadBalancingV2Client`,`DeleteRuleCommand`).sc(te).build(){},O=class extends c.classBuilder().ep(m).m(function(e,t,r,i){return[n(r,e.getEndpointParameterInstructions())]}).s(`ElasticLoadBalancing_v10`,`DeleteTargetGroup`,{}).n(`ElasticLoadBalancingV2Client`,`DeleteTargetGroupCommand`).sc(g).build(){},k=class extends c.classBuilder().ep(m).m(function(e,t,r,i){return[n(r,e.getEndpointParameterInstructions())]}).s(`ElasticLoadBalancing_v10`,`DeregisterTargets`,{}).n(`ElasticLoadBalancingV2Client`,`DeregisterTargetsCommand`).sc(f).build(){},A=class extends c.classBuilder().ep(m).m(function(e,t,r,i){return[n(r,e.getEndpointParameterInstructions())]}).s(`ElasticLoadBalancing_v10`,`ModifyListener`,{}).n(`ElasticLoadBalancingV2Client`,`ModifyListenerCommand`).sc(v).build(){},j=class extends c.classBuilder().ep(m).m(function(e,t,r,i){return[n(r,e.getEndpointParameterInstructions())]}).s(`ElasticLoadBalancing_v10`,`ModifyLoadBalancerAttributes`,{}).n(`ElasticLoadBalancingV2Client`,`ModifyLoadBalancerAttributesCommand`).sc(s).build(){},M=class extends c.classBuilder().ep(m).m(function(e,t,r,i){return[n(r,e.getEndpointParameterInstructions())]}).s(`ElasticLoadBalancing_v10`,`ModifyRule`,{}).n(`ElasticLoadBalancingV2Client`,`ModifyRuleCommand`).sc(r).build(){},N=class extends c.classBuilder().ep(m).m(function(e,t,r,i){return[n(r,e.getEndpointParameterInstructions())]}).s(`ElasticLoadBalancing_v10`,`ModifyTargetGroupAttributes`,{}).n(`ElasticLoadBalancingV2Client`,`ModifyTargetGroupAttributesCommand`).sc(_).build(){},P=class extends c.classBuilder().ep(m).m(function(e,t,r,i){return[n(r,e.getEndpointParameterInstructions())]}).s(`ElasticLoadBalancing_v10`,`RegisterTargets`,{}).n(`ElasticLoadBalancingV2Client`,`RegisterTargetsCommand`).sc(ee).build(){},F=class extends c.classBuilder().ep(m).m(function(e,t,r,i){return[n(r,e.getEndpointParameterInstructions())]}).s(`ElasticLoadBalancing_v10`,`RemoveTags`,{}).n(`ElasticLoadBalancingV2Client`,`RemoveTagsCommand`).sc(a).build(){},I=class extends c.classBuilder().ep(m).m(function(e,t,r,i){return[n(r,e.getEndpointParameterInstructions())]}).s(`ElasticLoadBalancing_v10`,`SetSecurityGroups`,{}).n(`ElasticLoadBalancingV2Client`,`SetSecurityGroupsCommand`).sc(i).build(){},L=class extends c.classBuilder().ep(m).m(function(e,t,r,i){return[n(r,e.getEndpointParameterInstructions())]}).s(`ElasticLoadBalancing_v10`,`SetSubnets`,{}).n(`ElasticLoadBalancingV2Client`,`SetSubnetsCommand`).sc(o).build(){},R=e();function z(){let e=(0,R.c)(2),n=y(),r;return e[0]===n?r=e[1]:(r={mutationFn:B,onSuccess:()=>{n.invalidateQueries({queryKey:[`elbv2`,`loadBalancers`]})}},e[0]=n,e[1]=r),t(r)}async function B(e){let t=new E({LoadBalancerArn:e});return await l().send(t)}function V(){let e=(0,R.c)(2),n=y(),r;return e[0]===n?r=e[1]:(r={mutationFn:H,onSuccess:(e,t)=>{n.invalidateQueries({queryKey:[`elbv2`,`loadBalancers`,t.loadBalancerArn,`attributes`]})}},e[0]=n,e[1]=r),t(r)}async function H(e){let t=new j({LoadBalancerArn:e.loadBalancerArn,Attributes:e.attributes.map(U)});return await l().send(t)}function U(e){return{Key:e.key,Value:e.value}}function W(){let e=(0,R.c)(2),n=y(),r;return e[0]===n?r=e[1]:(r={mutationFn:G,onSuccess:()=>{n.invalidateQueries({queryKey:[`elbv2`,`tags`]})}},e[0]=n,e[1]=r),t(r)}async function G(e){let t=new Set(e.tags.map(q)),n=e.initialKeys.filter(e=>!t.has(e)),r=l();e.tags.length>0&&await r.send(new b({ResourceArns:[e.resourceArn],Tags:e.tags.map(K)})),n.length>0&&await r.send(new F({ResourceArns:[e.resourceArn],TagKeys:n}))}function K(e){return{Key:e.key,Value:e.value}}function q(e){return e.key}function J(){let e=(0,R.c)(2),n=y(),r;return e[0]===n?r=e[1]:(r={mutationFn:Y,onSuccess:(e,t)=>{n.invalidateQueries({queryKey:[`elbv2`,`loadBalancers`,t.loadBalancerArn]})}},e[0]=n,e[1]=r),t(r)}async function Y(e){let t=new I({LoadBalancerArn:e.loadBalancerArn,SecurityGroups:e.securityGroupIds});return await l().send(t)}function ae(){let e=(0,R.c)(2),n=y(),r;return e[0]===n?r=e[1]:(r={mutationFn:oe,onSuccess:(e,t)=>{n.invalidateQueries({queryKey:[`elbv2`,`loadBalancers`,t.loadBalancerArn]})}},e[0]=n,e[1]=r),t(r)}async function oe(e){let t=new L({LoadBalancerArn:e.loadBalancerArn,Subnets:e.subnetIds});return await l().send(t)}function se(){let e=(0,R.c)(2),n=y(),r;return e[0]===n?r=e[1]:(r={mutationFn:ce,onSuccess:()=>{n.invalidateQueries({queryKey:[`elbv2`,`targetGroups`]})}},e[0]=n,e[1]=r),t(r)}async function ce(e){let t=e.tags.filter(ue).map(le),n=e.healthCheck.protocol===`HTTP`,r=new w({Name:e.name,Protocol:e.protocol,Port:e.port,VpcId:e.vpcId,TargetType:`instance`,HealthCheckProtocol:e.healthCheck.protocol,HealthCheckPath:n?e.healthCheck.path:void 0,HealthCheckPort:e.healthCheck.port,HealthCheckIntervalSeconds:e.healthCheck.intervalSeconds,HealthCheckTimeoutSeconds:e.healthCheck.timeoutSeconds,HealthyThresholdCount:e.healthCheck.healthyThresholdCount,UnhealthyThresholdCount:e.healthCheck.unhealthyThresholdCount,Matcher:n?{HttpCode:e.healthCheck.matcher}:void 0,Tags:t.length>0?t:void 0});return await l().send(r)}function le(e){return{Key:e.key,Value:e.value}}function ue(e){return e.key.length>0}function de(){let e=(0,R.c)(2),n=y(),r;return e[0]===n?r=e[1]:(r={mutationFn:fe,onSuccess:()=>{n.invalidateQueries({queryKey:[`elbv2`,`targetGroups`]})}},e[0]=n,e[1]=r),t(r)}async function fe(e){let t=new O({TargetGroupArn:e});return await l().send(t)}function pe(){let e=(0,R.c)(2),n=y(),r;return e[0]===n?r=e[1]:(r={mutationFn:me,onSuccess:(e,t)=>{n.invalidateQueries({queryKey:[`elbv2`,`targetGroups`,t.targetGroupArn,`attributes`]})}},e[0]=n,e[1]=r),t(r)}async function me(e){let t=new N({TargetGroupArn:e.targetGroupArn,Attributes:e.attributes.map(he)});return await l().send(t)}function he(e){return{Key:e.key,Value:e.value}}function X(e,t,n){return!(e===`HTTPS`||e===`TLS`)||!t?{}:{Certificates:[{CertificateArn:t}],SslPolicy:e===`HTTPS`?n:void 0}}function ge(){let e=(0,R.c)(2),n=y(),r;return e[0]===n?r=e[1]:(r={mutationFn:_e,onSuccess:(e,t)=>{n.invalidateQueries({queryKey:[`elbv2`,`listeners`,t.loadBalancerArn]})}},e[0]=n,e[1]=r),t(r)}async function _e(e){let t=[{Type:`forward`,TargetGroupArn:e.defaultTargetGroupArn}],n=new x({LoadBalancerArn:e.loadBalancerArn,Protocol:e.protocol,Port:e.port,DefaultActions:t,...X(e.protocol,e.certificateArn,e.sslPolicy)});return await l().send(n)}function ve(){let e=(0,R.c)(2),n=y(),r;return e[0]===n?r=e[1]:(r={mutationFn:ye,onSuccess:()=>{n.invalidateQueries({queryKey:[`elbv2`,`listeners`]})}},e[0]=n,e[1]=r),t(r)}async function ye(e){let t=new T({ListenerArn:e});return await l().send(t)}function be(){let e=(0,R.c)(2),n=y(),r;return e[0]===n?r=e[1]:(r={mutationFn:xe,onSuccess:(e,t)=>{n.invalidateQueries({queryKey:[`elbv2`,`listeners`,t.loadBalancerArn]})}},e[0]=n,e[1]=r),t(r)}async function xe(e){let t=[{Type:`forward`,TargetGroupArn:e.defaultTargetGroupArn}],n=new A({ListenerArn:e.listenerArn,Protocol:e.protocol,Port:e.port,DefaultActions:t,...X(e.protocol,e.certificateArn,e.sslPolicy)});return await l().send(n)}function Se(){let e=y();return t({mutationFn:async e=>{let t=l(),n=[],r=``;try{let i=e.listener.existingTargetGroupArn;if(e.listener.targetGroupMode===`new`){let a=e.listener.newTargetGroup;if(!a)throw Error(`internal error: new target group requested but no data supplied`);r=`creating target group`;let o=a.tags.filter(e=>e.key.length>0).map(e=>({Key:e.key,Value:e.value})),s=a.healthCheck.protocol===`HTTP`;if(i=(await t.send(new w({Name:a.name,Protocol:a.protocol,Port:a.port,VpcId:a.vpcId,TargetType:`instance`,HealthCheckProtocol:a.healthCheck.protocol,HealthCheckPath:s?a.healthCheck.path:void 0,HealthCheckPort:a.healthCheck.port,HealthCheckIntervalSeconds:a.healthCheck.intervalSeconds,HealthCheckTimeoutSeconds:a.healthCheck.timeoutSeconds,HealthyThresholdCount:a.healthCheck.healthyThresholdCount,UnhealthyThresholdCount:a.healthCheck.unhealthyThresholdCount,Matcher:s?{HttpCode:a.healthCheck.matcher}:void 0,Tags:o.length>0?o:void 0}))).TargetGroups?.[0]?.TargetGroupArn,!i)throw Error(`Target group was created but no ARN was returned by the API`);n.push({type:`Target Group`,id:i})}if(!i)throw Error(`Target group selection is required`);r=`creating load balancer`;let a=e.lb.tags.filter(e=>e.key.length>0).map(e=>({Key:e.key,Value:e.value})),o=(await t.send(new S({Name:e.lb.name,Scheme:e.lb.scheme,Type:e.lb.type,IpAddressType:`ipv4`,Subnets:e.lb.subnetIds,SecurityGroups:e.lb.securityGroupIds.length>0?e.lb.securityGroupIds:void 0,Tags:a.length>0?a:void 0}))).LoadBalancers?.[0]?.LoadBalancerArn;if(!o)throw Error(`Load balancer was created but no ARN was returned by the API`);return n.push({type:`Load Balancer`,id:o}),r=`creating listener`,await t.send(new x({LoadBalancerArn:o,Protocol:e.listener.protocol,Port:e.listener.port,DefaultActions:[{Type:`forward`,TargetGroupArn:i}],...X(e.listener.protocol,e.listener.certificateArn,e.listener.sslPolicy)})),n.push({type:`Listener`,id:void 0}),{loadBalancerArn:o,created:n}}catch(e){return{loadBalancerArn:void 0,created:n,error:e instanceof Error?e:Error(String(e)),failedStep:r}}},onSettled:()=>{e.invalidateQueries({queryKey:[`elbv2`]})}})}function Z(e){return e.map(e=>({Id:e.id,Port:e.port}))}function Ce(){let e=(0,R.c)(2),n=y(),r;return e[0]===n?r=e[1]:(r={mutationFn:we,onSuccess:(e,t)=>{n.invalidateQueries({queryKey:[`elbv2`,`targetGroups`,t.targetGroupArn,`health`]})}},e[0]=n,e[1]=r),t(r)}async function we(e){let t=new P({TargetGroupArn:e.targetGroupArn,Targets:Z(e.targets)});return await l().send(t)}function Q(){let e=(0,R.c)(2),n=y(),r;return e[0]===n?r=e[1]:(r={mutationFn:Te,onSuccess:(e,t)=>{n.invalidateQueries({queryKey:[`elbv2`,`targetGroups`,t.targetGroupArn,`health`]})}},e[0]=n,e[1]=r),t(r)}async function Te(e){let t=new k({TargetGroupArn:e.targetGroupArn,Targets:Z(e.targets)});return await l().send(t)}function $(e){return e.map(e=>{switch(e.field){case`host-header`:return{Field:e.field,HostHeaderConfig:{Values:e.values}};case`path-pattern`:return{Field:e.field,PathPatternConfig:{Values:e.values}};case`http-header`:return{Field:e.field,HttpHeaderConfig:{HttpHeaderName:e.httpHeaderName??``,Values:e.values}};case`http-request-method`:return{Field:e.field,HttpRequestMethodConfig:{Values:e.values}};case`source-ip`:return{Field:e.field,SourceIpConfig:{Values:e.values}};case`query-string`:return{Field:e.field,QueryStringConfig:{Values:(e.queryStringPairs??[]).map(e=>({Key:e.key&&e.key.length>0?e.key:void 0,Value:e.value}))}};default:return{Field:e.field,Values:e.values}}})}function Ee(){let e=(0,R.c)(2),n=y(),r;return e[0]===n?r=e[1]:(r={mutationFn:De,onSuccess:(e,t)=>{n.invalidateQueries({queryKey:[`elbv2`,`listeners`,t.listenerArn,`rules`]})}},e[0]=n,e[1]=r),t(r)}async function De(e){let t=[{Type:`forward`,TargetGroupArn:e.forwardTargetGroupArn}],n=new C({ListenerArn:e.listenerArn,Priority:e.priority,Conditions:$(e.conditions),Actions:t});return await l().send(n)}function Oe(){let e=(0,R.c)(2),n=y(),r;return e[0]===n?r=e[1]:(r={mutationFn:ke,onSuccess:(e,t)=>{n.invalidateQueries({queryKey:[`elbv2`,`listeners`,t.listenerArn,`rules`]})}},e[0]=n,e[1]=r),t(r)}async function ke(e){let t=new M({RuleArn:e.ruleArn,Conditions:e.conditions?$(e.conditions):void 0,Actions:e.forwardTargetGroupArn?[{Type:`forward`,TargetGroupArn:e.forwardTargetGroupArn}]:void 0});return await l().send(t)}function Ae(){let e=(0,R.c)(2),n=y(),r;return e[0]===n?r=e[1]:(r={mutationFn:je,onSuccess:(e,t)=>{n.invalidateQueries({queryKey:[`elbv2`,`listeners`,t.listenerArn,`rules`]})}},e[0]=n,e[1]=r),t(r)}async function je(e){let t=new D({RuleArn:e.ruleArn});return await l().send(t)}export{W as _,ve as a,de as c,V as d,Oe as f,ae as g,J as h,se as i,Q as l,Ce as m,Se as n,z as o,pe as p,Ee as r,Ae as s,ge as t,be as u};
\ No newline at end of file
diff --git a/spinifex/services/spinifexui/frontend/dist/assets/elbv22.js b/spinifex/services/spinifexui/frontend/dist/assets/elbv22.js
index a0bc8efe0..c6de29a47 100644
--- a/spinifex/services/spinifexui/frontend/dist/assets/elbv22.js
+++ b/spinifex/services/spinifexui/frontend/dist/assets/elbv22.js
@@ -1 +1 @@
-import{Br as e,Hr as t,Ir as n,Lr as r,Ur as i,Vr as a}from"./index.js";var o=/^(?:[a-zA-Z0-9]|[a-zA-Z0-9][a-zA-Z0-9-]*[a-zA-Z0-9])$/,s=i().min(1,`Name is required`).max(32,`Name must be 32 characters or less`).regex(o,`Name may contain only letters, digits, and hyphens; must start and end with alphanumeric`).refine(e=>!e.startsWith(`internal-`),`Name cannot start with 'internal-'`),c=i().min(1,`Name is required`).max(32,`Name must be 32 characters or less`).regex(o,`Name may contain only letters, digits, and hyphens; must start and end with alphanumeric`),l=e().int(`Port must be a whole number`).min(1,`Port must be at least 1`).max(65535,`Port must be at most 65535`),u=a({key:i().min(1,`Tag key is required`).max(128),value:i().max(256)}),d=a({protocol:n([`HTTP`]),path:i().min(1,`Path is required`).max(1024),port:i().min(1),intervalSeconds:e().int().min(5).max(300),timeoutSeconds:e().int().min(2).max(120),healthyThresholdCount:e().int().min(2).max(10),unhealthyThresholdCount:e().int().min(2).max(10),matcher:i().regex(/^\d{3}(?:[-,]\d{3})*$/,`Matcher must be HTTP codes like 200 or 200-299 or 200,201`)}),f=a({name:c,protocol:n([`HTTP`]),port:l,vpcId:i().min(1,`VPC is required`),healthCheck:d,tags:r(u)}),p=a({protocol:n([`HTTP`]),port:l,defaultTargetGroupArn:i().min(1,`Target group is required`)}),m=/^[!-~]+$/,h=a({field:n([`host-header`,`path-pattern`,`http-header`,`http-request-method`,`source-ip`,`query-string`]),httpHeaderName:i().max(40).optional(),rawValues:i().min(1,`At least one value is required`).max(640,`Too many values`)}).superRefine((e,t)=>{e.field===`http-header`&&!e.httpHeaderName&&t.addIssue({code:`custom`,path:[`httpHeaderName`],message:`Header name is required`});let n=e.rawValues.split(/[\n,]/).map(e=>e.trim()).filter(e=>e.length>0);if(n.length===0||n.length>5){t.addIssue({code:`custom`,path:[`rawValues`],message:`Enter 1 to 5 values, comma or newline separated`});return}for(let e of n)if(!m.test(e)){t.addIssue({code:`custom`,path:[`rawValues`],message:`Value "${e}" contains whitespace or non-ASCII characters`});return}}),g=a({priority:e().int(`Priority must be a whole number`).min(1,`Priority must be at least 1`).max(5e4,`Priority must be at most 50000`),conditions:r(h).min(1,`At least one condition is required`).max(5,`Up to 5 conditions per rule`),forwardTargetGroupArn:i().min(1,`Target group is required`)}),_=a({name:s,scheme:n([`internet-facing`,`internal`]),vpcId:i().min(1,`VPC is required`),subnetIds:r(i()).min(2,`At least 2 subnets are required for an ALB`),securityGroupIds:r(i()),tags:r(u),listener:a({protocol:n([`HTTP`]),port:l,targetGroupMode:n([`new`,`existing`]),existingTargetGroupArn:i().optional()}).refine(e=>e.targetGroupMode===`existing`?!!e.existingTargetGroupArn:!0,{error:`Target group is required`,path:[`existingTargetGroupArn`]})});a({targets:r(a({instanceId:i().min(1,`Instance is required`),port:l.optional()})).min(1,`At least one target is required`)}),t(i(),i());export{f as i,_ as n,g as r,p as t};
\ No newline at end of file
+import{_ as e,f as t,g as n,p as r,v as i,y as a}from"./SignatureV4.js";var o=/^(?:[a-zA-Z0-9]|[a-zA-Z0-9][a-zA-Z0-9-]*[a-zA-Z0-9])$/,s=a().min(1,`Name is required`).max(32,`Name must be 32 characters or less`).regex(o,`Name may contain only letters, digits, and hyphens; must start and end with alphanumeric`).refine(e=>!e.startsWith(`internal-`),`Name cannot start with 'internal-'`),c=a().min(1,`Name is required`).max(32,`Name must be 32 characters or less`).regex(o,`Name may contain only letters, digits, and hyphens; must start and end with alphanumeric`),l=n().int(`Port must be a whole number`).min(1,`Port must be at least 1`).max(65535,`Port must be at most 65535`),u=e({key:a().min(1,`Tag key is required`).max(128),value:a().max(256)}),d=e({protocol:t([`HTTP`,`TCP`]),path:a().min(1,`Path is required`).max(1024),port:a().min(1),intervalSeconds:n().int().min(5).max(300),timeoutSeconds:n().int().min(2).max(120),healthyThresholdCount:n().int().min(2).max(10),unhealthyThresholdCount:n().int().min(2).max(10),matcher:a().regex(/^\d{3}(?:[-,]\d{3})*$/,`Matcher must be HTTP codes like 200 or 200-299 or 200,201`)}),f=e({name:c,protocol:t([`HTTP`,`HTTPS`,`TCP`,`UDP`,`TLS`,`TCP_UDP`]),port:l,vpcId:a().min(1,`VPC is required`),healthCheck:d,tags:r(u)}),p=`ELBSecurityPolicy-2016-08`,m=[`application`,`network`],h=[`HTTP`,`HTTPS`],g=[`TCP`,`UDP`,`TLS`,`TCP_UDP`],_=[...h,...g];function v(e){return e===`network`?g:h}function y(e){return e===`network`?`TCP`:`HTTP`}function b(e){return e===`HTTPS`||e===`TLS`}var x=e({protocol:t(_),port:l,defaultTargetGroupArn:a().min(1,`Target group is required`),certificateArn:a().optional(),sslPolicy:a().optional()}).superRefine((e,t)=>{b(e.protocol)&&!e.certificateArn&&t.addIssue({code:`custom`,path:[`certificateArn`],message:`A certificate is required for HTTPS and TLS`})}),S=/^[!-~]+$/,C=e({field:t([`host-header`,`path-pattern`,`http-header`,`http-request-method`,`source-ip`,`query-string`]),httpHeaderName:a().max(40).optional(),rawValues:a().min(1,`At least one value is required`).max(640,`Too many values`)}).superRefine((e,t)=>{e.field===`http-header`&&!e.httpHeaderName&&t.addIssue({code:`custom`,path:[`httpHeaderName`],message:`Header name is required`});let n=e.rawValues.split(/[\n,]/).map(e=>e.trim()).filter(e=>e.length>0);if(n.length===0||n.length>5){t.addIssue({code:`custom`,path:[`rawValues`],message:`Enter 1 to 5 values, comma or newline separated`});return}for(let e of n)if(!S.test(e)){t.addIssue({code:`custom`,path:[`rawValues`],message:`Value "${e}" contains whitespace or non-ASCII characters`});return}}),w=e({priority:n().int(`Priority must be a whole number`).min(1,`Priority must be at least 1`).max(5e4,`Priority must be at most 50000`),conditions:r(C).min(1,`At least one condition is required`).max(5,`Up to 5 conditions per rule`),forwardTargetGroupArn:a().min(1,`Target group is required`)}),T=e({name:s,type:t(m),scheme:t([`internet-facing`,`internal`]),vpcId:a().min(1,`VPC is required`),subnetIds:r(a()).min(1,`At least 1 subnet is required`),securityGroupIds:r(a()),tags:r(u),listener:e({protocol:t(_),port:l,targetGroupMode:t([`new`,`existing`]),existingTargetGroupArn:a().optional(),certificateArn:a().optional(),sslPolicy:a().optional()}).superRefine((e,t)=>{e.targetGroupMode===`existing`&&!e.existingTargetGroupArn&&t.addIssue({code:`custom`,path:[`existingTargetGroupArn`],message:`Target group is required`}),b(e.protocol)&&!e.certificateArn&&t.addIssue({code:`custom`,path:[`certificateArn`],message:`A certificate is required for HTTPS/TLS`})})});e({targets:r(e({instanceId:a().min(1,`Instance is required`),port:l.optional()})).min(1,`At least one target is required`)}),i(a(),a());export{f as a,v as c,w as i,x as n,y as o,T as r,b as s,p as t};
\ No newline at end of file
diff --git a/spinifex/services/spinifexui/frontend/dist/assets/error-banner.js b/spinifex/services/spinifexui/frontend/dist/assets/error-banner.js
index a1313bca6..2f25310cd 100644
--- a/spinifex/services/spinifexui/frontend/dist/assets/error-banner.js
+++ b/spinifex/services/spinifexui/frontend/dist/assets/error-banner.js
@@ -1 +1 @@
-import{Fo as e,Ho as t,Ko as n,Uo as r,Vo as i,Wo as a,Yo as o,Zo as s,cs as c,ns as l,os as u,ts as d}from"./index.js";var f=class extends u{#e;#t=void 0;#n;#r;constructor(e,t){super(),this.#e=e,this.setOptions(t),this.bindMethods(),this.#i()}bindMethods(){this.mutate=this.mutate.bind(this),this.reset=this.reset.bind(this)}setOptions(e){let t=this.options;this.options=this.#e.defaultMutationOptions(e),d(this.options,t)||this.#e.getMutationCache().notify({type:`observerOptionsUpdated`,mutation:this.#n,observer:this}),t?.mutationKey&&this.options.mutationKey&&o(t.mutationKey)!==o(this.options.mutationKey)?this.reset():this.#n?.state.status===`pending`&&this.#n.setOptions(this.options)}onUnsubscribe(){this.hasListeners()||this.#n?.removeObserver(this)}onMutationUpdate(e){this.#i(),this.#a(e)}getCurrentResult(){return this.#t}reset(){this.#n?.removeObserver(this),this.#n=void 0,this.#i(),this.#a()}mutate(e,t){return this.#r=t,this.#n?.removeObserver(this),this.#n=this.#e.getMutationCache().build(this.#e,this.options),this.#n.addObserver(this),this.#n.execute(e)}#i(){let e=this.#n?.state??a();this.#t={...e,isPending:e.status===`pending`,isSuccess:e.status===`success`,isError:e.status===`error`,isIdle:e.status===`idle`,mutate:this.mutate,reset:this.reset}}#a(e){n.batch(()=>{if(this.#r&&this.hasListeners()){let t=this.#t.variables,n=this.#t.context,r={client:this.#e,meta:this.options.meta,mutationKey:this.options.mutationKey};if(e?.type===`success`){try{this.#r.onSuccess?.(e.data,t,n,r)}catch(e){Promise.reject(e)}try{this.#r.onSettled?.(e.data,null,t,n,r)}catch(e){Promise.reject(e)}}else if(e?.type===`error`){try{this.#r.onError?.(e.error,t,n,r)}catch(e){Promise.reject(e)}try{this.#r.onSettled?.(void 0,e.error,t,n,r)}catch(e){Promise.reject(e)}}}this.listeners.forEach(e=>{e(this.#t)})})}},p=c(r(),1);function m(e,t){let r=i(t),[a]=p.useState(()=>new f(r,e));p.useEffect(()=>{a.setOptions(e)},[a,e]);let o=p.useSyncExternalStore(p.useCallback(e=>a.subscribe(n.batchCalls(e)),[a]),()=>a.getCurrentResult(),()=>a.getCurrentResult()),c=p.useCallback((e,t)=>{a.mutate(e,t).catch(s)},[a]);if(o.error&&l(a.options.throwOnError,[o.error]))throw o.error;return{...o,mutate:c,mutateAsync:o.mutate}}var h=e(),g=t();function _(e){let t=(0,h.c)(7),{msg:n,error:r}=e,i;r&&(i=r.name===`Error`?r.message:r.name);let a;t[0]===n?a=t[1]:(a=n&&(0,g.jsx)(`h2`,{className:`text-sm text-destructive`,children:n}),t[0]=n,t[1]=a);let o;t[2]===i?o=t[3]:(o=i&&(0,g.jsx)(`p`,{className:`text-sm text-destructive`,children:i}),t[2]=i,t[3]=o);let s;return t[4]!==a||t[5]!==o?(s=(0,g.jsxs)(`div`,{className:`mb-6 max-w-4xl rounded-md border border-destructive bg-destructive/10 p-4`,children:[a,o]}),t[4]=a,t[5]=o,t[6]=s):s=t[6],s}export{m as n,_ as t};
\ No newline at end of file
+import{n as e,o as t,r as n,t as r}from"./compiler-runtime.js";import{Br as i,Gr as a,Lr as o,Rr as s,Ur as c,Xr as l,Yr as u,ei as d}from"./index.js";var f=class extends d{#e;#t=void 0;#n;#r;constructor(e,t){super(),this.#e=e,this.setOptions(t),this.bindMethods(),this.#i()}bindMethods(){this.mutate=this.mutate.bind(this),this.reset=this.reset.bind(this)}setOptions(e){let t=this.options;this.options=this.#e.defaultMutationOptions(e),u(this.options,t)||this.#e.getMutationCache().notify({type:`observerOptionsUpdated`,mutation:this.#n,observer:this}),t?.mutationKey&&this.options.mutationKey&&c(t.mutationKey)!==c(this.options.mutationKey)?this.reset():this.#n?.state.status===`pending`&&this.#n.setOptions(this.options)}onUnsubscribe(){this.hasListeners()||this.#n?.removeObserver(this)}onMutationUpdate(e){this.#i(),this.#a(e)}getCurrentResult(){return this.#t}reset(){this.#n?.removeObserver(this),this.#n=void 0,this.#i(),this.#a()}mutate(e,t){return this.#r=t,this.#n?.removeObserver(this),this.#n=this.#e.getMutationCache().build(this.#e,this.options),this.#n.addObserver(this),this.#n.execute(e)}#i(){let e=this.#n?.state??s();this.#t={...e,isPending:e.status===`pending`,isSuccess:e.status===`success`,isError:e.status===`error`,isIdle:e.status===`idle`,mutate:this.mutate,reset:this.reset}}#a(e){i.batch(()=>{if(this.#r&&this.hasListeners()){let t=this.#t.variables,n=this.#t.context,r={client:this.#e,meta:this.options.meta,mutationKey:this.options.mutationKey};if(e?.type===`success`){try{this.#r.onSuccess?.(e.data,t,n,r)}catch(e){Promise.reject(e)}try{this.#r.onSettled?.(e.data,null,t,n,r)}catch(e){Promise.reject(e)}}else if(e?.type===`error`){try{this.#r.onError?.(e.error,t,n,r)}catch(e){Promise.reject(e)}try{this.#r.onSettled?.(void 0,e.error,t,n,r)}catch(e){Promise.reject(e)}}}this.listeners.forEach(e=>{e(this.#t)})})}},p=t(n(),1);function m(e,t){let n=o(t),[r]=p.useState(()=>new f(n,e));p.useEffect(()=>{r.setOptions(e)},[r,e]);let s=p.useSyncExternalStore(p.useCallback(e=>r.subscribe(i.batchCalls(e)),[r]),()=>r.getCurrentResult(),()=>r.getCurrentResult()),c=p.useCallback((e,t)=>{r.mutate(e,t).catch(a)},[r]);if(s.error&&l(r.options.throwOnError,[s.error]))throw s.error;return{...s,mutate:c,mutateAsync:s.mutate}}var h=r(),g=e();function _(e){let t=(0,h.c)(7),{msg:n,error:r}=e,i;r&&(i=r.name===`Error`?r.message:r.name);let a;t[0]===n?a=t[1]:(a=n&&(0,g.jsx)(`h2`,{className:`text-sm text-destructive`,children:n}),t[0]=n,t[1]=a);let o;t[2]===i?o=t[3]:(o=i&&(0,g.jsx)(`p`,{className:`text-sm text-destructive`,children:i}),t[2]=i,t[3]=o);let s;return t[4]!==a||t[5]!==o?(s=(0,g.jsxs)(`div`,{className:`mb-6 max-w-4xl rounded-md border border-destructive bg-destructive/10 p-4`,children:[a,o]}),t[4]=a,t[5]=o,t[6]=s):s=t[6],s}export{m as n,_ as t};
\ No newline at end of file
diff --git a/spinifex/services/spinifexui/frontend/dist/assets/event-streams.js b/spinifex/services/spinifexui/frontend/dist/assets/event-streams.js
deleted file mode 100644
index c6213feed..000000000
--- a/spinifex/services/spinifexui/frontend/dist/assets/event-streams.js
+++ /dev/null
@@ -1 +0,0 @@
-import{Sr as e,br as t}from"./index.js";var n=class{marshaller;serializer;deserializer;serdeContext;defaultContentType;constructor({marshaller:e,serializer:t,deserializer:n,serdeContext:r,defaultContentType:i}){this.marshaller=e,this.serializer=t,this.deserializer=n,this.serdeContext=r,this.defaultContentType=i}async serializeEventStream({eventStream:e,requestSchema:t,initialRequest:n}){let r=this.marshaller,i=t.getEventStreamMember(),a=t.getMemberSchema(i),o=this.serializer,s=this.defaultContentType,c=Symbol(`initialRequestMarker`),l={async*[Symbol.asyncIterator](){if(n){let e={":event-type":{type:`string`,value:`initial-request`},":message-type":{type:`string`,value:`event`},":content-type":{type:`string`,value:s}};o.write(t,n);let r=o.flush();yield{[c]:!0,headers:e,body:r}}for await(let t of e)yield t}};return r.serialize(l,e=>{if(e[c])return{headers:e.headers,body:e.body};let t=``;for(let n in e)if(n!==`__type`){t=n;break}let{additionalHeaders:n,body:r,eventType:i,explicitPayloadContentType:o}=this.writeEventBody(t,a,e);return{headers:{":event-type":{type:`string`,value:i},":message-type":{type:`string`,value:`event`},":content-type":{type:`string`,value:o??s},...n},body:r}})}async deserializeEventStream({response:e,responseSchema:n,initialResponseContainer:r}){let i=this.marshaller,a=n.getEventStreamMember(),o=n.getMemberSchema(a).getMemberSchemas(),s=Symbol(`initialResponseMarker`),c=i.deserialize(e.body,async e=>{let r=``;for(let t in e)if(t!==`__type`){r=t;break}let i=e[r].body;if(r===`initial-response`){let e=await this.deserializer.read(n,i);return delete e[a],{[s]:!0,...e}}else if(r in o){let n=o[r];if(n.isStructSchema()){let a={},o=!1;for(let[s,c]of n.structIterator()){let{eventHeader:n,eventPayload:l}=c.getMergedTraits();if(o||=!!(n||l),l)c.isBlobSchema()?a[s]=i:c.isStringSchema()?a[s]=(this.serdeContext?.utf8Encoder??t)(i):c.isStructSchema()&&(a[s]=await this.deserializer.read(c,i));else if(n){let t=e[r].headers[s]?.value;t!=null&&(c.isNumericSchema()?t&&typeof t==`object`&&`bytes`in t?a[s]=BigInt(t.toString()):a[s]=Number(t):a[s]=t)}}if(o)return{[r]:a};if(i.byteLength===0)return{[r]:{}}}return{[r]:await this.deserializer.read(n,i)}}else return{$unknown:e}}),l=c[Symbol.asyncIterator](),u=await l.next();if(u.done)return c;if(u.value?.[s]){if(!n)throw Error(`@smithy::core/protocols - initial-response event encountered in event stream but no response schema given.`);for(let e in u.value)r[e]=u.value[e]}return{async*[Symbol.asyncIterator](){for(u?.value?.[s]||(yield u.value);;){let{done:e,value:t}=await l.next();if(e)break;yield t}}}}writeEventBody(t,n,r){let i=this.serializer,a=t,o=null,s,c=n.getSchema()[4].includes(t),l={};if(c){let e=n.getMemberSchema(t);if(e.isStructSchema()){for(let[n,i]of e.structIterator()){let{eventHeader:e,eventPayload:a}=i.getMergedTraits();if(a)o=n;else if(e){let e=r[t][n],a=`binary`;i.isNumericSchema()?a=(-2)**31<=e&&e<=2**31-1?`integer`:`long`:i.isTimestampSchema()?a=`timestamp`:i.isStringSchema()?a=`string`:i.isBooleanSchema()&&(a=`boolean`),e!=null&&(l[n]={type:a,value:e},delete r[t][n])}}if(o!==null){let n=e.getMemberSchema(o);n.isBlobSchema()?s=`application/octet-stream`:n.isStringSchema()&&(s=`text/plain`),i.write(n,r[t][o])}else i.write(e,r[t])}else if(e.isUnitSchema())i.write(e,{});else throw Error(`@smithy/core/event-streams - non-struct member not supported in event stream union.`)}else{let[e,n]=r[t];a=e,i.write(15,n)}let u=i.flush()??new Uint8Array;return{body:typeof u==`string`?(this.serdeContext?.utf8Decoder??e)(u):u,eventType:a,explicitPayloadContentType:s,additionalHeaders:l}}};export{n as EventStreamSerde};
\ No newline at end of file
diff --git a/spinifex/services/spinifexui/frontend/dist/assets/field.js b/spinifex/services/spinifexui/frontend/dist/assets/field.js
index a57c97b02..b21eecf74 100644
--- a/spinifex/services/spinifexui/frontend/dist/assets/field.js
+++ b/spinifex/services/spinifexui/frontend/dist/assets/field.js
@@ -1 +1 @@
-import{$a as e,Fo as t,Ho as n,To as r,Uo as i}from"./index.js";i();var a=t(),o=n();function s(t){let n=(0,a.c)(8),r,i;n[0]===t?(r=n[1],i=n[2]):({className:r,...i}=t,n[0]=t,n[1]=r,n[2]=i);let s;n[3]===r?s=n[4]:(s=e(`group/field-group @container/field-group flex w-full flex-col gap-4 data-[slot=checkbox-group]:gap-3 *:data-[slot=field-group]:gap-4`,r),n[3]=r,n[4]=s);let c;return n[5]!==i||n[6]!==s?(c=(0,o.jsx)(`div`,{className:s,"data-slot":`field-group`,...i}),n[5]=i,n[6]=s,n[7]=c):c=n[7],c}var c=r(`group/field flex w-full gap-2 data-[invalid=true]:text-destructive`,{variants:{orientation:{vertical:`flex-col [&>*]:w-full [&>.sr-only]:w-auto`,horizontal:`flex-row items-center has-[>[data-slot=field-content]]:items-start [&>[data-slot=field-label]]:flex-auto has-[>[data-slot=field-content]]:[&>[role=checkbox],[role=radio]]:mt-px`,responsive:`flex-col @md/field-group:flex-row @md/field-group:items-center @md/field-group:has-[>[data-slot=field-content]]:items-start [&>*]:w-full @md/field-group:[&>*]:w-auto [&>.sr-only]:w-auto @md/field-group:[&>[data-slot=field-label]]:flex-auto @md/field-group:has-[>[data-slot=field-content]]:[&>[role=checkbox],[role=radio]]:mt-px`}},defaultVariants:{orientation:`vertical`}});function l(t){let n=(0,a.c)(11),r,i,s;n[0]===t?(r=n[1],i=n[2],s=n[3]):({className:r,orientation:s,...i}=t,n[0]=t,n[1]=r,n[2]=i,n[3]=s);let l=s===void 0?`vertical`:s,u;n[4]!==r||n[5]!==l?(u=e(c({orientation:l}),r),n[4]=r,n[5]=l,n[6]=u):u=n[6];let d;return n[7]!==l||n[8]!==i||n[9]!==u?(d=(0,o.jsx)(`div`,{className:u,"data-orientation":l,"data-slot":`field`,role:`group`,...i}),n[7]=l,n[8]=i,n[9]=u,n[10]=d):d=n[10],d}function u(t){let n=(0,a.c)(8),r,i;n[0]===t?(r=n[1],i=n[2]):({className:r,...i}=t,n[0]=t,n[1]=r,n[2]=i);let s;n[3]===r?s=n[4]:(s=e(`flex w-fit items-center gap-2 text-xs/relaxed leading-snug font-medium group-data-[disabled=true]/field:opacity-50`,r),n[3]=r,n[4]=s);let c;return n[5]!==i||n[6]!==s?(c=(0,o.jsx)(`div`,{className:s,"data-slot":`field-label`,...i}),n[5]=i,n[6]=s,n[7]=c):c=n[7],c}function d(t){let n=(0,a.c)(8),r,i;n[0]===t?(r=n[1],i=n[2]):({className:r,...i}=t,n[0]=t,n[1]=r,n[2]=i);let s;n[3]===r?s=n[4]:(s=e(`text-left text-xs/relaxed leading-normal font-normal text-muted-foreground group-has-data-[orientation=horizontal]/field:text-balance [[data-variant=legend]+&]:-mt-1.5`,`last:mt-0 nth-last-2:-mt-1`,`[&>a]:underline [&>a]:underline-offset-4 [&>a:hover]:text-primary`,r),n[3]=r,n[4]=s);let c;return n[5]!==i||n[6]!==s?(c=(0,o.jsx)(`p`,{className:s,"data-slot":`field-description`,...i}),n[5]=i,n[6]=s,n[7]=c):c=n[7],c}function f(t){let n=(0,a.c)(15),r,i,s,c;n[0]===t?(r=n[1],i=n[2],s=n[3],c=n[4]):({className:i,children:r,errors:s,...c}=t,n[0]=t,n[1]=r,n[2]=i,n[3]=s,n[4]=c);let l;bb0:{if(r){l=r;break bb0}if(!s?.length){l=null;break bb0}let e;n[5]===s?e=n[6]:(e=[...new Map(s.map(m)).values()],n[5]=s,n[6]=e);let t=e;if(t?.length===1){l=t[0]?.message;break bb0}let i;n[7]===t?i=n[8]:(i=(0,o.jsx)(`ul`,{className:`ml-4 flex list-disc flex-col gap-1`,children:t.map(p)}),n[7]=t,n[8]=i),l=i}let u=l;if(!u)return null;let d;n[9]===i?d=n[10]:(d=e(`text-xs/relaxed font-normal text-destructive`,i),n[9]=i,n[10]=d);let f;return n[11]!==u||n[12]!==c||n[13]!==d?(f=(0,o.jsx)(`div`,{className:d,"data-slot":`field-error`,role:`alert`,...c,children:u}),n[11]=u,n[12]=c,n[13]=d,n[14]=f):f=n[14],f}function p(e){return e?.message&&(0,o.jsx)(`li`,{children:e.message},e.message)}function m(e){return[e?.message,e]}export{u as a,s as i,d as n,f as r,l as t};
\ No newline at end of file
+import{n as e,r as t,t as n}from"./compiler-runtime.js";import{t as r}from"./dist.js";import{w as i}from"./SignatureV4.js";import"./index.js";t();var a=n(),o=e();function s(e){let t=(0,a.c)(8),n,r;t[0]===e?(n=t[1],r=t[2]):({className:n,...r}=e,t[0]=e,t[1]=n,t[2]=r);let s;t[3]===n?s=t[4]:(s=i(`group/field-group @container/field-group flex w-full flex-col gap-4 data-[slot=checkbox-group]:gap-3 *:data-[slot=field-group]:gap-4`,n),t[3]=n,t[4]=s);let c;return t[5]!==r||t[6]!==s?(c=(0,o.jsx)(`div`,{className:s,"data-slot":`field-group`,...r}),t[5]=r,t[6]=s,t[7]=c):c=t[7],c}var c=r(`group/field flex w-full gap-2 data-[invalid=true]:text-destructive`,{variants:{orientation:{vertical:`flex-col [&>*]:w-full [&>.sr-only]:w-auto`,horizontal:`flex-row items-center has-[>[data-slot=field-content]]:items-start [&>[data-slot=field-label]]:flex-auto has-[>[data-slot=field-content]]:[&>[role=checkbox],[role=radio]]:mt-px`,responsive:`flex-col @md/field-group:flex-row @md/field-group:items-center @md/field-group:has-[>[data-slot=field-content]]:items-start [&>*]:w-full @md/field-group:[&>*]:w-auto [&>.sr-only]:w-auto @md/field-group:[&>[data-slot=field-label]]:flex-auto @md/field-group:has-[>[data-slot=field-content]]:[&>[role=checkbox],[role=radio]]:mt-px`}},defaultVariants:{orientation:`vertical`}});function l(e){let t=(0,a.c)(11),n,r,s;t[0]===e?(n=t[1],r=t[2],s=t[3]):({className:n,orientation:s,...r}=e,t[0]=e,t[1]=n,t[2]=r,t[3]=s);let l=s===void 0?`vertical`:s,u;t[4]!==n||t[5]!==l?(u=i(c({orientation:l}),n),t[4]=n,t[5]=l,t[6]=u):u=t[6];let d;return t[7]!==l||t[8]!==r||t[9]!==u?(d=(0,o.jsx)(`div`,{className:u,"data-orientation":l,"data-slot":`field`,role:`group`,...r}),t[7]=l,t[8]=r,t[9]=u,t[10]=d):d=t[10],d}function u(e){let t=(0,a.c)(8),n,r;t[0]===e?(n=t[1],r=t[2]):({className:n,...r}=e,t[0]=e,t[1]=n,t[2]=r);let s;t[3]===n?s=t[4]:(s=i(`flex w-fit items-center gap-2 text-xs/relaxed leading-snug font-medium group-data-[disabled=true]/field:opacity-50`,n),t[3]=n,t[4]=s);let c;return t[5]!==r||t[6]!==s?(c=(0,o.jsx)(`div`,{className:s,"data-slot":`field-label`,...r}),t[5]=r,t[6]=s,t[7]=c):c=t[7],c}function d(e){let t=(0,a.c)(8),n,r;t[0]===e?(n=t[1],r=t[2]):({className:n,...r}=e,t[0]=e,t[1]=n,t[2]=r);let s;t[3]===n?s=t[4]:(s=i(`text-left text-xs/relaxed leading-normal font-normal text-muted-foreground group-has-data-[orientation=horizontal]/field:text-balance [[data-variant=legend]+&]:-mt-1.5`,`last:mt-0 nth-last-2:-mt-1`,`[&>a]:underline [&>a]:underline-offset-4 [&>a:hover]:text-primary`,n),t[3]=n,t[4]=s);let c;return t[5]!==r||t[6]!==s?(c=(0,o.jsx)(`p`,{className:s,"data-slot":`field-description`,...r}),t[5]=r,t[6]=s,t[7]=c):c=t[7],c}function f(e){let t=(0,a.c)(15),n,r,s,c;t[0]===e?(n=t[1],r=t[2],s=t[3],c=t[4]):({className:r,children:n,errors:s,...c}=e,t[0]=e,t[1]=n,t[2]=r,t[3]=s,t[4]=c);let l;bb0:{if(n){l=n;break bb0}if(!s?.length){l=null;break bb0}let e;t[5]===s?e=t[6]:(e=[...new Map(s.map(m)).values()],t[5]=s,t[6]=e);let r=e;if(r?.length===1){l=r[0]?.message;break bb0}let i;t[7]===r?i=t[8]:(i=(0,o.jsx)(`ul`,{className:`ml-4 flex list-disc flex-col gap-1`,children:r.map(p)}),t[7]=r,t[8]=i),l=i}let u=l;if(!u)return null;let d;t[9]===r?d=t[10]:(d=i(`text-xs/relaxed font-normal text-destructive`,r),t[9]=r,t[10]=d);let f;return t[11]!==u||t[12]!==c||t[13]!==d?(f=(0,o.jsx)(`div`,{className:d,"data-slot":`field-error`,role:`alert`,...c,children:u}),t[11]=u,t[12]=c,t[13]=d,t[14]=f):f=t[14],f}function p(e){return e?.message&&(0,o.jsx)(`li`,{children:e.message},e.message)}function m(e){return[e?.message,e]}export{u as a,s as i,d as n,f as r,l as t};
\ No newline at end of file
diff --git a/spinifex/services/spinifexui/frontend/dist/assets/form-actions.js b/spinifex/services/spinifexui/frontend/dist/assets/form-actions.js
index 42245ee74..f2074c408 100644
--- a/spinifex/services/spinifexui/frontend/dist/assets/form-actions.js
+++ b/spinifex/services/spinifexui/frontend/dist/assets/form-actions.js
@@ -1 +1 @@
-import{Fo as e,Ho as t,Xa as n}from"./index.js";var r=e(),i=t();function a(e){let t=(0,r.c)(9),{isSubmitting:a,isPending:o,onCancel:s,submitLabel:c,pendingLabel:l}=e,u=a||o,d;t[0]!==u||t[1]!==s?(d=(0,i.jsx)(n,{disabled:u,onClick:s,type:`button`,variant:`outline`,children:`Cancel`}),t[0]=u,t[1]=s,t[2]=d):d=t[2];let f=u?l:c,p;t[3]!==u||t[4]!==f?(p=(0,i.jsx)(n,{disabled:u,type:`submit`,children:f}),t[3]=u,t[4]=f,t[5]=p):p=t[5];let m;return t[6]!==d||t[7]!==p?(m=(0,i.jsxs)(`div`,{className:`flex gap-2`,children:[d,p]}),t[6]=d,t[7]=p,t[8]=m):m=t[8],m}export{a as t};
\ No newline at end of file
+import{n as e,t}from"./compiler-runtime.js";import{t as n}from"./button.js";var r=t(),i=e();function a(e){let t=(0,r.c)(9),{isSubmitting:a,isPending:o,onCancel:s,submitLabel:c,pendingLabel:l}=e,u=a||o,d;t[0]!==u||t[1]!==s?(d=(0,i.jsx)(n,{disabled:u,onClick:s,type:`button`,variant:`outline`,children:`Cancel`}),t[0]=u,t[1]=s,t[2]=d):d=t[2];let f=u?l:c,p;t[3]!==u||t[4]!==f?(p=(0,i.jsx)(n,{disabled:u,type:`submit`,children:f}),t[3]=u,t[4]=f,t[5]=p):p=t[5];let m;return t[6]!==d||t[7]!==p?(m=(0,i.jsxs)(`div`,{className:`flex gap-2`,children:[d,p]}),t[6]=d,t[7]=p,t[8]=m):m=t[8],m}export{a as t};
\ No newline at end of file
diff --git a/spinifex/services/spinifexui/frontend/dist/assets/geist-cyrillic-ext-wght-normal.woff2 b/spinifex/services/spinifexui/frontend/dist/assets/geist-cyrillic-ext-wght-normal.woff2
new file mode 100644
index 000000000..4c462958a
Binary files /dev/null and b/spinifex/services/spinifexui/frontend/dist/assets/geist-cyrillic-ext-wght-normal.woff2 differ
diff --git a/spinifex/services/spinifexui/frontend/dist/assets/geist-cyrillic-wght-normal.woff2 b/spinifex/services/spinifexui/frontend/dist/assets/geist-cyrillic-wght-normal.woff2
index 2000e32fd..4b7a2bec6 100644
Binary files a/spinifex/services/spinifexui/frontend/dist/assets/geist-cyrillic-wght-normal.woff2 and b/spinifex/services/spinifexui/frontend/dist/assets/geist-cyrillic-wght-normal.woff2 differ
diff --git a/spinifex/services/spinifexui/frontend/dist/assets/geist-latin-ext-wght-normal.woff2 b/spinifex/services/spinifexui/frontend/dist/assets/geist-latin-ext-wght-normal.woff2
index 9608c47c4..ba90e2094 100644
Binary files a/spinifex/services/spinifexui/frontend/dist/assets/geist-latin-ext-wght-normal.woff2 and b/spinifex/services/spinifexui/frontend/dist/assets/geist-latin-ext-wght-normal.woff2 differ
diff --git a/spinifex/services/spinifexui/frontend/dist/assets/geist-latin-wght-normal.woff2 b/spinifex/services/spinifexui/frontend/dist/assets/geist-latin-wght-normal.woff2
index 93552f55a..991445d78 100644
Binary files a/spinifex/services/spinifexui/frontend/dist/assets/geist-latin-wght-normal.woff2 and b/spinifex/services/spinifexui/frontend/dist/assets/geist-latin-wght-normal.woff2 differ
diff --git a/spinifex/services/spinifexui/frontend/dist/assets/geist-vietnamese-wght-normal.woff2 b/spinifex/services/spinifexui/frontend/dist/assets/geist-vietnamese-wght-normal.woff2
new file mode 100644
index 000000000..ee965898b
Binary files /dev/null and b/spinifex/services/spinifexui/frontend/dist/assets/geist-vietnamese-wght-normal.woff2 differ
diff --git a/spinifex/services/spinifexui/frontend/dist/assets/iam.js b/spinifex/services/spinifexui/frontend/dist/assets/iam.js
index a5218c60c..19fc48f2e 100644
--- a/spinifex/services/spinifexui/frontend/dist/assets/iam.js
+++ b/spinifex/services/spinifexui/frontend/dist/assets/iam.js
@@ -1 +1 @@
-import{n as e}from"./error-banner.js";import{Bt as t,Fo as n,Gt as r,Ht as i,Kt as a,Rt as o,Ut as s,Vo as c,Vt as l,Wt as u,bt as d,or as f,qt as p,sr as m,zt as h}from"./index.js";var g=class extends f.classBuilder().ep(p).m(function(e,t,n,r){return[m(n,e.getEndpointParameterInstructions())]}).s(`AWSIdentityManagementV20100508`,`AttachUserPolicy`,{}).n(`IAMClient`,`AttachUserPolicyCommand`).sc(o).build(){},_=class extends f.classBuilder().ep(p).m(function(e,t,n,r){return[m(n,e.getEndpointParameterInstructions())]}).s(`AWSIdentityManagementV20100508`,`CreateAccessKey`,{}).n(`IAMClient`,`CreateAccessKeyCommand`).sc(h).build(){},v=class extends f.classBuilder().ep(p).m(function(e,t,n,r){return[m(n,e.getEndpointParameterInstructions())]}).s(`AWSIdentityManagementV20100508`,`CreatePolicy`,{}).n(`IAMClient`,`CreatePolicyCommand`).sc(t).build(){},y=class extends f.classBuilder().ep(p).m(function(e,t,n,r){return[m(n,e.getEndpointParameterInstructions())]}).s(`AWSIdentityManagementV20100508`,`CreateUser`,{}).n(`IAMClient`,`CreateUserCommand`).sc(l).build(){},b=class extends f.classBuilder().ep(p).m(function(e,t,n,r){return[m(n,e.getEndpointParameterInstructions())]}).s(`AWSIdentityManagementV20100508`,`DeleteAccessKey`,{}).n(`IAMClient`,`DeleteAccessKeyCommand`).sc(i).build(){},x=class extends f.classBuilder().ep(p).m(function(e,t,n,r){return[m(n,e.getEndpointParameterInstructions())]}).s(`AWSIdentityManagementV20100508`,`DeletePolicy`,{}).n(`IAMClient`,`DeletePolicyCommand`).sc(s).build(){},S=class extends f.classBuilder().ep(p).m(function(e,t,n,r){return[m(n,e.getEndpointParameterInstructions())]}).s(`AWSIdentityManagementV20100508`,`DeleteUser`,{}).n(`IAMClient`,`DeleteUserCommand`).sc(u).build(){},C=class extends f.classBuilder().ep(p).m(function(e,t,n,r){return[m(n,e.getEndpointParameterInstructions())]}).s(`AWSIdentityManagementV20100508`,`DetachUserPolicy`,{}).n(`IAMClient`,`DetachUserPolicyCommand`).sc(r).build(){},w=class extends f.classBuilder().ep(p).m(function(e,t,n,r){return[m(n,e.getEndpointParameterInstructions())]}).s(`AWSIdentityManagementV20100508`,`UpdateAccessKey`,{}).n(`IAMClient`,`UpdateAccessKeyCommand`).sc(a).build(){},T=n();function E(){let t=(0,T.c)(2),n=c(),r;return t[0]===n?r=t[1]:(r={mutationFn:D,onSuccess:()=>{n.invalidateQueries({queryKey:[`iam`,`users`]})}},t[0]=n,t[1]=r),e(r)}async function D(e){let t=new y({UserName:e.userName,Path:e.path||void 0});return await d().send(t)}function O(){let t=(0,T.c)(2),n=c(),r;return t[0]===n?r=t[1]:(r={mutationFn:k,onSuccess:()=>{n.invalidateQueries({queryKey:[`iam`,`users`]})}},t[0]=n,t[1]=r),e(r)}async function k(e){let t=new S({UserName:e});return await d().send(t)}function A(){let t=(0,T.c)(2),n=c(),r;return t[0]===n?r=t[1]:(r={mutationFn:j,onSuccess:()=>{n.invalidateQueries({queryKey:[`iam`,`access-keys`]})}},t[0]=n,t[1]=r),e(r)}async function j(e){let t=new _({UserName:e});return await d().send(t)}function M(){let t=(0,T.c)(2),n=c(),r;return t[0]===n?r=t[1]:(r={mutationFn:N,onSuccess:()=>{n.invalidateQueries({queryKey:[`iam`,`access-keys`]})}},t[0]=n,t[1]=r),e(r)}async function N(e){let{userName:t,accessKeyId:n}=e,r=new b({UserName:t,AccessKeyId:n});return await d().send(r)}function P(){let t=(0,T.c)(2),n=c(),r;return t[0]===n?r=t[1]:(r={mutationFn:F,onSuccess:()=>{n.invalidateQueries({queryKey:[`iam`,`access-keys`]})}},t[0]=n,t[1]=r),e(r)}async function F(e){let{userName:t,accessKeyId:n,status:r}=e,i=new w({UserName:t,AccessKeyId:n,Status:r});return await d().send(i)}function I(){let t=(0,T.c)(2),n=c(),r;return t[0]===n?r=t[1]:(r={mutationFn:L,onSuccess:()=>{n.invalidateQueries({queryKey:[`iam`,`policies`]})}},t[0]=n,t[1]=r),e(r)}async function L(e){let t=new v({PolicyName:e.policyName,Description:e.description||void 0,PolicyDocument:e.policyDocument});return await d().send(t)}function R(){let t=(0,T.c)(2),n=c(),r;return t[0]===n?r=t[1]:(r={mutationFn:z,onSuccess:()=>{n.invalidateQueries({queryKey:[`iam`,`policies`]})}},t[0]=n,t[1]=r),e(r)}async function z(e){let t=new x({PolicyArn:e});return await d().send(t)}function B(){let t=(0,T.c)(2),n=c(),r;return t[0]===n?r=t[1]:(r={mutationFn:V,onSuccess:()=>{n.invalidateQueries({queryKey:[`iam`,`attached-user-policies`]})}},t[0]=n,t[1]=r),e(r)}async function V(e){let{userName:t,policyArn:n}=e,r=new g({UserName:t,PolicyArn:n});return await d().send(r)}function H(){let t=(0,T.c)(2),n=c(),r;return t[0]===n?r=t[1]:(r={mutationFn:U,onSuccess:()=>{n.invalidateQueries({queryKey:[`iam`,`attached-user-policies`]})}},t[0]=n,t[1]=r),e(r)}async function U(e){let{userName:t,policyArn:n}=e,r=new C({UserName:t,PolicyArn:n});return await d().send(r)}export{M as a,H as c,E as i,P as l,A as n,R as o,I as r,O as s,B as t};
\ No newline at end of file
+import{t as e}from"./compiler-runtime.js";import{n as t}from"./error-banner.js";import{$ as n,A as r,Cr as i,D as a,E as o,F as s,I as c,L as l,M as ee,N as u,O as d,P as f,R as p,T as m,Tr as h,et as g,j as _,k as v,o as y,tt as b,z as x}from"./awsClient.js";import{Lr as S}from"./index.js";var C=class extends h.classBuilder().ep(b).m(function(e,t,n,r){return[i(n,e.getEndpointParameterInstructions())]}).s(`AWSIdentityManagementV20100508`,`AddRoleToInstanceProfile`,{}).n(`IAMClient`,`AddRoleToInstanceProfileCommand`).sc(m).build(){},w=class extends h.classBuilder().ep(b).m(function(e,t,n,r){return[i(n,e.getEndpointParameterInstructions())]}).s(`AWSIdentityManagementV20100508`,`AttachRolePolicy`,{}).n(`IAMClient`,`AttachRolePolicyCommand`).sc(o).build(){},T=class extends h.classBuilder().ep(b).m(function(e,t,n,r){return[i(n,e.getEndpointParameterInstructions())]}).s(`AWSIdentityManagementV20100508`,`AttachUserPolicy`,{}).n(`IAMClient`,`AttachUserPolicyCommand`).sc(a).build(){},E=class extends h.classBuilder().ep(b).m(function(e,t,n,r){return[i(n,e.getEndpointParameterInstructions())]}).s(`AWSIdentityManagementV20100508`,`CreateAccessKey`,{}).n(`IAMClient`,`CreateAccessKeyCommand`).sc(d).build(){},D=class extends h.classBuilder().ep(b).m(function(e,t,n,r){return[i(n,e.getEndpointParameterInstructions())]}).s(`AWSIdentityManagementV20100508`,`CreateInstanceProfile`,{}).n(`IAMClient`,`CreateInstanceProfileCommand`).sc(v).build(){},O=class extends h.classBuilder().ep(b).m(function(e,t,n,r){return[i(n,e.getEndpointParameterInstructions())]}).s(`AWSIdentityManagementV20100508`,`CreatePolicy`,{}).n(`IAMClient`,`CreatePolicyCommand`).sc(r).build(){},k=class extends h.classBuilder().ep(b).m(function(e,t,n,r){return[i(n,e.getEndpointParameterInstructions())]}).s(`AWSIdentityManagementV20100508`,`CreateRole`,{}).n(`IAMClient`,`CreateRoleCommand`).sc(_).build(){},A=class extends h.classBuilder().ep(b).m(function(e,t,n,r){return[i(n,e.getEndpointParameterInstructions())]}).s(`AWSIdentityManagementV20100508`,`CreateUser`,{}).n(`IAMClient`,`CreateUserCommand`).sc(ee).build(){},j=class extends h.classBuilder().ep(b).m(function(e,t,n,r){return[i(n,e.getEndpointParameterInstructions())]}).s(`AWSIdentityManagementV20100508`,`DeleteAccessKey`,{}).n(`IAMClient`,`DeleteAccessKeyCommand`).sc(u).build(){},M=class extends h.classBuilder().ep(b).m(function(e,t,n,r){return[i(n,e.getEndpointParameterInstructions())]}).s(`AWSIdentityManagementV20100508`,`DeleteInstanceProfile`,{}).n(`IAMClient`,`DeleteInstanceProfileCommand`).sc(f).build(){},N=class extends h.classBuilder().ep(b).m(function(e,t,n,r){return[i(n,e.getEndpointParameterInstructions())]}).s(`AWSIdentityManagementV20100508`,`DeletePolicy`,{}).n(`IAMClient`,`DeletePolicyCommand`).sc(s).build(){},P=class extends h.classBuilder().ep(b).m(function(e,t,n,r){return[i(n,e.getEndpointParameterInstructions())]}).s(`AWSIdentityManagementV20100508`,`DeleteRole`,{}).n(`IAMClient`,`DeleteRoleCommand`).sc(c).build(){},F=class extends h.classBuilder().ep(b).m(function(e,t,n,r){return[i(n,e.getEndpointParameterInstructions())]}).s(`AWSIdentityManagementV20100508`,`DeleteUser`,{}).n(`IAMClient`,`DeleteUserCommand`).sc(l).build(){},I=class extends h.classBuilder().ep(b).m(function(e,t,n,r){return[i(n,e.getEndpointParameterInstructions())]}).s(`AWSIdentityManagementV20100508`,`DetachRolePolicy`,{}).n(`IAMClient`,`DetachRolePolicyCommand`).sc(p).build(){},L=class extends h.classBuilder().ep(b).m(function(e,t,n,r){return[i(n,e.getEndpointParameterInstructions())]}).s(`AWSIdentityManagementV20100508`,`DetachUserPolicy`,{}).n(`IAMClient`,`DetachUserPolicyCommand`).sc(x).build(){},R=class extends h.classBuilder().ep(b).m(function(e,t,n,r){return[i(n,e.getEndpointParameterInstructions())]}).s(`AWSIdentityManagementV20100508`,`RemoveRoleFromInstanceProfile`,{}).n(`IAMClient`,`RemoveRoleFromInstanceProfileCommand`).sc(n).build(){},z=class extends h.classBuilder().ep(b).m(function(e,t,n,r){return[i(n,e.getEndpointParameterInstructions())]}).s(`AWSIdentityManagementV20100508`,`UpdateAccessKey`,{}).n(`IAMClient`,`UpdateAccessKeyCommand`).sc(g).build(){},B=e();function V(){let e=(0,B.c)(2),n=S(),r;return e[0]===n?r=e[1]:(r={mutationFn:H,onSuccess:()=>{n.invalidateQueries({queryKey:[`iam`,`users`]})}},e[0]=n,e[1]=r),t(r)}async function H(e){let t=new A({UserName:e.userName,Path:e.path||void 0});return await y().send(t)}function U(){let e=(0,B.c)(2),n=S(),r;return e[0]===n?r=e[1]:(r={mutationFn:W,onSuccess:()=>{n.invalidateQueries({queryKey:[`iam`,`users`]})}},e[0]=n,e[1]=r),t(r)}async function W(e){let t=new F({UserName:e});return await y().send(t)}function G(){let e=(0,B.c)(2),n=S(),r;return e[0]===n?r=e[1]:(r={mutationFn:te,onSuccess:()=>{n.invalidateQueries({queryKey:[`iam`,`access-keys`]})}},e[0]=n,e[1]=r),t(r)}async function te(e){let t=new E({UserName:e});return await y().send(t)}function K(){let e=(0,B.c)(2),n=S(),r;return e[0]===n?r=e[1]:(r={mutationFn:q,onSuccess:()=>{n.invalidateQueries({queryKey:[`iam`,`access-keys`]})}},e[0]=n,e[1]=r),t(r)}async function q(e){let{userName:t,accessKeyId:n}=e,r=new j({UserName:t,AccessKeyId:n});return await y().send(r)}function J(){let e=(0,B.c)(2),n=S(),r;return e[0]===n?r=e[1]:(r={mutationFn:Y,onSuccess:()=>{n.invalidateQueries({queryKey:[`iam`,`access-keys`]})}},e[0]=n,e[1]=r),t(r)}async function Y(e){let{userName:t,accessKeyId:n,status:r}=e,i=new z({UserName:t,AccessKeyId:n,Status:r});return await y().send(i)}function X(){let e=(0,B.c)(2),n=S(),r;return e[0]===n?r=e[1]:(r={mutationFn:Z,onSuccess:()=>{n.invalidateQueries({queryKey:[`iam`,`policies`]})}},e[0]=n,e[1]=r),t(r)}async function Z(e){let t=new O({PolicyName:e.policyName,Description:e.description||void 0,PolicyDocument:e.policyDocument});return await y().send(t)}function Q(){let e=(0,B.c)(2),n=S(),r;return e[0]===n?r=e[1]:(r={mutationFn:ne,onSuccess:()=>{n.invalidateQueries({queryKey:[`iam`,`policies`]})}},e[0]=n,e[1]=r),t(r)}async function ne(e){let t=new N({PolicyArn:e});return await y().send(t)}function re(){let e=(0,B.c)(2),n=S(),r;return e[0]===n?r=e[1]:(r={mutationFn:ie,onSuccess:()=>{n.invalidateQueries({queryKey:[`iam`,`attached-user-policies`]})}},e[0]=n,e[1]=r),t(r)}async function ie(e){let{userName:t,policyArn:n}=e,r=new T({UserName:t,PolicyArn:n});return await y().send(r)}function ae(){let e=(0,B.c)(2),n=S(),r;return e[0]===n?r=e[1]:(r={mutationFn:oe,onSuccess:()=>{n.invalidateQueries({queryKey:[`iam`,`attached-user-policies`]})}},e[0]=n,e[1]=r),t(r)}async function oe(e){let{userName:t,policyArn:n}=e,r=new L({UserName:t,PolicyArn:n});return await y().send(r)}function se(){let e=(0,B.c)(2),n=S(),r;return e[0]===n?r=e[1]:(r={mutationFn:ce,onSuccess:()=>{n.invalidateQueries({queryKey:[`iam`,`roles`]})}},e[0]=n,e[1]=r),t(r)}async function ce(e){let t=new k({RoleName:e.roleName,Path:e.path||void 0,Description:e.description||void 0,AssumeRolePolicyDocument:e.assumeRolePolicyDocument});return await y().send(t)}function le(){let e=(0,B.c)(2),n=S(),r;return e[0]===n?r=e[1]:(r={mutationFn:ue,onSuccess:()=>{n.invalidateQueries({queryKey:[`iam`,`roles`]})}},e[0]=n,e[1]=r),t(r)}async function ue(e){let t=new P({RoleName:e});return await y().send(t)}function de(){let e=(0,B.c)(2),n=S(),r;return e[0]===n?r=e[1]:(r={mutationFn:fe,onSuccess:()=>{n.invalidateQueries({queryKey:[`iam`,`attached-role-policies`]})}},e[0]=n,e[1]=r),t(r)}async function fe(e){let{roleName:t,policyArn:n}=e,r=new w({RoleName:t,PolicyArn:n});return await y().send(r)}function pe(){let e=(0,B.c)(2),n=S(),r;return e[0]===n?r=e[1]:(r={mutationFn:$,onSuccess:()=>{n.invalidateQueries({queryKey:[`iam`,`attached-role-policies`]})}},e[0]=n,e[1]=r),t(r)}async function $(e){let{roleName:t,policyArn:n}=e,r=new I({RoleName:t,PolicyArn:n});return await y().send(r)}function me(){let e=(0,B.c)(2),n=S(),r;return e[0]===n?r=e[1]:(r={mutationFn:he,onSuccess:()=>{n.invalidateQueries({queryKey:[`iam`,`instance-profiles`]})}},e[0]=n,e[1]=r),t(r)}async function he(e){let t=new D({InstanceProfileName:e.instanceProfileName,Path:e.path||void 0});return await y().send(t)}function ge(){let e=(0,B.c)(2),n=S(),r;return e[0]===n?r=e[1]:(r={mutationFn:_e,onSuccess:()=>{n.invalidateQueries({queryKey:[`iam`,`instance-profiles`]})}},e[0]=n,e[1]=r),t(r)}async function _e(e){let t=new M({InstanceProfileName:e});return await y().send(t)}function ve(){let e=(0,B.c)(2),n=S(),r;return e[0]===n?r=e[1]:(r={mutationFn:ye,onSuccess:()=>{n.invalidateQueries({queryKey:[`iam`,`instance-profiles`]})}},e[0]=n,e[1]=r),t(r)}async function ye(e){let{instanceProfileName:t,roleName:n}=e,r=new C({InstanceProfileName:t,RoleName:n});return await y().send(r)}function be(){let e=(0,B.c)(2),n=S(),r;return e[0]===n?r=e[1]:(r={mutationFn:xe,onSuccess:()=>{n.invalidateQueries({queryKey:[`iam`,`instance-profiles`]})}},e[0]=n,e[1]=r),t(r)}async function xe(e){let{instanceProfileName:t,roleName:n}=e,r=new R({InstanceProfileName:t,RoleName:n});return await y().send(r)}export{J as _,me as a,V as c,Q as d,le as f,be as g,ae as h,G as i,K as l,pe as m,de as n,X as o,U as p,re as r,se as s,ve as t,ge as u};
\ No newline at end of file
diff --git a/spinifex/services/spinifexui/frontend/dist/assets/iam2.js b/spinifex/services/spinifexui/frontend/dist/assets/iam2.js
index f8a12a2fd..7622be6d8 100644
--- a/spinifex/services/spinifexui/frontend/dist/assets/iam2.js
+++ b/spinifex/services/spinifexui/frontend/dist/assets/iam2.js
@@ -1 +1 @@
-import{Ur as e,Vr as t}from"./index.js";var n=t({userName:e().min(1,`User name is required`).max(64,`User name must be 64 characters or less`).regex(/^[\w+=,.@-]+$/,`User name contains invalid characters`),path:e().optional()}),r=t({policyName:e().min(1,`Policy name is required`).max(128,`Policy name must be 128 characters or less`).regex(/^[\w+=,.@-]+$/,`Policy name contains invalid characters`),description:e().optional(),policyDocument:e().min(1,`Policy document is required`).refine(e=>{try{return JSON.parse(e),!0}catch{return!1}},{message:`Policy document must be valid JSON`})});export{n,r as t};
\ No newline at end of file
+import{_ as e,y as t}from"./SignatureV4.js";var n=e({userName:t().min(1,`User name is required`).max(64,`User name must be 64 characters or less`).regex(/^[\w+=,.@-]+$/,`User name contains invalid characters`),path:t().optional()}),r=e({policyName:t().min(1,`Policy name is required`).max(128,`Policy name must be 128 characters or less`).regex(/^[\w+=,.@-]+$/,`Policy name contains invalid characters`),description:t().optional(),policyDocument:t().min(1,`Policy document is required`).refine(e=>{try{return JSON.parse(e),!0}catch{return!1}},{message:`Policy document must be valid JSON`})}),i=JSON.stringify({Version:`2012-10-17`,Statement:[{Effect:`Allow`,Principal:{Service:`ec2.amazonaws.com`},Action:`sts:AssumeRole`}]},null,2),a=e({roleName:t().min(1,`Role name is required`).max(64,`Role name must be 64 characters or less`).regex(/^[\w+=,.@-]+$/,`Role name contains invalid characters`),path:t().optional(),description:t().optional(),assumeRolePolicyDocument:t().min(1,`Trust policy is required`).refine(e=>{try{return JSON.parse(e),!0}catch{return!1}},{message:`Trust policy must be valid JSON`})}),o=e({instanceProfileName:t().min(1,`Instance profile name is required`).max(128,`Instance profile name must be 128 characters or less`).regex(/^[\w+=,.@-]+$/,`Instance profile name contains invalid characters`),path:t().optional()});export{n as a,a as i,o as n,r,i as t};
\ No newline at end of file
diff --git a/spinifex/services/spinifexui/frontend/dist/assets/import-key-pair.js b/spinifex/services/spinifexui/frontend/dist/assets/import-key-pair.js
index 62364d25e..062c871d6 100644
--- a/spinifex/services/spinifexui/frontend/dist/assets/import-key-pair.js
+++ b/spinifex/services/spinifexui/frontend/dist/assets/import-key-pair.js
@@ -1,3 +1,3 @@
-import{t as e}from"./error-banner.js";import{t}from"./back-link.js";import{t as n}from"./cli-command-panel.js";import{z as r}from"./ec2.js";import{Fo as i,Ho as a,Ro as o,Xi as s}from"./index.js";import{i as c,t as l}from"./zod.js";import{t as u}from"./form-actions.js";import{t as d}from"./page-heading.js";import{a as f,r as p,t as m}from"./field.js";import{g as h}from"./ec22.js";import{t as g}from"./textarea.js";var _=i(),v=a();function y(){let i=(0,_.c)(53),a=o(),y=r(),x;i[0]===Symbol.for(`react.memo_cache_sentinel`)?(x={resolver:l(h)},i[0]=x):x=i[0];let{handleSubmit:S,register:C,watch:w,formState:T}=c(x),{errors:E,isSubmitting:D}=T,O;i[1]!==y||i[2]!==a?(O=async e=>{await y.mutateAsync(e),a({to:`/ec2/describe-key-pairs`})},i[1]=y,i[2]=a,i[3]=O):O=i[3];let k=O,A,j;i[4]===Symbol.for(`react.memo_cache_sentinel`)?(A=(0,v.jsx)(t,{to:`/ec2/describe-key-pairs`,children:`Back to key pairs`}),j=(0,v.jsx)(d,{title:`Import Key Pair`}),i[4]=A,i[5]=j):(A=i[4],j=i[5]);let M;i[6]===y.error?M=i[7]:(M=y.error&&(0,v.jsx)(e,{error:y.error,msg:`Failed to import key pair`}),i[6]=y.error,i[7]=M);let N;i[8]!==S||i[9]!==k?(N=S(k),i[8]=S,i[9]=k,i[10]=N):N=i[10];let P;i[11]===Symbol.for(`react.memo_cache_sentinel`)?(P=(0,v.jsx)(f,{children:(0,v.jsx)(`label`,{htmlFor:`keyName`,children:`Key Pair Name`})}),i[11]=P):P=i[11];let F=!!E.keyName,I;i[12]===C?I=i[13]:(I=C(`keyName`),i[12]=C,i[13]=I);let L;i[14]!==F||i[15]!==I?(L=(0,v.jsx)(s,{"aria-invalid":F,id:`keyName`,placeholder:`my-key-pair…`,...I}),i[14]=F,i[15]=I,i[16]=L):L=i[16];let R;i[17]===E.keyName?R=i[18]:(R=(0,v.jsx)(p,{errors:[E.keyName]}),i[17]=E.keyName,i[18]=R);let z;i[19]!==L||i[20]!==R?(z=(0,v.jsxs)(m,{children:[P,L,R]}),i[19]=L,i[20]=R,i[21]=z):z=i[21];let B,V;i[22]===Symbol.for(`react.memo_cache_sentinel`)?(B=(0,v.jsx)(f,{children:(0,v.jsx)(`label`,{htmlFor:`publicKeyMaterial`,children:`Public Key`})}),V=(0,v.jsx)(`p`,{className:`text-xs text-muted-foreground`,id:`publicKey-description`,children:`Paste your OpenSSH public key (e.g. the contents of id_rsa.pub)`}),i[22]=B,i[23]=V):(B=i[22],V=i[23]);let H=!!E.publicKeyMaterial,U;i[24]===C?U=i[25]:(U=C(`publicKeyMaterial`),i[24]=C,i[25]=U);let W;i[26]!==H||i[27]!==U?(W=(0,v.jsx)(g,{"aria-invalid":H,id:`publicKeyMaterial`,placeholder:`ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQC…`,rows:8,...U}),i[26]=H,i[27]=U,i[28]=W):W=i[28];let G;i[29]===E.publicKeyMaterial?G=i[30]:(G=(0,v.jsx)(p,{errors:[E.publicKeyMaterial]}),i[29]=E.publicKeyMaterial,i[30]=G);let K;i[31]!==W||i[32]!==G?(K=(0,v.jsxs)(m,{children:[B,V,W,G]}),i[31]=W,i[32]=G,i[33]=K):K=i[33];let q;i[34]===w?q=i[35]:(q=b(w),i[34]=w,i[35]=q);let J;i[36]===q?J=i[37]:(J=(0,v.jsx)(n,{commands:q}),i[36]=q,i[37]=J);let Y;i[38]===a?Y=i[39]:(Y=async()=>await a({to:`/ec2/describe-key-pairs`}),i[38]=a,i[39]=Y);let X;i[40]!==y.isPending||i[41]!==D||i[42]!==Y?(X=(0,v.jsx)(u,{isPending:y.isPending,isSubmitting:D,onCancel:Y,pendingLabel:`Importing…`,submitLabel:`Import Key Pair`}),i[40]=y.isPending,i[41]=D,i[42]=Y,i[43]=X):X=i[43];let Z;i[44]!==z||i[45]!==K||i[46]!==J||i[47]!==X||i[48]!==N?(Z=(0,v.jsxs)(`form`,{className:`max-w-4xl space-y-6`,onSubmit:N,children:[z,K,J,X]}),i[44]=z,i[45]=K,i[46]=J,i[47]=X,i[48]=N,i[49]=Z):Z=i[49];let Q;return i[50]!==Z||i[51]!==M?(Q=(0,v.jsxs)(v.Fragment,{children:[A,j,M,Z]}),i[50]=Z,i[51]=M,i[52]=Q):Q=i[52],Q}function b(e){let t=e(`keyName`);return[{label:`Import Key Pair`,parts:[{type:`bin`,value:`AWS_PROFILE=spinifex aws ec2 import-key-pair`},{type:`flag`,value:` \\
+import{n as e,t}from"./compiler-runtime.js";import{t as n}from"./error-banner.js";import{t as r}from"./back-link.js";import{t as ee}from"./cli-command-panel.js";import{V as i}from"./ec2.js";import{Ir as te,Rn as a}from"./index.js";import{a as o,i as s,t as ne}from"./zod.js";import{t as re}from"./form-actions.js";import{t as c}from"./page-heading.js";import{a as l,r as u,t as d}from"./field.js";import{g as f}from"./ec22.js";import{t as p}from"./textarea.js";var m=t(),h=e();function g(){let e=(0,m.c)(57),t=te(),g=i(),v;e[0]===Symbol.for(`react.memo_cache_sentinel`)?(v={resolver:ne(f)},e[0]=v):v=e[0];let{handleSubmit:y,register:b,control:x,formState:S}=s(v),{errors:C,isSubmitting:w}=S,T;e[1]===x?T=e[2]:(T={control:x},e[1]=x,e[2]=T);let E=o(T),D;e[3]===E?D=e[4]:(D=e=>e?E[e]:void 0,e[3]=E,e[4]=D);let O=D,k;e[5]!==g||e[6]!==t?(k=async e=>{await g.mutateAsync(e),t({to:`/ec2/describe-key-pairs`})},e[5]=g,e[6]=t,e[7]=k):k=e[7];let A=k,j,M;e[8]===Symbol.for(`react.memo_cache_sentinel`)?(j=(0,h.jsx)(r,{to:`/ec2/describe-key-pairs`,children:`Back to key pairs`}),M=(0,h.jsx)(c,{title:`Import Key Pair`}),e[8]=j,e[9]=M):(j=e[8],M=e[9]);let N;e[10]===g.error?N=e[11]:(N=g.error&&(0,h.jsx)(n,{error:g.error,msg:`Failed to import key pair`}),e[10]=g.error,e[11]=N);let P;e[12]!==y||e[13]!==A?(P=y(A),e[12]=y,e[13]=A,e[14]=P):P=e[14];let F;e[15]===Symbol.for(`react.memo_cache_sentinel`)?(F=(0,h.jsx)(l,{children:(0,h.jsx)(`label`,{htmlFor:`keyName`,children:`Key Pair Name`})}),e[15]=F):F=e[15];let I=!!C.keyName,L;e[16]===b?L=e[17]:(L=b(`keyName`),e[16]=b,e[17]=L);let R;e[18]!==I||e[19]!==L?(R=(0,h.jsx)(a,{"aria-invalid":I,id:`keyName`,placeholder:`my-key-pair…`,...L}),e[18]=I,e[19]=L,e[20]=R):R=e[20];let z;e[21]===C.keyName?z=e[22]:(z=(0,h.jsx)(u,{errors:[C.keyName]}),e[21]=C.keyName,e[22]=z);let B;e[23]!==R||e[24]!==z?(B=(0,h.jsxs)(d,{children:[F,R,z]}),e[23]=R,e[24]=z,e[25]=B):B=e[25];let V,H;e[26]===Symbol.for(`react.memo_cache_sentinel`)?(V=(0,h.jsx)(l,{children:(0,h.jsx)(`label`,{htmlFor:`publicKeyMaterial`,children:`Public Key`})}),H=(0,h.jsx)(`p`,{className:`text-xs text-muted-foreground`,id:`publicKey-description`,children:`Paste your OpenSSH public key (e.g. the contents of id_rsa.pub)`}),e[26]=V,e[27]=H):(V=e[26],H=e[27]);let U=!!C.publicKeyMaterial,W;e[28]===b?W=e[29]:(W=b(`publicKeyMaterial`),e[28]=b,e[29]=W);let G;e[30]!==U||e[31]!==W?(G=(0,h.jsx)(p,{"aria-invalid":U,id:`publicKeyMaterial`,placeholder:`ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQC…`,rows:8,...W}),e[30]=U,e[31]=W,e[32]=G):G=e[32];let K;e[33]===C.publicKeyMaterial?K=e[34]:(K=(0,h.jsx)(u,{errors:[C.publicKeyMaterial]}),e[33]=C.publicKeyMaterial,e[34]=K);let q;e[35]!==G||e[36]!==K?(q=(0,h.jsxs)(d,{children:[V,H,G,K]}),e[35]=G,e[36]=K,e[37]=q):q=e[37];let J;e[38]===O?J=e[39]:(J=_(O),e[38]=O,e[39]=J);let Y;e[40]===J?Y=e[41]:(Y=(0,h.jsx)(ee,{commands:J}),e[40]=J,e[41]=Y);let X;e[42]===t?X=e[43]:(X=async()=>await t({to:`/ec2/describe-key-pairs`}),e[42]=t,e[43]=X);let Z;e[44]!==g.isPending||e[45]!==w||e[46]!==X?(Z=(0,h.jsx)(re,{isPending:g.isPending,isSubmitting:w,onCancel:X,pendingLabel:`Importing…`,submitLabel:`Import Key Pair`}),e[44]=g.isPending,e[45]=w,e[46]=X,e[47]=Z):Z=e[47];let Q;e[48]!==B||e[49]!==q||e[50]!==Y||e[51]!==Z||e[52]!==P?(Q=(0,h.jsxs)(`form`,{className:`max-w-4xl space-y-6`,onSubmit:P,children:[B,q,Y,Z]}),e[48]=B,e[49]=q,e[50]=Y,e[51]=Z,e[52]=P,e[53]=Q):Q=e[53];let $;return e[54]!==Q||e[55]!==N?($=(0,h.jsxs)(h.Fragment,{children:[j,M,N,Q]}),e[54]=Q,e[55]=N,e[56]=$):$=e[56],$}function _(e){let t=e(`keyName`);return[{label:`Import Key Pair`,parts:[{type:`bin`,value:`AWS_PROFILE=spinifex aws ec2 import-key-pair`},{type:`flag`,value:` \\
--key-name`},{type:`value`,value:` ${(typeof t==`string`?t:``)||``}`},{type:`flag`,value:` \\
- --public-key-material`},{type:`value`,value:` fileb://path/to/key.pub`}]}]}export{y as component};
\ No newline at end of file
+ --public-key-material`},{type:`value`,value:` fileb://path/to/key.pub`}]}]}export{g as component};
\ No newline at end of file
diff --git a/spinifex/services/spinifexui/frontend/dist/assets/index.browser.js b/spinifex/services/spinifexui/frontend/dist/assets/index.browser.js
new file mode 100644
index 000000000..0d31b7af8
--- /dev/null
+++ b/spinifex/services/spinifexui/frontend/dist/assets/index.browser.js
@@ -0,0 +1,6 @@
+var e=e=>new TextEncoder().encode(e),t=typeof Buffer<`u`&&Buffer.from?function(e){return Buffer.from(e,`utf8`)}:e;function n(e){return e instanceof Uint8Array?e:typeof e==`string`?t(e):ArrayBuffer.isView(e)?new Uint8Array(e.buffer,e.byteOffset,e.byteLength/Uint8Array.BYTES_PER_ELEMENT):new Uint8Array(e)}function r(e){return typeof e==`string`?e.length===0:e.byteLength===0}function i(e){return new Uint8Array([(e&4278190080)>>24,(e&16711680)>>16,(e&65280)>>8,e&255])}function a(e){if(!Uint32Array.from){for(var t=new Uint32Array(e.length),n=0;n0&&a[a.length-1]))&&(s[0]===6||s[0]===2)){n=0;continue}if(s[0]===3&&(!a||s[1]>a[0]&&s[1]=e.length&&(e=void 0),{value:e&&e[r++],done:!e}}};throw TypeError(t?`Object is not iterable.`:`Symbol.iterator is not defined.`)}var l=`ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/`,u=Object.entries(l).reduce((e,[t,n])=>(e[n]=Number(t),e),{}),d=l.split(``),f=e=>{let t=e.length/4*3;e.slice(-2)===`==`?t-=2:e.slice(-1)===`=`&&t--;let n=new ArrayBuffer(t),r=new DataView(n);for(let t=0;t>=6;let a=t/4*3;n>>=i%8;let o=Math.floor(i/8);for(let e=0;e>t)}}return new Uint8Array(n)},p=e=>new TextEncoder().encode(e);function m(e){let t;t=typeof e==`string`?p(e):e;let n=typeof t==`object`&&typeof t.length==`number`,r=typeof t==`object`&&typeof t.byteOffset==`number`&&typeof t.byteLength==`number`;if(!n&&!r)throw Error(`@smithy/util-base64: toBase64 encoder function only accepts string | Uint8Array.`);let i=``;for(let e=0;e>t]}i+=`==`.slice(0,4-a)}return i}function ee(e,t,n,r){return class i extends Uint8Array{static fromString(e,n=`utf-8`){if(typeof e==`string`)return n===`base64`?i.mutate(r(e)):i.mutate(t(e));throw Error(`Unsupported conversion from ${typeof e} to Uint8ArrayBlobAdapter.`)}static mutate(e){return Object.setPrototypeOf(e,i.prototype),e}transformToString(t=`utf-8`){return t===`base64`?n(this):e(this)}}}var h=e=>{if(typeof e==`string`)return e;if(typeof e!=`object`||typeof e.byteOffset!=`number`||typeof e.byteLength!=`number`)throw Error(`@smithy/util-utf8: toUtf8 encoder function only accepts string | Uint8Array.`);return new TextDecoder(`utf-8`).decode(e)},g=Array.from({length:256},(e,t)=>t.toString(16).padStart(2,`0`));function _(e){return typeof crypto<`u`&&typeof crypto.randomUUID==`function`?()=>crypto.randomUUID():()=>{let t=new Uint8Array(16);return e(t),t[6]=t[6]&15|64,t[8]=t[8]&63|128,g[t[0]]+g[t[1]]+g[t[2]]+g[t[3]]+`-`+g[t[4]]+g[t[5]]+`-`+g[t[6]]+g[t[7]]+`-`+g[t[8]]+g[t[9]]+`-`+g[t[10]]+g[t[11]]+g[t[12]]+g[t[13]]+g[t[14]]+g[t[15]]}}var v=e=>{if(e!=null){if(typeof e==`string`){let t=parseFloat(e);if(!Number.isNaN(t))return String(t)!==String(e)&&de.warn(ue(`Expected number but observed string: ${e}`)),t}if(typeof e==`number`)return e;throw TypeError(`Expected number, got ${typeof e}: ${e}`)}},te=Math.ceil(2**127*(2-2**-23)),ne=e=>{let t=v(e);if(t!==void 0&&!Number.isNaN(t)&&t!==1/0&&t!==-1/0&&Math.abs(t)>te)throw TypeError(`Expected 32-bit float, got ${e}`);return t},re=e=>{if(e!=null){if(Number.isInteger(e)&&!Number.isNaN(e))return e;throw TypeError(`Expected integer, got ${typeof e}: ${e}`)}},ie=e=>y(e,16),ae=e=>y(e,8),y=(e,t)=>{let n=re(e);if(n!==void 0&&oe(n,t)!==n)throw TypeError(`Expected ${t}-bit integer, got ${e}`);return n},oe=(e,t)=>{switch(t){case 32:return Int32Array.of(e)[0];case 16:return Int16Array.of(e)[0];case 8:return Int8Array.of(e)[0]}},se=e=>v(typeof e==`string`?x(e):e),ce=e=>ne(typeof e==`string`?x(e):e),b=/(-?(?:0|[1-9]\d*)(?:\.\d+)?(?:[eE][+-]?\d+)?)|(-?Infinity)|(NaN)/g,x=e=>{let t=e.match(b);if(t===null||t[0].length!==e.length)throw TypeError(`Expected real number, got implicit NaN`);return parseFloat(e)},S=e=>ie(typeof e==`string`?x(e):e),le=e=>ae(typeof e==`string`?x(e):e),ue=e=>String(TypeError(e).stack||e).split(`
+`).slice(0,5).filter(e=>!e.includes(`stackTraceWarning`)).join(`
+`),de={warn:console.warn},fe=[`Sun`,`Mon`,`Tue`,`Wed`,`Thu`,`Fri`,`Sat`],C=[`Jan`,`Feb`,`Mar`,`Apr`,`May`,`Jun`,`Jul`,`Aug`,`Sep`,`Oct`,`Nov`,`Dec`];function pe(e){let t=e.getUTCFullYear(),n=e.getUTCMonth(),r=e.getUTCDay(),i=e.getUTCDate(),a=e.getUTCHours(),o=e.getUTCMinutes(),s=e.getUTCSeconds(),c=i<10?`0${i}`:`${i}`,l=a<10?`0${a}`:`${a}`,u=o<10?`0${o}`:`${o}`,d=s<10?`0${s}`:`${s}`;return`${fe[r]}, ${c} ${C[n]} ${t} ${l}:${u}:${d} GMT`}var me=new RegExp(/^(\d{4})-(\d{2})-(\d{2})[tT](\d{2}):(\d{2}):(\d{2})(?:\.(\d+))?(([-+]\d{2}\:\d{2})|[zZ])$/),he=e=>{if(e==null)return;if(typeof e!=`string`)throw TypeError(`RFC-3339 date-times must be expressed as strings`);let t=me.exec(e);if(!t)throw TypeError(`Invalid RFC-3339 date-time value`);let[n,r,i,a,o,s,c,l,u]=t,d=w(S(P(r)),M(i,`month`,1,12),M(a,`day`,1,31),{hours:o,minutes:s,seconds:c,fractionalMilliseconds:l});return u.toUpperCase()!=`Z`&&d.setTime(d.getTime()-xe(u)),d},ge=new RegExp(/^(?:Mon|Tue|Wed|Thu|Fri|Sat|Sun), (\d{2}) (Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec) (\d{4}) (\d{1,2}):(\d{2}):(\d{2})(?:\.(\d+))? GMT$/),_e=new RegExp(/^(?:Monday|Tuesday|Wednesday|Thursday|Friday|Saturday|Sunday), (\d{2})-(Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)-(\d{2}) (\d{1,2}):(\d{2}):(\d{2})(?:\.(\d+))? GMT$/),ve=new RegExp(/^(?:Mon|Tue|Wed|Thu|Fri|Sat|Sun) (Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec) ( [1-9]|\d{2}) (\d{1,2}):(\d{2}):(\d{2})(?:\.(\d+))? (\d{4})$/),ye=e=>{if(e==null)return;if(typeof e!=`string`)throw TypeError(`RFC-7231 date-times must be expressed as strings`);let t=ge.exec(e);if(t){let[e,n,r,i,a,o,s,c]=t;return w(S(P(i)),O(r),M(n,`day`,1,31),{hours:a,minutes:o,seconds:s,fractionalMilliseconds:c})}if(t=_e.exec(e),t){let[e,n,r,i,a,o,s,c]=t;return D(w(T(i),O(r),M(n,`day`,1,31),{hours:a,minutes:o,seconds:s,fractionalMilliseconds:c}))}if(t=ve.exec(e),t){let[e,n,r,i,a,o,s,c]=t;return w(S(P(c)),O(n),M(r.trimLeft(),`day`,1,31),{hours:i,minutes:a,seconds:o,fractionalMilliseconds:s})}throw TypeError(`Invalid RFC-7231 date-time value`)},be=e=>{if(e==null)return;let t;if(typeof e==`number`)t=e;else if(typeof e==`string`)t=se(e);else if(typeof e==`object`&&e.tag===1)t=e.value;else throw TypeError(`Epoch timestamps must be expressed as floating point numbers or their string representation`);if(Number.isNaN(t)||t===1/0||t===-1/0)throw TypeError(`Epoch timestamps must be valid, non-Infinite, non-NaN numerics`);return new Date(Math.round(t*1e3))},w=(e,t,n,r)=>{let i=t-1;return A(e,i,n),new Date(Date.UTC(e,i,n,M(r.hours,`hour`,0,23),M(r.minutes,`minute`,0,59),M(r.seconds,`seconds`,0,60),N(r.fractionalMilliseconds)))},T=e=>{let t=new Date().getUTCFullYear(),n=Math.floor(t/100)*100+S(P(e));return ne.getTime()-new Date().getTime()>E?new Date(Date.UTC(e.getUTCFullYear()-100,e.getUTCMonth(),e.getUTCDate(),e.getUTCHours(),e.getUTCMinutes(),e.getUTCSeconds(),e.getUTCMilliseconds())):e,O=e=>{let t=C.indexOf(e);if(t<0)throw TypeError(`Invalid month: ${e}`);return t+1},k=[31,28,31,30,31,30,31,31,30,31,30,31],A=(e,t,n)=>{let r=k[t];if(t===1&&j(e)&&(r=29),n>r)throw TypeError(`Invalid day for ${C[t]} in ${e}: ${n}`)},j=e=>e%4==0&&(e%100!=0||e%400==0),M=(e,t,n,r)=>{let i=le(P(e));if(ir)throw TypeError(`${t} must be between ${n} and ${r}, inclusive`);return i},N=e=>e==null?0:ce(`0.`+e)*1e3,xe=e=>{let t=e[0],n=1;if(t==`+`)n=1;else if(t==`-`)n=-1;else throw TypeError(`Offset direction, ${t}, must be "+" or "-"`);let r=Number(e.substring(1,3)),i=Number(e.substring(4,6));return n*(r*60+i)*60*1e3},P=e=>{let t=0;for(;te&&typeof e==`object`&&(e instanceof F||`deserializeJSON`in e)?e:typeof e==`string`||Object.getPrototypeOf(e)===String.prototype?F(String(e)):F(JSON.stringify(e)),F.fromObject=F.from;function Se(e){return(e.includes(`,`)||e.includes(`"`))&&(e=`"${e.replace(/"/g,`\\"`)}"`),e}var I=`(?:Mon|Tue|Wed|Thu|Fri|Sat|Sun)(?:[ne|u?r]?s?day)?`,L=`(Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)`,R=`(\\d?\\d):(\\d{2}):(\\d{2})(?:\\.(\\d+))?`,z=`(\\d?\\d)`,B=`(\\d{4})`,Ce=new RegExp(/^(\d{4})-(\d\d)-(\d\d)[tT](\d\d):(\d\d):(\d\d)(\.(\d+))?(([-+]\d\d:\d\d)|[zZ])$/),we=RegExp(`^${I}, ${z} ${L} ${B} ${R} GMT$`),Te=RegExp(`^${I}, ${z}-${L}-(\\d\\d) ${R} GMT$`),Ee=RegExp(`^${I} ${L} ( [1-9]|\\d\\d) ${R} ${B}$`),V=[`Jan`,`Feb`,`Mar`,`Apr`,`May`,`Jun`,`Jul`,`Aug`,`Sep`,`Oct`,`Nov`,`Dec`],De=e=>{if(e==null)return;let t=NaN;if(typeof e==`number`)t=e;else if(typeof e==`string`){if(!/^-?\d*\.?\d+$/.test(e))throw TypeError(`parseEpochTimestamp - numeric string invalid.`);t=Number.parseFloat(e)}else typeof e==`object`&&e.tag===1&&(t=e.value);if(isNaN(t)||Math.abs(t)===1/0)throw TypeError(`Epoch timestamps must be valid finite numbers.`);return new Date(Math.round(t*1e3))},Oe=e=>{if(e==null)return;if(typeof e!=`string`)throw TypeError(`RFC3339 timestamps must be strings`);let t=Ce.exec(e);if(!t)throw TypeError(`Invalid RFC3339 timestamp format ${e}`);let[,n,r,i,a,o,s,,c,l]=t;H(r,1,12),H(i,1,31),H(a,0,23),H(o,0,59),H(s,0,60);let u=new Date(Date.UTC(Number(n),Number(r)-1,Number(i),Number(a),Number(o),Number(s),Number(c)?Math.round(parseFloat(`0.${c}`)*1e3):0));if(u.setUTCFullYear(Number(n)),l.toUpperCase()!=`Z`){let[,e,t,n]=/([+-])(\d\d):(\d\d)/.exec(l)||[void 0,`+`,0,0],r=e===`-`?1:-1;u.setTime(u.getTime()+r*(Number(t)*60*60*1e3+Number(n)*60*1e3))}return u},ke=e=>{if(e==null)return;if(typeof e!=`string`)throw TypeError(`RFC7231 timestamps must be strings.`);let t,n,r,i,a,o,s,c;if((c=we.exec(e))?[,t,n,r,i,a,o,s]=c:(c=Te.exec(e))?([,t,n,r,i,a,o,s]=c,r=(Number(r)+1900).toString()):(c=Ee.exec(e))&&([,n,t,i,a,o,s,r]=c),r&&o){let e=Date.UTC(Number(r),V.indexOf(n),Number(t),Number(i),Number(a),Number(o),s?Math.round(parseFloat(`0.${s}`)*1e3):0);H(t,1,31),H(i,0,23),H(a,0,59),H(o,0,60);let c=new Date(e);return c.setUTCFullYear(Number(r)),c}throw TypeError(`Invalid RFC7231 date-time value ${e}.`)};function H(e,t,n){let r=Number(e);if(rn)throw Error(`Value ${r} out of range [${t}, ${n}]`)}function Ae(e,t,n){if(n<=0||!Number.isInteger(n))throw Error(`Invalid number of delimiters (`+n+`) for splitEvery.`);let r=e.split(t);if(n===1)return r;let i=[],a=``;for(let e=0;e{let t=e.length,n=[],r=!1,i,a=0;for(let o=0;o{e=e.trim();let t=e.length;return t<2?e:(e[0]===`"`&&e[t-1]===`"`&&(e=e.slice(1,t-1)),e.replace(/\\"/g,`"`))})},U=/^-?\d*(\.\d+)?$/,Me=class e{string;type;constructor(e,t){if(this.string=e,this.type=t,!U.test(e))throw Error(`@smithy/core/serde - NumericValue must only contain [0-9], at most one decimal point ".", and an optional negation prefix "-".`)}toString(){return this.string}static[Symbol.hasInstance](t){if(!t||typeof t!=`object`)return!1;let n=t;return e.prototype.isPrototypeOf(t)||n.type===`bigDecimal`&&U.test(n.string)}},W={},G={};for(let e=0;e<256;e++){let t=e.toString(16).toLowerCase();t.length===1&&(t=`0${t}`),W[e]=t,G[t]=e}function Ne(e){if(e.length%2!=0)throw Error(`Hex encoded strings must have an even number length`);let t=new Uint8Array(e.length/2);for(let n=0;n{if(typeof e==`string`){if(q)return q.encode(e).byteLength;let t=e.length;for(let n=t-1;n>=0;n--){let r=e.charCodeAt(n);r>127&&r<=2047?t++:r>2047&&r<=65535&&(t+=2),r>=56320&&r<=57343&&n--}return t}else if(typeof e.byteLength==`number`)return e.byteLength;else if(typeof e.size==`number`)return e.size;throw Error(`Body Length computation failed for ${e}`)},Fe=e=>typeof e==`string`?p(e):ArrayBuffer.isView(e)?new Uint8Array(e.buffer,e.byteOffset,e.byteLength/Uint8Array.BYTES_PER_ELEMENT):new Uint8Array(e),Ie=e=>typeof ArrayBuffer==`function`&&e instanceof ArrayBuffer||Object.prototype.toString.call(e)===`[object ArrayBuffer]`,Le=typeof ReadableStream==`function`?ReadableStream:function(){},Re=class extends Le{},J=e=>typeof ReadableStream==`function`&&(e?.constructor?.name===ReadableStream.name||e instanceof ReadableStream),ze=({expectedChecksum:e,checksum:t,source:n,checksumSourceLocation:r,base64Encoder:i})=>{if(!J(n))throw Error(`@smithy/util-stream: unsupported source type ${n?.constructor?.name??n} in ChecksumStream.`);let a=i??m;if(typeof TransformStream!=`function`)throw Error(`@smithy/util-stream: unable to instantiate ChecksumStream because API unavailable: ReadableStream/TransformStream.`);let o=new TransformStream({start(){},async transform(e,n){t.update(e),n.enqueue(e)},async flush(n){let i=a(await t.digest());if(e!==i){let t=Error(`Checksum mismatch: expected "${e}" but received "${i}" in response header "${r}".`);n.error(t)}else n.terminate()}});n.pipeThrough(o);let s=o.readable;return Object.setPrototypeOf(s,Re.prototype),s},Be=class{allocByteArray;byteLength=0;byteArrays=[];constructor(e){this.allocByteArray=e}push(e){this.byteArrays.push(e),this.byteLength+=e.byteLength}flush(){if(this.byteArrays.length===1){let e=this.byteArrays[0];return this.reset(),e}let e=this.allocByteArray(this.byteLength),t=0;for(let n=0;nnew Uint8Array(e))],s=-1,c=async e=>{let{value:l,done:u}=await r.read(),d=l;if(u){if(s!==-1){let t=Y(o,s);X(t)>0&&e.enqueue(t)}e.close()}else{let r=We(d,!1);if(s!==r&&(s>=0&&e.enqueue(Y(o,s)),s=r),s===-1){e.enqueue(d);return}let l=X(d);a+=l;let u=X(o[s]);if(l>=t&&u===0)e.enqueue(d);else{let r=Ue(o,s,d);!i&&a>t*2&&(i=!0,n?.warn(`@smithy/util-stream - stream chunk size ${l} is below threshold of ${t}, automatically buffering.`)),r>=t?e.enqueue(Y(o,s)):await c(e)}}};return new ReadableStream({pull:c})}var He=Ve;function Ue(e,t,n){switch(t){case 0:return e[0]+=n,X(e[0]);case 1:case 2:return e[t].push(n),X(e[t])}}function Y(e,t){switch(t){case 0:let n=e[0];return e[0]=``,n;case 1:case 2:return e[t].flush()}throw Error(`@smithy/util-stream - invalid index ${t} given to flush()`)}function X(e){return e?.byteLength??e?.length??0}function We(e,t=!0){return t&&typeof Buffer<`u`&&e instanceof Buffer?2:e instanceof Uint8Array?1:typeof e==`string`?0:-1}var Ge=(e,t)=>{let{base64Encoder:n,bodyLengthChecker:r,checksumAlgorithmFn:i,checksumLocationName:a,streamHasher:o}=t,s=n!==void 0&&r!==void 0&&i!==void 0&&a!==void 0&&o!==void 0,c=s?o(i,e):void 0,l=e.getReader();return new ReadableStream({async pull(e){let{value:t,done:i}=await l.read();if(i){if(e.enqueue(`0\r
+`),s){let t=n(await c);e.enqueue(`${a}:${t}\r\n`),e.enqueue(`\r
+`)}e.close()}else e.enqueue(`${(r(t)||0).toString(16)}\r\n${t}\r\n`)}})},Ke=async e=>typeof Blob==`function`&&e instanceof Blob||e.constructor?.name===`Blob`?Blob.prototype.arrayBuffer===void 0?qe(e):new Uint8Array(await e.arrayBuffer()):Je(e);async function qe(e){let t=f(await Ye(e));return new Uint8Array(t)}async function Je(e){let t=[],n=e.getReader(),r=!1,i=0;for(;!r;){let{done:e,value:a}=await n.read();a&&(t.push(a),i+=a.length),r=e}let a=new Uint8Array(i),o=0;for(let e of t)a.set(e,o),o+=e.length;return a}function Ye(e){return new Promise((t,n)=>{let r=new FileReader;r.onloadend=()=>{if(r.readyState!==2)return n(Error(`Reader aborted too early`));let e=r.result??``,i=e.indexOf(`,`),a=i>-1?i+1:e.length;t(e.substring(a))},r.onabort=()=>n(Error(`Read aborted`)),r.onerror=()=>n(r.error),r.readAsDataURL(e)})}var Z=`The stream has already been transformed.`,Xe=e=>{if(!Q(e)&&!J(e)){let t=e?.__proto__?.constructor?.name||e;throw Error(`Unexpected stream implementation, expect Blob or ReadableStream, got ${t}`)}let t=!1,n=async()=>{if(t)throw Error(Z);return t=!0,await Ke(e)},r=e=>{if(typeof e.stream!=`function`)throw Error(`Cannot transform payload Blob to web stream. Please make sure the Blob.stream() is polyfilled.
+If you are using React Native, this API is not yet supported, see: https://react-native.canny.io/feature-requests/p/fetch-streaming-body`);return e.stream()};return Object.assign(e,{transformToByteArray:n,transformToString:async e=>{let t=await n();if(e===`base64`)return m(t);if(e===`hex`)return K(t);if(e===void 0||e===`utf8`||e===`utf-8`)return h(t);if(typeof TextDecoder==`function`)return new TextDecoder(e).decode(t);throw Error(`TextDecoder is not available, please make sure polyfill is provided.`)},transformToWebStream:()=>{if(t)throw Error(Z);if(t=!0,Q(e))return r(e);if(J(e))return e;throw Error(`Cannot transform payload to web stream, got ${e}`)}})},Q=e=>typeof Blob==`function`&&e instanceof Blob,Ze=class extends ee(h,p,m,f){},$=_(e=>crypto.getRandomValues(e)),Qe=$;export{s as A,he as C,p as D,m as E,n as F,e as I,a as M,i as N,f as O,r as P,be as S,h as T,Oe as _,Ge as a,F as b,Ie as c,Ne as d,K as f,De as g,Ae as h,Xe as i,c as j,o as k,Fe as l,je as m,Qe as n,He as o,Me as p,$ as r,ze as s,Ze as t,Pe as u,ke as v,ye as w,pe as x,Se as y};
\ No newline at end of file
diff --git a/spinifex/services/spinifexui/frontend/dist/assets/index.css b/spinifex/services/spinifexui/frontend/dist/assets/index.css
index 9e3b15d43..687571047 100644
--- a/spinifex/services/spinifexui/frontend/dist/assets/index.css
+++ b/spinifex/services/spinifexui/frontend/dist/assets/index.css
@@ -1,2 +1,2 @@
/*! tailwindcss v4.3.0 | MIT License | https://tailwindcss.com */
-@layer properties{@supports (((-webkit-hyphens:none)) and (not (margin-trim:inline))) or ((-moz-orient:inline) and (not (color:rgb(from red r g b)))){*,:before,:after,::backdrop{--tw-translate-x:0;--tw-translate-y:0;--tw-translate-z:0;--tw-scale-x:1;--tw-scale-y:1;--tw-scale-z:1;--tw-rotate-x:initial;--tw-rotate-y:initial;--tw-rotate-z:initial;--tw-skew-x:initial;--tw-skew-y:initial;--tw-pan-x:initial;--tw-pan-y:initial;--tw-pinch-zoom:initial;--tw-space-y-reverse:0;--tw-space-x-reverse:0;--tw-divide-x-reverse:0;--tw-border-style:solid;--tw-divide-y-reverse:0;--tw-leading:initial;--tw-font-weight:initial;--tw-tracking:initial;--tw-ordinal:initial;--tw-slashed-zero:initial;--tw-numeric-figure:initial;--tw-numeric-spacing:initial;--tw-numeric-fraction:initial;--tw-shadow:0 0 #0000;--tw-shadow-color:initial;--tw-shadow-alpha:100%;--tw-inset-shadow:0 0 #0000;--tw-inset-shadow-color:initial;--tw-inset-shadow-alpha:100%;--tw-ring-color:initial;--tw-ring-shadow:0 0 #0000;--tw-inset-ring-color:initial;--tw-inset-ring-shadow:0 0 #0000;--tw-ring-inset:initial;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-offset-shadow:0 0 #0000;--tw-outline-style:solid;--tw-blur:initial;--tw-brightness:initial;--tw-contrast:initial;--tw-grayscale:initial;--tw-hue-rotate:initial;--tw-invert:initial;--tw-opacity:initial;--tw-saturate:initial;--tw-sepia:initial;--tw-drop-shadow:initial;--tw-drop-shadow-color:initial;--tw-drop-shadow-alpha:100%;--tw-drop-shadow-size:initial;--tw-backdrop-blur:initial;--tw-backdrop-brightness:initial;--tw-backdrop-contrast:initial;--tw-backdrop-grayscale:initial;--tw-backdrop-hue-rotate:initial;--tw-backdrop-invert:initial;--tw-backdrop-opacity:initial;--tw-backdrop-saturate:initial;--tw-backdrop-sepia:initial;--tw-duration:initial;--tw-ease:initial;--tw-content:""}}}@layer theme{:root,:host{--font-sans:ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";--font-mono:ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;--color-red-100:#ffe2e2;--color-red-800:#9f0712;--color-red-900:#82181a;--color-amber-500:#f99c00;--color-yellow-100:#fef9c2;--color-yellow-800:#874b00;--color-yellow-900:#733e0a;--color-green-100:#dcfce7;--color-green-600:#00a544;--color-green-800:#016630;--color-green-900:#0d542b;--color-emerald-400:#00d294;--color-emerald-500:#00bb7f;--color-emerald-700:#007956;--color-blue-500:#3080ff;--color-gray-100:#f3f4f6;--color-gray-800:#1e2939;--color-zinc-100:#f4f4f5;--color-zinc-400:#9f9fa9;--color-zinc-500:#71717b;--color-zinc-800:#27272a;--color-black:#000;--spacing:.25rem;--container-xs:20rem;--container-sm:24rem;--container-md:28rem;--container-lg:32rem;--container-2xl:42rem;--container-4xl:56rem;--container-6xl:72rem;--text-xs:.75rem;--text-xs--line-height:calc(1 / .75);--text-sm:.875rem;--text-sm--line-height:calc(1.25 / .875);--text-lg:1.125rem;--text-lg--line-height:calc(1.75 / 1.125);--text-2xl:1.5rem;--text-2xl--line-height:calc(2 / 1.5);--text-3xl:1.875rem;--text-3xl--line-height:calc(2.25 / 1.875);--text-4xl:2.25rem;--text-4xl--line-height:calc(2.5 / 2.25);--font-weight-normal:400;--font-weight-medium:500;--font-weight-semibold:600;--font-weight-bold:700;--tracking-wider:.05em;--tracking-widest:.1em;--leading-snug:1.375;--leading-normal:1.5;--leading-relaxed:1.625;--radius-sm:calc(var(--radius) - 2px);--ease-in-out:cubic-bezier(.4, 0, .2, 1);--animate-pulse:pulse 2s cubic-bezier(.4, 0, .6, 1) infinite;--blur-xs:4px;--default-transition-duration:.15s;--default-transition-timing-function:cubic-bezier(.4, 0, .2, 1);--default-font-family:var(--font-sans);--default-mono-font-family:var(--font-mono)}@supports (color:lab(0% 0 0)){:root,:host{--color-red-100:lab(92.243% 10.2865 3.83865);--color-red-800:lab(33.7174% 55.8993 41.0293);--color-red-900:lab(28.5139% 44.5539 29.0463);--color-amber-500:lab(72.7183% 31.8672 97.9407);--color-yellow-100:lab(97.3564% -4.51407 27.344);--color-yellow-800:lab(38.7484% 23.5833 51.4916);--color-yellow-900:lab(32.3865% 21.1273 38.5959);--color-green-100:lab(96.1861% -13.8464 6.52365);--color-green-600:lab(59.0978% -58.6621 41.2579);--color-green-800:lab(37.4616% -36.7971 22.9692);--color-green-900:lab(30.797% -29.6927 17.382);--color-emerald-400:lab(75.0771% -60.7313 19.4147);--color-emerald-500:lab(66.9756% -58.27 19.5419);--color-emerald-700:lab(44.4871% -41.0396 11.0361);--color-blue-500:lab(54.1736% 13.3369 -74.6839);--color-gray-100:lab(96.1596% -.0823438 -1.13575);--color-gray-800:lab(16.1051% -1.18239 -11.7533);--color-zinc-100:lab(96.1634% .0993311 -.364041);--color-zinc-400:lab(65.6464% 1.53497 -5.42429);--color-zinc-500:lab(47.8878% 1.65477 -5.77283);--color-zinc-800:lab(15.7305% .613764 -2.16959)}}}@layer base{*,:after,:before,::backdrop{box-sizing:border-box;border:0 solid;margin:0;padding:0}::file-selector-button{box-sizing:border-box;border:0 solid;margin:0;padding:0}html,:host{-webkit-text-size-adjust:100%;tab-size:4;line-height:1.5;font-family:var(--default-font-family,ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji");font-feature-settings:var(--default-font-feature-settings,normal);font-variation-settings:var(--default-font-variation-settings,normal);-webkit-tap-highlight-color:transparent}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,samp,pre{font-family:var(--default-mono-font-family,ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace);font-feature-settings:var(--default-mono-font-feature-settings,normal);font-variation-settings:var(--default-mono-font-variation-settings,normal);font-size:1em}small{font-size:80%}sub,sup{vertical-align:baseline;font-size:75%;line-height:0;position:relative}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}:-moz-focusring{outline:auto}progress{vertical-align:baseline}summary{display:list-item}ol,ul,menu{list-style:none}img,svg,video,canvas,audio,iframe,embed,object{vertical-align:middle;display:block}img,video{max-width:100%;height:auto}button,input,select,optgroup,textarea{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}::file-selector-button{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}:where(select:is([multiple],[size])) optgroup{font-weight:bolder}:where(select:is([multiple],[size])) optgroup option{padding-inline-start:20px}::file-selector-button{margin-inline-end:4px}::placeholder{opacity:1}@supports (not ((-webkit-appearance:-apple-pay-button))) or (contain-intrinsic-size:1px){::placeholder{color:currentColor}@supports (color:color-mix(in lab, red, red)){::placeholder{color:color-mix(in oklab, currentcolor 50%, transparent)}}}textarea{resize:vertical}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-date-and-time-value{min-height:1lh;text-align:inherit}::-webkit-datetime-edit{display:inline-flex}::-webkit-datetime-edit-fields-wrapper{padding:0}::-webkit-datetime-edit{padding-block:0}::-webkit-datetime-edit-year-field{padding-block:0}::-webkit-datetime-edit-month-field{padding-block:0}::-webkit-datetime-edit-day-field{padding-block:0}::-webkit-datetime-edit-hour-field{padding-block:0}::-webkit-datetime-edit-minute-field{padding-block:0}::-webkit-datetime-edit-second-field{padding-block:0}::-webkit-datetime-edit-millisecond-field{padding-block:0}::-webkit-datetime-edit-meridiem-field{padding-block:0}::-webkit-calendar-picker-indicator{line-height:1}:-moz-ui-invalid{box-shadow:none}button,input:where([type=button],[type=reset],[type=submit]){appearance:button}::file-selector-button{appearance:button}::-webkit-inner-spin-button{height:auto}::-webkit-outer-spin-button{height:auto}[hidden]:where(:not([hidden=until-found])){display:none!important}*{overscroll-behavior:none;border-color:var(--border);outline-color:var(--ring)}@supports (color:color-mix(in lab, red, red)){*{outline-color:color-mix(in oklab, var(--ring) 50%, transparent)}}*{scrollbar-width:thin}body{background-color:var(--background);width:100%;min-height:100svh;color:var(--foreground);font-family:var(--font-sans), sans-serif;-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale}.font-mono{font-family:var(--font-mono), monospace}button:not(:disabled),[role=button]:not(:disabled){cursor:pointer}}@layer components;@layer utilities{.\@container\/card-header{container:card-header/inline-size}.\@container\/field-group{container:field-group/inline-size}.\@container{container-type:inline-size}.pointer-events-none{pointer-events:none}.collapse{visibility:collapse}.invisible{visibility:hidden}.visible{visibility:visible}.sr-only{clip-path:inset(50%);white-space:nowrap;border-width:0;width:1px;height:1px;margin:-1px;padding:0;position:absolute;overflow:hidden}.not-sr-only{clip-path:none;white-space:normal;width:auto;height:auto;margin:0;padding:0;position:static;overflow:visible}.absolute{position:absolute}.fixed{position:fixed}.relative{position:relative}.static{position:static}.sticky{position:sticky}.inset-0{inset:calc(var(--spacing) * 0)}.inset-y-0{inset-block:calc(var(--spacing) * 0)}.top-0{top:calc(var(--spacing) * 0)}.top-1\.5{top:calc(var(--spacing) * 1.5)}.top-1\/2{top:50%}.top-3\.5{top:calc(var(--spacing) * 3.5)}.top-4{top:calc(var(--spacing) * 4)}.right-0{right:calc(var(--spacing) * 0)}.right-1{right:calc(var(--spacing) * 1)}.right-2{right:calc(var(--spacing) * 2)}.right-3{right:calc(var(--spacing) * 3)}.right-4{right:calc(var(--spacing) * 4)}.bottom-0{bottom:calc(var(--spacing) * 0)}.left-0{left:calc(var(--spacing) * 0)}.left-1\/2{left:50%}.isolate{isolation:isolate}.isolation-auto{isolation:auto}.z-10{z-index:10}.z-20{z-index:20}.z-50{z-index:50}.order-first{order:-9999}.order-last{order:9999}.col-span-2{grid-column:span 2/span 2}.col-start-2{grid-column-start:2}.row-span-2{grid-row:span 2/span 2}.row-start-1{grid-row-start:1}.container{width:100%}@media (width>=40rem){.container{max-width:40rem}}@media (width>=48rem){.container{max-width:48rem}}@media (width>=64rem){.container{max-width:64rem}}@media (width>=80rem){.container{max-width:80rem}}@media (width>=96rem){.container{max-width:96rem}}.-mx-1{margin-inline:calc(var(--spacing) * -1)}.mx-0{margin-inline:calc(var(--spacing) * 0)}.mx-2{margin-inline:calc(var(--spacing) * 2)}.mx-3\.5{margin-inline:calc(var(--spacing) * 3.5)}.mx-auto{margin-inline:auto}.-my-2{margin-block:calc(var(--spacing) * -2)}.my-1{margin-block:calc(var(--spacing) * 1)}.my-2{margin-block:calc(var(--spacing) * 2)}.mt-1{margin-top:calc(var(--spacing) * 1)}.mt-2{margin-top:calc(var(--spacing) * 2)}.mt-3{margin-top:calc(var(--spacing) * 3)}.mt-4{margin-top:calc(var(--spacing) * 4)}.mt-auto{margin-top:auto}.mr-2{margin-right:calc(var(--spacing) * 2)}.mr-auto{margin-right:auto}.mb-1{margin-bottom:calc(var(--spacing) * 1)}.mb-1\.5{margin-bottom:calc(var(--spacing) * 1.5)}.mb-2{margin-bottom:calc(var(--spacing) * 2)}.mb-3{margin-bottom:calc(var(--spacing) * 3)}.mb-4{margin-bottom:calc(var(--spacing) * 4)}.mb-6{margin-bottom:calc(var(--spacing) * 6)}.-ml-1{margin-left:calc(var(--spacing) * -1)}.ml-1{margin-left:calc(var(--spacing) * 1)}.ml-4{margin-left:calc(var(--spacing) * 4)}.ml-auto{margin-left:auto}.block{display:block}.contents{display:contents}.flex{display:flex}.flow-root{display:flow-root}.grid{display:grid}.hidden{display:none}.inline{display:inline}.inline-block{display:inline-block}.inline-flex{display:inline-flex}.inline-grid{display:inline-grid}.inline-table{display:inline-table}.list-item{display:list-item}.table{display:table}.table-caption{display:table-caption}.table-cell{display:table-cell}.table-column{display:table-column}.table-column-group{display:table-column-group}.table-footer-group{display:table-footer-group}.table-header-group{display:table-header-group}.table-row{display:table-row}.table-row-group{display:table-row-group}.field-sizing-content{field-sizing:content}.aspect-square{aspect-ratio:1}.size-2\.5{width:calc(var(--spacing) * 2.5);height:calc(var(--spacing) * 2.5)}.size-3{width:calc(var(--spacing) * 3);height:calc(var(--spacing) * 3)}.size-3\.5{width:calc(var(--spacing) * 3.5);height:calc(var(--spacing) * 3.5)}.size-4{width:calc(var(--spacing) * 4);height:calc(var(--spacing) * 4)}.size-5{width:calc(var(--spacing) * 5);height:calc(var(--spacing) * 5)}.size-6{width:calc(var(--spacing) * 6);height:calc(var(--spacing) * 6)}.size-7{width:calc(var(--spacing) * 7);height:calc(var(--spacing) * 7)}.size-8{width:calc(var(--spacing) * 8);height:calc(var(--spacing) * 8)}.size-12{width:calc(var(--spacing) * 12);height:calc(var(--spacing) * 12)}.size-\[1\.2rem\]{width:1.2rem;height:1.2rem}.size-full{width:100%;height:100%}.h-3{height:calc(var(--spacing) * 3)}.h-4{height:calc(var(--spacing) * 4)}.h-5{height:calc(var(--spacing) * 5)}.h-6{height:calc(var(--spacing) * 6)}.h-7{height:calc(var(--spacing) * 7)}.h-8{height:calc(var(--spacing) * 8)}.h-10{height:calc(var(--spacing) * 10)}.h-12{height:calc(var(--spacing) * 12)}.h-\[calc\(--spacing\(4\.75\)\)\]{height:calc(calc(var(--spacing) * 4.75))}.h-auto{height:auto}.h-full{height:100%}.h-px{height:1px}.h-svh{height:100svh}.max-h-\(--available-height\){max-height:var(--available-height)}.max-h-72{max-height:calc(var(--spacing) * 72)}.max-h-96{max-height:calc(var(--spacing) * 96)}.max-h-\[min\(calc\(--spacing\(72\)---spacing\(9\)\)\,calc\(var\(--available-height\)---spacing\(9\)\)\)\]{max-height:min(calc(calc(var(--spacing) * 72) - calc(var(--spacing) * 9)), calc(var(--available-height) - calc(var(--spacing) * 9)))}.min-h-0{min-height:calc(var(--spacing) * 0)}.min-h-7{min-height:calc(var(--spacing) * 7)}.min-h-16{min-height:calc(var(--spacing) * 16)}.min-h-svh{min-height:100svh}.w-\(--anchor-width\){width:var(--anchor-width)}.w-\(--sidebar-width\){width:var(--sidebar-width)}.w-4{width:calc(var(--spacing) * 4)}.w-5{width:calc(var(--spacing) * 5)}.w-24{width:calc(var(--spacing) * 24)}.w-32{width:calc(var(--spacing) * 32)}.w-36{width:calc(var(--spacing) * 36)}.w-48{width:calc(var(--spacing) * 48)}.w-auto{width:auto}.w-fit{width:fit-content}.w-full{width:100%}.max-w-\(--available-width\){max-width:var(--available-width)}.max-w-\(--skeleton-width\){max-width:var(--skeleton-width)}.max-w-2xl{max-width:var(--container-2xl)}.max-w-4xl{max-width:var(--container-4xl)}.max-w-6xl{max-width:var(--container-6xl)}.max-w-md{max-width:var(--container-md)}.max-w-sm{max-width:var(--container-sm)}.max-w-xs{max-width:var(--container-xs)}.min-w-0{min-width:calc(var(--spacing) * 0)}.min-w-5{min-width:calc(var(--spacing) * 5)}.min-w-16{min-width:calc(var(--spacing) * 16)}.min-w-32{min-width:calc(var(--spacing) * 32)}.min-w-\[calc\(var\(--anchor-width\)\+--spacing\(7\)\)\]{min-width:calc(var(--anchor-width) + calc(var(--spacing) * 7))}.flex-1{flex:1}.shrink{flex-shrink:1}.shrink-0{flex-shrink:0}.grow{flex-grow:1}.border-collapse{border-collapse:collapse}.origin-\(--transform-origin\){transform-origin:var(--transform-origin)}.-translate-x-1\/2{--tw-translate-x:calc(calc(1 / 2 * 100%) * -1);translate:var(--tw-translate-x) var(--tw-translate-y)}.-translate-x-px{--tw-translate-x:-1px;translate:var(--tw-translate-x) var(--tw-translate-y)}.translate-x-px{--tw-translate-x:1px;translate:var(--tw-translate-x) var(--tw-translate-y)}.-translate-y-1\/2{--tw-translate-y:calc(calc(1 / 2 * 100%) * -1);translate:var(--tw-translate-x) var(--tw-translate-y)}.translate-y-\[calc\(-50\%-2px\)\]{--tw-translate-y:calc(-50% - 2px);translate:var(--tw-translate-x) var(--tw-translate-y)}.translate-none{translate:none}.scale-0{--tw-scale-x:0%;--tw-scale-y:0%;--tw-scale-z:0%;scale:var(--tw-scale-x) var(--tw-scale-y)}.scale-100{--tw-scale-x:100%;--tw-scale-y:100%;--tw-scale-z:100%;scale:var(--tw-scale-x) var(--tw-scale-y)}.scale-3d{scale:var(--tw-scale-x) var(--tw-scale-y) var(--tw-scale-z)}.rotate-0{rotate:0deg}.rotate-45{rotate:45deg}.rotate-90{rotate:90deg}.rotate-180{rotate:180deg}.transform{transform:var(--tw-rotate-x,) var(--tw-rotate-y,) var(--tw-rotate-z,) var(--tw-skew-x,) var(--tw-skew-y,)}.animate-pulse{animation:var(--animate-pulse)}.cursor-default{cursor:default}.cursor-pointer{cursor:pointer}.cursor-text{cursor:text}.touch-pinch-zoom{--tw-pinch-zoom:pinch-zoom;touch-action:var(--tw-pan-x,) var(--tw-pan-y,) var(--tw-pinch-zoom,)}.resize{resize:both}.resize-none{resize:none}.scroll-my-1{scroll-margin-block:calc(var(--spacing) * 1)}.scroll-py-1{scroll-padding-block:calc(var(--spacing) * 1)}.list-inside{list-style-position:inside}.list-disc{list-style-type:disc}.auto-rows-min{grid-auto-rows:min-content}.grid-cols-1{grid-template-columns:repeat(1,minmax(0,1fr))}.grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.grid-cols-\[minmax\(0\,1fr\)\]{grid-template-columns:minmax(0,1fr)}.grid-rows-\[auto_1fr\]{grid-template-rows:auto 1fr}.flex-col{flex-direction:column}.flex-col-reverse{flex-direction:column-reverse}.flex-row{flex-direction:row}.flex-wrap{flex-wrap:wrap}.place-items-center{place-items:center}.items-center{align-items:center}.items-end{align-items:flex-end}.items-start{align-items:flex-start}.justify-between{justify-content:space-between}.justify-center{justify-content:center}.justify-end{justify-content:flex-end}.justify-start{justify-content:flex-start}.gap-0{gap:calc(var(--spacing) * 0)}.gap-0\.5{gap:calc(var(--spacing) * .5)}.gap-1{gap:calc(var(--spacing) * 1)}.gap-1\.5{gap:calc(var(--spacing) * 1.5)}.gap-2{gap:calc(var(--spacing) * 2)}.gap-3{gap:calc(var(--spacing) * 3)}.gap-4{gap:calc(var(--spacing) * 4)}.gap-8{gap:calc(var(--spacing) * 8)}.gap-px{gap:1px}:where(.space-y-0>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 0) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 0) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-0\.5>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * .5) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * .5) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-1>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 1) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 1) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-2>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 2) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 2) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-3>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 3) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 3) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-4>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 4) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 4) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-6>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 6) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 6) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-reverse>:not(:last-child)){--tw-space-y-reverse:1}:where(.-space-x-2>:not(:last-child)){--tw-space-x-reverse:0;margin-inline-start:calc(calc(var(--spacing) * -2) * var(--tw-space-x-reverse));margin-inline-end:calc(calc(var(--spacing) * -2) * calc(1 - var(--tw-space-x-reverse)))}:where(.space-x-reverse>:not(:last-child)){--tw-space-x-reverse:1}:where(.divide-x>:not(:last-child)){--tw-divide-x-reverse:0;border-inline-style:var(--tw-border-style);border-inline-start-width:calc(1px * var(--tw-divide-x-reverse));border-inline-end-width:calc(1px * calc(1 - var(--tw-divide-x-reverse)))}:where(.divide-y>:not(:last-child)){--tw-divide-y-reverse:0;border-bottom-style:var(--tw-border-style);border-top-style:var(--tw-border-style);border-top-width:calc(1px * var(--tw-divide-y-reverse));border-bottom-width:calc(1px * calc(1 - var(--tw-divide-y-reverse)))}:where(.divide-y-reverse>:not(:last-child)){--tw-divide-y-reverse:1}.self-end{align-self:flex-end}.self-start{align-self:flex-start}.justify-self-end{justify-self:flex-end}.truncate{text-overflow:ellipsis;white-space:nowrap;overflow:hidden}.overflow-auto{overflow:auto}.overflow-hidden{overflow:hidden}.overflow-x-auto{overflow-x:auto}.overflow-x-hidden{overflow-x:hidden}.overflow-y-auto{overflow-y:auto}.overscroll-contain{overscroll-behavior:contain}.rounded{border-radius:.25rem}.rounded-\[2px\]{border-radius:2px}.rounded-\[calc\(var\(--radius-sm\)\+2px\)\]{border-radius:calc(var(--radius-sm) + 2px)}.rounded-\[calc\(var\(--radius-sm\)-2px\)\]{border-radius:calc(var(--radius-sm) - 2px)}.rounded-full{border-radius:3.40282e38px}.rounded-lg{border-radius:calc(var(--radius) + 2px)}.rounded-md{border-radius:var(--radius)}.rounded-none{border-radius:0}.rounded-sm{border-radius:calc(var(--radius) - 2px)}.rounded-xl{border-radius:calc(var(--radius) + 4px)}.rounded-s{border-start-start-radius:.25rem;border-end-start-radius:.25rem}.rounded-ss{border-start-start-radius:.25rem}.rounded-e{border-start-end-radius:.25rem;border-end-end-radius:.25rem}.rounded-se{border-start-end-radius:.25rem}.rounded-ee{border-end-end-radius:.25rem}.rounded-es{border-end-start-radius:.25rem}.rounded-t{border-top-left-radius:.25rem;border-top-right-radius:.25rem}.rounded-t-lg{border-top-left-radius:calc(var(--radius) + 2px);border-top-right-radius:calc(var(--radius) + 2px)}.rounded-l{border-top-left-radius:.25rem;border-bottom-left-radius:.25rem}.rounded-tl{border-top-left-radius:.25rem}.rounded-r{border-top-right-radius:.25rem;border-bottom-right-radius:.25rem}.rounded-tr{border-top-right-radius:.25rem}.rounded-b{border-bottom-right-radius:.25rem;border-bottom-left-radius:.25rem}.rounded-b-lg{border-bottom-right-radius:calc(var(--radius) + 2px);border-bottom-left-radius:calc(var(--radius) + 2px)}.rounded-br{border-bottom-right-radius:.25rem}.rounded-bl{border-bottom-left-radius:.25rem}.border{border-style:var(--tw-border-style);border-width:1px}.border-0{border-style:var(--tw-border-style);border-width:0}.border-2{border-style:var(--tw-border-style);border-width:2px}.border-x{border-inline-style:var(--tw-border-style);border-inline-width:1px}.border-y{border-block-style:var(--tw-border-style);border-block-width:1px}.border-s{border-inline-start-style:var(--tw-border-style);border-inline-start-width:1px}.border-e{border-inline-end-style:var(--tw-border-style);border-inline-end-width:1px}.border-bs{border-block-start-style:var(--tw-border-style);border-block-start-width:1px}.border-be{border-block-end-style:var(--tw-border-style);border-block-end-width:1px}.border-t{border-top-style:var(--tw-border-style);border-top-width:1px}.border-r{border-right-style:var(--tw-border-style);border-right-width:1px}.border-b{border-bottom-style:var(--tw-border-style);border-bottom-width:1px}.border-b-2{border-bottom-style:var(--tw-border-style);border-bottom-width:2px}.border-l{border-left-style:var(--tw-border-style);border-left-width:1px}.border-l-2{border-left-style:var(--tw-border-style);border-left-width:2px}.border-dashed{--tw-border-style:dashed;border-style:dashed}.border-amber-500\/30{border-color:#f99c004d}@supports (color:color-mix(in lab, red, red)){.border-amber-500\/30{border-color:color-mix(in oklab, var(--color-amber-500) 30%, transparent)}}.border-blue-500\/30{border-color:#3080ff4d}@supports (color:color-mix(in lab, red, red)){.border-blue-500\/30{border-color:color-mix(in oklab, var(--color-blue-500) 30%, transparent)}}.border-border{border-color:var(--border)}.border-destructive{border-color:var(--destructive)}.border-emerald-500{border-color:var(--color-emerald-500)}.border-emerald-500\/30{border-color:#00bb7f4d}@supports (color:color-mix(in lab, red, red)){.border-emerald-500\/30{border-color:color-mix(in oklab, var(--color-emerald-500) 30%, transparent)}}.border-foreground\/10{border-color:var(--foreground)}@supports (color:color-mix(in lab, red, red)){.border-foreground\/10{border-color:color-mix(in oklab, var(--foreground) 10%, transparent)}}.border-foreground\/20{border-color:var(--foreground)}@supports (color:color-mix(in lab, red, red)){.border-foreground\/20{border-color:color-mix(in oklab, var(--foreground) 20%, transparent)}}.border-input{border-color:var(--input)}.border-muted{border-color:var(--muted)}.border-sidebar-border{border-color:var(--sidebar-border)}.border-transparent{border-color:#0000}.bg-amber-500\/10{background-color:#f99c001a}@supports (color:color-mix(in lab, red, red)){.bg-amber-500\/10{background-color:color-mix(in oklab, var(--color-amber-500) 10%, transparent)}}.bg-background{background-color:var(--background)}.bg-black\/80{background-color:#000c}@supports (color:color-mix(in lab, red, red)){.bg-black\/80{background-color:color-mix(in oklab, var(--color-black) 80%, transparent)}}.bg-blue-500\/10{background-color:#3080ff1a}@supports (color:color-mix(in lab, red, red)){.bg-blue-500\/10{background-color:color-mix(in oklab, var(--color-blue-500) 10%, transparent)}}.bg-border,.bg-border\/50{background-color:var(--border)}@supports (color:color-mix(in lab, red, red)){.bg-border\/50{background-color:color-mix(in oklab, var(--border) 50%, transparent)}}.bg-card{background-color:var(--card)}.bg-destructive\/10{background-color:var(--destructive)}@supports (color:color-mix(in lab, red, red)){.bg-destructive\/10{background-color:color-mix(in oklab, var(--destructive) 10%, transparent)}}.bg-emerald-500\/10{background-color:#00bb7f1a}@supports (color:color-mix(in lab, red, red)){.bg-emerald-500\/10{background-color:color-mix(in oklab, var(--color-emerald-500) 10%, transparent)}}.bg-foreground{background-color:var(--foreground)}.bg-gray-100{background-color:var(--color-gray-100)}.bg-green-100{background-color:var(--color-green-100)}.bg-input\/20{background-color:var(--input)}@supports (color:color-mix(in lab, red, red)){.bg-input\/20{background-color:color-mix(in oklab, var(--input) 20%, transparent)}}.bg-muted{background-color:var(--muted)}.bg-muted-foreground\/10{background-color:var(--muted-foreground)}@supports (color:color-mix(in lab, red, red)){.bg-muted-foreground\/10{background-color:color-mix(in oklab, var(--muted-foreground) 10%, transparent)}}.bg-muted\/20{background-color:var(--muted)}@supports (color:color-mix(in lab, red, red)){.bg-muted\/20{background-color:color-mix(in oklab, var(--muted) 20%, transparent)}}.bg-muted\/30{background-color:var(--muted)}@supports (color:color-mix(in lab, red, red)){.bg-muted\/30{background-color:color-mix(in oklab, var(--muted) 30%, transparent)}}.bg-muted\/50{background-color:var(--muted)}@supports (color:color-mix(in lab, red, red)){.bg-muted\/50{background-color:color-mix(in oklab, var(--muted) 50%, transparent)}}.bg-popover{background-color:var(--popover)}.bg-primary{background-color:var(--primary)}.bg-red-100{background-color:var(--color-red-100)}.bg-secondary{background-color:var(--secondary)}.bg-sidebar{background-color:var(--sidebar)}.bg-sidebar-border{background-color:var(--sidebar-border)}.bg-transparent{background-color:#0000}.bg-yellow-100{background-color:var(--color-yellow-100)}.bg-zinc-100{background-color:var(--color-zinc-100)}.bg-clip-padding{background-clip:padding-box}.bg-repeat{background-repeat:repeat}.mask-no-clip{-webkit-mask-clip:no-clip;mask-clip:no-clip}.mask-repeat{-webkit-mask-repeat:repeat;mask-repeat:repeat}.fill-foreground{fill:var(--foreground)}.object-cover{object-fit:cover}.p-0{padding:calc(var(--spacing) * 0)}.p-1{padding:calc(var(--spacing) * 1)}.p-2{padding:calc(var(--spacing) * 2)}.p-3{padding:calc(var(--spacing) * 3)}.p-4{padding:calc(var(--spacing) * 4)}.p-6{padding:calc(var(--spacing) * 6)}.p-8{padding:calc(var(--spacing) * 8)}.px-0{padding-inline:calc(var(--spacing) * 0)}.px-1{padding-inline:calc(var(--spacing) * 1)}.px-1\.5{padding-inline:calc(var(--spacing) * 1.5)}.px-2{padding-inline:calc(var(--spacing) * 2)}.px-2\.5{padding-inline:calc(var(--spacing) * 2.5)}.px-3{padding-inline:calc(var(--spacing) * 3)}.px-4{padding-inline:calc(var(--spacing) * 4)}.py-0\.5{padding-block:calc(var(--spacing) * .5)}.py-1{padding-block:calc(var(--spacing) * 1)}.py-1\.5{padding-block:calc(var(--spacing) * 1.5)}.py-2{padding-block:calc(var(--spacing) * 2)}.py-3{padding-block:calc(var(--spacing) * 3)}.py-4{padding-block:calc(var(--spacing) * 4)}.pt-0{padding-top:calc(var(--spacing) * 0)}.pt-2{padding-top:calc(var(--spacing) * 2)}.pt-4{padding-top:calc(var(--spacing) * 4)}.pr-2{padding-right:calc(var(--spacing) * 2)}.pr-4{padding-right:calc(var(--spacing) * 4)}.pr-8{padding-right:calc(var(--spacing) * 8)}.pb-0{padding-bottom:calc(var(--spacing) * 0)}.pb-0\.5{padding-bottom:calc(var(--spacing) * .5)}.pb-1{padding-bottom:calc(var(--spacing) * 1)}.pb-2{padding-bottom:calc(var(--spacing) * 2)}.pb-4{padding-bottom:calc(var(--spacing) * 4)}.pl-2{padding-left:calc(var(--spacing) * 2)}.pl-4{padding-left:calc(var(--spacing) * 4)}.text-center{text-align:center}.text-left{text-align:left}.text-right{text-align:right}.font-mono{font-family:var(--font-mono)}.font-sans{font-family:var(--font-sans)}.text-2xl{font-size:var(--text-2xl);line-height:var(--tw-leading,var(--text-2xl--line-height))}.text-3xl{font-size:var(--text-3xl);line-height:var(--tw-leading,var(--text-3xl--line-height))}.text-4xl{font-size:var(--text-4xl);line-height:var(--tw-leading,var(--text-4xl--line-height))}.text-lg{font-size:var(--text-lg);line-height:var(--tw-leading,var(--text-lg--line-height))}.text-sm{font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height))}.text-sm\/relaxed{font-size:var(--text-sm);line-height:var(--leading-relaxed)}.text-xs{font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height))}.text-xs\/relaxed{font-size:var(--text-xs);line-height:var(--leading-relaxed)}.text-\[0\.625rem\]{font-size:.625rem}.text-\[11px\]{font-size:11px}.leading-none{--tw-leading:1;line-height:1}.leading-normal{--tw-leading:var(--leading-normal);line-height:var(--leading-normal)}.leading-snug{--tw-leading:var(--leading-snug);line-height:var(--leading-snug)}.font-bold{--tw-font-weight:var(--font-weight-bold);font-weight:var(--font-weight-bold)}.font-medium{--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium)}.font-normal{--tw-font-weight:var(--font-weight-normal);font-weight:var(--font-weight-normal)}.font-semibold{--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold)}.tracking-wider{--tw-tracking:var(--tracking-wider);letter-spacing:var(--tracking-wider)}.tracking-widest{--tw-tracking:var(--tracking-widest);letter-spacing:var(--tracking-widest)}.text-balance{text-wrap:balance}.text-wrap{text-wrap:wrap}.break-all{word-break:break-all}.text-clip{text-overflow:clip}.text-ellipsis{text-overflow:ellipsis}.whitespace-nowrap{white-space:nowrap}.whitespace-pre-wrap{white-space:pre-wrap}.text-amber-500{color:var(--color-amber-500)}.text-background{color:var(--background)}.text-card-foreground{color:var(--card-foreground)}.text-destructive{color:var(--destructive)}.text-emerald-700{color:var(--color-emerald-700)}.text-foreground{color:var(--foreground)}.text-gray-800{color:var(--color-gray-800)}.text-green-600{color:var(--color-green-600)}.text-green-800{color:var(--color-green-800)}.text-muted-foreground{color:var(--muted-foreground)}.text-popover-foreground{color:var(--popover-foreground)}.text-primary{color:var(--primary)}.text-primary-foreground{color:var(--primary-foreground)}.text-red-800{color:var(--color-red-800)}.text-secondary-foreground{color:var(--secondary-foreground)}.text-sidebar-foreground,.text-sidebar-foreground\/70{color:var(--sidebar-foreground)}@supports (color:color-mix(in lab, red, red)){.text-sidebar-foreground\/70{color:color-mix(in oklab, var(--sidebar-foreground) 70%, transparent)}}.text-tactical-amber{color:var(--tactical-amber)}.text-tactical-cyan{color:var(--tactical-cyan)}.text-tactical-green{color:var(--tactical-green)}.text-yellow-800{color:var(--color-yellow-800)}.text-zinc-500{color:var(--color-zinc-500)}.capitalize{text-transform:capitalize}.lowercase{text-transform:lowercase}.normal-case{text-transform:none}.uppercase{text-transform:uppercase}.italic{font-style:italic}.not-italic{font-style:normal}.diagonal-fractions{--tw-numeric-fraction:diagonal-fractions;font-variant-numeric:var(--tw-ordinal,) var(--tw-slashed-zero,) var(--tw-numeric-figure,) var(--tw-numeric-spacing,) var(--tw-numeric-fraction,)}.lining-nums{--tw-numeric-figure:lining-nums;font-variant-numeric:var(--tw-ordinal,) var(--tw-slashed-zero,) var(--tw-numeric-figure,) var(--tw-numeric-spacing,) var(--tw-numeric-fraction,)}.oldstyle-nums{--tw-numeric-figure:oldstyle-nums;font-variant-numeric:var(--tw-ordinal,) var(--tw-slashed-zero,) var(--tw-numeric-figure,) var(--tw-numeric-spacing,) var(--tw-numeric-fraction,)}.ordinal{--tw-ordinal:ordinal;font-variant-numeric:var(--tw-ordinal,) var(--tw-slashed-zero,) var(--tw-numeric-figure,) var(--tw-numeric-spacing,) var(--tw-numeric-fraction,)}.proportional-nums{--tw-numeric-spacing:proportional-nums;font-variant-numeric:var(--tw-ordinal,) var(--tw-slashed-zero,) var(--tw-numeric-figure,) var(--tw-numeric-spacing,) var(--tw-numeric-fraction,)}.slashed-zero{--tw-slashed-zero:slashed-zero;font-variant-numeric:var(--tw-ordinal,) var(--tw-slashed-zero,) var(--tw-numeric-figure,) var(--tw-numeric-spacing,) var(--tw-numeric-fraction,)}.stacked-fractions{--tw-numeric-fraction:stacked-fractions;font-variant-numeric:var(--tw-ordinal,) var(--tw-slashed-zero,) var(--tw-numeric-figure,) var(--tw-numeric-spacing,) var(--tw-numeric-fraction,)}.tabular-nums{--tw-numeric-spacing:tabular-nums;font-variant-numeric:var(--tw-ordinal,) var(--tw-slashed-zero,) var(--tw-numeric-figure,) var(--tw-numeric-spacing,) var(--tw-numeric-fraction,)}.normal-nums{font-variant-numeric:normal}.line-through{text-decoration-line:line-through}.no-underline{text-decoration-line:none}.overline{text-decoration-line:overline}.underline{text-decoration-line:underline}.underline-offset-4{text-underline-offset:4px}.antialiased{-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale}.subpixel-antialiased{-webkit-font-smoothing:auto;-moz-osx-font-smoothing:auto}.opacity-50{opacity:.5}.bg-blend-color{background-blend-mode:color}.shadow{--tw-shadow:0 1px 3px 0 var(--tw-shadow-color,#0000001a), 0 1px 2px -1px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.shadow-\[0_0_0_1px_hsl\(var\(--sidebar-border\)\)\]{--tw-shadow:0 0 0 1px var(--tw-shadow-color,hsl(var(--sidebar-border)));box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.shadow-lg{--tw-shadow:0 10px 15px -3px var(--tw-shadow-color,#0000001a), 0 4px 6px -4px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.shadow-md{--tw-shadow:0 4px 6px -1px var(--tw-shadow-color,#0000001a), 0 2px 4px -2px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.shadow-none{--tw-shadow:0 0 #0000;box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.ring-0{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(0px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.ring-1{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.ring-2{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.inset-ring{--tw-inset-ring-shadow:inset 0 0 0 1px var(--tw-inset-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.ring-background{--tw-ring-color:var(--background)}.ring-foreground\/10{--tw-ring-color:var(--foreground)}@supports (color:color-mix(in lab, red, red)){.ring-foreground\/10{--tw-ring-color:color-mix(in oklab, var(--foreground) 10%, transparent)}}.ring-sidebar-ring{--tw-ring-color:var(--sidebar-ring)}.outline-hidden{--tw-outline-style:none;outline-style:none}@media (forced-colors:active){.outline-hidden{outline-offset:2px;outline:2px solid #0000}}.outline{outline-style:var(--tw-outline-style);outline-width:1px}.blur{--tw-blur:blur(8px);filter:var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,)}.drop-shadow{--tw-drop-shadow-size:drop-shadow(0 1px 2px var(--tw-drop-shadow-color,#0000001a)) drop-shadow(0 1px 1px var(--tw-drop-shadow-color,#0000000f));--tw-drop-shadow:drop-shadow(0 1px 2px #0000001a) drop-shadow(0 1px 1px #0000000f);filter:var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,)}.filter{filter:var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,)}.backdrop-blur{--tw-backdrop-blur:blur(8px);-webkit-backdrop-filter:var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,);backdrop-filter:var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,)}.backdrop-grayscale{--tw-backdrop-grayscale:grayscale(100%);-webkit-backdrop-filter:var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,);backdrop-filter:var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,)}.backdrop-invert{--tw-backdrop-invert:invert(100%);-webkit-backdrop-filter:var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,);backdrop-filter:var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,)}.backdrop-sepia{--tw-backdrop-sepia:sepia(100%);-webkit-backdrop-filter:var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,);backdrop-filter:var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,)}.backdrop-filter{-webkit-backdrop-filter:var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,);backdrop-filter:var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,)}.transition{transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to,opacity,box-shadow,transform,translate,scale,rotate,filter,-webkit-backdrop-filter,backdrop-filter,display,content-visibility,overlay,pointer-events;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-\[left\,right\,width\]{transition-property:left,right,width;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-\[margin\,opacity\]{transition-property:margin,opacity;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-\[width\,height\,padding\]{transition-property:width,height,padding;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-\[width\]{transition-property:width;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-all{transition-property:all;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-colors{transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-transform{transition-property:transform,translate,scale,rotate;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.duration-100{--tw-duration:.1s;transition-duration:.1s}.duration-200{--tw-duration:.2s;transition-duration:.2s}.ease-in-out{--tw-ease:var(--ease-in-out);transition-timing-function:var(--ease-in-out)}.ease-linear{--tw-ease:linear;transition-timing-function:linear}.outline-none{--tw-outline-style:none;outline-style:none}.select-none{-webkit-user-select:none;user-select:none}:where(.divide-x-reverse>:not(:last-child)){--tw-divide-x-reverse:1}.ring-inset{--tw-ring-inset:inset}.group-focus-within\/menu-item\:opacity-100:is(:where(.group\/menu-item):focus-within *){opacity:1}@media (hover:hover){.group-hover\/menu-item\:opacity-100:is(:where(.group\/menu-item):hover *){opacity:1}}.group-focus\/dropdown-menu-item\:text-accent-foreground:is(:where(.group\/dropdown-menu-item):focus *){color:var(--accent-foreground)}.group-has-data-\[orientation\=horizontal\]\/field\:text-balance:is(:where(.group\/field):has([data-orientation=horizontal]) *){text-wrap:balance}.group-has-data-\[sidebar\=menu-action\]\/menu-item\:pr-8:is(:where(.group\/menu-item):has([data-sidebar=menu-action]) *){padding-right:calc(var(--spacing) * 8)}.group-has-data-\[size\=lg\]\/avatar-group\:size-10:is(:where(.group\/avatar-group):has([data-size=lg]) *){width:calc(var(--spacing) * 10);height:calc(var(--spacing) * 10)}.group-has-data-\[size\=sm\]\/avatar-group\:size-6:is(:where(.group\/avatar-group):has([data-size=sm]) *){width:calc(var(--spacing) * 6);height:calc(var(--spacing) * 6)}.group-has-data-\[slot\=combobox-clear\]\/input-group\:hidden:is(:where(.group\/input-group):has([data-slot=combobox-clear]) *){display:none}.group-has-\[\>input\]\/input-group\:pt-2:is(:where(.group\/input-group):has(>input) *){padding-top:calc(var(--spacing) * 2)}.group-has-\[\>input\]\/input-group\:pb-2:is(:where(.group\/input-group):has(>input) *){padding-bottom:calc(var(--spacing) * 2)}.group-data-empty\/combobox-content\:flex:is(:where(.group\/combobox-content)[data-empty] *){display:flex}.group-data-\[collapsible\=icon\]\:-mt-8:is(:where(.group)[data-collapsible=icon] *){margin-top:calc(var(--spacing) * -8)}.group-data-\[collapsible\=icon\]\:hidden:is(:where(.group)[data-collapsible=icon] *){display:none}.group-data-\[collapsible\=icon\]\:size-8\!:is(:where(.group)[data-collapsible=icon] *){width:calc(var(--spacing) * 8)!important;height:calc(var(--spacing) * 8)!important}.group-data-\[collapsible\=icon\]\:w-\(--sidebar-width-icon\):is(:where(.group)[data-collapsible=icon] *){width:var(--sidebar-width-icon)}.group-data-\[collapsible\=icon\]\:w-\[calc\(var\(--sidebar-width-icon\)\+\(--spacing\(4\)\)\)\]:is(:where(.group)[data-collapsible=icon] *){width:calc(var(--sidebar-width-icon) + (calc(var(--spacing) * 4)))}.group-data-\[collapsible\=icon\]\:w-\[calc\(var\(--sidebar-width-icon\)\+\(--spacing\(4\)\)\+2px\)\]:is(:where(.group)[data-collapsible=icon] *){width:calc(var(--sidebar-width-icon) + (calc(var(--spacing) * 4)) + 2px)}.group-data-\[collapsible\=icon\]\:overflow-hidden:is(:where(.group)[data-collapsible=icon] *){overflow:hidden}.group-data-\[collapsible\=icon\]\:p-0\!:is(:where(.group)[data-collapsible=icon] *){padding:calc(var(--spacing) * 0)!important}.group-data-\[collapsible\=icon\]\:p-2\!:is(:where(.group)[data-collapsible=icon] *){padding:calc(var(--spacing) * 2)!important}.group-data-\[collapsible\=icon\]\:opacity-0:is(:where(.group)[data-collapsible=icon] *){opacity:0}.group-data-\[collapsible\=offExamples\]\:right-\[calc\(var\(--sidebar-width\)\*-1\)\]:is(:where(.group)[data-collapsible=offExamples] *){right:calc(var(--sidebar-width) * -1)}.group-data-\[collapsible\=offExamples\]\:left-\[calc\(var\(--sidebar-width\)\*-1\)\]:is(:where(.group)[data-collapsible=offExamples] *){left:calc(var(--sidebar-width) * -1)}.group-data-\[collapsible\=offExamples\]\:w-0:is(:where(.group)[data-collapsible=offExamples] *){width:calc(var(--spacing) * 0)}.group-data-\[collapsible\=offExamples\]\:translate-x-0:is(:where(.group)[data-collapsible=offExamples] *){--tw-translate-x:calc(var(--spacing) * 0);translate:var(--tw-translate-x) var(--tw-translate-y)}.group-data-\[disabled\=true\]\:pointer-events-none:is(:where(.group)[data-disabled=true] *){pointer-events:none}.group-data-\[disabled\=true\]\:opacity-50:is(:where(.group)[data-disabled=true] *),.group-data-\[disabled\=true\]\/field\:opacity-50:is(:where(.group\/field)[data-disabled=true] *),.group-data-\[disabled\=true\]\/input-group\:opacity-50:is(:where(.group\/input-group)[data-disabled=true] *){opacity:.5}.group-data-\[panel-open\]\:rotate-180:is(:where(.group)[data-panel-open] *){rotate:180deg}.group-data-\[side\=left\]\:-right-4:is(:where(.group)[data-side=left] *){right:calc(var(--spacing) * -4)}.group-data-\[side\=left\]\:border-r:is(:where(.group)[data-side=left] *){border-right-style:var(--tw-border-style);border-right-width:1px}.group-data-\[side\=right\]\:left-0:is(:where(.group)[data-side=right] *){left:calc(var(--spacing) * 0)}.group-data-\[side\=right\]\:rotate-180:is(:where(.group)[data-side=right] *){rotate:180deg}.group-data-\[side\=right\]\:border-l:is(:where(.group)[data-side=right] *){border-left-style:var(--tw-border-style);border-left-width:1px}.group-data-\[size\=default\]\/avatar\:size-2\.5:is(:where(.group\/avatar)[data-size=default] *){width:calc(var(--spacing) * 2.5);height:calc(var(--spacing) * 2.5)}.group-data-\[size\=lg\]\/avatar\:size-3:is(:where(.group\/avatar)[data-size=lg] *){width:calc(var(--spacing) * 3);height:calc(var(--spacing) * 3)}.group-data-\[size\=sm\]\/alert-dialog-content\:grid:is(:where(.group\/alert-dialog-content)[data-size=sm] *){display:grid}.group-data-\[size\=sm\]\/alert-dialog-content\:grid-cols-2:is(:where(.group\/alert-dialog-content)[data-size=sm] *){grid-template-columns:repeat(2,minmax(0,1fr))}.group-data-\[size\=sm\]\/avatar\:size-2:is(:where(.group\/avatar)[data-size=sm] *){width:calc(var(--spacing) * 2);height:calc(var(--spacing) * 2)}.group-data-\[size\=sm\]\/avatar\:text-xs:is(:where(.group\/avatar)[data-size=sm] *){font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height))}.group-data-\[size\=sm\]\/card\:px-3:is(:where(.group\/card)[data-size=sm] *){padding-inline:calc(var(--spacing) * 3)}.group-data-\[variant\=floating\]\:rounded-lg:is(:where(.group)[data-variant=floating] *){border-radius:calc(var(--radius) + 2px)}.group-data-\[variant\=floating\]\:shadow-sm:is(:where(.group)[data-variant=floating] *){--tw-shadow:0 1px 3px 0 var(--tw-shadow-color,#0000001a), 0 1px 2px -1px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.group-data-\[variant\=floating\]\:ring-1:is(:where(.group)[data-variant=floating] *){--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.group-data-\[variant\=floating\]\:ring-sidebar-border:is(:where(.group)[data-variant=floating] *){--tw-ring-color:var(--sidebar-border)}.group-data-\[variant\=outline\]\/field-group\:-mb-2:is(:where(.group\/field-group)[data-variant=outline] *){margin-bottom:calc(var(--spacing) * -2)}@media (hover:hover){.peer-hover\/menu-button\:text-sidebar-accent-foreground:is(:where(.peer\/menu-button):hover~*){color:var(--sidebar-accent-foreground)}}.peer-disabled\:cursor-not-allowed:is(:where(.peer):disabled~*){cursor:not-allowed}.peer-disabled\:opacity-50:is(:where(.peer):disabled~*){opacity:.5}.peer-data-active\/menu-button\:text-sidebar-accent-foreground:is(:where(.peer\/menu-button)[data-active]~*){color:var(--sidebar-accent-foreground)}.peer-data-\[size\=default\]\/menu-button\:top-1\.5:is(:where(.peer\/menu-button)[data-size=default]~*){top:calc(var(--spacing) * 1.5)}.peer-data-\[size\=lg\]\/menu-button\:top-2\.5:is(:where(.peer\/menu-button)[data-size=lg]~*){top:calc(var(--spacing) * 2.5)}.peer-data-\[size\=sm\]\/menu-button\:top-1:is(:where(.peer\/menu-button)[data-size=sm]~*){top:calc(var(--spacing) * 1)}.file\:inline-flex::file-selector-button{display:inline-flex}.file\:h-6::file-selector-button{height:calc(var(--spacing) * 6)}.file\:border-0::file-selector-button{border-style:var(--tw-border-style);border-width:0}.file\:bg-transparent::file-selector-button{background-color:#0000}.file\:text-xs\/relaxed::file-selector-button{font-size:var(--text-xs);line-height:var(--leading-relaxed)}.file\:font-medium::file-selector-button{--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium)}.file\:text-foreground::file-selector-button{color:var(--foreground)}.placeholder\:text-muted-foreground::placeholder{color:var(--muted-foreground)}.after\:absolute:after{content:var(--tw-content);position:absolute}.after\:-inset-2:after{content:var(--tw-content);inset:calc(var(--spacing) * -2)}.after\:inset-0:after{content:var(--tw-content);inset:calc(var(--spacing) * 0)}.after\:inset-y-0:after{content:var(--tw-content);inset-block:calc(var(--spacing) * 0)}.after\:left-1\/2:after{content:var(--tw-content);left:50%}.after\:w-\[2px\]:after{content:var(--tw-content);width:2px}.after\:rounded-full:after{content:var(--tw-content);border-radius:3.40282e38px}.after\:border:after{content:var(--tw-content);border-style:var(--tw-border-style);border-width:1px}.after\:border-border:after{content:var(--tw-content);border-color:var(--border)}.after\:mix-blend-darken:after{content:var(--tw-content);mix-blend-mode:darken}.group-data-\[collapsible\=offExamples\]\:after\:left-full:is(:where(.group)[data-collapsible=offExamples] *):after{content:var(--tw-content);left:100%}.last\:mt-0:last-child{margin-top:calc(var(--spacing) * 0)}.last\:border-0:last-child{border-style:var(--tw-border-style);border-width:0}.last\:border-b-0:last-child{border-bottom-style:var(--tw-border-style);border-bottom-width:0}.focus-within\:border-ring:focus-within{border-color:var(--ring)}.focus-within\:ring-2:focus-within{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.focus-within\:ring-ring\/30:focus-within{--tw-ring-color:var(--ring)}@supports (color:color-mix(in lab, red, red)){.focus-within\:ring-ring\/30:focus-within{--tw-ring-color:color-mix(in oklab, var(--ring) 30%, transparent)}}@media (hover:hover){.hover\:bg-accent:hover{background-color:var(--accent)}.hover\:bg-destructive\/20:hover{background-color:var(--destructive)}@supports (color:color-mix(in lab, red, red)){.hover\:bg-destructive\/20:hover{background-color:color-mix(in oklab, var(--destructive) 20%, transparent)}}.hover\:bg-input\/40:hover{background-color:var(--input)}@supports (color:color-mix(in lab, red, red)){.hover\:bg-input\/40:hover{background-color:color-mix(in oklab, var(--input) 40%, transparent)}}.hover\:bg-input\/50:hover{background-color:var(--input)}@supports (color:color-mix(in lab, red, red)){.hover\:bg-input\/50:hover{background-color:color-mix(in oklab, var(--input) 50%, transparent)}}.hover\:bg-muted:hover{background-color:var(--muted)}.hover\:bg-primary\/80:hover{background-color:var(--primary)}@supports (color:color-mix(in lab, red, red)){.hover\:bg-primary\/80:hover{background-color:color-mix(in oklab, var(--primary) 80%, transparent)}}.hover\:bg-secondary\/80:hover{background-color:var(--secondary)}@supports (color:color-mix(in lab, red, red)){.hover\:bg-secondary\/80:hover{background-color:color-mix(in oklab, var(--secondary) 80%, transparent)}}.hover\:bg-sidebar-accent:hover{background-color:var(--sidebar-accent)}.hover\:text-accent-foreground:hover{color:var(--accent-foreground)}.hover\:text-foreground:hover{color:var(--foreground)}.hover\:text-muted-foreground:hover{color:var(--muted-foreground)}.hover\:text-sidebar-accent-foreground:hover{color:var(--sidebar-accent-foreground)}.hover\:underline:hover{text-decoration-line:underline}.hover\:opacity-100:hover{opacity:1}.hover\:shadow-\[0_0_0_1px_hsl\(var\(--sidebar-accent\)\)\]:hover{--tw-shadow:0 0 0 1px var(--tw-shadow-color,hsl(var(--sidebar-accent)));box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.hover\:group-data-\[collapsible\=offExamples\]\:bg-sidebar:hover:is(:where(.group)[data-collapsible=offExamples] *){background-color:var(--sidebar)}.hover\:after\:bg-sidebar-border:hover:after{content:var(--tw-content);background-color:var(--sidebar-border)}}.focus\:bg-accent:focus{background-color:var(--accent)}.focus\:text-accent-foreground:focus,:is(.focus\:\*\*\:text-accent-foreground:focus *),:is(.not-data-\[variant\=destructive\]\:focus\:\*\*\:text-accent-foreground:not([data-variant=destructive]):focus *){color:var(--accent-foreground)}.focus-visible\:border-destructive\/40:focus-visible{border-color:var(--destructive)}@supports (color:color-mix(in lab, red, red)){.focus-visible\:border-destructive\/40:focus-visible{border-color:color-mix(in oklab, var(--destructive) 40%, transparent)}}.focus-visible\:border-ring:focus-visible{border-color:var(--ring)}.focus-visible\:ring-0:focus-visible{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(0px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.focus-visible\:ring-2:focus-visible,.focus-visible\:ring-\[2px\]:focus-visible{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.focus-visible\:ring-\[3px\]:focus-visible{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(3px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.focus-visible\:ring-destructive\/20:focus-visible{--tw-ring-color:var(--destructive)}@supports (color:color-mix(in lab, red, red)){.focus-visible\:ring-destructive\/20:focus-visible{--tw-ring-color:color-mix(in oklab, var(--destructive) 20%, transparent)}}.focus-visible\:ring-ring\/30:focus-visible{--tw-ring-color:var(--ring)}@supports (color:color-mix(in lab, red, red)){.focus-visible\:ring-ring\/30:focus-visible{--tw-ring-color:color-mix(in oklab, var(--ring) 30%, transparent)}}.focus-visible\:ring-ring\/50:focus-visible{--tw-ring-color:var(--ring)}@supports (color:color-mix(in lab, red, red)){.focus-visible\:ring-ring\/50:focus-visible{--tw-ring-color:color-mix(in oklab, var(--ring) 50%, transparent)}}.focus-visible\:outline-none:focus-visible{--tw-outline-style:none;outline-style:none}.active\:bg-sidebar-accent:active{background-color:var(--sidebar-accent)}.active\:text-sidebar-accent-foreground:active{color:var(--sidebar-accent-foreground)}.disabled\:pointer-events-none:disabled{pointer-events:none}.disabled\:cursor-not-allowed:disabled{cursor:not-allowed}.disabled\:opacity-50:disabled{opacity:.5}:where([data-side=left]) .in-data-\[side\=left\]\:cursor-w-resize{cursor:w-resize}:where([data-side=right]) .in-data-\[side\=right\]\:cursor-e-resize{cursor:e-resize}:where([data-slot=combobox-content]) .in-data-\[slot\=combobox-content\]\:focus-within\:border-inherit:focus-within{border-color:inherit}:where([data-slot=combobox-content]) .in-data-\[slot\=combobox-content\]\:focus-within\:ring-0:focus-within{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(0px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.has-disabled\:pointer-events-none:has(:disabled){pointer-events:none}.has-disabled\:cursor-not-allowed:has(:disabled){cursor:not-allowed}.has-disabled\:opacity-50:has(:disabled){opacity:.5}.has-aria-invalid\:border-destructive:has([aria-invalid=true]){border-color:var(--destructive)}.has-aria-invalid\:ring-2:has([aria-invalid=true]){--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.has-aria-invalid\:ring-destructive\/20:has([aria-invalid=true]){--tw-ring-color:var(--destructive)}@supports (color:color-mix(in lab, red, red)){.has-aria-invalid\:ring-destructive\/20:has([aria-invalid=true]){--tw-ring-color:color-mix(in oklab, var(--destructive) 20%, transparent)}}.has-data-checked\:bg-primary\/5:has([data-checked]){background-color:var(--primary)}@supports (color:color-mix(in lab, red, red)){.has-data-checked\:bg-primary\/5:has([data-checked]){background-color:color-mix(in oklab, var(--primary) 5%, transparent)}}.has-data-\[align\=block-end\]\:rounded-md:has([data-align=block-end]),.has-data-\[align\=block-start\]\:rounded-md:has([data-align=block-start]){border-radius:var(--radius)}.has-data-\[icon\=inline-end\]\:pr-1\.5:has([data-icon=inline-end]){padding-right:calc(var(--spacing) * 1.5)}.has-data-\[icon\=inline-end\]\:pr-2:has([data-icon=inline-end]){padding-right:calc(var(--spacing) * 2)}.has-data-\[icon\=inline-start\]\:pl-1\.5:has([data-icon=inline-start]){padding-left:calc(var(--spacing) * 1.5)}.has-data-\[icon\=inline-start\]\:pl-2:has([data-icon=inline-start]){padding-left:calc(var(--spacing) * 2)}.has-data-\[slot\=alert-dialog-media\]\:grid-rows-\[auto_auto_1fr\]:has([data-slot=alert-dialog-media]){grid-template-rows:auto auto 1fr}.has-data-\[slot\=alert-dialog-media\]\:gap-x-4:has([data-slot=alert-dialog-media]){column-gap:calc(var(--spacing) * 4)}.has-data-\[slot\=card-action\]\:grid-cols-\[1fr_auto\]:has([data-slot=card-action]){grid-template-columns:1fr auto}.has-data-\[slot\=card-description\]\:grid-rows-\[auto_auto\]:has([data-slot=card-description]){grid-template-rows:auto auto}.has-data-\[slot\=combobox-chip\]\:px-1:has([data-slot=combobox-chip]){padding-inline:calc(var(--spacing) * 1)}.has-data-\[slot\=combobox-chip-remove\]\:pr-0:has([data-slot=combobox-chip-remove]){padding-right:calc(var(--spacing) * 0)}.has-data-\[variant\=inset\]\:bg-sidebar:has([data-variant=inset]){background-color:var(--sidebar)}.has-\[\[data-slot\=input-group-control\]\:focus-visible\]\:border-ring:has([data-slot=input-group-control]:focus-visible){border-color:var(--ring)}.has-\[\[data-slot\=input-group-control\]\:focus-visible\]\:ring-2:has([data-slot=input-group-control]:focus-visible){--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.has-\[\[data-slot\=input-group-control\]\:focus-visible\]\:ring-ring\/30:has([data-slot=input-group-control]:focus-visible){--tw-ring-color:var(--ring)}@supports (color:color-mix(in lab, red, red)){.has-\[\[data-slot\=input-group-control\]\:focus-visible\]\:ring-ring\/30:has([data-slot=input-group-control]:focus-visible){--tw-ring-color:color-mix(in oklab, var(--ring) 30%, transparent)}}.has-\[\[data-slot\]\[aria-invalid\=true\]\]\:border-destructive:has([data-slot][aria-invalid=true]){border-color:var(--destructive)}.has-\[\[data-slot\]\[aria-invalid\=true\]\]\:ring-2:has([data-slot][aria-invalid=true]){--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.has-\[\[data-slot\]\[aria-invalid\=true\]\]\:ring-destructive\/20:has([data-slot][aria-invalid=true]){--tw-ring-color:var(--destructive)}@supports (color:color-mix(in lab, red, red)){.has-\[\[data-slot\]\[aria-invalid\=true\]\]\:ring-destructive\/20:has([data-slot][aria-invalid=true]){--tw-ring-color:color-mix(in oklab, var(--destructive) 20%, transparent)}}.has-\[textarea\]\:rounded-md:has(:is(textarea)){border-radius:var(--radius)}.has-\[\>\[data-align\=block-end\]\]\:h-auto:has(>[data-align=block-end]){height:auto}.has-\[\>\[data-align\=block-end\]\]\:flex-col:has(>[data-align=block-end]){flex-direction:column}.has-\[\>\[data-align\=block-start\]\]\:h-auto:has(>[data-align=block-start]){height:auto}.has-\[\>\[data-align\=block-start\]\]\:flex-col:has(>[data-align=block-start]){flex-direction:column}.has-\[\>\[data-slot\=checkbox-group\]\]\:gap-3:has(>[data-slot=checkbox-group]){gap:calc(var(--spacing) * 3)}.has-\[\>\[data-slot\=field-content\]\]\:items-start:has(>[data-slot=field-content]){align-items:flex-start}.has-\[\>\[data-slot\=field\]\]\:w-full:has(>[data-slot=field]){width:100%}.has-\[\>\[data-slot\=field\]\]\:flex-col:has(>[data-slot=field]){flex-direction:column}.has-\[\>\[data-slot\=field\]\]\:rounded-md:has(>[data-slot=field]){border-radius:var(--radius)}.has-\[\>\[data-slot\=field\]\]\:border:has(>[data-slot=field]){border-style:var(--tw-border-style);border-width:1px}.has-\[\>\[data-slot\=radio-group\]\]\:gap-3:has(>[data-slot=radio-group]){gap:calc(var(--spacing) * 3)}.has-\[\>button\]\:mr-\[-0\.275rem\]:has(>button){margin-right:-.275rem}.has-\[\>button\]\:ml-\[-0\.275rem\]:has(>button){margin-left:-.275rem}.has-\[\>img\:first-child\]\:pt-0:has(>img:first-child){padding-top:calc(var(--spacing) * 0)}.has-\[\>kbd\]\:mr-\[-0\.275rem\]:has(>kbd){margin-right:-.275rem}.has-\[\>kbd\]\:ml-\[-0\.275rem\]:has(>kbd){margin-left:-.275rem}.has-\[\>svg\]\:p-0:has(>svg){padding:calc(var(--spacing) * 0)}.has-\[\>textarea\]\:h-auto:has(>textarea){height:auto}.aria-disabled\:pointer-events-none[aria-disabled=true]{pointer-events:none}.aria-disabled\:opacity-50[aria-disabled=true]{opacity:.5}.aria-expanded\:bg-muted[aria-expanded=true]{background-color:var(--muted)}.aria-expanded\:bg-secondary[aria-expanded=true]{background-color:var(--secondary)}.aria-expanded\:text-foreground[aria-expanded=true]{color:var(--foreground)}.aria-expanded\:text-secondary-foreground[aria-expanded=true]{color:var(--secondary-foreground)}.aria-invalid\:border-destructive[aria-invalid=true]{border-color:var(--destructive)}.aria-invalid\:ring-0[aria-invalid=true]{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(0px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.aria-invalid\:ring-2[aria-invalid=true],.aria-invalid\:ring-\[2px\][aria-invalid=true]{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.aria-invalid\:ring-destructive\/20[aria-invalid=true]{--tw-ring-color:var(--destructive)}@supports (color:color-mix(in lab, red, red)){.aria-invalid\:ring-destructive\/20[aria-invalid=true]{--tw-ring-color:color-mix(in oklab, var(--destructive) 20%, transparent)}}.data-active\:bg-sidebar-accent[data-active]{background-color:var(--sidebar-accent)}.data-active\:font-medium[data-active]{--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium)}.data-active\:text-sidebar-accent-foreground[data-active]{color:var(--sidebar-accent-foreground)}.data-closed\:overflow-hidden[data-closed]{overflow:hidden}.data-disabled\:pointer-events-none[data-disabled]{pointer-events:none}.data-disabled\:opacity-50[data-disabled]{opacity:.5}.data-empty\:p-0[data-empty]{padding:calc(var(--spacing) * 0)}.data-ending-style\:opacity-0[data-ending-style]{opacity:0}.data-highlighted\:bg-accent[data-highlighted]{background-color:var(--accent)}.data-highlighted\:text-accent-foreground[data-highlighted],:is(.not-data-\[variant\=destructive\]\:data-highlighted\:\*\*\:text-accent-foreground:not([data-variant=destructive])[data-highlighted] *){color:var(--accent-foreground)}.data-inset\:pl-8[data-inset]{padding-left:calc(var(--spacing) * 8)}.data-open\:bg-accent[data-open]{background-color:var(--accent)}.data-open\:text-accent-foreground[data-open]{color:var(--accent-foreground)}.data-open\:opacity-100[data-open]{opacity:1}@media (hover:hover){.data-open\:hover\:bg-sidebar-accent[data-open]:hover{background-color:var(--sidebar-accent)}.data-open\:hover\:text-sidebar-accent-foreground[data-open]:hover{color:var(--sidebar-accent-foreground)}}.data-placeholder\:text-muted-foreground[data-placeholder]{color:var(--muted-foreground)}.data-pressed\:bg-transparent[data-pressed]{background-color:#0000}.data-selected\:border-primary[data-selected]{border-color:var(--primary)}.data-selected\:text-foreground[data-selected]{color:var(--foreground)}.data-starting-style\:opacity-0[data-starting-style]{opacity:0}.data-\[chips\=true\]\:min-w-\(--anchor-width\)[data-chips=true]{min-width:var(--anchor-width)}.data-\[invalid\=true\]\:text-destructive[data-invalid=true]{color:var(--destructive)}.data-\[orientation\=horizontal\]\:h-px[data-orientation=horizontal]{height:1px}.data-\[orientation\=horizontal\]\:w-full[data-orientation=horizontal]{width:100%}.data-\[orientation\=vertical\]\:w-px[data-orientation=vertical]{width:1px}.data-\[orientation\=vertical\]\:self-stretch[data-orientation=vertical]{align-self:stretch}.data-\[side\=bottom\]\:inset-x-0[data-side=bottom]{inset-inline:calc(var(--spacing) * 0)}.data-\[side\=bottom\]\:top-1[data-side=bottom]{top:calc(var(--spacing) * 1)}.data-\[side\=bottom\]\:bottom-0[data-side=bottom]{bottom:calc(var(--spacing) * 0)}.data-\[side\=bottom\]\:h-auto[data-side=bottom]{height:auto}.data-\[side\=bottom\]\:border-t[data-side=bottom]{border-top-style:var(--tw-border-style);border-top-width:1px}.data-\[side\=left\]\:inset-y-0[data-side=left]{inset-block:calc(var(--spacing) * 0)}.data-\[side\=left\]\:top-1\/2\![data-side=left]{top:50%!important}.data-\[side\=left\]\:-right-1[data-side=left]{right:calc(var(--spacing) * -1)}.data-\[side\=left\]\:left-0[data-side=left]{left:calc(var(--spacing) * 0)}.data-\[side\=left\]\:h-full[data-side=left]{height:100%}.data-\[side\=left\]\:w-3\/4[data-side=left]{width:75%}.data-\[side\=left\]\:-translate-y-1\/2[data-side=left]{--tw-translate-y:calc(calc(1 / 2 * 100%) * -1);translate:var(--tw-translate-x) var(--tw-translate-y)}.data-\[side\=left\]\:border-r[data-side=left]{border-right-style:var(--tw-border-style);border-right-width:1px}.data-\[side\=right\]\:inset-y-0[data-side=right]{inset-block:calc(var(--spacing) * 0)}.data-\[side\=right\]\:top-1\/2\![data-side=right]{top:50%!important}.data-\[side\=right\]\:right-0[data-side=right]{right:calc(var(--spacing) * 0)}.data-\[side\=right\]\:-left-1[data-side=right]{left:calc(var(--spacing) * -1)}.data-\[side\=right\]\:h-full[data-side=right]{height:100%}.data-\[side\=right\]\:w-3\/4[data-side=right]{width:75%}.data-\[side\=right\]\:-translate-y-1\/2[data-side=right]{--tw-translate-y:calc(calc(1 / 2 * 100%) * -1);translate:var(--tw-translate-x) var(--tw-translate-y)}.data-\[side\=right\]\:border-l[data-side=right]{border-left-style:var(--tw-border-style);border-left-width:1px}.data-\[side\=top\]\:inset-x-0[data-side=top]{inset-inline:calc(var(--spacing) * 0)}.data-\[side\=top\]\:top-0[data-side=top]{top:calc(var(--spacing) * 0)}.data-\[side\=top\]\:-bottom-2\.5[data-side=top]{bottom:calc(var(--spacing) * -2.5)}.data-\[side\=top\]\:h-auto[data-side=top]{height:auto}.data-\[side\=top\]\:border-b[data-side=top]{border-bottom-style:var(--tw-border-style);border-bottom-width:1px}.data-\[size\=default\]\:h-7[data-size=default]{height:calc(var(--spacing) * 7)}.data-\[size\=default\]\:max-w-xs[data-size=default]{max-width:var(--container-xs)}.data-\[size\=lg\]\:size-10[data-size=lg]{width:calc(var(--spacing) * 10);height:calc(var(--spacing) * 10)}.data-\[size\=md\]\:text-xs[data-size=md]{font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height))}.data-\[size\=sm\]\:size-6[data-size=sm]{width:calc(var(--spacing) * 6);height:calc(var(--spacing) * 6)}.data-\[size\=sm\]\:h-6[data-size=sm]{height:calc(var(--spacing) * 6)}.data-\[size\=sm\]\:max-w-64[data-size=sm]{max-width:calc(var(--spacing) * 64)}.data-\[size\=sm\]\:gap-3[data-size=sm]{gap:calc(var(--spacing) * 3)}.data-\[size\=sm\]\:py-3[data-size=sm]{padding-block:calc(var(--spacing) * 3)}.data-\[size\=sm\]\:text-xs[data-size=sm]{font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height))}:is(.\*\:data-\[slot\=avatar\]\:ring-2>*)[data-slot=avatar]{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}:is(.\*\:data-\[slot\=avatar\]\:ring-background>*)[data-slot=avatar]{--tw-ring-color:var(--background)}.data-\[slot\=checkbox-group\]\:gap-3[data-slot=checkbox-group]{gap:calc(var(--spacing) * 3)}:is(.\*\:data-\[slot\=field\]\:p-2>*)[data-slot=field]{padding:calc(var(--spacing) * 2)}:is(.\*\:data-\[slot\=field-group\]\:gap-4>*)[data-slot=field-group]{gap:calc(var(--spacing) * 4)}:is(.\*\:data-\[slot\=input-group\]\:m-1>*)[data-slot=input-group]{margin:calc(var(--spacing) * 1)}:is(.\*\:data-\[slot\=input-group\]\:mb-0>*)[data-slot=input-group]{margin-bottom:calc(var(--spacing) * 0)}:is(.\*\:data-\[slot\=input-group\]\:h-7>*)[data-slot=input-group]{height:calc(var(--spacing) * 7)}:is(.\*\:data-\[slot\=input-group\]\:border-none>*)[data-slot=input-group]{--tw-border-style:none;border-style:none}:is(.\*\:data-\[slot\=input-group\]\:bg-input\/20>*)[data-slot=input-group]{background-color:var(--input)}@supports (color:color-mix(in lab, red, red)){:is(.\*\:data-\[slot\=input-group\]\:bg-input\/20>*)[data-slot=input-group]{background-color:color-mix(in oklab, var(--input) 20%, transparent)}}:is(.\*\:data-\[slot\=input-group\]\:shadow-none>*)[data-slot=input-group]{--tw-shadow:0 0 #0000;box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}:is(.\*\*\:data-\[slot\=kbd\]\:rounded-\[calc\(var\(--radius-sm\)-2px\)\] *)[data-slot=kbd]{border-radius:calc(var(--radius-sm) - 2px)}:is(.\*\*\:data-\[slot\=kbd\]\:rounded-md *)[data-slot=kbd]{border-radius:var(--radius)}:is(.\*\*\:data-\[slot\=kbd\]\:bg-muted-foreground\/10 *)[data-slot=kbd]{background-color:var(--muted-foreground)}@supports (color:color-mix(in lab, red, red)){:is(.\*\*\:data-\[slot\=kbd\]\:bg-muted-foreground\/10 *)[data-slot=kbd]{background-color:color-mix(in oklab, var(--muted-foreground) 10%, transparent)}}:is(.\*\*\:data-\[slot\=kbd\]\:px-1 *)[data-slot=kbd]{padding-inline:calc(var(--spacing) * 1)}:is(.\*\*\:data-\[slot\=kbd\]\:text-\[0\.625rem\] *)[data-slot=kbd]{font-size:.625rem}:is(.\*\:data-\[slot\=select-value\]\:line-clamp-1>*)[data-slot=select-value]{-webkit-line-clamp:1;-webkit-box-orient:vertical;display:-webkit-box;overflow:hidden}:is(.\*\:data-\[slot\=select-value\]\:flex>*)[data-slot=select-value]{display:flex}:is(.\*\:data-\[slot\=select-value\]\:items-center>*)[data-slot=select-value]{align-items:center}:is(.\*\:data-\[slot\=select-value\]\:gap-1\.5>*)[data-slot=select-value]{gap:calc(var(--spacing) * 1.5)}.data-\[variant\=destructive\]\:text-destructive[data-variant=destructive]{color:var(--destructive)}.data-\[variant\=destructive\]\:focus\:bg-destructive\/10[data-variant=destructive]:focus{background-color:var(--destructive)}@supports (color:color-mix(in lab, red, red)){.data-\[variant\=destructive\]\:focus\:bg-destructive\/10[data-variant=destructive]:focus{background-color:color-mix(in oklab, var(--destructive) 10%, transparent)}}.data-\[variant\=destructive\]\:focus\:text-destructive[data-variant=destructive]:focus{color:var(--destructive)}.data-\[variant\=label\]\:text-xs\/relaxed[data-variant=label]{font-size:var(--text-xs);line-height:var(--leading-relaxed)}.data-\[variant\=legend\]\:text-sm[data-variant=legend]{font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height))}.nth-last-2\:-mt-1:nth-last-child(2){margin-top:calc(var(--spacing) * -1)}@supports ((-webkit-backdrop-filter:var(--tw)) or (backdrop-filter:var(--tw))){.supports-backdrop-filter\:backdrop-blur-xs{--tw-backdrop-blur:blur(var(--blur-xs));-webkit-backdrop-filter:var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,);backdrop-filter:var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,)}}@media (width>=40rem){.sm\:flex{display:flex}.sm\:max-w-lg{max-width:var(--container-lg)}.sm\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.sm\:grid-cols-\[1fr_1fr_1fr_auto\]{grid-template-columns:1fr 1fr 1fr auto}.sm\:grid-cols-\[1fr_auto\]{grid-template-columns:1fr auto}.sm\:flex-row{flex-direction:row}.sm\:justify-end{justify-content:flex-end}.sm\:group-data-\[size\=default\]\/alert-dialog-content\:row-span-2:is(:where(.group\/alert-dialog-content)[data-size=default] *){grid-row:span 2/span 2}.sm\:group-data-\[size\=default\]\/alert-dialog-content\:place-items-start:is(:where(.group\/alert-dialog-content)[data-size=default] *){place-items:start}.sm\:group-data-\[size\=default\]\/alert-dialog-content\:text-left:is(:where(.group\/alert-dialog-content)[data-size=default] *){text-align:left}.sm\:group-data-\[size\=default\]\/alert-dialog-content\:group-has-data-\[slot\=alert-dialog-media\]\/alert-dialog-content\:col-start-2:is(:where(.group\/alert-dialog-content)[data-size=default] *):is(:where(.group\/alert-dialog-content):has([data-slot=alert-dialog-media]) *){grid-column-start:2}.sm\:group-data-\[size\=default\]\/alert-dialog-content\:has-data-\[slot\=alert-dialog-media\]\:grid-rows-\[auto_1fr\]:is(:where(.group\/alert-dialog-content)[data-size=default] *):has([data-slot=alert-dialog-media]){grid-template-rows:auto 1fr}.data-\[side\=left\]\:sm\:max-w-sm[data-side=left],.data-\[side\=right\]\:sm\:max-w-sm[data-side=right],.data-\[size\=default\]\:sm\:max-w-sm[data-size=default]{max-width:var(--container-sm)}}@media (width>=48rem){.md\:block{display:block}.md\:flex{display:flex}.md\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.md\:grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.md\:grid-cols-4{grid-template-columns:repeat(4,minmax(0,1fr))}.md\:text-xs\/relaxed{font-size:var(--text-xs);line-height:var(--leading-relaxed)}.md\:text-pretty{text-wrap:pretty}.md\:opacity-0{opacity:0}.md\:peer-data-\[variant\=inset\]\:m-2:is(:where(.peer)[data-variant=inset]~*){margin:calc(var(--spacing) * 2)}.md\:peer-data-\[variant\=inset\]\:ml-0:is(:where(.peer)[data-variant=inset]~*){margin-left:calc(var(--spacing) * 0)}.md\:peer-data-\[variant\=inset\]\:rounded-xl:is(:where(.peer)[data-variant=inset]~*){border-radius:calc(var(--radius) + 4px)}.md\:peer-data-\[variant\=inset\]\:shadow-sm:is(:where(.peer)[data-variant=inset]~*){--tw-shadow:0 1px 3px 0 var(--tw-shadow-color,#0000001a), 0 1px 2px -1px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.md\:peer-data-\[variant\=inset\]\:peer-data-\[state\=collapsed\]\:ml-2:is(:where(.peer)[data-variant=inset]~*):is(:where(.peer)[data-state=collapsed]~*){margin-left:calc(var(--spacing) * 2)}.md\:after\:hidden:after{content:var(--tw-content);display:none}}@media (width>=64rem){.lg\:block{display:block}}@media (width>=80rem){.xl\:grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}}@container field-group (width>=28rem){.\@md\/field-group\:flex-row{flex-direction:row}.\@md\/field-group\:items-center{align-items:center}.\@md\/field-group\:has-\[\>\[data-slot\=field-content\]\]\:items-start:has(>[data-slot=field-content]){align-items:flex-start}}.dark\:scale-0:where(.dark,.dark *){--tw-scale-x:0%;--tw-scale-y:0%;--tw-scale-z:0%;scale:var(--tw-scale-x) var(--tw-scale-y)}.dark\:scale-100:where(.dark,.dark *){--tw-scale-x:100%;--tw-scale-y:100%;--tw-scale-z:100%;scale:var(--tw-scale-x) var(--tw-scale-y)}.dark\:-rotate-90:where(.dark,.dark *){rotate:-90deg}.dark\:rotate-0:where(.dark,.dark *){rotate:0deg}.dark\:bg-destructive\/20:where(.dark,.dark *){background-color:var(--destructive)}@supports (color:color-mix(in lab, red, red)){.dark\:bg-destructive\/20:where(.dark,.dark *){background-color:color-mix(in oklab, var(--destructive) 20%, transparent)}}.dark\:bg-gray-800:where(.dark,.dark *){background-color:var(--color-gray-800)}.dark\:bg-green-900:where(.dark,.dark *){background-color:var(--color-green-900)}.dark\:bg-input\/30:where(.dark,.dark *){background-color:var(--input)}@supports (color:color-mix(in lab, red, red)){.dark\:bg-input\/30:where(.dark,.dark *){background-color:color-mix(in oklab, var(--input) 30%, transparent)}}.dark\:bg-muted\/30:where(.dark,.dark *){background-color:var(--muted)}@supports (color:color-mix(in lab, red, red)){.dark\:bg-muted\/30:where(.dark,.dark *){background-color:color-mix(in oklab, var(--muted) 30%, transparent)}}.dark\:bg-popover:where(.dark,.dark *){background-color:var(--popover)}.dark\:bg-red-900:where(.dark,.dark *){background-color:var(--color-red-900)}.dark\:bg-transparent:where(.dark,.dark *){background-color:#0000}.dark\:bg-yellow-900:where(.dark,.dark *){background-color:var(--color-yellow-900)}.dark\:bg-zinc-800:where(.dark,.dark *){background-color:var(--color-zinc-800)}.dark\:text-emerald-400:where(.dark,.dark *){color:var(--color-emerald-400)}.dark\:text-gray-100:where(.dark,.dark *){color:var(--color-gray-100)}.dark\:text-green-100:where(.dark,.dark *){color:var(--color-green-100)}.dark\:text-red-100:where(.dark,.dark *){color:var(--color-red-100)}.dark\:text-yellow-100:where(.dark,.dark *){color:var(--color-yellow-100)}.dark\:text-zinc-400:where(.dark,.dark *){color:var(--color-zinc-400)}.dark\:invert:where(.dark,.dark *){--tw-invert:invert(100%);filter:var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,)}.dark\:after\:mix-blend-lighten:where(.dark,.dark *):after{content:var(--tw-content);mix-blend-mode:lighten}@media (hover:hover){.dark\:hover\:bg-destructive\/30:where(.dark,.dark *):hover{background-color:var(--destructive)}@supports (color:color-mix(in lab, red, red)){.dark\:hover\:bg-destructive\/30:where(.dark,.dark *):hover{background-color:color-mix(in oklab, var(--destructive) 30%, transparent)}}.dark\:hover\:bg-input\/50:where(.dark,.dark *):hover{background-color:var(--input)}@supports (color:color-mix(in lab, red, red)){.dark\:hover\:bg-input\/50:where(.dark,.dark *):hover{background-color:color-mix(in oklab, var(--input) 50%, transparent)}}.dark\:hover\:bg-muted\/50:where(.dark,.dark *):hover{background-color:var(--muted)}@supports (color:color-mix(in lab, red, red)){.dark\:hover\:bg-muted\/50:where(.dark,.dark *):hover{background-color:color-mix(in oklab, var(--muted) 50%, transparent)}}}.dark\:focus-visible\:ring-destructive\/40:where(.dark,.dark *):focus-visible{--tw-ring-color:var(--destructive)}@supports (color:color-mix(in lab, red, red)){.dark\:focus-visible\:ring-destructive\/40:where(.dark,.dark *):focus-visible{--tw-ring-color:color-mix(in oklab, var(--destructive) 40%, transparent)}}.dark\:has-aria-invalid\:border-destructive\/50:where(.dark,.dark *):has([aria-invalid=true]){border-color:var(--destructive)}@supports (color:color-mix(in lab, red, red)){.dark\:has-aria-invalid\:border-destructive\/50:where(.dark,.dark *):has([aria-invalid=true]){border-color:color-mix(in oklab, var(--destructive) 50%, transparent)}}.dark\:has-aria-invalid\:ring-destructive\/40:where(.dark,.dark *):has([aria-invalid=true]){--tw-ring-color:var(--destructive)}@supports (color:color-mix(in lab, red, red)){.dark\:has-aria-invalid\:ring-destructive\/40:where(.dark,.dark *):has([aria-invalid=true]){--tw-ring-color:color-mix(in oklab, var(--destructive) 40%, transparent)}}.dark\:has-data-checked\:bg-primary\/10:where(.dark,.dark *):has([data-checked]){background-color:var(--primary)}@supports (color:color-mix(in lab, red, red)){.dark\:has-data-checked\:bg-primary\/10:where(.dark,.dark *):has([data-checked]){background-color:color-mix(in oklab, var(--primary) 10%, transparent)}}.dark\:has-\[\[data-slot\]\[aria-invalid\=true\]\]\:ring-destructive\/40:where(.dark,.dark *):has([data-slot][aria-invalid=true]){--tw-ring-color:var(--destructive)}@supports (color:color-mix(in lab, red, red)){.dark\:has-\[\[data-slot\]\[aria-invalid\=true\]\]\:ring-destructive\/40:where(.dark,.dark *):has([data-slot][aria-invalid=true]){--tw-ring-color:color-mix(in oklab, var(--destructive) 40%, transparent)}}.dark\:aria-invalid\:border-destructive\/50:where(.dark,.dark *)[aria-invalid=true]{border-color:var(--destructive)}@supports (color:color-mix(in lab, red, red)){.dark\:aria-invalid\:border-destructive\/50:where(.dark,.dark *)[aria-invalid=true]{border-color:color-mix(in oklab, var(--destructive) 50%, transparent)}}.dark\:aria-invalid\:ring-destructive\/40:where(.dark,.dark *)[aria-invalid=true]{--tw-ring-color:var(--destructive)}@supports (color:color-mix(in lab, red, red)){.dark\:aria-invalid\:ring-destructive\/40:where(.dark,.dark *)[aria-invalid=true]{--tw-ring-color:color-mix(in oklab, var(--destructive) 40%, transparent)}}.dark\:data-\[variant\=destructive\]\:focus\:bg-destructive\/20:where(.dark,.dark *)[data-variant=destructive]:focus{background-color:var(--destructive)}@supports (color:color-mix(in lab, red, red)){.dark\:data-\[variant\=destructive\]\:focus\:bg-destructive\/20:where(.dark,.dark *)[data-variant=destructive]:focus{background-color:color-mix(in oklab, var(--destructive) 20%, transparent)}}.\[\&_svg\]\:pointer-events-none svg{pointer-events:none}.\[\&_svg\]\:size-4 svg{width:calc(var(--spacing) * 4);height:calc(var(--spacing) * 4)}.\[\&_svg\]\:shrink-0 svg{flex-shrink:0}.\[\&_svg\:not\(\[class\*\=\'size-\'\]\)\]\:size-2\.5 svg:not([class*=size-]){width:calc(var(--spacing) * 2.5);height:calc(var(--spacing) * 2.5)}.\[\&_svg\:not\(\[class\*\=\'size-\'\]\)\]\:size-3 svg:not([class*=size-]){width:calc(var(--spacing) * 3);height:calc(var(--spacing) * 3)}.\[\&_svg\:not\(\[class\*\=\'size-\'\]\)\]\:size-3\.5 svg:not([class*=size-]){width:calc(var(--spacing) * 3.5);height:calc(var(--spacing) * 3.5)}.\[\&_svg\:not\(\[class\*\=\'size-\'\]\)\]\:size-4 svg:not([class*=size-]){width:calc(var(--spacing) * 4);height:calc(var(--spacing) * 4)}.\[\.border-b\]\:pb-2.border-b{padding-bottom:calc(var(--spacing) * 2)}.\[\.border-b\]\:pb-4.border-b{padding-bottom:calc(var(--spacing) * 4)}.group-data-\[size\=sm\]\/card\:\[\.border-b\]\:pb-3:is(:where(.group\/card)[data-size=sm] *).border-b{padding-bottom:calc(var(--spacing) * 3)}.\[\.border-t\]\:pt-2.border-t{padding-top:calc(var(--spacing) * 2)}.\[\.border-t\]\:pt-4.border-t{padding-top:calc(var(--spacing) * 4)}.group-data-\[size\=sm\]\/card\:\[\.border-t\]\:pt-3:is(:where(.group\/card)[data-size=sm] *).border-t{padding-top:calc(var(--spacing) * 3)}:is(.\*\:\[a\]\:underline>*):is(a){text-decoration-line:underline}:is(.\*\:\[a\]\:underline-offset-3>*):is(a){text-underline-offset:3px}@media (hover:hover){.\[a\]\:hover\:bg-destructive\/20:is(a):hover{background-color:var(--destructive)}@supports (color:color-mix(in lab, red, red)){.\[a\]\:hover\:bg-destructive\/20:is(a):hover{background-color:color-mix(in oklab, var(--destructive) 20%, transparent)}}.\[a\]\:hover\:bg-muted:is(a):hover{background-color:var(--muted)}.\[a\]\:hover\:bg-primary\/80:is(a):hover{background-color:var(--primary)}@supports (color:color-mix(in lab, red, red)){.\[a\]\:hover\:bg-primary\/80:is(a):hover{background-color:color-mix(in oklab, var(--primary) 80%, transparent)}}.\[a\]\:hover\:bg-secondary\/80:is(a):hover{background-color:var(--secondary)}@supports (color:color-mix(in lab, red, red)){.\[a\]\:hover\:bg-secondary\/80:is(a):hover{background-color:color-mix(in oklab, var(--secondary) 80%, transparent)}}.\[a\]\:hover\:text-muted-foreground:is(a):hover{color:var(--muted-foreground)}:is(.\*\:\[a\]\:hover\:text-foreground>*):is(a):hover{color:var(--foreground)}}:is(.\*\:\[img\:first-child\]\:rounded-t-lg>*):is(img:first-child){border-top-left-radius:calc(var(--radius) + 2px);border-top-right-radius:calc(var(--radius) + 2px)}:is(.\*\:\[img\:last-child\]\:rounded-b-lg>*):is(img:last-child){border-bottom-right-radius:calc(var(--radius) + 2px);border-bottom-left-radius:calc(var(--radius) + 2px)}:is(.\*\:\[span\]\:last\:flex>*):is(span):last-child{display:flex}:is(.\*\:\[span\]\:last\:items-center>*):is(span):last-child{align-items:center}:is(.\*\:\[span\]\:last\:gap-2>*):is(span):last-child{gap:calc(var(--spacing) * 2)}:is(.data-\[variant\=destructive\]\:\*\:\[svg\]\:text-destructive[data-variant=destructive]>*):is(svg){color:var(--destructive)}:is(.\*\:\[svg\:not\(\[class\*\=\'size-\'\]\)\]\:size-4>*):is(svg:not([class*=size-])){width:calc(var(--spacing) * 4);height:calc(var(--spacing) * 4)}.\[\&\>\*\]\:w-full>*{width:100%}@container field-group (width>=28rem){.\@md\/field-group\:\[\&\>\*\]\:w-auto>*{width:auto}}.\[\&\>\.sr-only\]\:w-auto>.sr-only{width:auto}.\[\&\>\[data-slot\=field-label\]\]\:flex-auto>[data-slot=field-label]{flex:auto}@container field-group (width>=28rem){.\@md\/field-group\:\[\&\>\[data-slot\=field-label\]\]\:flex-auto>[data-slot=field-label]{flex:auto}}.has-\[\>\[data-slot\=field-content\]\]\:\[\&\>\[role\=checkbox\]\,\[role\=radio\]\]\:mt-px:has(>[data-slot=field-content])>[role=checkbox],.has-\[\>\[data-slot\=field-content\]\]\:\[\&\>\[role\=checkbox\]\,\[role\=radio\]\]\:mt-px:has(>[data-slot=field-content]) [role=radio]{margin-top:1px}@container field-group (width>=28rem){.\@md\/field-group\:has-\[\>\[data-slot\=field-content\]\]\:\[\&\>\[role\=checkbox\]\,\[role\=radio\]\]\:mt-px:has(>[data-slot=field-content])>[role=checkbox],.\@md\/field-group\:has-\[\>\[data-slot\=field-content\]\]\:\[\&\>\[role\=checkbox\]\,\[role\=radio\]\]\:mt-px:has(>[data-slot=field-content]) [role=radio]{margin-top:1px}}.\[\&\>a\]\:underline>a{text-decoration-line:underline}.\[\&\>a\]\:underline-offset-4>a{text-underline-offset:4px}.\[\&\>a\:hover\]\:text-primary>a:hover{color:var(--primary)}.\[\&\>button\]\:hidden>button{display:none}.has-\[\>\[data-align\=block-end\]\]\:\[\&\>input\]\:pt-3:has(>[data-align=block-end])>input{padding-top:calc(var(--spacing) * 3)}.has-\[\>\[data-align\=block-start\]\]\:\[\&\>input\]\:pb-3:has(>[data-align=block-start])>input{padding-bottom:calc(var(--spacing) * 3)}.has-\[\>\[data-align\=inline-end\]\]\:\[\&\>input\]\:pr-1\.5:has(>[data-align=inline-end])>input{padding-right:calc(var(--spacing) * 1.5)}.has-\[\>\[data-align\=inline-start\]\]\:\[\&\>input\]\:pl-1\.5:has(>[data-align=inline-start])>input{padding-left:calc(var(--spacing) * 1.5)}.\[\&\>span\:last-child\]\:truncate>span:last-child{text-overflow:ellipsis;white-space:nowrap;overflow:hidden}.\[\&\>svg\]\:pointer-events-none>svg{pointer-events:none}.\[\&\>svg\]\:size-2\.5\!>svg{width:calc(var(--spacing) * 2.5)!important;height:calc(var(--spacing) * 2.5)!important}.\[\&\>svg\]\:size-4>svg{width:calc(var(--spacing) * 4);height:calc(var(--spacing) * 4)}.\[\&\>svg\]\:shrink-0>svg{flex-shrink:0}.\[\&\>svg\]\:text-sidebar-accent-foreground>svg{color:var(--sidebar-accent-foreground)}.group-has-data-\[size\=lg\]\/avatar-group\:\[\&\>svg\]\:size-5:is(:where(.group\/avatar-group):has([data-size=lg]) *)>svg{width:calc(var(--spacing) * 5);height:calc(var(--spacing) * 5)}.group-has-data-\[size\=sm\]\/avatar-group\:\[\&\>svg\]\:size-3:is(:where(.group\/avatar-group):has([data-size=sm]) *)>svg{width:calc(var(--spacing) * 3);height:calc(var(--spacing) * 3)}.group-data-\[size\=default\]\/avatar\:\[\&\>svg\]\:size-2:is(:where(.group\/avatar)[data-size=default] *)>svg,.group-data-\[size\=lg\]\/avatar\:\[\&\>svg\]\:size-2:is(:where(.group\/avatar)[data-size=lg] *)>svg{width:calc(var(--spacing) * 2);height:calc(var(--spacing) * 2)}.group-data-\[size\=sm\]\/avatar\:\[\&\>svg\]\:hidden:is(:where(.group\/avatar)[data-size=sm] *)>svg{display:none}.\[\&\>svg\:not\(\[class\*\=\'size-\'\]\)\]\:size-3>svg:not([class*=size-]){width:calc(var(--spacing) * 3);height:calc(var(--spacing) * 3)}.\[\&\>svg\:not\(\[class\*\=\'size-\'\]\)\]\:size-3\.5>svg:not([class*=size-]){width:calc(var(--spacing) * 3.5);height:calc(var(--spacing) * 3.5)}[data-side=left][data-collapsible=offExamples] .\[\[data-side\=left\]\[data-collapsible\=offExamples\]_\&\]\:-right-2{right:calc(var(--spacing) * -2)}[data-side=left][data-state=collapsed] .\[\[data-side\=left\]\[data-state\=collapsed\]_\&\]\:cursor-e-resize{cursor:e-resize}[data-side=right][data-collapsible=offExamples] .\[\[data-side\=right\]\[data-collapsible\=offExamples\]_\&\]\:-left-2{left:calc(var(--spacing) * -2)}[data-side=right][data-state=collapsed] .\[\[data-side\=right\]\[data-state\=collapsed\]_\&\]\:cursor-w-resize{cursor:w-resize}[data-variant=legend]+.\[\[data-variant\=legend\]\+\&\]\:-mt-1\.5{margin-top:calc(var(--spacing) * -1.5)}}@font-face{font-family:Geist Variable;font-style:normal;font-display:swap;font-weight:100 900;src:url(/assets/geist-cyrillic-wght-normal.woff2)format("woff2-variations");unicode-range:U+301,U+400-45F,U+490-491,U+4B0-4B1,U+2116}@font-face{font-family:Geist Variable;font-style:normal;font-display:swap;font-weight:100 900;src:url(/assets/geist-latin-ext-wght-normal.woff2)format("woff2-variations");unicode-range:U+100-2BA,U+2BD-2C5,U+2C7-2CC,U+2CE-2D7,U+2DD-2FF,U+304,U+308,U+329,U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF}@font-face{font-family:Geist Variable;font-style:normal;font-display:swap;font-weight:100 900;src:url(/assets/geist-latin-wght-normal.woff2)format("woff2-variations");unicode-range:U+??,U+131,U+152-153,U+2BB-2BC,U+2C6,U+2DA,U+2DC,U+304,U+308,U+329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD}@font-face{font-family:JetBrains Mono Variable;font-style:normal;font-display:swap;font-weight:100 800;src:url(data:font/woff2;base64,d09GMgABAAAAAAfsABQAAAAAEAwAAAeCAAEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAGhwbHhwoP0hWQVJbBmA/U1RBVIFiJyYAdC9qEQgKhGSEAAsgADCGCAE2AiQDOgQgBYlMB4EUDAcbLQ4onoexrSC/2ZyLAa8p8VHB8/x3Vue+V0hVJalMJg2nx/TCrQXxBeqLjQG7FyM1WEa/X1tEXN7cFz9EJEMmMUz3RihWSSKeQCbcIou0izz/C8v+fq3VfajEa9gDD11CImXS7qL/RJFVzC1qiB6KmKeD6TZdQ6IRGv78dL6uSVVCfgni5mzu7kcgQBgAEAQTQRCoL++STTYybkJxNfQxAAIAGu8OdEB9teW2jh4BpgDqFjAeSEByW3zFP0CBBgNMsMCGEDjgggdhiEAUAeIIED7ABTDUEnkIE9Q9ahFgKttcVhApo4ACB4qobHaccgDfEjFO6aaWUhjMLt2SyIvHKoDqoA4CSUwEIYQCEjhAO9R1G6keDeDZGjNo+AhxOjCEGTr1WeIF3kYBiLAOKvkJSMiKX0VdAyQt3SDJClCkxJCHkCzfqyVTriJZLcolS32JZHUekq2TYNkYtCtjYHMQXSxGjXDz2t/yLWXzDzxz+o3zFwDEaN23F+13pyMdQAEaSKAR9vcGq4A4MTSKCElGW+M7UcY7xqkggITb28ZJhlqc9q2twYKTt0NjixBgYvO9BIihEBLYuOFXQzfIQ7dXGUEEEgFDooBfAzqiQbpJrhiWSuKJCRFKYbHCyJKI2G5GiZbNAvgAu5pc3vwx4G+g3aDkhklABiSz0BICXrYghtYhx/cdJ+44rY2oZ0aMNRFz3VZjb6W33F3gzltqtOCV8tTHSpOeXuItfvr5lCdfzFpqtEitvqdcdGGFd28ZqqC0tPbeChGXgrIlnhSWu/eUso4uKWFLugyDzQJhflY4659+WjQ++6x72WUMv9G8mw6QJl7BVxX5fe/kpUsOvnZwee9uQ0cGXYd0o89XB2748sDSnt8d2VphdOTTgceDVvOds0v9P/s7HPq15aGun/6Vllb56f1dl0t1LejqrNkpdRZsG8TOnM5vkBG5oiVyVGnS8LHps5cfNWJs6qKPfaNSxiQNBUm3cKNWROr0GSur7Za31k1vieq7LH11VF+jXdRIasRKflc7jkobm1Z9te1IyZA0pDkhLR98+H37Zf1c/8at+dB7x+7GfVyTfJMPiYztsnl59Y5l4j+0n1RXlpHnF3Tq7HecmNF/CJodEMAikruxiyJaGLvHOdAfoA+oDvpjBm2b91cHGRZMU9n25xEU0A8fgEEAdKI3Q1iDtc034sug5YVMkE2jsE+BIkwSoQ3gxXMqz9tELp48bd0cFKOKS7xYjEuXBnZP5ia7DyiO/X/YI+PQSbt2uSdqAkWL9nQbV1XB94/+uPfdZz8dnXYFBYrcTl2SIR/ybxJNJPz/Gupb0JaZeens2ekC7EKr8t+Ls/P5VJPYJdHKyqfg2nqU6bhlidzcddQV/7MmecTzJ5VPcKXkNKSEogHjYFx6QZ7rQ+FSe8njaiNuOnXS8H2ScQ619c2mC3VTtauL0rRbXd/CkSOP37FY9Zkjz8+GibYUMOEWF+RdrFS8Ecv1SHOpPUPZGEIpjPvFyU5cXKjd6OXqorTqy9GwRd++HVufPGnVsW+aO3vggKZ18jR9sXaTC1PWTEsVUaK0FkNySbTQDqlm2PfDjZcu4aalnSLKjnOoYQ0nUlqqXcGpPu/4VgV/xU2pAqW4BW3qzhQ8/hFKhV2qE3+BKAtDqBXjfgnVdH4y0wg5tbVNRenNdTWOrenWLcupQdmsbq5b+18piTe/xRdp1xbILxNPJGInm2z6hoB21Lal0i+ePTtd7B45+3XhFJ329evskXm7qurUVREotqSluSo/L29d3qDhI4YOQqWhI4YNvBNfsMHeXKemXrxQfKeuPOGRVayA3JtkJKEgbPp+dXUDluddutRYLFoXGXWX6N3WFaGLbQtRSitVYNacTNSdy7AaG/HSaUEANcBoGXNdcZvZsOqQ1icBDv21/gzAoYPHH/WDW0qNR3QTYKEAEHig6o13NXbND06CQPlRtYjGNnSktRc09k1mAMDvAlDKfQjgy6fssInlfzmNAjKkDxoxHOBLdVRAIVt9j4qo+hA1w9T1aNBNTUOTTNUHLbqokE+UAfJXCIGw/IxCSL5GRUJeR40rL/UxTm4Q08H6MbCs70ObuNyIIXrINHQYInF06UUlevTjbQzTh5upiDMzMMogUtEnjPs/Y7jAHCJeB0GBHh04tC6FiB6ZFB1oArUSIoFoqhzCeAN6lHwm0T4C3VVPWvjpSMXReuWesMEcoqrmgtNBGd2noWeV0hNAz9rFeShNJxHGsPa3HXeKTk8b55hahySYHaYKKFFLpCfN8rsoaJn01CR04Gkc+5k7KVTCmClX8Q10HCrUEkVlSX+XO33oQR9609tJ516H497WSobWs5Up6TLaS10/dessIskgJSLiDlWvHVUywpkQ7hdPZqGyiEF0uVQerVcPamT1A3eKXdyI1vG9OoflrSXihZ1qqGE3nhmAgiIbRCQgPLEPtOM3UQwTLYaYYomNlpA44opnjV6jkD6id80OOrzf6BzmMD6eEa1zKyeYG1fzfEf16V6jw9XYOaar1/b2kP/IYX8oR2mcFvv2GtBV3JXgd437AQAA)format("woff2-variations");unicode-range:U+460-52F,U+1C80-1C8A,U+20B4,U+2DE0-2DFF,U+A640-A69F,U+FE2E-FE2F}@font-face{font-family:JetBrains Mono Variable;font-style:normal;font-display:swap;font-weight:100 800;src:url(/assets/jetbrains-mono-cyrillic-wght-normal.woff2)format("woff2-variations");unicode-range:U+301,U+400-45F,U+490-491,U+4B0-4B1,U+2116}@font-face{font-family:JetBrains Mono Variable;font-style:normal;font-display:swap;font-weight:100 800;src:url(/assets/jetbrains-mono-greek-wght-normal.woff2)format("woff2-variations");unicode-range:U+370-377,U+37A-37F,U+384-38A,U+38C,U+38E-3A1,U+3A3-3FF}@font-face{font-family:JetBrains Mono Variable;font-style:normal;font-display:swap;font-weight:100 800;src:url(/assets/jetbrains-mono-vietnamese-wght-normal.woff2)format("woff2-variations");unicode-range:U+102-103,U+110-111,U+128-129,U+168-169,U+1A0-1A1,U+1AF-1B0,U+300-301,U+303-304,U+308-309,U+323,U+329,U+1EA0-1EF9,U+20AB}@font-face{font-family:JetBrains Mono Variable;font-style:normal;font-display:swap;font-weight:100 800;src:url(/assets/jetbrains-mono-latin-ext-wght-normal.woff2)format("woff2-variations");unicode-range:U+100-2BA,U+2BD-2C5,U+2C7-2CC,U+2CE-2D7,U+2DD-2FF,U+304,U+308,U+329,U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF}@font-face{font-family:JetBrains Mono Variable;font-style:normal;font-display:swap;font-weight:100 800;src:url(/assets/jetbrains-mono-latin-wght-normal.woff2)format("woff2-variations");unicode-range:U+??,U+131,U+152-153,U+2BB-2BC,U+2C6,U+2DA,U+2DC,U+304,U+308,U+329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD}:root{--font-sans:"Geist Variable", sans-serif;--font-mono:"JetBrains Mono Variable", monospace;--background:#f4f1ec;--foreground:#1d2930;--card:#f9f8f6;--card-foreground:#1d2930;--popover:#fbfaf9;--popover-foreground:#1d2930;--primary:#305030;--primary-foreground:#f5f3ef;--secondary:#eae5d7;--secondary-foreground:#263740;--muted:#e9e7e2;--muted-foreground:#576b75;--accent:#e7e2da;--accent-foreground:#1d2930;--destructive:#c32222;--destructive-foreground:#fff;--border:#b6c9b6;--input:#dedbd3;--ring:#3e743e;--chart-1:#2e6b2e;--chart-2:#cfa117;--chart-3:#297aa3;--chart-4:#cc8c33;--chart-5:#b82e2e;--radius:.25rem;--sidebar:#f5f3f0;--sidebar-background:#f5f3f0;--sidebar-foreground:#263740;--sidebar-primary:#305030;--sidebar-primary-foreground:#f5f3ef;--sidebar-accent:#e7e2da;--sidebar-accent-foreground:#1d2930;--sidebar-border:#c4d4c4;--sidebar-ring:#3e743e;--tactical-amber:#c2940a;--tactical-amber-dim:#d6b85c;--tactical-green:#267326;--tactical-green-dim:#7ab87a;--tactical-cyan:#24868f;--tactical-cyan-dim:#5eb2ba;--tactical-red:#bd2828;--tactical-olive:#64783a;--scanline-opacity:.015}.dark{--background:#0d1012;--foreground:#e0ddd1;--card:#14181a;--card-foreground:#e0ddd1;--popover:#111517;--popover-foreground:#e0ddd1;--primary:#fa0;--primary-foreground:#0d1012;--secondary:#323922;--secondary-foreground:#c6d2ac;--muted:#202527;--muted-foreground:#819098;--accent:#292f32;--accent-foreground:#ebe8e0;--destructive:#c32222;--destructive-foreground:#fff;--border:#203c32;--input:#292f32;--ring:#ffbf00;--chart-1:#ffbf00;--chart-2:#22c38e;--chart-3:#1aa1e6;--chart-4:#86ac39;--chart-5:#d92626;--sidebar:#111517;--sidebar-background:#111517;--sidebar-foreground:#e0ddd1;--sidebar-primary:#ffbf00;--sidebar-primary-foreground:#0d1012;--sidebar-accent:#292f32;--sidebar-accent-foreground:#ebe8e0;--sidebar-border:#203c32;--sidebar-ring:#ffbf00;--tactical-amber:#ffbf00;--tactical-amber-dim:#a17d12;--tactical-green:#22c38e;--tactical-green-dim:#20604a;--tactical-cyan:#17bfcf;--tactical-cyan-dim:#196066;--tactical-red:#d92626;--tactical-olive:#576b2e;--scanline-opacity:.03}@property --tw-translate-x{syntax:"*";inherits:false;initial-value:0}@property --tw-translate-y{syntax:"*";inherits:false;initial-value:0}@property --tw-translate-z{syntax:"*";inherits:false;initial-value:0}@property --tw-scale-x{syntax:"*";inherits:false;initial-value:1}@property --tw-scale-y{syntax:"*";inherits:false;initial-value:1}@property --tw-scale-z{syntax:"*";inherits:false;initial-value:1}@property --tw-rotate-x{syntax:"*";inherits:false}@property --tw-rotate-y{syntax:"*";inherits:false}@property --tw-rotate-z{syntax:"*";inherits:false}@property --tw-skew-x{syntax:"*";inherits:false}@property --tw-skew-y{syntax:"*";inherits:false}@property --tw-pan-x{syntax:"*";inherits:false}@property --tw-pan-y{syntax:"*";inherits:false}@property --tw-pinch-zoom{syntax:"*";inherits:false}@property --tw-space-y-reverse{syntax:"*";inherits:false;initial-value:0}@property --tw-space-x-reverse{syntax:"*";inherits:false;initial-value:0}@property --tw-divide-x-reverse{syntax:"*";inherits:false;initial-value:0}@property --tw-border-style{syntax:"*";inherits:false;initial-value:solid}@property --tw-divide-y-reverse{syntax:"*";inherits:false;initial-value:0}@property --tw-leading{syntax:"*";inherits:false}@property --tw-font-weight{syntax:"*";inherits:false}@property --tw-tracking{syntax:"*";inherits:false}@property --tw-ordinal{syntax:"*";inherits:false}@property --tw-slashed-zero{syntax:"*";inherits:false}@property --tw-numeric-figure{syntax:"*";inherits:false}@property --tw-numeric-spacing{syntax:"*";inherits:false}@property --tw-numeric-fraction{syntax:"*";inherits:false}@property --tw-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-shadow-color{syntax:"*";inherits:false}@property --tw-shadow-alpha{syntax:"";inherits:false;initial-value:100%}@property --tw-inset-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-inset-shadow-color{syntax:"*";inherits:false}@property --tw-inset-shadow-alpha{syntax:"";inherits:false;initial-value:100%}@property --tw-ring-color{syntax:"*";inherits:false}@property --tw-ring-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-inset-ring-color{syntax:"*";inherits:false}@property --tw-inset-ring-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-ring-inset{syntax:"*";inherits:false}@property --tw-ring-offset-width{syntax:"";inherits:false;initial-value:0}@property --tw-ring-offset-color{syntax:"*";inherits:false;initial-value:#fff}@property --tw-ring-offset-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-outline-style{syntax:"*";inherits:false;initial-value:solid}@property --tw-blur{syntax:"*";inherits:false}@property --tw-brightness{syntax:"*";inherits:false}@property --tw-contrast{syntax:"*";inherits:false}@property --tw-grayscale{syntax:"*";inherits:false}@property --tw-hue-rotate{syntax:"*";inherits:false}@property --tw-invert{syntax:"*";inherits:false}@property --tw-opacity{syntax:"*";inherits:false}@property --tw-saturate{syntax:"*";inherits:false}@property --tw-sepia{syntax:"*";inherits:false}@property --tw-drop-shadow{syntax:"*";inherits:false}@property --tw-drop-shadow-color{syntax:"*";inherits:false}@property --tw-drop-shadow-alpha{syntax:"";inherits:false;initial-value:100%}@property --tw-drop-shadow-size{syntax:"*";inherits:false}@property --tw-backdrop-blur{syntax:"*";inherits:false}@property --tw-backdrop-brightness{syntax:"*";inherits:false}@property --tw-backdrop-contrast{syntax:"*";inherits:false}@property --tw-backdrop-grayscale{syntax:"*";inherits:false}@property --tw-backdrop-hue-rotate{syntax:"*";inherits:false}@property --tw-backdrop-invert{syntax:"*";inherits:false}@property --tw-backdrop-opacity{syntax:"*";inherits:false}@property --tw-backdrop-saturate{syntax:"*";inherits:false}@property --tw-backdrop-sepia{syntax:"*";inherits:false}@property --tw-duration{syntax:"*";inherits:false}@property --tw-ease{syntax:"*";inherits:false}@property --tw-content{syntax:"*";inherits:false;initial-value:""}@keyframes pulse{50%{opacity:.5}}
+@layer properties{@supports (((-webkit-hyphens:none)) and (not (margin-trim:inline))) or ((-moz-orient:inline) and (not (color:rgb(from red r g b)))){*,:before,:after,::backdrop{--tw-translate-x:0;--tw-translate-y:0;--tw-translate-z:0;--tw-scale-x:1;--tw-scale-y:1;--tw-scale-z:1;--tw-rotate-x:initial;--tw-rotate-y:initial;--tw-rotate-z:initial;--tw-skew-x:initial;--tw-skew-y:initial;--tw-pan-x:initial;--tw-pan-y:initial;--tw-pinch-zoom:initial;--tw-space-y-reverse:0;--tw-space-x-reverse:0;--tw-divide-x-reverse:0;--tw-border-style:solid;--tw-divide-y-reverse:0;--tw-leading:initial;--tw-font-weight:initial;--tw-tracking:initial;--tw-ordinal:initial;--tw-slashed-zero:initial;--tw-numeric-figure:initial;--tw-numeric-spacing:initial;--tw-numeric-fraction:initial;--tw-shadow:0 0 #0000;--tw-shadow-color:initial;--tw-shadow-alpha:100%;--tw-inset-shadow:0 0 #0000;--tw-inset-shadow-color:initial;--tw-inset-shadow-alpha:100%;--tw-ring-color:initial;--tw-ring-shadow:0 0 #0000;--tw-inset-ring-color:initial;--tw-inset-ring-shadow:0 0 #0000;--tw-ring-inset:initial;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-offset-shadow:0 0 #0000;--tw-outline-style:solid;--tw-blur:initial;--tw-brightness:initial;--tw-contrast:initial;--tw-grayscale:initial;--tw-hue-rotate:initial;--tw-invert:initial;--tw-opacity:initial;--tw-saturate:initial;--tw-sepia:initial;--tw-drop-shadow:initial;--tw-drop-shadow-color:initial;--tw-drop-shadow-alpha:100%;--tw-drop-shadow-size:initial;--tw-backdrop-blur:initial;--tw-backdrop-brightness:initial;--tw-backdrop-contrast:initial;--tw-backdrop-grayscale:initial;--tw-backdrop-hue-rotate:initial;--tw-backdrop-invert:initial;--tw-backdrop-opacity:initial;--tw-backdrop-saturate:initial;--tw-backdrop-sepia:initial;--tw-duration:initial;--tw-ease:initial;--tw-content:""}}}@layer theme{:root,:host{--font-sans:ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";--font-mono:ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;--color-red-100:#ffe2e2;--color-red-800:#9f0712;--color-red-900:#82181a;--color-amber-400:#fcbb00;--color-amber-500:#f99c00;--color-amber-700:#b75000;--color-yellow-100:#fef9c2;--color-yellow-800:#874b00;--color-yellow-900:#733e0a;--color-green-100:#dcfce7;--color-green-600:#00a544;--color-green-800:#016630;--color-green-900:#0d542b;--color-emerald-400:#00d294;--color-emerald-500:#00bb7f;--color-emerald-700:#007956;--color-blue-500:#3080ff;--color-gray-100:#f3f4f6;--color-gray-800:#1e2939;--color-zinc-100:#f4f4f5;--color-zinc-400:#9f9fa9;--color-zinc-500:#71717b;--color-zinc-800:#27272a;--color-black:#000;--spacing:.25rem;--container-xs:20rem;--container-sm:24rem;--container-md:28rem;--container-lg:32rem;--container-2xl:42rem;--container-4xl:56rem;--container-6xl:72rem;--text-xs:.75rem;--text-xs--line-height:calc(1 / .75);--text-sm:.875rem;--text-sm--line-height:calc(1.25 / .875);--text-lg:1.125rem;--text-lg--line-height:calc(1.75 / 1.125);--text-2xl:1.5rem;--text-2xl--line-height:calc(2 / 1.5);--text-3xl:1.875rem;--text-3xl--line-height:calc(2.25 / 1.875);--text-4xl:2.25rem;--text-4xl--line-height:calc(2.5 / 2.25);--font-weight-normal:400;--font-weight-medium:500;--font-weight-semibold:600;--font-weight-bold:700;--tracking-wider:.05em;--tracking-widest:.1em;--leading-snug:1.375;--leading-normal:1.5;--leading-relaxed:1.625;--radius-sm:calc(var(--radius) - 2px);--ease-in-out:cubic-bezier(.4, 0, .2, 1);--animate-spin:spin 1s linear infinite;--animate-pulse:pulse 2s cubic-bezier(.4, 0, .6, 1) infinite;--blur-xs:4px;--default-transition-duration:.15s;--default-transition-timing-function:cubic-bezier(.4, 0, .2, 1);--default-font-family:var(--font-sans);--default-mono-font-family:var(--font-mono);--color-foreground:var(--foreground)}@supports (color:lab(0% 0 0)){:root,:host{--color-red-100:lab(92.243% 10.2865 3.83865);--color-red-800:lab(33.7174% 55.8993 41.0293);--color-red-900:lab(28.5139% 44.5539 29.0463);--color-amber-400:lab(80.1641% 16.6016 99.2089);--color-amber-500:lab(72.7183% 31.8672 97.9407);--color-amber-700:lab(47.2709% 42.9082 69.2966);--color-yellow-100:lab(97.3564% -4.51407 27.344);--color-yellow-800:lab(38.7484% 23.5833 51.4916);--color-yellow-900:lab(32.3865% 21.1273 38.5959);--color-green-100:lab(96.1861% -13.8464 6.52365);--color-green-600:lab(59.0978% -58.6621 41.2579);--color-green-800:lab(37.4616% -36.7971 22.9692);--color-green-900:lab(30.797% -29.6927 17.382);--color-emerald-400:lab(75.0771% -60.7313 19.4147);--color-emerald-500:lab(66.9756% -58.27 19.5419);--color-emerald-700:lab(44.4871% -41.0396 11.0361);--color-blue-500:lab(54.1736% 13.3369 -74.6839);--color-gray-100:lab(96.1596% -.0823438 -1.13575);--color-gray-800:lab(16.1051% -1.18239 -11.7533);--color-zinc-100:lab(96.1634% .0993311 -.364041);--color-zinc-400:lab(65.6464% 1.53497 -5.42429);--color-zinc-500:lab(47.8878% 1.65477 -5.77283);--color-zinc-800:lab(15.7305% .613764 -2.16959)}}}@layer base{*,:after,:before,::backdrop{box-sizing:border-box;border:0 solid;margin:0;padding:0}::file-selector-button{box-sizing:border-box;border:0 solid;margin:0;padding:0}html,:host{-webkit-text-size-adjust:100%;tab-size:4;line-height:1.5;font-family:var(--default-font-family,ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji");font-feature-settings:var(--default-font-feature-settings,normal);font-variation-settings:var(--default-font-variation-settings,normal);-webkit-tap-highlight-color:transparent}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,samp,pre{font-family:var(--default-mono-font-family,ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace);font-feature-settings:var(--default-mono-font-feature-settings,normal);font-variation-settings:var(--default-mono-font-variation-settings,normal);font-size:1em}small{font-size:80%}sub,sup{vertical-align:baseline;font-size:75%;line-height:0;position:relative}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}:-moz-focusring{outline:auto}progress{vertical-align:baseline}summary{display:list-item}ol,ul,menu{list-style:none}img,svg,video,canvas,audio,iframe,embed,object{vertical-align:middle;display:block}img,video{max-width:100%;height:auto}button,input,select,optgroup,textarea{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}::file-selector-button{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}:where(select:is([multiple],[size])) optgroup{font-weight:bolder}:where(select:is([multiple],[size])) optgroup option{padding-inline-start:20px}::file-selector-button{margin-inline-end:4px}::placeholder{opacity:1}@supports (not ((-webkit-appearance:-apple-pay-button))) or (contain-intrinsic-size:1px){::placeholder{color:currentColor}@supports (color:color-mix(in lab, red, red)){::placeholder{color:color-mix(in oklab, currentcolor 50%, transparent)}}}textarea{resize:vertical}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-date-and-time-value{min-height:1lh;text-align:inherit}::-webkit-datetime-edit{display:inline-flex}::-webkit-datetime-edit-fields-wrapper{padding:0}::-webkit-datetime-edit{padding-block:0}::-webkit-datetime-edit-year-field{padding-block:0}::-webkit-datetime-edit-month-field{padding-block:0}::-webkit-datetime-edit-day-field{padding-block:0}::-webkit-datetime-edit-hour-field{padding-block:0}::-webkit-datetime-edit-minute-field{padding-block:0}::-webkit-datetime-edit-second-field{padding-block:0}::-webkit-datetime-edit-millisecond-field{padding-block:0}::-webkit-datetime-edit-meridiem-field{padding-block:0}::-webkit-calendar-picker-indicator{line-height:1}:-moz-ui-invalid{box-shadow:none}button,input:where([type=button],[type=reset],[type=submit]){appearance:button}::file-selector-button{appearance:button}::-webkit-inner-spin-button{height:auto}::-webkit-outer-spin-button{height:auto}[hidden]:where(:not([hidden=until-found])){display:none!important}*{border-color:var(--border);outline-color:var(--ring)}@supports (color:color-mix(in lab, red, red)){*{outline-color:color-mix(in oklab, var(--ring) 50%, transparent)}}*{scrollbar-width:thin}html,body{overscroll-behavior:none}body{background-color:var(--background);width:100%;min-height:100svh;color:var(--foreground);font-family:var(--font-sans), sans-serif;-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale}.font-mono{font-family:var(--font-mono), monospace}button:not(:disabled),[role=button]:not(:disabled){cursor:pointer}}@layer components;@layer utilities{.\@container\/card-header{container:card-header/inline-size}.\@container\/field-group{container:field-group/inline-size}.\@container{container-type:inline-size}.pointer-events-none{pointer-events:none}.collapse{visibility:collapse}.invisible{visibility:hidden}.visible{visibility:visible}.sr-only{clip-path:inset(50%);white-space:nowrap;border-width:0;width:1px;height:1px;margin:-1px;padding:0;position:absolute;overflow:hidden}.not-sr-only{clip-path:none;white-space:normal;width:auto;height:auto;margin:0;padding:0;position:static;overflow:visible}.absolute{position:absolute}.fixed{position:fixed}.relative{position:relative}.static{position:static}.sticky{position:sticky}.inset-0{inset:calc(var(--spacing) * 0)}.inset-y-0{inset-block:calc(var(--spacing) * 0)}.top-0{top:calc(var(--spacing) * 0)}.top-1\.5{top:calc(var(--spacing) * 1.5)}.top-1\/2{top:50%}.top-3\.5{top:calc(var(--spacing) * 3.5)}.top-4{top:calc(var(--spacing) * 4)}.right-0{right:calc(var(--spacing) * 0)}.right-1{right:calc(var(--spacing) * 1)}.right-2{right:calc(var(--spacing) * 2)}.right-3{right:calc(var(--spacing) * 3)}.right-4{right:calc(var(--spacing) * 4)}.bottom-0{bottom:calc(var(--spacing) * 0)}.left-0{left:calc(var(--spacing) * 0)}.left-1\/2{left:50%}.isolate{isolation:isolate}.isolation-auto{isolation:auto}.z-10{z-index:10}.z-20{z-index:20}.z-50{z-index:50}.order-first{order:-9999}.order-last{order:9999}.col-span-2{grid-column:span 2/span 2}.col-start-2{grid-column-start:2}.row-span-2{grid-row:span 2/span 2}.row-start-1{grid-row-start:1}.container{width:100%}@media (width>=40rem){.container{max-width:40rem}}@media (width>=48rem){.container{max-width:48rem}}@media (width>=64rem){.container{max-width:64rem}}@media (width>=80rem){.container{max-width:80rem}}@media (width>=96rem){.container{max-width:96rem}}.-mx-1{margin-inline:calc(var(--spacing) * -1)}.mx-0{margin-inline:calc(var(--spacing) * 0)}.mx-2{margin-inline:calc(var(--spacing) * 2)}.mx-3\.5{margin-inline:calc(var(--spacing) * 3.5)}.mx-auto{margin-inline:auto}.-my-2{margin-block:calc(var(--spacing) * -2)}.my-1{margin-block:calc(var(--spacing) * 1)}.my-2{margin-block:calc(var(--spacing) * 2)}.mt-0\.5{margin-top:calc(var(--spacing) * .5)}.mt-1{margin-top:calc(var(--spacing) * 1)}.mt-2{margin-top:calc(var(--spacing) * 2)}.mt-3{margin-top:calc(var(--spacing) * 3)}.mt-4{margin-top:calc(var(--spacing) * 4)}.mt-auto{margin-top:auto}.mr-2{margin-right:calc(var(--spacing) * 2)}.mr-auto{margin-right:auto}.mb-1{margin-bottom:calc(var(--spacing) * 1)}.mb-1\.5{margin-bottom:calc(var(--spacing) * 1.5)}.mb-2{margin-bottom:calc(var(--spacing) * 2)}.mb-3{margin-bottom:calc(var(--spacing) * 3)}.mb-4{margin-bottom:calc(var(--spacing) * 4)}.mb-6{margin-bottom:calc(var(--spacing) * 6)}.-ml-1{margin-left:calc(var(--spacing) * -1)}.ml-1{margin-left:calc(var(--spacing) * 1)}.ml-4{margin-left:calc(var(--spacing) * 4)}.ml-auto{margin-left:auto}.block{display:block}.contents{display:contents}.flex{display:flex}.flow-root{display:flow-root}.grid{display:grid}.hidden{display:none}.inline{display:inline}.inline-block{display:inline-block}.inline-flex{display:inline-flex}.inline-grid{display:inline-grid}.inline-table{display:inline-table}.list-item{display:list-item}.table{display:table}.table-caption{display:table-caption}.table-cell{display:table-cell}.table-column{display:table-column}.table-column-group{display:table-column-group}.table-footer-group{display:table-footer-group}.table-header-group{display:table-header-group}.table-row{display:table-row}.table-row-group{display:table-row-group}.field-sizing-content{field-sizing:content}.aspect-square{aspect-ratio:1}.size-2\.5{width:calc(var(--spacing) * 2.5);height:calc(var(--spacing) * 2.5)}.size-3{width:calc(var(--spacing) * 3);height:calc(var(--spacing) * 3)}.size-3\.5{width:calc(var(--spacing) * 3.5);height:calc(var(--spacing) * 3.5)}.size-4{width:calc(var(--spacing) * 4);height:calc(var(--spacing) * 4)}.size-5{width:calc(var(--spacing) * 5);height:calc(var(--spacing) * 5)}.size-6{width:calc(var(--spacing) * 6);height:calc(var(--spacing) * 6)}.size-7{width:calc(var(--spacing) * 7);height:calc(var(--spacing) * 7)}.size-8{width:calc(var(--spacing) * 8);height:calc(var(--spacing) * 8)}.size-12{width:calc(var(--spacing) * 12);height:calc(var(--spacing) * 12)}.size-\[1\.2rem\]{width:1.2rem;height:1.2rem}.size-full{width:100%;height:100%}.h-3{height:calc(var(--spacing) * 3)}.h-4{height:calc(var(--spacing) * 4)}.h-5{height:calc(var(--spacing) * 5)}.h-6{height:calc(var(--spacing) * 6)}.h-7{height:calc(var(--spacing) * 7)}.h-8{height:calc(var(--spacing) * 8)}.h-10{height:calc(var(--spacing) * 10)}.h-12{height:calc(var(--spacing) * 12)}.h-\[calc\(--spacing\(4\.75\)\)\]{height:calc(calc(var(--spacing) * 4.75))}.h-auto{height:auto}.h-full{height:100%}.h-px{height:1px}.h-svh{height:100svh}.max-h-\(--available-height\){max-height:var(--available-height)}.max-h-72{max-height:calc(var(--spacing) * 72)}.max-h-96{max-height:calc(var(--spacing) * 96)}.max-h-\[85vh\]{max-height:85vh}.max-h-\[min\(calc\(--spacing\(72\)---spacing\(9\)\)\,calc\(var\(--available-height\)---spacing\(9\)\)\)\]{max-height:min(calc(calc(var(--spacing) * 72) - calc(var(--spacing) * 9)), calc(var(--available-height) - calc(var(--spacing) * 9)))}.min-h-0{min-height:calc(var(--spacing) * 0)}.min-h-7{min-height:calc(var(--spacing) * 7)}.min-h-16{min-height:calc(var(--spacing) * 16)}.min-h-svh{min-height:100svh}.w-\(--anchor-width\){width:var(--anchor-width)}.w-\(--sidebar-width\){width:var(--sidebar-width)}.w-4{width:calc(var(--spacing) * 4)}.w-5{width:calc(var(--spacing) * 5)}.w-24{width:calc(var(--spacing) * 24)}.w-32{width:calc(var(--spacing) * 32)}.w-36{width:calc(var(--spacing) * 36)}.w-48{width:calc(var(--spacing) * 48)}.w-auto{width:auto}.w-fit{width:fit-content}.w-full{width:100%}.max-w-\(--available-width\){max-width:var(--available-width)}.max-w-\(--skeleton-width\){max-width:var(--skeleton-width)}.max-w-2xl{max-width:var(--container-2xl)}.max-w-4xl{max-width:var(--container-4xl)}.max-w-6xl{max-width:var(--container-6xl)}.max-w-md{max-width:var(--container-md)}.max-w-sm{max-width:var(--container-sm)}.max-w-xs{max-width:var(--container-xs)}.min-w-0{min-width:calc(var(--spacing) * 0)}.min-w-5{min-width:calc(var(--spacing) * 5)}.min-w-16{min-width:calc(var(--spacing) * 16)}.min-w-32{min-width:calc(var(--spacing) * 32)}.min-w-\[calc\(var\(--anchor-width\)\+--spacing\(7\)\)\]{min-width:calc(var(--anchor-width) + calc(var(--spacing) * 7))}.flex-1{flex:1}.shrink{flex-shrink:1}.shrink-0{flex-shrink:0}.grow{flex-grow:1}.border-collapse{border-collapse:collapse}.origin-\(--transform-origin\){transform-origin:var(--transform-origin)}.-translate-x-1\/2{--tw-translate-x:calc(calc(1 / 2 * 100%) * -1);translate:var(--tw-translate-x) var(--tw-translate-y)}.-translate-x-px{--tw-translate-x:-1px;translate:var(--tw-translate-x) var(--tw-translate-y)}.translate-x-px{--tw-translate-x:1px;translate:var(--tw-translate-x) var(--tw-translate-y)}.-translate-y-1\/2{--tw-translate-y:calc(calc(1 / 2 * 100%) * -1);translate:var(--tw-translate-x) var(--tw-translate-y)}.translate-y-\[calc\(-50\%-2px\)\]{--tw-translate-y:calc(-50% - 2px);translate:var(--tw-translate-x) var(--tw-translate-y)}.translate-none{translate:none}.scale-0{--tw-scale-x:0%;--tw-scale-y:0%;--tw-scale-z:0%;scale:var(--tw-scale-x) var(--tw-scale-y)}.scale-100{--tw-scale-x:100%;--tw-scale-y:100%;--tw-scale-z:100%;scale:var(--tw-scale-x) var(--tw-scale-y)}.scale-3d{scale:var(--tw-scale-x) var(--tw-scale-y) var(--tw-scale-z)}.rotate-0{rotate:0deg}.rotate-45{rotate:45deg}.rotate-90{rotate:90deg}.rotate-180{rotate:180deg}.transform{transform:var(--tw-rotate-x,) var(--tw-rotate-y,) var(--tw-rotate-z,) var(--tw-skew-x,) var(--tw-skew-y,)}.animate-pulse{animation:var(--animate-pulse)}.animate-spin{animation:var(--animate-spin)}.cursor-default{cursor:default}.cursor-pointer{cursor:pointer}.cursor-text{cursor:text}.touch-pinch-zoom{--tw-pinch-zoom:pinch-zoom;touch-action:var(--tw-pan-x,) var(--tw-pan-y,) var(--tw-pinch-zoom,)}.resize{resize:both}.resize-none{resize:none}.scroll-my-1{scroll-margin-block:calc(var(--spacing) * 1)}.scroll-py-1{scroll-padding-block:calc(var(--spacing) * 1)}.list-inside{list-style-position:inside}.list-disc{list-style-type:disc}.auto-rows-min{grid-auto-rows:min-content}.grid-cols-1{grid-template-columns:repeat(1,minmax(0,1fr))}.grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.grid-cols-4{grid-template-columns:repeat(4,minmax(0,1fr))}.grid-cols-\[minmax\(0\,1fr\)\]{grid-template-columns:minmax(0,1fr)}.grid-rows-\[auto_1fr\]{grid-template-rows:auto 1fr}.flex-col{flex-direction:column}.flex-col-reverse{flex-direction:column-reverse}.flex-row{flex-direction:row}.flex-wrap{flex-wrap:wrap}.place-items-center{place-items:center}.items-center{align-items:center}.items-end{align-items:flex-end}.items-start{align-items:flex-start}.justify-between{justify-content:space-between}.justify-center{justify-content:center}.justify-end{justify-content:flex-end}.justify-start{justify-content:flex-start}.gap-0{gap:calc(var(--spacing) * 0)}.gap-0\.5{gap:calc(var(--spacing) * .5)}.gap-1{gap:calc(var(--spacing) * 1)}.gap-1\.5{gap:calc(var(--spacing) * 1.5)}.gap-2{gap:calc(var(--spacing) * 2)}.gap-3{gap:calc(var(--spacing) * 3)}.gap-4{gap:calc(var(--spacing) * 4)}.gap-8{gap:calc(var(--spacing) * 8)}.gap-px{gap:1px}:where(.space-y-0>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 0) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 0) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-0\.5>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * .5) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * .5) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-1>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 1) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 1) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-2>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 2) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 2) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-3>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 3) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 3) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-4>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 4) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 4) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-6>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 6) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 6) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-reverse>:not(:last-child)){--tw-space-y-reverse:1}:where(.-space-x-2>:not(:last-child)){--tw-space-x-reverse:0;margin-inline-start:calc(calc(var(--spacing) * -2) * var(--tw-space-x-reverse));margin-inline-end:calc(calc(var(--spacing) * -2) * calc(1 - var(--tw-space-x-reverse)))}:where(.space-x-reverse>:not(:last-child)){--tw-space-x-reverse:1}:where(.divide-x>:not(:last-child)){--tw-divide-x-reverse:0;border-inline-style:var(--tw-border-style);border-inline-start-width:calc(1px * var(--tw-divide-x-reverse));border-inline-end-width:calc(1px * calc(1 - var(--tw-divide-x-reverse)))}:where(.divide-y>:not(:last-child)){--tw-divide-y-reverse:0;border-bottom-style:var(--tw-border-style);border-top-style:var(--tw-border-style);border-top-width:calc(1px * var(--tw-divide-y-reverse));border-bottom-width:calc(1px * calc(1 - var(--tw-divide-y-reverse)))}:where(.divide-y-reverse>:not(:last-child)){--tw-divide-y-reverse:1}.self-end{align-self:flex-end}.self-start{align-self:flex-start}.justify-self-end{justify-self:flex-end}.truncate{text-overflow:ellipsis;white-space:nowrap;overflow:hidden}.overflow-auto{overflow:auto}.overflow-hidden{overflow:hidden}.overflow-x-auto{overflow-x:auto}.overflow-x-hidden{overflow-x:hidden}.overflow-y-auto{overflow-y:auto}.overscroll-contain{overscroll-behavior:contain}.rounded{border-radius:.25rem}.rounded-\[2px\]{border-radius:2px}.rounded-\[calc\(var\(--radius-sm\)\+2px\)\]{border-radius:calc(var(--radius-sm) + 2px)}.rounded-\[calc\(var\(--radius-sm\)-2px\)\]{border-radius:calc(var(--radius-sm) - 2px)}.rounded-full{border-radius:3.40282e38px}.rounded-lg{border-radius:calc(var(--radius) + 2px)}.rounded-md{border-radius:var(--radius)}.rounded-none{border-radius:0}.rounded-sm{border-radius:calc(var(--radius) - 2px)}.rounded-xl{border-radius:calc(var(--radius) + 4px)}.rounded-s{border-start-start-radius:.25rem;border-end-start-radius:.25rem}.rounded-ss{border-start-start-radius:.25rem}.rounded-e{border-start-end-radius:.25rem;border-end-end-radius:.25rem}.rounded-se{border-start-end-radius:.25rem}.rounded-ee{border-end-end-radius:.25rem}.rounded-es{border-end-start-radius:.25rem}.rounded-t{border-top-left-radius:.25rem;border-top-right-radius:.25rem}.rounded-t-lg{border-top-left-radius:calc(var(--radius) + 2px);border-top-right-radius:calc(var(--radius) + 2px)}.rounded-l{border-top-left-radius:.25rem;border-bottom-left-radius:.25rem}.rounded-tl{border-top-left-radius:.25rem}.rounded-r{border-top-right-radius:.25rem;border-bottom-right-radius:.25rem}.rounded-tr{border-top-right-radius:.25rem}.rounded-b{border-bottom-right-radius:.25rem;border-bottom-left-radius:.25rem}.rounded-b-lg{border-bottom-right-radius:calc(var(--radius) + 2px);border-bottom-left-radius:calc(var(--radius) + 2px)}.rounded-br{border-bottom-right-radius:.25rem}.rounded-bl{border-bottom-left-radius:.25rem}.border{border-style:var(--tw-border-style);border-width:1px}.border-0{border-style:var(--tw-border-style);border-width:0}.border-2{border-style:var(--tw-border-style);border-width:2px}.border-x{border-inline-style:var(--tw-border-style);border-inline-width:1px}.border-y{border-block-style:var(--tw-border-style);border-block-width:1px}.border-s{border-inline-start-style:var(--tw-border-style);border-inline-start-width:1px}.border-e{border-inline-end-style:var(--tw-border-style);border-inline-end-width:1px}.border-bs{border-block-start-style:var(--tw-border-style);border-block-start-width:1px}.border-be{border-block-end-style:var(--tw-border-style);border-block-end-width:1px}.border-t{border-top-style:var(--tw-border-style);border-top-width:1px}.border-r{border-right-style:var(--tw-border-style);border-right-width:1px}.border-b{border-bottom-style:var(--tw-border-style);border-bottom-width:1px}.border-b-2{border-bottom-style:var(--tw-border-style);border-bottom-width:2px}.border-l{border-left-style:var(--tw-border-style);border-left-width:1px}.border-l-2{border-left-style:var(--tw-border-style);border-left-width:2px}.border-dashed{--tw-border-style:dashed;border-style:dashed}.border-amber-500{border-color:var(--color-amber-500)}.border-amber-500\/30{border-color:#f99c004d}@supports (color:color-mix(in lab, red, red)){.border-amber-500\/30{border-color:color-mix(in oklab, var(--color-amber-500) 30%, transparent)}}.border-blue-500\/30{border-color:#3080ff4d}@supports (color:color-mix(in lab, red, red)){.border-blue-500\/30{border-color:color-mix(in oklab, var(--color-blue-500) 30%, transparent)}}.border-border{border-color:var(--border)}.border-destructive{border-color:var(--destructive)}.border-emerald-500{border-color:var(--color-emerald-500)}.border-emerald-500\/30{border-color:#00bb7f4d}@supports (color:color-mix(in lab, red, red)){.border-emerald-500\/30{border-color:color-mix(in oklab, var(--color-emerald-500) 30%, transparent)}}.border-foreground\/10{border-color:var(--foreground)}@supports (color:color-mix(in lab, red, red)){.border-foreground\/10{border-color:color-mix(in oklab, var(--foreground) 10%, transparent)}}.border-foreground\/20{border-color:var(--foreground)}@supports (color:color-mix(in lab, red, red)){.border-foreground\/20{border-color:color-mix(in oklab, var(--foreground) 20%, transparent)}}.border-input{border-color:var(--input)}.border-muted{border-color:var(--muted)}.border-sidebar-border{border-color:var(--sidebar-border)}.border-tactical-amber\/40{border-color:var(--tactical-amber)}@supports (color:color-mix(in lab, red, red)){.border-tactical-amber\/40{border-color:color-mix(in oklab, var(--tactical-amber) 40%, transparent)}}.border-transparent{border-color:#0000}.bg-amber-500\/10{background-color:#f99c001a}@supports (color:color-mix(in lab, red, red)){.bg-amber-500\/10{background-color:color-mix(in oklab, var(--color-amber-500) 10%, transparent)}}.bg-background{background-color:var(--background)}.bg-black\/80{background-color:#000c}@supports (color:color-mix(in lab, red, red)){.bg-black\/80{background-color:color-mix(in oklab, var(--color-black) 80%, transparent)}}.bg-blue-500\/10{background-color:#3080ff1a}@supports (color:color-mix(in lab, red, red)){.bg-blue-500\/10{background-color:color-mix(in oklab, var(--color-blue-500) 10%, transparent)}}.bg-border,.bg-border\/50{background-color:var(--border)}@supports (color:color-mix(in lab, red, red)){.bg-border\/50{background-color:color-mix(in oklab, var(--border) 50%, transparent)}}.bg-card{background-color:var(--card)}.bg-destructive,.bg-destructive\/10{background-color:var(--destructive)}@supports (color:color-mix(in lab, red, red)){.bg-destructive\/10{background-color:color-mix(in oklab, var(--destructive) 10%, transparent)}}.bg-emerald-500\/10{background-color:#00bb7f1a}@supports (color:color-mix(in lab, red, red)){.bg-emerald-500\/10{background-color:color-mix(in oklab, var(--color-emerald-500) 10%, transparent)}}.bg-foreground{background-color:var(--foreground)}.bg-gray-100{background-color:var(--color-gray-100)}.bg-green-100{background-color:var(--color-green-100)}.bg-input\/20{background-color:var(--input)}@supports (color:color-mix(in lab, red, red)){.bg-input\/20{background-color:color-mix(in oklab, var(--input) 20%, transparent)}}.bg-muted{background-color:var(--muted)}.bg-muted-foreground\/10{background-color:var(--muted-foreground)}@supports (color:color-mix(in lab, red, red)){.bg-muted-foreground\/10{background-color:color-mix(in oklab, var(--muted-foreground) 10%, transparent)}}.bg-muted\/20{background-color:var(--muted)}@supports (color:color-mix(in lab, red, red)){.bg-muted\/20{background-color:color-mix(in oklab, var(--muted) 20%, transparent)}}.bg-muted\/30{background-color:var(--muted)}@supports (color:color-mix(in lab, red, red)){.bg-muted\/30{background-color:color-mix(in oklab, var(--muted) 30%, transparent)}}.bg-muted\/50{background-color:var(--muted)}@supports (color:color-mix(in lab, red, red)){.bg-muted\/50{background-color:color-mix(in oklab, var(--muted) 50%, transparent)}}.bg-popover{background-color:var(--popover)}.bg-primary{background-color:var(--primary)}.bg-red-100{background-color:var(--color-red-100)}.bg-secondary{background-color:var(--secondary)}.bg-sidebar{background-color:var(--sidebar)}.bg-sidebar-border{background-color:var(--sidebar-border)}.bg-tactical-amber\/5{background-color:var(--tactical-amber)}@supports (color:color-mix(in lab, red, red)){.bg-tactical-amber\/5{background-color:color-mix(in oklab, var(--tactical-amber) 5%, transparent)}}.bg-transparent{background-color:#0000}.bg-yellow-100{background-color:var(--color-yellow-100)}.bg-zinc-100{background-color:var(--color-zinc-100)}.bg-clip-padding{background-clip:padding-box}.bg-repeat{background-repeat:repeat}.mask-no-clip{-webkit-mask-clip:no-clip;mask-clip:no-clip}.mask-repeat{-webkit-mask-repeat:repeat;mask-repeat:repeat}.fill-foreground{fill:var(--foreground)}.object-cover{object-fit:cover}.p-0{padding:calc(var(--spacing) * 0)}.p-1{padding:calc(var(--spacing) * 1)}.p-2{padding:calc(var(--spacing) * 2)}.p-3{padding:calc(var(--spacing) * 3)}.p-4{padding:calc(var(--spacing) * 4)}.p-6{padding:calc(var(--spacing) * 6)}.p-8{padding:calc(var(--spacing) * 8)}.px-0{padding-inline:calc(var(--spacing) * 0)}.px-1{padding-inline:calc(var(--spacing) * 1)}.px-1\.5{padding-inline:calc(var(--spacing) * 1.5)}.px-2{padding-inline:calc(var(--spacing) * 2)}.px-2\.5{padding-inline:calc(var(--spacing) * 2.5)}.px-3{padding-inline:calc(var(--spacing) * 3)}.px-4{padding-inline:calc(var(--spacing) * 4)}.py-0\.5{padding-block:calc(var(--spacing) * .5)}.py-1{padding-block:calc(var(--spacing) * 1)}.py-1\.5{padding-block:calc(var(--spacing) * 1.5)}.py-2{padding-block:calc(var(--spacing) * 2)}.py-3{padding-block:calc(var(--spacing) * 3)}.py-4{padding-block:calc(var(--spacing) * 4)}.pt-0{padding-top:calc(var(--spacing) * 0)}.pt-2{padding-top:calc(var(--spacing) * 2)}.pt-4{padding-top:calc(var(--spacing) * 4)}.pr-2{padding-right:calc(var(--spacing) * 2)}.pr-4{padding-right:calc(var(--spacing) * 4)}.pr-8{padding-right:calc(var(--spacing) * 8)}.pb-0{padding-bottom:calc(var(--spacing) * 0)}.pb-0\.5{padding-bottom:calc(var(--spacing) * .5)}.pb-1{padding-bottom:calc(var(--spacing) * 1)}.pb-2{padding-bottom:calc(var(--spacing) * 2)}.pb-4{padding-bottom:calc(var(--spacing) * 4)}.pl-2{padding-left:calc(var(--spacing) * 2)}.pl-4{padding-left:calc(var(--spacing) * 4)}.text-center{text-align:center}.text-left{text-align:left}.text-right{text-align:right}.font-mono{font-family:var(--font-mono)}.font-sans{font-family:var(--font-sans)}.text-2xl{font-size:var(--text-2xl);line-height:var(--tw-leading,var(--text-2xl--line-height))}.text-3xl{font-size:var(--text-3xl);line-height:var(--tw-leading,var(--text-3xl--line-height))}.text-4xl{font-size:var(--text-4xl);line-height:var(--tw-leading,var(--text-4xl--line-height))}.text-lg{font-size:var(--text-lg);line-height:var(--tw-leading,var(--text-lg--line-height))}.text-sm{font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height))}.text-sm\/relaxed{font-size:var(--text-sm);line-height:var(--leading-relaxed)}.text-xs{font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height))}.text-xs\/relaxed{font-size:var(--text-xs);line-height:var(--leading-relaxed)}.text-\[0\.625rem\]{font-size:.625rem}.text-\[11px\]{font-size:11px}.leading-none{--tw-leading:1;line-height:1}.leading-normal{--tw-leading:var(--leading-normal);line-height:var(--leading-normal)}.leading-snug{--tw-leading:var(--leading-snug);line-height:var(--leading-snug)}.font-bold{--tw-font-weight:var(--font-weight-bold);font-weight:var(--font-weight-bold)}.font-medium{--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium)}.font-normal{--tw-font-weight:var(--font-weight-normal);font-weight:var(--font-weight-normal)}.font-semibold{--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold)}.tracking-wider{--tw-tracking:var(--tracking-wider);letter-spacing:var(--tracking-wider)}.tracking-widest{--tw-tracking:var(--tracking-widest);letter-spacing:var(--tracking-widest)}.text-balance{text-wrap:balance}.text-wrap{text-wrap:wrap}.break-all{word-break:break-all}.text-clip{text-overflow:clip}.text-ellipsis{text-overflow:ellipsis}.whitespace-nowrap{white-space:nowrap}.whitespace-pre-wrap{white-space:pre-wrap}.text-amber-500{color:var(--color-amber-500)}.text-amber-700{color:var(--color-amber-700)}.text-background{color:var(--background)}.text-card-foreground{color:var(--card-foreground)}.text-destructive{color:var(--destructive)}.text-emerald-700{color:var(--color-emerald-700)}.text-foreground{color:var(--foreground)}.text-gray-800{color:var(--color-gray-800)}.text-green-600{color:var(--color-green-600)}.text-green-800{color:var(--color-green-800)}.text-muted-foreground{color:var(--muted-foreground)}.text-popover-foreground{color:var(--popover-foreground)}.text-primary{color:var(--primary)}.text-primary-foreground{color:var(--primary-foreground)}.text-red-800{color:var(--color-red-800)}.text-secondary-foreground{color:var(--secondary-foreground)}.text-sidebar-foreground,.text-sidebar-foreground\/70{color:var(--sidebar-foreground)}@supports (color:color-mix(in lab, red, red)){.text-sidebar-foreground\/70{color:color-mix(in oklab, var(--sidebar-foreground) 70%, transparent)}}.text-tactical-amber{color:var(--tactical-amber)}.text-tactical-cyan{color:var(--tactical-cyan)}.text-tactical-green{color:var(--tactical-green)}.text-yellow-800{color:var(--color-yellow-800)}.text-zinc-500{color:var(--color-zinc-500)}.capitalize{text-transform:capitalize}.lowercase{text-transform:lowercase}.normal-case{text-transform:none}.uppercase{text-transform:uppercase}.italic{font-style:italic}.not-italic{font-style:normal}.diagonal-fractions{--tw-numeric-fraction:diagonal-fractions;font-variant-numeric:var(--tw-ordinal,) var(--tw-slashed-zero,) var(--tw-numeric-figure,) var(--tw-numeric-spacing,) var(--tw-numeric-fraction,)}.lining-nums{--tw-numeric-figure:lining-nums;font-variant-numeric:var(--tw-ordinal,) var(--tw-slashed-zero,) var(--tw-numeric-figure,) var(--tw-numeric-spacing,) var(--tw-numeric-fraction,)}.oldstyle-nums{--tw-numeric-figure:oldstyle-nums;font-variant-numeric:var(--tw-ordinal,) var(--tw-slashed-zero,) var(--tw-numeric-figure,) var(--tw-numeric-spacing,) var(--tw-numeric-fraction,)}.ordinal{--tw-ordinal:ordinal;font-variant-numeric:var(--tw-ordinal,) var(--tw-slashed-zero,) var(--tw-numeric-figure,) var(--tw-numeric-spacing,) var(--tw-numeric-fraction,)}.proportional-nums{--tw-numeric-spacing:proportional-nums;font-variant-numeric:var(--tw-ordinal,) var(--tw-slashed-zero,) var(--tw-numeric-figure,) var(--tw-numeric-spacing,) var(--tw-numeric-fraction,)}.slashed-zero{--tw-slashed-zero:slashed-zero;font-variant-numeric:var(--tw-ordinal,) var(--tw-slashed-zero,) var(--tw-numeric-figure,) var(--tw-numeric-spacing,) var(--tw-numeric-fraction,)}.stacked-fractions{--tw-numeric-fraction:stacked-fractions;font-variant-numeric:var(--tw-ordinal,) var(--tw-slashed-zero,) var(--tw-numeric-figure,) var(--tw-numeric-spacing,) var(--tw-numeric-fraction,)}.tabular-nums{--tw-numeric-spacing:tabular-nums;font-variant-numeric:var(--tw-ordinal,) var(--tw-slashed-zero,) var(--tw-numeric-figure,) var(--tw-numeric-spacing,) var(--tw-numeric-fraction,)}.normal-nums{font-variant-numeric:normal}.line-through{text-decoration-line:line-through}.no-underline{text-decoration-line:none}.overline{text-decoration-line:overline}.underline{text-decoration-line:underline}.underline-offset-2{text-underline-offset:2px}.underline-offset-4{text-underline-offset:4px}.antialiased{-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale}.subpixel-antialiased{-webkit-font-smoothing:auto;-moz-osx-font-smoothing:auto}.opacity-50{opacity:.5}.bg-blend-color{background-blend-mode:color}.shadow{--tw-shadow:0 1px 3px 0 var(--tw-shadow-color,#0000001a), 0 1px 2px -1px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.shadow-\[0_0_0_1px_hsl\(var\(--sidebar-border\)\)\]{--tw-shadow:0 0 0 1px var(--tw-shadow-color,hsl(var(--sidebar-border)));box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.shadow-lg{--tw-shadow:0 10px 15px -3px var(--tw-shadow-color,#0000001a), 0 4px 6px -4px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.shadow-md{--tw-shadow:0 4px 6px -1px var(--tw-shadow-color,#0000001a), 0 2px 4px -2px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.shadow-none{--tw-shadow:0 0 #0000;box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.ring-0{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(0px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.ring-1{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.ring-2{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.inset-ring{--tw-inset-ring-shadow:inset 0 0 0 1px var(--tw-inset-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.ring-background{--tw-ring-color:var(--background)}.ring-foreground\/10{--tw-ring-color:var(--foreground)}@supports (color:color-mix(in lab, red, red)){.ring-foreground\/10{--tw-ring-color:color-mix(in oklab, var(--foreground) 10%, transparent)}}.ring-sidebar-ring{--tw-ring-color:var(--sidebar-ring)}.outline-hidden{--tw-outline-style:none;outline-style:none}@media (forced-colors:active){.outline-hidden{outline-offset:2px;outline:2px solid #0000}}.outline{outline-style:var(--tw-outline-style);outline-width:1px}.blur{--tw-blur:blur(8px);filter:var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,)}.drop-shadow{--tw-drop-shadow-size:drop-shadow(0 1px 2px var(--tw-drop-shadow-color,#0000001a)) drop-shadow(0 1px 1px var(--tw-drop-shadow-color,#0000000f));--tw-drop-shadow:drop-shadow(0 1px 2px #0000001a) drop-shadow(0 1px 1px #0000000f);filter:var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,)}.filter{filter:var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,)}.backdrop-blur{--tw-backdrop-blur:blur(8px);-webkit-backdrop-filter:var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,);backdrop-filter:var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,)}.backdrop-grayscale{--tw-backdrop-grayscale:grayscale(100%);-webkit-backdrop-filter:var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,);backdrop-filter:var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,)}.backdrop-invert{--tw-backdrop-invert:invert(100%);-webkit-backdrop-filter:var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,);backdrop-filter:var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,)}.backdrop-sepia{--tw-backdrop-sepia:sepia(100%);-webkit-backdrop-filter:var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,);backdrop-filter:var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,)}.backdrop-filter{-webkit-backdrop-filter:var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,);backdrop-filter:var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,)}.transition{transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to,opacity,box-shadow,transform,translate,scale,rotate,filter,-webkit-backdrop-filter,backdrop-filter,display,content-visibility,overlay,pointer-events;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-\[left\,right\,width\]{transition-property:left,right,width;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-\[margin\,opacity\]{transition-property:margin,opacity;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-\[width\,height\,padding\]{transition-property:width,height,padding;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-\[width\]{transition-property:width;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-all{transition-property:all;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-colors{transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-transform{transition-property:transform,translate,scale,rotate;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.duration-100{--tw-duration:.1s;transition-duration:.1s}.duration-200{--tw-duration:.2s;transition-duration:.2s}.ease-in-out{--tw-ease:var(--ease-in-out);transition-timing-function:var(--ease-in-out)}.ease-linear{--tw-ease:linear;transition-timing-function:linear}.outline-none{--tw-outline-style:none;outline-style:none}.select-none{-webkit-user-select:none;user-select:none}:where(.divide-x-reverse>:not(:last-child)){--tw-divide-x-reverse:1}.ring-inset{--tw-ring-inset:inset}.group-focus-within\/menu-item\:opacity-100:is(:where(.group\/menu-item):focus-within *){opacity:1}@media (hover:hover){.group-hover\/menu-item\:opacity-100:is(:where(.group\/menu-item):hover *){opacity:1}}.group-focus\/dropdown-menu-item\:text-accent-foreground:is(:where(.group\/dropdown-menu-item):focus *){color:var(--accent-foreground)}.group-has-data-\[orientation\=horizontal\]\/field\:text-balance:is(:where(.group\/field):has([data-orientation=horizontal]) *){text-wrap:balance}.group-has-data-\[sidebar\=menu-action\]\/menu-item\:pr-8:is(:where(.group\/menu-item):has([data-sidebar=menu-action]) *){padding-right:calc(var(--spacing) * 8)}.group-has-data-\[size\=lg\]\/avatar-group\:size-10:is(:where(.group\/avatar-group):has([data-size=lg]) *){width:calc(var(--spacing) * 10);height:calc(var(--spacing) * 10)}.group-has-data-\[size\=sm\]\/avatar-group\:size-6:is(:where(.group\/avatar-group):has([data-size=sm]) *){width:calc(var(--spacing) * 6);height:calc(var(--spacing) * 6)}.group-has-data-\[slot\=combobox-clear\]\/input-group\:hidden:is(:where(.group\/input-group):has([data-slot=combobox-clear]) *){display:none}.group-has-\[\>input\]\/input-group\:pt-2:is(:where(.group\/input-group):has(>input) *){padding-top:calc(var(--spacing) * 2)}.group-has-\[\>input\]\/input-group\:pb-2:is(:where(.group\/input-group):has(>input) *){padding-bottom:calc(var(--spacing) * 2)}.group-data-empty\/combobox-content\:flex:is(:where(.group\/combobox-content)[data-empty] *){display:flex}.group-data-\[collapsible\=icon\]\:-mt-8:is(:where(.group)[data-collapsible=icon] *){margin-top:calc(var(--spacing) * -8)}.group-data-\[collapsible\=icon\]\:hidden:is(:where(.group)[data-collapsible=icon] *){display:none}.group-data-\[collapsible\=icon\]\:size-8\!:is(:where(.group)[data-collapsible=icon] *){width:calc(var(--spacing) * 8)!important;height:calc(var(--spacing) * 8)!important}.group-data-\[collapsible\=icon\]\:w-\(--sidebar-width-icon\):is(:where(.group)[data-collapsible=icon] *){width:var(--sidebar-width-icon)}.group-data-\[collapsible\=icon\]\:w-\[calc\(var\(--sidebar-width-icon\)\+\(--spacing\(4\)\)\)\]:is(:where(.group)[data-collapsible=icon] *){width:calc(var(--sidebar-width-icon) + (calc(var(--spacing) * 4)))}.group-data-\[collapsible\=icon\]\:w-\[calc\(var\(--sidebar-width-icon\)\+\(--spacing\(4\)\)\+2px\)\]:is(:where(.group)[data-collapsible=icon] *){width:calc(var(--sidebar-width-icon) + (calc(var(--spacing) * 4)) + 2px)}.group-data-\[collapsible\=icon\]\:overflow-hidden:is(:where(.group)[data-collapsible=icon] *){overflow:hidden}.group-data-\[collapsible\=icon\]\:p-0\!:is(:where(.group)[data-collapsible=icon] *){padding:calc(var(--spacing) * 0)!important}.group-data-\[collapsible\=icon\]\:p-2\!:is(:where(.group)[data-collapsible=icon] *){padding:calc(var(--spacing) * 2)!important}.group-data-\[collapsible\=icon\]\:opacity-0:is(:where(.group)[data-collapsible=icon] *){opacity:0}.group-data-\[collapsible\=offExamples\]\:right-\[calc\(var\(--sidebar-width\)\*-1\)\]:is(:where(.group)[data-collapsible=offExamples] *){right:calc(var(--sidebar-width) * -1)}.group-data-\[collapsible\=offExamples\]\:left-\[calc\(var\(--sidebar-width\)\*-1\)\]:is(:where(.group)[data-collapsible=offExamples] *){left:calc(var(--sidebar-width) * -1)}.group-data-\[collapsible\=offExamples\]\:w-0:is(:where(.group)[data-collapsible=offExamples] *){width:calc(var(--spacing) * 0)}.group-data-\[collapsible\=offExamples\]\:translate-x-0:is(:where(.group)[data-collapsible=offExamples] *){--tw-translate-x:calc(var(--spacing) * 0);translate:var(--tw-translate-x) var(--tw-translate-y)}.group-data-\[disabled\=true\]\:pointer-events-none:is(:where(.group)[data-disabled=true] *){pointer-events:none}.group-data-\[disabled\=true\]\:opacity-50:is(:where(.group)[data-disabled=true] *),.group-data-\[disabled\=true\]\/field\:opacity-50:is(:where(.group\/field)[data-disabled=true] *),.group-data-\[disabled\=true\]\/input-group\:opacity-50:is(:where(.group\/input-group)[data-disabled=true] *){opacity:.5}.group-data-\[panel-open\]\:rotate-180:is(:where(.group)[data-panel-open] *){rotate:180deg}.group-data-\[side\=left\]\:-right-4:is(:where(.group)[data-side=left] *){right:calc(var(--spacing) * -4)}.group-data-\[side\=left\]\:border-r:is(:where(.group)[data-side=left] *){border-right-style:var(--tw-border-style);border-right-width:1px}.group-data-\[side\=right\]\:left-0:is(:where(.group)[data-side=right] *){left:calc(var(--spacing) * 0)}.group-data-\[side\=right\]\:rotate-180:is(:where(.group)[data-side=right] *){rotate:180deg}.group-data-\[side\=right\]\:border-l:is(:where(.group)[data-side=right] *){border-left-style:var(--tw-border-style);border-left-width:1px}.group-data-\[size\=default\]\/avatar\:size-2\.5:is(:where(.group\/avatar)[data-size=default] *){width:calc(var(--spacing) * 2.5);height:calc(var(--spacing) * 2.5)}.group-data-\[size\=lg\]\/avatar\:size-3:is(:where(.group\/avatar)[data-size=lg] *){width:calc(var(--spacing) * 3);height:calc(var(--spacing) * 3)}.group-data-\[size\=sm\]\/alert-dialog-content\:grid:is(:where(.group\/alert-dialog-content)[data-size=sm] *){display:grid}.group-data-\[size\=sm\]\/alert-dialog-content\:grid-cols-2:is(:where(.group\/alert-dialog-content)[data-size=sm] *){grid-template-columns:repeat(2,minmax(0,1fr))}.group-data-\[size\=sm\]\/avatar\:size-2:is(:where(.group\/avatar)[data-size=sm] *){width:calc(var(--spacing) * 2);height:calc(var(--spacing) * 2)}.group-data-\[size\=sm\]\/avatar\:text-xs:is(:where(.group\/avatar)[data-size=sm] *){font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height))}.group-data-\[size\=sm\]\/card\:px-3:is(:where(.group\/card)[data-size=sm] *){padding-inline:calc(var(--spacing) * 3)}.group-data-\[variant\=floating\]\:rounded-lg:is(:where(.group)[data-variant=floating] *){border-radius:calc(var(--radius) + 2px)}.group-data-\[variant\=floating\]\:shadow-sm:is(:where(.group)[data-variant=floating] *){--tw-shadow:0 1px 3px 0 var(--tw-shadow-color,#0000001a), 0 1px 2px -1px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.group-data-\[variant\=floating\]\:ring-1:is(:where(.group)[data-variant=floating] *){--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.group-data-\[variant\=floating\]\:ring-sidebar-border:is(:where(.group)[data-variant=floating] *){--tw-ring-color:var(--sidebar-border)}.group-data-\[variant\=outline\]\/field-group\:-mb-2:is(:where(.group\/field-group)[data-variant=outline] *){margin-bottom:calc(var(--spacing) * -2)}@media (hover:hover){.peer-hover\/menu-button\:text-sidebar-accent-foreground:is(:where(.peer\/menu-button):hover~*){color:var(--sidebar-accent-foreground)}}.peer-disabled\:cursor-not-allowed:is(:where(.peer):disabled~*){cursor:not-allowed}.peer-disabled\:opacity-50:is(:where(.peer):disabled~*){opacity:.5}.peer-data-active\/menu-button\:text-sidebar-accent-foreground:is(:where(.peer\/menu-button)[data-active]~*){color:var(--sidebar-accent-foreground)}.peer-data-\[size\=default\]\/menu-button\:top-1\.5:is(:where(.peer\/menu-button)[data-size=default]~*){top:calc(var(--spacing) * 1.5)}.peer-data-\[size\=lg\]\/menu-button\:top-2\.5:is(:where(.peer\/menu-button)[data-size=lg]~*){top:calc(var(--spacing) * 2.5)}.peer-data-\[size\=sm\]\/menu-button\:top-1:is(:where(.peer\/menu-button)[data-size=sm]~*){top:calc(var(--spacing) * 1)}.file\:inline-flex::file-selector-button{display:inline-flex}.file\:h-6::file-selector-button{height:calc(var(--spacing) * 6)}.file\:border-0::file-selector-button{border-style:var(--tw-border-style);border-width:0}.file\:bg-transparent::file-selector-button{background-color:#0000}.file\:text-xs\/relaxed::file-selector-button{font-size:var(--text-xs);line-height:var(--leading-relaxed)}.file\:font-medium::file-selector-button{--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium)}.file\:text-foreground::file-selector-button{color:var(--foreground)}.placeholder\:text-muted-foreground::placeholder{color:var(--muted-foreground)}.after\:absolute:after{content:var(--tw-content);position:absolute}.after\:-inset-2:after{content:var(--tw-content);inset:calc(var(--spacing) * -2)}.after\:inset-0:after{content:var(--tw-content);inset:calc(var(--spacing) * 0)}.after\:inset-y-0:after{content:var(--tw-content);inset-block:calc(var(--spacing) * 0)}.after\:left-1\/2:after{content:var(--tw-content);left:50%}.after\:w-\[2px\]:after{content:var(--tw-content);width:2px}.after\:rounded-full:after{content:var(--tw-content);border-radius:3.40282e38px}.after\:border:after{content:var(--tw-content);border-style:var(--tw-border-style);border-width:1px}.after\:border-border:after{content:var(--tw-content);border-color:var(--border)}.after\:mix-blend-darken:after{content:var(--tw-content);mix-blend-mode:darken}.group-data-\[collapsible\=offExamples\]\:after\:left-full:is(:where(.group)[data-collapsible=offExamples] *):after{content:var(--tw-content);left:100%}.last\:mt-0:last-child{margin-top:calc(var(--spacing) * 0)}.last\:border-0:last-child{border-style:var(--tw-border-style);border-width:0}.last\:border-b-0:last-child{border-bottom-style:var(--tw-border-style);border-bottom-width:0}.autofill\:\[-webkit-text-fill-color\:var\(--color-foreground\)\]:autofill{-webkit-text-fill-color:var(--color-foreground)}.autofill\:\[transition\:background-color_9999s_ease-in-out_0s\]:autofill{transition:background-color 9999s ease-in-out}.focus-within\:border-ring:focus-within{border-color:var(--ring)}.focus-within\:ring-2:focus-within{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.focus-within\:ring-ring\/30:focus-within{--tw-ring-color:var(--ring)}@supports (color:color-mix(in lab, red, red)){.focus-within\:ring-ring\/30:focus-within{--tw-ring-color:color-mix(in oklab, var(--ring) 30%, transparent)}}@media (hover:hover){.hover\:bg-accent:hover{background-color:var(--accent)}.hover\:bg-destructive\/20:hover{background-color:var(--destructive)}@supports (color:color-mix(in lab, red, red)){.hover\:bg-destructive\/20:hover{background-color:color-mix(in oklab, var(--destructive) 20%, transparent)}}.hover\:bg-input\/40:hover{background-color:var(--input)}@supports (color:color-mix(in lab, red, red)){.hover\:bg-input\/40:hover{background-color:color-mix(in oklab, var(--input) 40%, transparent)}}.hover\:bg-input\/50:hover{background-color:var(--input)}@supports (color:color-mix(in lab, red, red)){.hover\:bg-input\/50:hover{background-color:color-mix(in oklab, var(--input) 50%, transparent)}}.hover\:bg-muted:hover{background-color:var(--muted)}.hover\:bg-primary\/80:hover{background-color:var(--primary)}@supports (color:color-mix(in lab, red, red)){.hover\:bg-primary\/80:hover{background-color:color-mix(in oklab, var(--primary) 80%, transparent)}}.hover\:bg-secondary\/80:hover{background-color:var(--secondary)}@supports (color:color-mix(in lab, red, red)){.hover\:bg-secondary\/80:hover{background-color:color-mix(in oklab, var(--secondary) 80%, transparent)}}.hover\:bg-sidebar-accent:hover{background-color:var(--sidebar-accent)}.hover\:text-accent-foreground:hover{color:var(--accent-foreground)}.hover\:text-foreground:hover{color:var(--foreground)}.hover\:text-muted-foreground:hover{color:var(--muted-foreground)}.hover\:text-sidebar-accent-foreground:hover{color:var(--sidebar-accent-foreground)}.hover\:underline:hover{text-decoration-line:underline}.hover\:opacity-100:hover{opacity:1}.hover\:shadow-\[0_0_0_1px_hsl\(var\(--sidebar-accent\)\)\]:hover{--tw-shadow:0 0 0 1px var(--tw-shadow-color,hsl(var(--sidebar-accent)));box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.hover\:group-data-\[collapsible\=offExamples\]\:bg-sidebar:hover:is(:where(.group)[data-collapsible=offExamples] *){background-color:var(--sidebar)}.hover\:after\:bg-sidebar-border:hover:after{content:var(--tw-content);background-color:var(--sidebar-border)}}.focus\:bg-accent:focus{background-color:var(--accent)}.focus\:text-accent-foreground:focus,:is(.focus\:\*\*\:text-accent-foreground:focus *),:is(.not-data-\[variant\=destructive\]\:focus\:\*\*\:text-accent-foreground:not([data-variant=destructive]):focus *){color:var(--accent-foreground)}.focus-visible\:border-destructive\/40:focus-visible{border-color:var(--destructive)}@supports (color:color-mix(in lab, red, red)){.focus-visible\:border-destructive\/40:focus-visible{border-color:color-mix(in oklab, var(--destructive) 40%, transparent)}}.focus-visible\:border-ring:focus-visible{border-color:var(--ring)}.focus-visible\:ring-0:focus-visible{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(0px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.focus-visible\:ring-2:focus-visible,.focus-visible\:ring-\[2px\]:focus-visible{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.focus-visible\:ring-\[3px\]:focus-visible{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(3px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.focus-visible\:ring-destructive\/20:focus-visible{--tw-ring-color:var(--destructive)}@supports (color:color-mix(in lab, red, red)){.focus-visible\:ring-destructive\/20:focus-visible{--tw-ring-color:color-mix(in oklab, var(--destructive) 20%, transparent)}}.focus-visible\:ring-ring\/30:focus-visible{--tw-ring-color:var(--ring)}@supports (color:color-mix(in lab, red, red)){.focus-visible\:ring-ring\/30:focus-visible{--tw-ring-color:color-mix(in oklab, var(--ring) 30%, transparent)}}.focus-visible\:ring-ring\/50:focus-visible{--tw-ring-color:var(--ring)}@supports (color:color-mix(in lab, red, red)){.focus-visible\:ring-ring\/50:focus-visible{--tw-ring-color:color-mix(in oklab, var(--ring) 50%, transparent)}}.focus-visible\:outline-none:focus-visible{--tw-outline-style:none;outline-style:none}.active\:bg-sidebar-accent:active{background-color:var(--sidebar-accent)}.active\:text-sidebar-accent-foreground:active{color:var(--sidebar-accent-foreground)}.disabled\:pointer-events-none:disabled{pointer-events:none}.disabled\:cursor-not-allowed:disabled{cursor:not-allowed}.disabled\:opacity-50:disabled{opacity:.5}:where([data-side=left]) .in-data-\[side\=left\]\:cursor-w-resize{cursor:w-resize}:where([data-side=right]) .in-data-\[side\=right\]\:cursor-e-resize{cursor:e-resize}:where([data-slot=combobox-content]) .in-data-\[slot\=combobox-content\]\:focus-within\:border-inherit:focus-within{border-color:inherit}:where([data-slot=combobox-content]) .in-data-\[slot\=combobox-content\]\:focus-within\:ring-0:focus-within{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(0px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.has-disabled\:pointer-events-none:has(:disabled){pointer-events:none}.has-disabled\:cursor-not-allowed:has(:disabled){cursor:not-allowed}.has-disabled\:opacity-50:has(:disabled){opacity:.5}.has-aria-invalid\:border-destructive:has([aria-invalid=true]){border-color:var(--destructive)}.has-aria-invalid\:ring-2:has([aria-invalid=true]){--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.has-aria-invalid\:ring-destructive\/20:has([aria-invalid=true]){--tw-ring-color:var(--destructive)}@supports (color:color-mix(in lab, red, red)){.has-aria-invalid\:ring-destructive\/20:has([aria-invalid=true]){--tw-ring-color:color-mix(in oklab, var(--destructive) 20%, transparent)}}.has-data-checked\:bg-primary\/5:has([data-checked]){background-color:var(--primary)}@supports (color:color-mix(in lab, red, red)){.has-data-checked\:bg-primary\/5:has([data-checked]){background-color:color-mix(in oklab, var(--primary) 5%, transparent)}}.has-data-\[align\=block-end\]\:rounded-md:has([data-align=block-end]),.has-data-\[align\=block-start\]\:rounded-md:has([data-align=block-start]){border-radius:var(--radius)}.has-data-\[icon\=inline-end\]\:pr-1\.5:has([data-icon=inline-end]){padding-right:calc(var(--spacing) * 1.5)}.has-data-\[icon\=inline-end\]\:pr-2:has([data-icon=inline-end]){padding-right:calc(var(--spacing) * 2)}.has-data-\[icon\=inline-start\]\:pl-1\.5:has([data-icon=inline-start]){padding-left:calc(var(--spacing) * 1.5)}.has-data-\[icon\=inline-start\]\:pl-2:has([data-icon=inline-start]){padding-left:calc(var(--spacing) * 2)}.has-data-\[slot\=alert-dialog-media\]\:grid-rows-\[auto_auto_1fr\]:has([data-slot=alert-dialog-media]){grid-template-rows:auto auto 1fr}.has-data-\[slot\=alert-dialog-media\]\:gap-x-4:has([data-slot=alert-dialog-media]){column-gap:calc(var(--spacing) * 4)}.has-data-\[slot\=card-action\]\:grid-cols-\[1fr_auto\]:has([data-slot=card-action]){grid-template-columns:1fr auto}.has-data-\[slot\=card-description\]\:grid-rows-\[auto_auto\]:has([data-slot=card-description]){grid-template-rows:auto auto}.has-data-\[slot\=combobox-chip\]\:px-1:has([data-slot=combobox-chip]){padding-inline:calc(var(--spacing) * 1)}.has-data-\[slot\=combobox-chip-remove\]\:pr-0:has([data-slot=combobox-chip-remove]){padding-right:calc(var(--spacing) * 0)}.has-data-\[variant\=inset\]\:bg-sidebar:has([data-variant=inset]){background-color:var(--sidebar)}.has-\[\[data-slot\=input-group-control\]\:focus-visible\]\:border-ring:has([data-slot=input-group-control]:focus-visible){border-color:var(--ring)}.has-\[\[data-slot\=input-group-control\]\:focus-visible\]\:ring-2:has([data-slot=input-group-control]:focus-visible){--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.has-\[\[data-slot\=input-group-control\]\:focus-visible\]\:ring-ring\/30:has([data-slot=input-group-control]:focus-visible){--tw-ring-color:var(--ring)}@supports (color:color-mix(in lab, red, red)){.has-\[\[data-slot\=input-group-control\]\:focus-visible\]\:ring-ring\/30:has([data-slot=input-group-control]:focus-visible){--tw-ring-color:color-mix(in oklab, var(--ring) 30%, transparent)}}.has-\[\[data-slot\]\[aria-invalid\=true\]\]\:border-destructive:has([data-slot][aria-invalid=true]){border-color:var(--destructive)}.has-\[\[data-slot\]\[aria-invalid\=true\]\]\:ring-2:has([data-slot][aria-invalid=true]){--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.has-\[\[data-slot\]\[aria-invalid\=true\]\]\:ring-destructive\/20:has([data-slot][aria-invalid=true]){--tw-ring-color:var(--destructive)}@supports (color:color-mix(in lab, red, red)){.has-\[\[data-slot\]\[aria-invalid\=true\]\]\:ring-destructive\/20:has([data-slot][aria-invalid=true]){--tw-ring-color:color-mix(in oklab, var(--destructive) 20%, transparent)}}.has-\[textarea\]\:rounded-md:has(:is(textarea)){border-radius:var(--radius)}.has-\[\>\[data-align\=block-end\]\]\:h-auto:has(>[data-align=block-end]){height:auto}.has-\[\>\[data-align\=block-end\]\]\:flex-col:has(>[data-align=block-end]){flex-direction:column}.has-\[\>\[data-align\=block-start\]\]\:h-auto:has(>[data-align=block-start]){height:auto}.has-\[\>\[data-align\=block-start\]\]\:flex-col:has(>[data-align=block-start]){flex-direction:column}.has-\[\>\[data-slot\=checkbox-group\]\]\:gap-3:has(>[data-slot=checkbox-group]){gap:calc(var(--spacing) * 3)}.has-\[\>\[data-slot\=field-content\]\]\:items-start:has(>[data-slot=field-content]){align-items:flex-start}.has-\[\>\[data-slot\=field\]\]\:w-full:has(>[data-slot=field]){width:100%}.has-\[\>\[data-slot\=field\]\]\:flex-col:has(>[data-slot=field]){flex-direction:column}.has-\[\>\[data-slot\=field\]\]\:rounded-md:has(>[data-slot=field]){border-radius:var(--radius)}.has-\[\>\[data-slot\=field\]\]\:border:has(>[data-slot=field]){border-style:var(--tw-border-style);border-width:1px}.has-\[\>\[data-slot\=radio-group\]\]\:gap-3:has(>[data-slot=radio-group]){gap:calc(var(--spacing) * 3)}.has-\[\>button\]\:mr-\[-0\.275rem\]:has(>button){margin-right:-.275rem}.has-\[\>button\]\:ml-\[-0\.275rem\]:has(>button){margin-left:-.275rem}.has-\[\>img\:first-child\]\:pt-0:has(>img:first-child){padding-top:calc(var(--spacing) * 0)}.has-\[\>kbd\]\:mr-\[-0\.275rem\]:has(>kbd){margin-right:-.275rem}.has-\[\>kbd\]\:ml-\[-0\.275rem\]:has(>kbd){margin-left:-.275rem}.has-\[\>svg\]\:p-0:has(>svg){padding:calc(var(--spacing) * 0)}.has-\[\>textarea\]\:h-auto:has(>textarea){height:auto}.aria-disabled\:pointer-events-none[aria-disabled=true]{pointer-events:none}.aria-disabled\:opacity-50[aria-disabled=true]{opacity:.5}.aria-expanded\:bg-muted[aria-expanded=true]{background-color:var(--muted)}.aria-expanded\:bg-secondary[aria-expanded=true]{background-color:var(--secondary)}.aria-expanded\:text-foreground[aria-expanded=true]{color:var(--foreground)}.aria-expanded\:text-secondary-foreground[aria-expanded=true]{color:var(--secondary-foreground)}.aria-invalid\:border-destructive[aria-invalid=true]{border-color:var(--destructive)}.aria-invalid\:ring-0[aria-invalid=true]{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(0px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.aria-invalid\:ring-2[aria-invalid=true],.aria-invalid\:ring-\[2px\][aria-invalid=true]{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.aria-invalid\:ring-destructive\/20[aria-invalid=true]{--tw-ring-color:var(--destructive)}@supports (color:color-mix(in lab, red, red)){.aria-invalid\:ring-destructive\/20[aria-invalid=true]{--tw-ring-color:color-mix(in oklab, var(--destructive) 20%, transparent)}}.data-active\:bg-sidebar-accent[data-active]{background-color:var(--sidebar-accent)}.data-active\:font-medium[data-active]{--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium)}.data-active\:text-sidebar-accent-foreground[data-active]{color:var(--sidebar-accent-foreground)}.data-closed\:overflow-hidden[data-closed]{overflow:hidden}.data-disabled\:pointer-events-none[data-disabled]{pointer-events:none}.data-disabled\:opacity-50[data-disabled]{opacity:.5}.data-empty\:p-0[data-empty]{padding:calc(var(--spacing) * 0)}.data-ending-style\:opacity-0[data-ending-style]{opacity:0}.data-highlighted\:bg-accent[data-highlighted]{background-color:var(--accent)}.data-highlighted\:text-accent-foreground[data-highlighted],:is(.not-data-\[variant\=destructive\]\:data-highlighted\:\*\*\:text-accent-foreground:not([data-variant=destructive])[data-highlighted] *){color:var(--accent-foreground)}.data-inset\:pl-8[data-inset]{padding-left:calc(var(--spacing) * 8)}.data-open\:bg-accent[data-open]{background-color:var(--accent)}.data-open\:text-accent-foreground[data-open]{color:var(--accent-foreground)}.data-open\:opacity-100[data-open]{opacity:1}@media (hover:hover){.data-open\:hover\:bg-sidebar-accent[data-open]:hover{background-color:var(--sidebar-accent)}.data-open\:hover\:text-sidebar-accent-foreground[data-open]:hover{color:var(--sidebar-accent-foreground)}}.data-placeholder\:text-muted-foreground[data-placeholder]{color:var(--muted-foreground)}.data-pressed\:bg-transparent[data-pressed]{background-color:#0000}.data-selected\:border-primary[data-selected]{border-color:var(--primary)}.data-selected\:text-foreground[data-selected]{color:var(--foreground)}.data-starting-style\:opacity-0[data-starting-style]{opacity:0}.data-\[chips\=true\]\:min-w-\(--anchor-width\)[data-chips=true]{min-width:var(--anchor-width)}.data-\[invalid\=true\]\:text-destructive[data-invalid=true]{color:var(--destructive)}.data-\[orientation\=horizontal\]\:h-px[data-orientation=horizontal]{height:1px}.data-\[orientation\=horizontal\]\:w-full[data-orientation=horizontal]{width:100%}.data-\[orientation\=vertical\]\:w-px[data-orientation=vertical]{width:1px}.data-\[orientation\=vertical\]\:self-stretch[data-orientation=vertical]{align-self:stretch}.data-\[side\=bottom\]\:inset-x-0[data-side=bottom]{inset-inline:calc(var(--spacing) * 0)}.data-\[side\=bottom\]\:top-1[data-side=bottom]{top:calc(var(--spacing) * 1)}.data-\[side\=bottom\]\:bottom-0[data-side=bottom]{bottom:calc(var(--spacing) * 0)}.data-\[side\=bottom\]\:h-auto[data-side=bottom]{height:auto}.data-\[side\=bottom\]\:border-t[data-side=bottom]{border-top-style:var(--tw-border-style);border-top-width:1px}.data-\[side\=left\]\:inset-y-0[data-side=left]{inset-block:calc(var(--spacing) * 0)}.data-\[side\=left\]\:top-1\/2\![data-side=left]{top:50%!important}.data-\[side\=left\]\:-right-1[data-side=left]{right:calc(var(--spacing) * -1)}.data-\[side\=left\]\:left-0[data-side=left]{left:calc(var(--spacing) * 0)}.data-\[side\=left\]\:h-full[data-side=left]{height:100%}.data-\[side\=left\]\:w-3\/4[data-side=left]{width:75%}.data-\[side\=left\]\:-translate-y-1\/2[data-side=left]{--tw-translate-y:calc(calc(1 / 2 * 100%) * -1);translate:var(--tw-translate-x) var(--tw-translate-y)}.data-\[side\=left\]\:border-r[data-side=left]{border-right-style:var(--tw-border-style);border-right-width:1px}.data-\[side\=right\]\:inset-y-0[data-side=right]{inset-block:calc(var(--spacing) * 0)}.data-\[side\=right\]\:top-1\/2\![data-side=right]{top:50%!important}.data-\[side\=right\]\:right-0[data-side=right]{right:calc(var(--spacing) * 0)}.data-\[side\=right\]\:-left-1[data-side=right]{left:calc(var(--spacing) * -1)}.data-\[side\=right\]\:h-full[data-side=right]{height:100%}.data-\[side\=right\]\:w-3\/4[data-side=right]{width:75%}.data-\[side\=right\]\:-translate-y-1\/2[data-side=right]{--tw-translate-y:calc(calc(1 / 2 * 100%) * -1);translate:var(--tw-translate-x) var(--tw-translate-y)}.data-\[side\=right\]\:border-l[data-side=right]{border-left-style:var(--tw-border-style);border-left-width:1px}.data-\[side\=top\]\:inset-x-0[data-side=top]{inset-inline:calc(var(--spacing) * 0)}.data-\[side\=top\]\:top-0[data-side=top]{top:calc(var(--spacing) * 0)}.data-\[side\=top\]\:-bottom-2\.5[data-side=top]{bottom:calc(var(--spacing) * -2.5)}.data-\[side\=top\]\:h-auto[data-side=top]{height:auto}.data-\[side\=top\]\:border-b[data-side=top]{border-bottom-style:var(--tw-border-style);border-bottom-width:1px}.data-\[size\=default\]\:h-7[data-size=default]{height:calc(var(--spacing) * 7)}.data-\[size\=default\]\:max-w-xs[data-size=default]{max-width:var(--container-xs)}.data-\[size\=lg\]\:size-10[data-size=lg]{width:calc(var(--spacing) * 10);height:calc(var(--spacing) * 10)}.data-\[size\=md\]\:text-xs[data-size=md]{font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height))}.data-\[size\=sm\]\:size-6[data-size=sm]{width:calc(var(--spacing) * 6);height:calc(var(--spacing) * 6)}.data-\[size\=sm\]\:h-6[data-size=sm]{height:calc(var(--spacing) * 6)}.data-\[size\=sm\]\:max-w-64[data-size=sm]{max-width:calc(var(--spacing) * 64)}.data-\[size\=sm\]\:gap-3[data-size=sm]{gap:calc(var(--spacing) * 3)}.data-\[size\=sm\]\:py-3[data-size=sm]{padding-block:calc(var(--spacing) * 3)}.data-\[size\=sm\]\:text-xs[data-size=sm]{font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height))}:is(.\*\:data-\[slot\=avatar\]\:ring-2>*)[data-slot=avatar]{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}:is(.\*\:data-\[slot\=avatar\]\:ring-background>*)[data-slot=avatar]{--tw-ring-color:var(--background)}.data-\[slot\=checkbox-group\]\:gap-3[data-slot=checkbox-group]{gap:calc(var(--spacing) * 3)}:is(.\*\:data-\[slot\=field\]\:p-2>*)[data-slot=field]{padding:calc(var(--spacing) * 2)}:is(.\*\:data-\[slot\=field-group\]\:gap-4>*)[data-slot=field-group]{gap:calc(var(--spacing) * 4)}:is(.\*\:data-\[slot\=input-group\]\:m-1>*)[data-slot=input-group]{margin:calc(var(--spacing) * 1)}:is(.\*\:data-\[slot\=input-group\]\:mb-0>*)[data-slot=input-group]{margin-bottom:calc(var(--spacing) * 0)}:is(.\*\:data-\[slot\=input-group\]\:h-7>*)[data-slot=input-group]{height:calc(var(--spacing) * 7)}:is(.\*\:data-\[slot\=input-group\]\:border-none>*)[data-slot=input-group]{--tw-border-style:none;border-style:none}:is(.\*\:data-\[slot\=input-group\]\:bg-input\/20>*)[data-slot=input-group]{background-color:var(--input)}@supports (color:color-mix(in lab, red, red)){:is(.\*\:data-\[slot\=input-group\]\:bg-input\/20>*)[data-slot=input-group]{background-color:color-mix(in oklab, var(--input) 20%, transparent)}}:is(.\*\:data-\[slot\=input-group\]\:shadow-none>*)[data-slot=input-group]{--tw-shadow:0 0 #0000;box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}:is(.\*\*\:data-\[slot\=kbd\]\:rounded-\[calc\(var\(--radius-sm\)-2px\)\] *)[data-slot=kbd]{border-radius:calc(var(--radius-sm) - 2px)}:is(.\*\*\:data-\[slot\=kbd\]\:rounded-md *)[data-slot=kbd]{border-radius:var(--radius)}:is(.\*\*\:data-\[slot\=kbd\]\:bg-muted-foreground\/10 *)[data-slot=kbd]{background-color:var(--muted-foreground)}@supports (color:color-mix(in lab, red, red)){:is(.\*\*\:data-\[slot\=kbd\]\:bg-muted-foreground\/10 *)[data-slot=kbd]{background-color:color-mix(in oklab, var(--muted-foreground) 10%, transparent)}}:is(.\*\*\:data-\[slot\=kbd\]\:px-1 *)[data-slot=kbd]{padding-inline:calc(var(--spacing) * 1)}:is(.\*\*\:data-\[slot\=kbd\]\:text-\[0\.625rem\] *)[data-slot=kbd]{font-size:.625rem}:is(.\*\:data-\[slot\=select-value\]\:line-clamp-1>*)[data-slot=select-value]{-webkit-line-clamp:1;-webkit-box-orient:vertical;display:-webkit-box;overflow:hidden}:is(.\*\:data-\[slot\=select-value\]\:flex>*)[data-slot=select-value]{display:flex}:is(.\*\:data-\[slot\=select-value\]\:items-center>*)[data-slot=select-value]{align-items:center}:is(.\*\:data-\[slot\=select-value\]\:gap-1\.5>*)[data-slot=select-value]{gap:calc(var(--spacing) * 1.5)}.data-\[variant\=destructive\]\:text-destructive[data-variant=destructive]{color:var(--destructive)}.data-\[variant\=destructive\]\:focus\:bg-destructive\/10[data-variant=destructive]:focus{background-color:var(--destructive)}@supports (color:color-mix(in lab, red, red)){.data-\[variant\=destructive\]\:focus\:bg-destructive\/10[data-variant=destructive]:focus{background-color:color-mix(in oklab, var(--destructive) 10%, transparent)}}.data-\[variant\=destructive\]\:focus\:text-destructive[data-variant=destructive]:focus{color:var(--destructive)}.data-\[variant\=label\]\:text-xs\/relaxed[data-variant=label]{font-size:var(--text-xs);line-height:var(--leading-relaxed)}.data-\[variant\=legend\]\:text-sm[data-variant=legend]{font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height))}.nth-last-2\:-mt-1:nth-last-child(2){margin-top:calc(var(--spacing) * -1)}@supports ((-webkit-backdrop-filter:var(--tw)) or (backdrop-filter:var(--tw))){.supports-backdrop-filter\:backdrop-blur-xs{--tw-backdrop-blur:blur(var(--blur-xs));-webkit-backdrop-filter:var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,);backdrop-filter:var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,)}}@media (width>=40rem){.sm\:flex{display:flex}.sm\:max-w-lg{max-width:var(--container-lg)}.sm\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.sm\:grid-cols-\[1fr_1fr_1fr_auto\]{grid-template-columns:1fr 1fr 1fr auto}.sm\:grid-cols-\[1fr_auto\]{grid-template-columns:1fr auto}.sm\:flex-row{flex-direction:row}.sm\:justify-end{justify-content:flex-end}.sm\:group-data-\[size\=default\]\/alert-dialog-content\:row-span-2:is(:where(.group\/alert-dialog-content)[data-size=default] *){grid-row:span 2/span 2}.sm\:group-data-\[size\=default\]\/alert-dialog-content\:place-items-start:is(:where(.group\/alert-dialog-content)[data-size=default] *){place-items:start}.sm\:group-data-\[size\=default\]\/alert-dialog-content\:text-left:is(:where(.group\/alert-dialog-content)[data-size=default] *){text-align:left}.sm\:group-data-\[size\=default\]\/alert-dialog-content\:group-has-data-\[slot\=alert-dialog-media\]\/alert-dialog-content\:col-start-2:is(:where(.group\/alert-dialog-content)[data-size=default] *):is(:where(.group\/alert-dialog-content):has([data-slot=alert-dialog-media]) *){grid-column-start:2}.sm\:group-data-\[size\=default\]\/alert-dialog-content\:has-data-\[slot\=alert-dialog-media\]\:grid-rows-\[auto_1fr\]:is(:where(.group\/alert-dialog-content)[data-size=default] *):has([data-slot=alert-dialog-media]){grid-template-rows:auto 1fr}.data-\[side\=left\]\:sm\:max-w-sm[data-side=left],.data-\[side\=right\]\:sm\:max-w-sm[data-side=right],.data-\[size\=default\]\:sm\:max-w-sm[data-size=default]{max-width:var(--container-sm)}}@media (width>=48rem){.md\:block{display:block}.md\:flex{display:flex}.md\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.md\:grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.md\:grid-cols-4{grid-template-columns:repeat(4,minmax(0,1fr))}.md\:text-xs\/relaxed{font-size:var(--text-xs);line-height:var(--leading-relaxed)}.md\:text-pretty{text-wrap:pretty}.md\:opacity-0{opacity:0}.md\:peer-data-\[variant\=inset\]\:m-2:is(:where(.peer)[data-variant=inset]~*){margin:calc(var(--spacing) * 2)}.md\:peer-data-\[variant\=inset\]\:ml-0:is(:where(.peer)[data-variant=inset]~*){margin-left:calc(var(--spacing) * 0)}.md\:peer-data-\[variant\=inset\]\:rounded-xl:is(:where(.peer)[data-variant=inset]~*){border-radius:calc(var(--radius) + 4px)}.md\:peer-data-\[variant\=inset\]\:shadow-sm:is(:where(.peer)[data-variant=inset]~*){--tw-shadow:0 1px 3px 0 var(--tw-shadow-color,#0000001a), 0 1px 2px -1px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.md\:peer-data-\[variant\=inset\]\:peer-data-\[state\=collapsed\]\:ml-2:is(:where(.peer)[data-variant=inset]~*):is(:where(.peer)[data-state=collapsed]~*){margin-left:calc(var(--spacing) * 2)}.md\:after\:hidden:after{content:var(--tw-content);display:none}}@media (width>=64rem){.lg\:block{display:block}}@media (width>=80rem){.xl\:grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}}@container field-group (width>=28rem){.\@md\/field-group\:flex-row{flex-direction:row}.\@md\/field-group\:items-center{align-items:center}.\@md\/field-group\:has-\[\>\[data-slot\=field-content\]\]\:items-start:has(>[data-slot=field-content]){align-items:flex-start}}.dark\:scale-0:where(.dark,.dark *){--tw-scale-x:0%;--tw-scale-y:0%;--tw-scale-z:0%;scale:var(--tw-scale-x) var(--tw-scale-y)}.dark\:scale-100:where(.dark,.dark *){--tw-scale-x:100%;--tw-scale-y:100%;--tw-scale-z:100%;scale:var(--tw-scale-x) var(--tw-scale-y)}.dark\:-rotate-90:where(.dark,.dark *){rotate:-90deg}.dark\:rotate-0:where(.dark,.dark *){rotate:0deg}.dark\:bg-destructive\/20:where(.dark,.dark *){background-color:var(--destructive)}@supports (color:color-mix(in lab, red, red)){.dark\:bg-destructive\/20:where(.dark,.dark *){background-color:color-mix(in oklab, var(--destructive) 20%, transparent)}}.dark\:bg-gray-800:where(.dark,.dark *){background-color:var(--color-gray-800)}.dark\:bg-green-900:where(.dark,.dark *){background-color:var(--color-green-900)}.dark\:bg-input\/30:where(.dark,.dark *){background-color:var(--input)}@supports (color:color-mix(in lab, red, red)){.dark\:bg-input\/30:where(.dark,.dark *){background-color:color-mix(in oklab, var(--input) 30%, transparent)}}.dark\:bg-muted\/30:where(.dark,.dark *){background-color:var(--muted)}@supports (color:color-mix(in lab, red, red)){.dark\:bg-muted\/30:where(.dark,.dark *){background-color:color-mix(in oklab, var(--muted) 30%, transparent)}}.dark\:bg-popover:where(.dark,.dark *){background-color:var(--popover)}.dark\:bg-red-900:where(.dark,.dark *){background-color:var(--color-red-900)}.dark\:bg-transparent:where(.dark,.dark *){background-color:#0000}.dark\:bg-yellow-900:where(.dark,.dark *){background-color:var(--color-yellow-900)}.dark\:bg-zinc-800:where(.dark,.dark *){background-color:var(--color-zinc-800)}.dark\:text-amber-400:where(.dark,.dark *){color:var(--color-amber-400)}.dark\:text-emerald-400:where(.dark,.dark *){color:var(--color-emerald-400)}.dark\:text-gray-100:where(.dark,.dark *){color:var(--color-gray-100)}.dark\:text-green-100:where(.dark,.dark *){color:var(--color-green-100)}.dark\:text-red-100:where(.dark,.dark *){color:var(--color-red-100)}.dark\:text-yellow-100:where(.dark,.dark *){color:var(--color-yellow-100)}.dark\:text-zinc-400:where(.dark,.dark *){color:var(--color-zinc-400)}.dark\:invert:where(.dark,.dark *){--tw-invert:invert(100%);filter:var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,)}.dark\:after\:mix-blend-lighten:where(.dark,.dark *):after{content:var(--tw-content);mix-blend-mode:lighten}@media (hover:hover){.dark\:hover\:bg-destructive\/30:where(.dark,.dark *):hover{background-color:var(--destructive)}@supports (color:color-mix(in lab, red, red)){.dark\:hover\:bg-destructive\/30:where(.dark,.dark *):hover{background-color:color-mix(in oklab, var(--destructive) 30%, transparent)}}.dark\:hover\:bg-input\/50:where(.dark,.dark *):hover{background-color:var(--input)}@supports (color:color-mix(in lab, red, red)){.dark\:hover\:bg-input\/50:where(.dark,.dark *):hover{background-color:color-mix(in oklab, var(--input) 50%, transparent)}}.dark\:hover\:bg-muted\/50:where(.dark,.dark *):hover{background-color:var(--muted)}@supports (color:color-mix(in lab, red, red)){.dark\:hover\:bg-muted\/50:where(.dark,.dark *):hover{background-color:color-mix(in oklab, var(--muted) 50%, transparent)}}}.dark\:focus-visible\:ring-destructive\/40:where(.dark,.dark *):focus-visible{--tw-ring-color:var(--destructive)}@supports (color:color-mix(in lab, red, red)){.dark\:focus-visible\:ring-destructive\/40:where(.dark,.dark *):focus-visible{--tw-ring-color:color-mix(in oklab, var(--destructive) 40%, transparent)}}.dark\:has-aria-invalid\:border-destructive\/50:where(.dark,.dark *):has([aria-invalid=true]){border-color:var(--destructive)}@supports (color:color-mix(in lab, red, red)){.dark\:has-aria-invalid\:border-destructive\/50:where(.dark,.dark *):has([aria-invalid=true]){border-color:color-mix(in oklab, var(--destructive) 50%, transparent)}}.dark\:has-aria-invalid\:ring-destructive\/40:where(.dark,.dark *):has([aria-invalid=true]){--tw-ring-color:var(--destructive)}@supports (color:color-mix(in lab, red, red)){.dark\:has-aria-invalid\:ring-destructive\/40:where(.dark,.dark *):has([aria-invalid=true]){--tw-ring-color:color-mix(in oklab, var(--destructive) 40%, transparent)}}.dark\:has-data-checked\:bg-primary\/10:where(.dark,.dark *):has([data-checked]){background-color:var(--primary)}@supports (color:color-mix(in lab, red, red)){.dark\:has-data-checked\:bg-primary\/10:where(.dark,.dark *):has([data-checked]){background-color:color-mix(in oklab, var(--primary) 10%, transparent)}}.dark\:has-\[\[data-slot\]\[aria-invalid\=true\]\]\:ring-destructive\/40:where(.dark,.dark *):has([data-slot][aria-invalid=true]){--tw-ring-color:var(--destructive)}@supports (color:color-mix(in lab, red, red)){.dark\:has-\[\[data-slot\]\[aria-invalid\=true\]\]\:ring-destructive\/40:where(.dark,.dark *):has([data-slot][aria-invalid=true]){--tw-ring-color:color-mix(in oklab, var(--destructive) 40%, transparent)}}.dark\:aria-invalid\:border-destructive\/50:where(.dark,.dark *)[aria-invalid=true]{border-color:var(--destructive)}@supports (color:color-mix(in lab, red, red)){.dark\:aria-invalid\:border-destructive\/50:where(.dark,.dark *)[aria-invalid=true]{border-color:color-mix(in oklab, var(--destructive) 50%, transparent)}}.dark\:aria-invalid\:ring-destructive\/40:where(.dark,.dark *)[aria-invalid=true]{--tw-ring-color:var(--destructive)}@supports (color:color-mix(in lab, red, red)){.dark\:aria-invalid\:ring-destructive\/40:where(.dark,.dark *)[aria-invalid=true]{--tw-ring-color:color-mix(in oklab, var(--destructive) 40%, transparent)}}.dark\:data-\[variant\=destructive\]\:focus\:bg-destructive\/20:where(.dark,.dark *)[data-variant=destructive]:focus{background-color:var(--destructive)}@supports (color:color-mix(in lab, red, red)){.dark\:data-\[variant\=destructive\]\:focus\:bg-destructive\/20:where(.dark,.dark *)[data-variant=destructive]:focus{background-color:color-mix(in oklab, var(--destructive) 20%, transparent)}}.\[\&_svg\]\:pointer-events-none svg{pointer-events:none}.\[\&_svg\]\:size-4 svg{width:calc(var(--spacing) * 4);height:calc(var(--spacing) * 4)}.\[\&_svg\]\:shrink-0 svg{flex-shrink:0}.\[\&_svg\:not\(\[class\*\=\'size-\'\]\)\]\:size-2\.5 svg:not([class*=size-]){width:calc(var(--spacing) * 2.5);height:calc(var(--spacing) * 2.5)}.\[\&_svg\:not\(\[class\*\=\'size-\'\]\)\]\:size-3 svg:not([class*=size-]){width:calc(var(--spacing) * 3);height:calc(var(--spacing) * 3)}.\[\&_svg\:not\(\[class\*\=\'size-\'\]\)\]\:size-3\.5 svg:not([class*=size-]){width:calc(var(--spacing) * 3.5);height:calc(var(--spacing) * 3.5)}.\[\&_svg\:not\(\[class\*\=\'size-\'\]\)\]\:size-4 svg:not([class*=size-]){width:calc(var(--spacing) * 4);height:calc(var(--spacing) * 4)}.\[\.border-b\]\:pb-2.border-b{padding-bottom:calc(var(--spacing) * 2)}.\[\.border-b\]\:pb-4.border-b{padding-bottom:calc(var(--spacing) * 4)}.group-data-\[size\=sm\]\/card\:\[\.border-b\]\:pb-3:is(:where(.group\/card)[data-size=sm] *).border-b{padding-bottom:calc(var(--spacing) * 3)}.\[\.border-t\]\:pt-2.border-t{padding-top:calc(var(--spacing) * 2)}.\[\.border-t\]\:pt-4.border-t{padding-top:calc(var(--spacing) * 4)}.group-data-\[size\=sm\]\/card\:\[\.border-t\]\:pt-3:is(:where(.group\/card)[data-size=sm] *).border-t{padding-top:calc(var(--spacing) * 3)}:is(.\*\:\[a\]\:underline>*):is(a){text-decoration-line:underline}:is(.\*\:\[a\]\:underline-offset-3>*):is(a){text-underline-offset:3px}@media (hover:hover){.\[a\]\:hover\:bg-destructive\/20:is(a):hover{background-color:var(--destructive)}@supports (color:color-mix(in lab, red, red)){.\[a\]\:hover\:bg-destructive\/20:is(a):hover{background-color:color-mix(in oklab, var(--destructive) 20%, transparent)}}.\[a\]\:hover\:bg-muted:is(a):hover{background-color:var(--muted)}.\[a\]\:hover\:bg-primary\/80:is(a):hover{background-color:var(--primary)}@supports (color:color-mix(in lab, red, red)){.\[a\]\:hover\:bg-primary\/80:is(a):hover{background-color:color-mix(in oklab, var(--primary) 80%, transparent)}}.\[a\]\:hover\:bg-secondary\/80:is(a):hover{background-color:var(--secondary)}@supports (color:color-mix(in lab, red, red)){.\[a\]\:hover\:bg-secondary\/80:is(a):hover{background-color:color-mix(in oklab, var(--secondary) 80%, transparent)}}.\[a\]\:hover\:text-muted-foreground:is(a):hover{color:var(--muted-foreground)}:is(.\*\:\[a\]\:hover\:text-foreground>*):is(a):hover{color:var(--foreground)}}:is(.\*\:\[img\:first-child\]\:rounded-t-lg>*):is(img:first-child){border-top-left-radius:calc(var(--radius) + 2px);border-top-right-radius:calc(var(--radius) + 2px)}:is(.\*\:\[img\:last-child\]\:rounded-b-lg>*):is(img:last-child){border-bottom-right-radius:calc(var(--radius) + 2px);border-bottom-left-radius:calc(var(--radius) + 2px)}:is(.\*\:\[span\]\:last\:flex>*):is(span):last-child{display:flex}:is(.\*\:\[span\]\:last\:items-center>*):is(span):last-child{align-items:center}:is(.\*\:\[span\]\:last\:gap-2>*):is(span):last-child{gap:calc(var(--spacing) * 2)}:is(.data-\[variant\=destructive\]\:\*\:\[svg\]\:text-destructive[data-variant=destructive]>*):is(svg){color:var(--destructive)}:is(.\*\:\[svg\:not\(\[class\*\=\'size-\'\]\)\]\:size-4>*):is(svg:not([class*=size-])){width:calc(var(--spacing) * 4);height:calc(var(--spacing) * 4)}.\[\&\>\*\]\:w-full>*{width:100%}@container field-group (width>=28rem){.\@md\/field-group\:\[\&\>\*\]\:w-auto>*{width:auto}}.\[\&\>\.sr-only\]\:w-auto>.sr-only{width:auto}.\[\&\>\[data-slot\=field-label\]\]\:flex-auto>[data-slot=field-label]{flex:auto}@container field-group (width>=28rem){.\@md\/field-group\:\[\&\>\[data-slot\=field-label\]\]\:flex-auto>[data-slot=field-label]{flex:auto}}.has-\[\>\[data-slot\=field-content\]\]\:\[\&\>\[role\=checkbox\]\,\[role\=radio\]\]\:mt-px:has(>[data-slot=field-content])>[role=checkbox],.has-\[\>\[data-slot\=field-content\]\]\:\[\&\>\[role\=checkbox\]\,\[role\=radio\]\]\:mt-px:has(>[data-slot=field-content]) [role=radio]{margin-top:1px}@container field-group (width>=28rem){.\@md\/field-group\:has-\[\>\[data-slot\=field-content\]\]\:\[\&\>\[role\=checkbox\]\,\[role\=radio\]\]\:mt-px:has(>[data-slot=field-content])>[role=checkbox],.\@md\/field-group\:has-\[\>\[data-slot\=field-content\]\]\:\[\&\>\[role\=checkbox\]\,\[role\=radio\]\]\:mt-px:has(>[data-slot=field-content]) [role=radio]{margin-top:1px}}.\[\&\>a\]\:underline>a{text-decoration-line:underline}.\[\&\>a\]\:underline-offset-4>a{text-underline-offset:4px}.\[\&\>a\:hover\]\:text-primary>a:hover{color:var(--primary)}.\[\&\>button\]\:hidden>button{display:none}.has-\[\>\[data-align\=block-end\]\]\:\[\&\>input\]\:pt-3:has(>[data-align=block-end])>input{padding-top:calc(var(--spacing) * 3)}.has-\[\>\[data-align\=block-start\]\]\:\[\&\>input\]\:pb-3:has(>[data-align=block-start])>input{padding-bottom:calc(var(--spacing) * 3)}.has-\[\>\[data-align\=inline-end\]\]\:\[\&\>input\]\:pr-1\.5:has(>[data-align=inline-end])>input{padding-right:calc(var(--spacing) * 1.5)}.has-\[\>\[data-align\=inline-start\]\]\:\[\&\>input\]\:pl-1\.5:has(>[data-align=inline-start])>input{padding-left:calc(var(--spacing) * 1.5)}.\[\&\>span\:last-child\]\:truncate>span:last-child{text-overflow:ellipsis;white-space:nowrap;overflow:hidden}.\[\&\>svg\]\:pointer-events-none>svg{pointer-events:none}.\[\&\>svg\]\:size-2\.5\!>svg{width:calc(var(--spacing) * 2.5)!important;height:calc(var(--spacing) * 2.5)!important}.\[\&\>svg\]\:size-4>svg{width:calc(var(--spacing) * 4);height:calc(var(--spacing) * 4)}.\[\&\>svg\]\:shrink-0>svg{flex-shrink:0}.\[\&\>svg\]\:text-sidebar-accent-foreground>svg{color:var(--sidebar-accent-foreground)}.group-has-data-\[size\=lg\]\/avatar-group\:\[\&\>svg\]\:size-5:is(:where(.group\/avatar-group):has([data-size=lg]) *)>svg{width:calc(var(--spacing) * 5);height:calc(var(--spacing) * 5)}.group-has-data-\[size\=sm\]\/avatar-group\:\[\&\>svg\]\:size-3:is(:where(.group\/avatar-group):has([data-size=sm]) *)>svg{width:calc(var(--spacing) * 3);height:calc(var(--spacing) * 3)}.group-data-\[size\=default\]\/avatar\:\[\&\>svg\]\:size-2:is(:where(.group\/avatar)[data-size=default] *)>svg,.group-data-\[size\=lg\]\/avatar\:\[\&\>svg\]\:size-2:is(:where(.group\/avatar)[data-size=lg] *)>svg{width:calc(var(--spacing) * 2);height:calc(var(--spacing) * 2)}.group-data-\[size\=sm\]\/avatar\:\[\&\>svg\]\:hidden:is(:where(.group\/avatar)[data-size=sm] *)>svg{display:none}.\[\&\>svg\:not\(\[class\*\=\'size-\'\]\)\]\:size-3>svg:not([class*=size-]){width:calc(var(--spacing) * 3);height:calc(var(--spacing) * 3)}.\[\&\>svg\:not\(\[class\*\=\'size-\'\]\)\]\:size-3\.5>svg:not([class*=size-]){width:calc(var(--spacing) * 3.5);height:calc(var(--spacing) * 3.5)}[data-side=left][data-collapsible=offExamples] .\[\[data-side\=left\]\[data-collapsible\=offExamples\]_\&\]\:-right-2{right:calc(var(--spacing) * -2)}[data-side=left][data-state=collapsed] .\[\[data-side\=left\]\[data-state\=collapsed\]_\&\]\:cursor-e-resize{cursor:e-resize}[data-side=right][data-collapsible=offExamples] .\[\[data-side\=right\]\[data-collapsible\=offExamples\]_\&\]\:-left-2{left:calc(var(--spacing) * -2)}[data-side=right][data-state=collapsed] .\[\[data-side\=right\]\[data-state\=collapsed\]_\&\]\:cursor-w-resize{cursor:w-resize}[data-variant=legend]+.\[\[data-variant\=legend\]\+\&\]\:-mt-1\.5{margin-top:calc(var(--spacing) * -1.5)}}@font-face{font-family:Geist Variable;font-style:normal;font-display:swap;font-weight:100 900;src:url(/assets/geist-cyrillic-ext-wght-normal.woff2)format("woff2-variations");unicode-range:U+460-52F,U+1C80-1C8A,U+20B4,U+2DE0-2DFF,U+A640-A69F,U+FE2E-FE2F}@font-face{font-family:Geist Variable;font-style:normal;font-display:swap;font-weight:100 900;src:url(/assets/geist-cyrillic-wght-normal.woff2)format("woff2-variations");unicode-range:U+301,U+400-45F,U+490-491,U+4B0-4B1,U+2116}@font-face{font-family:Geist Variable;font-style:normal;font-display:swap;font-weight:100 900;src:url(/assets/geist-vietnamese-wght-normal.woff2)format("woff2-variations");unicode-range:U+102-103,U+110-111,U+128-129,U+168-169,U+1A0-1A1,U+1AF-1B0,U+300-301,U+303-304,U+308-309,U+323,U+329,U+1EA0-1EF9,U+20AB}@font-face{font-family:Geist Variable;font-style:normal;font-display:swap;font-weight:100 900;src:url(/assets/geist-latin-ext-wght-normal.woff2)format("woff2-variations");unicode-range:U+100-2BA,U+2BD-2C5,U+2C7-2CC,U+2CE-2D7,U+2DD-2FF,U+304,U+308,U+329,U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF}@font-face{font-family:Geist Variable;font-style:normal;font-display:swap;font-weight:100 900;src:url(/assets/geist-latin-wght-normal.woff2)format("woff2-variations");unicode-range:U+??,U+131,U+152-153,U+2BB-2BC,U+2C6,U+2DA,U+2DC,U+304,U+308,U+329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD}@font-face{font-family:JetBrains Mono Variable;font-style:normal;font-display:swap;font-weight:100 800;src:url(data:font/woff2;base64,d09GMgABAAAAAAfsABQAAAAAEAwAAAeCAAEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAGhwbHhwoP0hWQVJbBmA/U1RBVIFiJyYAdC9qEQgKhGSEAAsgADCGCAE2AiQDOgQgBYlMB4EUDAcbLQ4onoexrSC/2ZyLAa8p8VHB8/x3Vue+V0hVJalMJg2nx/TCrQXxBeqLjQG7FyM1WEa/X1tEXN7cFz9EJEMmMUz3RihWSSKeQCbcIou0izz/C8v+fq3VfajEa9gDD11CImXS7qL/RJFVzC1qiB6KmKeD6TZdQ6IRGv78dL6uSVVCfgni5mzu7kcgQBgAEAQTQRCoL++STTYybkJxNfQxAAIAGu8OdEB9teW2jh4BpgDqFjAeSEByW3zFP0CBBgNMsMCGEDjgggdhiEAUAeIIED7ABTDUEnkIE9Q9ahFgKttcVhApo4ACB4qobHaccgDfEjFO6aaWUhjMLt2SyIvHKoDqoA4CSUwEIYQCEjhAO9R1G6keDeDZGjNo+AhxOjCEGTr1WeIF3kYBiLAOKvkJSMiKX0VdAyQt3SDJClCkxJCHkCzfqyVTriJZLcolS32JZHUekq2TYNkYtCtjYHMQXSxGjXDz2t/yLWXzDzxz+o3zFwDEaN23F+13pyMdQAEaSKAR9vcGq4A4MTSKCElGW+M7UcY7xqkggITb28ZJhlqc9q2twYKTt0NjixBgYvO9BIihEBLYuOFXQzfIQ7dXGUEEEgFDooBfAzqiQbpJrhiWSuKJCRFKYbHCyJKI2G5GiZbNAvgAu5pc3vwx4G+g3aDkhklABiSz0BICXrYghtYhx/cdJ+44rY2oZ0aMNRFz3VZjb6W33F3gzltqtOCV8tTHSpOeXuItfvr5lCdfzFpqtEitvqdcdGGFd28ZqqC0tPbeChGXgrIlnhSWu/eUso4uKWFLugyDzQJhflY4659+WjQ++6x72WUMv9G8mw6QJl7BVxX5fe/kpUsOvnZwee9uQ0cGXYd0o89XB2748sDSnt8d2VphdOTTgceDVvOds0v9P/s7HPq15aGun/6Vllb56f1dl0t1LejqrNkpdRZsG8TOnM5vkBG5oiVyVGnS8LHps5cfNWJs6qKPfaNSxiQNBUm3cKNWROr0GSur7Za31k1vieq7LH11VF+jXdRIasRKflc7jkobm1Z9te1IyZA0pDkhLR98+H37Zf1c/8at+dB7x+7GfVyTfJMPiYztsnl59Y5l4j+0n1RXlpHnF3Tq7HecmNF/CJodEMAikruxiyJaGLvHOdAfoA+oDvpjBm2b91cHGRZMU9n25xEU0A8fgEEAdKI3Q1iDtc034sug5YVMkE2jsE+BIkwSoQ3gxXMqz9tELp48bd0cFKOKS7xYjEuXBnZP5ia7DyiO/X/YI+PQSbt2uSdqAkWL9nQbV1XB94/+uPfdZz8dnXYFBYrcTl2SIR/ybxJNJPz/Gupb0JaZeens2ekC7EKr8t+Ls/P5VJPYJdHKyqfg2nqU6bhlidzcddQV/7MmecTzJ5VPcKXkNKSEogHjYFx6QZ7rQ+FSe8njaiNuOnXS8H2ScQ619c2mC3VTtauL0rRbXd/CkSOP37FY9Zkjz8+GibYUMOEWF+RdrFS8Ecv1SHOpPUPZGEIpjPvFyU5cXKjd6OXqorTqy9GwRd++HVufPGnVsW+aO3vggKZ18jR9sXaTC1PWTEsVUaK0FkNySbTQDqlm2PfDjZcu4aalnSLKjnOoYQ0nUlqqXcGpPu/4VgV/xU2pAqW4BW3qzhQ8/hFKhV2qE3+BKAtDqBXjfgnVdH4y0wg5tbVNRenNdTWOrenWLcupQdmsbq5b+18piTe/xRdp1xbILxNPJGInm2z6hoB21Lal0i+ePTtd7B45+3XhFJ329evskXm7qurUVREotqSluSo/L29d3qDhI4YOQqWhI4YNvBNfsMHeXKemXrxQfKeuPOGRVayA3JtkJKEgbPp+dXUDluddutRYLFoXGXWX6N3WFaGLbQtRSitVYNacTNSdy7AaG/HSaUEANcBoGXNdcZvZsOqQ1icBDv21/gzAoYPHH/WDW0qNR3QTYKEAEHig6o13NXbND06CQPlRtYjGNnSktRc09k1mAMDvAlDKfQjgy6fssInlfzmNAjKkDxoxHOBLdVRAIVt9j4qo+hA1w9T1aNBNTUOTTNUHLbqokE+UAfJXCIGw/IxCSL5GRUJeR40rL/UxTm4Q08H6MbCs70ObuNyIIXrINHQYInF06UUlevTjbQzTh5upiDMzMMogUtEnjPs/Y7jAHCJeB0GBHh04tC6FiB6ZFB1oArUSIoFoqhzCeAN6lHwm0T4C3VVPWvjpSMXReuWesMEcoqrmgtNBGd2noWeV0hNAz9rFeShNJxHGsPa3HXeKTk8b55hahySYHaYKKFFLpCfN8rsoaJn01CR04Gkc+5k7KVTCmClX8Q10HCrUEkVlSX+XO33oQR9609tJ516H497WSobWs5Up6TLaS10/dessIskgJSLiDlWvHVUywpkQ7hdPZqGyiEF0uVQerVcPamT1A3eKXdyI1vG9OoflrSXihZ1qqGE3nhmAgiIbRCQgPLEPtOM3UQwTLYaYYomNlpA44opnjV6jkD6id80OOrzf6BzmMD6eEa1zKyeYG1fzfEf16V6jw9XYOaar1/b2kP/IYX8oR2mcFvv2GtBV3JXgd437AQAA)format("woff2-variations");unicode-range:U+460-52F,U+1C80-1C8A,U+20B4,U+2DE0-2DFF,U+A640-A69F,U+FE2E-FE2F}@font-face{font-family:JetBrains Mono Variable;font-style:normal;font-display:swap;font-weight:100 800;src:url(/assets/jetbrains-mono-cyrillic-wght-normal.woff2)format("woff2-variations");unicode-range:U+301,U+400-45F,U+490-491,U+4B0-4B1,U+2116}@font-face{font-family:JetBrains Mono Variable;font-style:normal;font-display:swap;font-weight:100 800;src:url(/assets/jetbrains-mono-greek-wght-normal.woff2)format("woff2-variations");unicode-range:U+370-377,U+37A-37F,U+384-38A,U+38C,U+38E-3A1,U+3A3-3FF}@font-face{font-family:JetBrains Mono Variable;font-style:normal;font-display:swap;font-weight:100 800;src:url(/assets/jetbrains-mono-vietnamese-wght-normal.woff2)format("woff2-variations");unicode-range:U+102-103,U+110-111,U+128-129,U+168-169,U+1A0-1A1,U+1AF-1B0,U+300-301,U+303-304,U+308-309,U+323,U+329,U+1EA0-1EF9,U+20AB}@font-face{font-family:JetBrains Mono Variable;font-style:normal;font-display:swap;font-weight:100 800;src:url(/assets/jetbrains-mono-latin-ext-wght-normal.woff2)format("woff2-variations");unicode-range:U+100-2BA,U+2BD-2C5,U+2C7-2CC,U+2CE-2D7,U+2DD-2FF,U+304,U+308,U+329,U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF}@font-face{font-family:JetBrains Mono Variable;font-style:normal;font-display:swap;font-weight:100 800;src:url(/assets/jetbrains-mono-latin-wght-normal.woff2)format("woff2-variations");unicode-range:U+??,U+131,U+152-153,U+2BB-2BC,U+2C6,U+2DA,U+2DC,U+304,U+308,U+329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD}:root{--font-sans:"Geist Variable", sans-serif;--font-mono:"JetBrains Mono Variable", monospace;--background:#f4f1ec;--foreground:#1d2930;--card:#f9f8f6;--card-foreground:#1d2930;--popover:#fbfaf9;--popover-foreground:#1d2930;--primary:#305030;--primary-foreground:#f5f3ef;--secondary:#eae5d7;--secondary-foreground:#263740;--muted:#e9e7e2;--muted-foreground:#576b75;--accent:#e7e2da;--accent-foreground:#1d2930;--destructive:#c32222;--destructive-foreground:#fff;--border:#b6c9b6;--input:#dedbd3;--ring:#3e743e;--chart-1:#2e6b2e;--chart-2:#cfa117;--chart-3:#297aa3;--chart-4:#cc8c33;--chart-5:#b82e2e;--radius:.25rem;--sidebar:#f5f3f0;--sidebar-background:#f5f3f0;--sidebar-foreground:#263740;--sidebar-primary:#305030;--sidebar-primary-foreground:#f5f3ef;--sidebar-accent:#e7e2da;--sidebar-accent-foreground:#1d2930;--sidebar-border:#c4d4c4;--sidebar-ring:#3e743e;--tactical-amber:#c2940a;--tactical-amber-dim:#d6b85c;--tactical-green:#267326;--tactical-green-dim:#7ab87a;--tactical-cyan:#24868f;--tactical-cyan-dim:#5eb2ba;--tactical-red:#bd2828;--tactical-olive:#64783a;--scanline-opacity:.015}.dark{--background:#0d1012;--foreground:#e0ddd1;--card:#14181a;--card-foreground:#e0ddd1;--popover:#111517;--popover-foreground:#e0ddd1;--primary:#fa0;--primary-foreground:#0d1012;--secondary:#323922;--secondary-foreground:#c6d2ac;--muted:#202527;--muted-foreground:#819098;--accent:#292f32;--accent-foreground:#ebe8e0;--destructive:#c32222;--destructive-foreground:#fff;--border:#203c32;--input:#292f32;--ring:#ffbf00;--chart-1:#ffbf00;--chart-2:#22c38e;--chart-3:#1aa1e6;--chart-4:#86ac39;--chart-5:#d92626;--sidebar:#111517;--sidebar-background:#111517;--sidebar-foreground:#e0ddd1;--sidebar-primary:#ffbf00;--sidebar-primary-foreground:#0d1012;--sidebar-accent:#292f32;--sidebar-accent-foreground:#ebe8e0;--sidebar-border:#203c32;--sidebar-ring:#ffbf00;--tactical-amber:#ffbf00;--tactical-amber-dim:#a17d12;--tactical-green:#22c38e;--tactical-green-dim:#20604a;--tactical-cyan:#17bfcf;--tactical-cyan-dim:#196066;--tactical-red:#d92626;--tactical-olive:#576b2e;--scanline-opacity:.03}@property --tw-translate-x{syntax:"*";inherits:false;initial-value:0}@property --tw-translate-y{syntax:"*";inherits:false;initial-value:0}@property --tw-translate-z{syntax:"*";inherits:false;initial-value:0}@property --tw-scale-x{syntax:"*";inherits:false;initial-value:1}@property --tw-scale-y{syntax:"*";inherits:false;initial-value:1}@property --tw-scale-z{syntax:"*";inherits:false;initial-value:1}@property --tw-rotate-x{syntax:"*";inherits:false}@property --tw-rotate-y{syntax:"*";inherits:false}@property --tw-rotate-z{syntax:"*";inherits:false}@property --tw-skew-x{syntax:"*";inherits:false}@property --tw-skew-y{syntax:"*";inherits:false}@property --tw-pan-x{syntax:"*";inherits:false}@property --tw-pan-y{syntax:"*";inherits:false}@property --tw-pinch-zoom{syntax:"*";inherits:false}@property --tw-space-y-reverse{syntax:"*";inherits:false;initial-value:0}@property --tw-space-x-reverse{syntax:"*";inherits:false;initial-value:0}@property --tw-divide-x-reverse{syntax:"*";inherits:false;initial-value:0}@property --tw-border-style{syntax:"*";inherits:false;initial-value:solid}@property --tw-divide-y-reverse{syntax:"*";inherits:false;initial-value:0}@property --tw-leading{syntax:"*";inherits:false}@property --tw-font-weight{syntax:"*";inherits:false}@property --tw-tracking{syntax:"*";inherits:false}@property --tw-ordinal{syntax:"*";inherits:false}@property --tw-slashed-zero{syntax:"*";inherits:false}@property --tw-numeric-figure{syntax:"*";inherits:false}@property --tw-numeric-spacing{syntax:"*";inherits:false}@property --tw-numeric-fraction{syntax:"*";inherits:false}@property --tw-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-shadow-color{syntax:"*";inherits:false}@property --tw-shadow-alpha{syntax:"";inherits:false;initial-value:100%}@property --tw-inset-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-inset-shadow-color{syntax:"*";inherits:false}@property --tw-inset-shadow-alpha{syntax:"";inherits:false;initial-value:100%}@property --tw-ring-color{syntax:"*";inherits:false}@property --tw-ring-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-inset-ring-color{syntax:"*";inherits:false}@property --tw-inset-ring-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-ring-inset{syntax:"*";inherits:false}@property --tw-ring-offset-width{syntax:"";inherits:false;initial-value:0}@property --tw-ring-offset-color{syntax:"*";inherits:false;initial-value:#fff}@property --tw-ring-offset-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-outline-style{syntax:"*";inherits:false;initial-value:solid}@property --tw-blur{syntax:"*";inherits:false}@property --tw-brightness{syntax:"*";inherits:false}@property --tw-contrast{syntax:"*";inherits:false}@property --tw-grayscale{syntax:"*";inherits:false}@property --tw-hue-rotate{syntax:"*";inherits:false}@property --tw-invert{syntax:"*";inherits:false}@property --tw-opacity{syntax:"*";inherits:false}@property --tw-saturate{syntax:"*";inherits:false}@property --tw-sepia{syntax:"*";inherits:false}@property --tw-drop-shadow{syntax:"*";inherits:false}@property --tw-drop-shadow-color{syntax:"*";inherits:false}@property --tw-drop-shadow-alpha{syntax:"";inherits:false;initial-value:100%}@property --tw-drop-shadow-size{syntax:"*";inherits:false}@property --tw-backdrop-blur{syntax:"*";inherits:false}@property --tw-backdrop-brightness{syntax:"*";inherits:false}@property --tw-backdrop-contrast{syntax:"*";inherits:false}@property --tw-backdrop-grayscale{syntax:"*";inherits:false}@property --tw-backdrop-hue-rotate{syntax:"*";inherits:false}@property --tw-backdrop-invert{syntax:"*";inherits:false}@property --tw-backdrop-opacity{syntax:"*";inherits:false}@property --tw-backdrop-saturate{syntax:"*";inherits:false}@property --tw-backdrop-sepia{syntax:"*";inherits:false}@property --tw-duration{syntax:"*";inherits:false}@property --tw-ease{syntax:"*";inherits:false}@property --tw-content{syntax:"*";inherits:false;initial-value:""}@keyframes spin{to{transform:rotate(360deg)}}@keyframes pulse{50%{opacity:.5}}
diff --git a/spinifex/services/spinifexui/frontend/dist/assets/index.js b/spinifex/services/spinifexui/frontend/dist/assets/index.js
index 780dbbb86..80a390990 100644
--- a/spinifex/services/spinifexui/frontend/dist/assets/index.js
+++ b/spinifex/services/spinifexui/frontend/dist/assets/index.js
@@ -1,92 +1,10 @@
-const __vite__mapDeps=(i,m=__vite__mapDeps,d=(m.f||(m.f=["assets/login.js","assets/collapsible.js","assets/zod.js","assets/card.js","assets/field.js","assets/_auth.js","assets/useQuery.js","assets/useBaseQuery.js","assets/chevron-down.js","assets/chevron-right.js","assets/page-heading.js","assets/state-badge.js","assets/badge.js","assets/admin.js","assets/nodes.js","assets/service-metrics.js","assets/ls.js","assets/useSuspenseQuery.js","assets/list-card.js","assets/create-bucket.js","assets/s3.js","assets/error-banner.js","assets/back-link.js","assets/cli-command-panel.js","assets/check.js","assets/copy.js","assets/use-copy-to-clipboard.js","assets/form-actions.js","assets/create-user.js","assets/iam.js","assets/iam2.js","assets/create-policy.js","assets/textarea.js","assets/create-vpc.js","assets/ec2.js","assets/select.js","assets/ec22.js","assets/create-volume.js","assets/create-target-group.js","assets/elbv2.js","assets/target-group-form.js","assets/plus.js","assets/trash-2.js","assets/elbv22.js","assets/create-subnet.js","assets/create-snapshot.js","assets/create-security-group.js","assets/create-route-table.js","assets/create-placement-group.js","assets/create-nat-gateway.js","assets/create-load-balancer.js","assets/import-key-pair.js","assets/create-key-pair.js","assets/alert-dialog.js","assets/download.js","assets/triangle-alert.js","assets/create-internet-gateway.js","assets/run-instances.js","assets/allocate-address.js","assets/_bucket.js","assets/upload-button.js","assets/delete-confirmation-dialog.js","assets/list-users.js","assets/list-policies.js","assets/describe-vpcs.js","assets/describe-volumes.js","assets/describe-target-groups.js","assets/describe-subnets.js","assets/describe-snapshots.js","assets/describe-security-groups.js","assets/describe-route-tables.js","assets/describe-placement-groups.js","assets/describe-nat-gateways.js","assets/describe-load-balancers.js","assets/describe-key-pairs.js","assets/describe-internet-gateways.js","assets/describe-instances.js","assets/describe-images.js","assets/system-managed.js","assets/describe-addresses.js","assets/_.js","assets/_userName.js","assets/detail-row.js","assets/_policyArn.js","assets/_id16.js","assets/_id15.js","assets/_id14.js","assets/_id13.js","assets/tabs.js","assets/_id12.js","assets/_id11.js","assets/_id10.js","assets/_id9.js","assets/_id8.js","assets/_id7.js","assets/_id6.js","assets/_id5.js","assets/_id4.js","assets/_id3.js","assets/ami-details.js","assets/_id2.js","assets/_id.js"])))=>i.map(i=>d[i]);
-var e=Object.create,t=Object.defineProperty,n=Object.getOwnPropertyDescriptor,r=Object.getOwnPropertyNames,i=Object.getPrototypeOf,a=Object.prototype.hasOwnProperty,o=(e,t)=>()=>(t||(e((t={exports:{}}).exports,t),e=null),t.exports),s=(e,i,o,s)=>{if(i&&typeof i==`object`||typeof i==`function`)for(var c=r(i),l=0,u=c.length,d;li[e]).bind(null,d),enumerable:!(s=n(i,d))||s.enumerable});return e},c=(n,r,a)=>(a=n==null?{}:e(i(n)),s(r||!n||!n.__esModule?t(a,`default`,{value:n,enumerable:!0}):a,n));(function(){let e=document.createElement(`link`).relList;if(e&&e.supports&&e.supports(`modulepreload`))return;for(let e of document.querySelectorAll(`link[rel="modulepreload"]`))n(e);new MutationObserver(e=>{for(let t of e)if(t.type===`childList`)for(let e of t.addedNodes)e.tagName===`LINK`&&e.rel===`modulepreload`&&n(e)}).observe(document,{childList:!0,subtree:!0});function t(e){let t={};return e.integrity&&(t.integrity=e.integrity),e.referrerPolicy&&(t.referrerPolicy=e.referrerPolicy),e.crossOrigin===`use-credentials`?t.credentials=`include`:e.crossOrigin===`anonymous`?t.credentials=`omit`:t.credentials=`same-origin`,t}function n(e){if(e.ep)return;e.ep=!0;let n=t(e);fetch(e.href,n)}})();var l=class{constructor(){this.listeners=new Set,this.subscribe=this.subscribe.bind(this)}subscribe(e){return this.listeners.add(e),this.onSubscribe(),()=>{this.listeners.delete(e),this.onUnsubscribe()}}hasListeners(){return this.listeners.size>0}onSubscribe(){}onUnsubscribe(){}},u=new class extends l{#e;#t;#n;constructor(){super(),this.#n=e=>{if(typeof window<`u`&&window.addEventListener){let t=()=>e();return window.addEventListener(`visibilitychange`,t,!1),()=>{window.removeEventListener(`visibilitychange`,t)}}}}onSubscribe(){this.#t||this.setEventListener(this.#n)}onUnsubscribe(){this.hasListeners()||(this.#t?.(),this.#t=void 0)}setEventListener(e){this.#n=e,this.#t?.(),this.#t=e(e=>{typeof e==`boolean`?this.setFocused(e):this.onFocus()})}setFocused(e){this.#e!==e&&(this.#e=e,this.onFocus())}onFocus(){let e=this.isFocused();this.listeners.forEach(t=>{t(e)})}isFocused(){return typeof this.#e==`boolean`?this.#e:globalThis.document?.visibilityState!==`hidden`}},d={setTimeout:(e,t)=>setTimeout(e,t),clearTimeout:e=>clearTimeout(e),setInterval:(e,t)=>setInterval(e,t),clearInterval:e=>clearInterval(e)},f=new class{#e=d;setTimeoutProvider(e){this.#e=e}setTimeout(e,t){return this.#e.setTimeout(e,t)}clearTimeout(e){this.#e.clearTimeout(e)}setInterval(e,t){return this.#e.setInterval(e,t)}clearInterval(e){this.#e.clearInterval(e)}};function p(e){setTimeout(e,0)}var m=typeof window>`u`||`Deno`in globalThis;function h(){}function g(e,t){return typeof e==`function`?e(t):e}function _(e){return typeof e==`number`&&e>=0&&e!==1/0}function v(e,t){return Math.max(e+(t||0)-Date.now(),0)}function y(e,t){return typeof e==`function`?e(t):e}function b(e,t){return typeof e==`function`?e(t):e}function x(e,t){let{type:n=`all`,exact:r,fetchStatus:i,predicate:a,queryKey:o,stale:s}=e;if(o){if(r){if(t.queryHash!==C(o,t.options))return!1}else if(!T(t.queryKey,o))return!1}if(n!==`all`){let e=t.isActive();if(n===`active`&&!e||n===`inactive`&&e)return!1}return!(typeof s==`boolean`&&t.isStale()!==s||i&&i!==t.state.fetchStatus||a&&!a(t))}function S(e,t){let{exact:n,status:r,predicate:i,mutationKey:a}=e;if(a){if(!t.options.mutationKey)return!1;if(n){if(w(t.options.mutationKey)!==w(a))return!1}else if(!T(t.options.mutationKey,a))return!1}return!(r&&t.state.status!==r||i&&!i(t))}function C(e,t){return(t?.queryKeyHashFn||w)(e)}function w(e){return JSON.stringify(e,(e,t)=>te(t)?Object.keys(t).sort().reduce((e,n)=>(e[n]=t[n],e),{}):t)}function T(e,t){return e===t?!0:typeof e==typeof t&&e&&t&&typeof e==`object`&&typeof t==`object`?Object.keys(t).every(n=>T(e[n],t[n])):!1}var E=Object.prototype.hasOwnProperty;function ee(e,t,n=0){if(e===t)return e;if(n>500)return t;let r=O(e)&&O(t);if(!r&&!(te(e)&&te(t)))return t;let i=(r?e:Object.keys(e)).length,a=r?t:Object.keys(t),o=a.length,s=r?Array(o):{},c=0;for(let l=0;l{f.setTimeout(t,e)})}function k(e,t,n){return typeof n.structuralSharing==`function`?n.structuralSharing(e,t):n.structuralSharing===!1?t:ee(e,t)}function A(e,t,n=0){let r=[...e,t];return n&&r.length>n?r.slice(1):r}function ie(e,t,n=0){let r=[t,...e];return n&&r.length>n?r.slice(0,-1):r}var j=Symbol();function ae(e,t){return!e.queryFn&&t?.initialPromise?()=>t.initialPromise:!e.queryFn||e.queryFn===j?()=>Promise.reject(Error(`Missing queryFn: '${e.queryHash}'`)):e.queryFn}function oe(e,t){return typeof e==`function`?e(...t):!!e}function se(e,t,n){let r=!1,i;return Object.defineProperty(e,`signal`,{enumerable:!0,get:()=>(i??=t(),r?i:(r=!0,i.aborted?n():i.addEventListener(`abort`,n,{once:!0}),i))}),e}var ce=(()=>{let e=()=>m;return{isServer(){return e()},setIsServer(t){e=t}}})();function M(){let e,t,n=new Promise((n,r)=>{e=n,t=r});n.status=`pending`,n.catch(()=>{});function r(e){Object.assign(n,e),delete n.resolve,delete n.reject}return n.resolve=t=>{r({status:`fulfilled`,value:t}),e(t)},n.reject=e=>{r({status:`rejected`,reason:e}),t(e)},n}var le=p;function ue(){let e=[],t=0,n=e=>{e()},r=e=>{e()},i=le,a=r=>{t?e.push(r):i(()=>{n(r)})},o=()=>{let t=e;e=[],t.length&&i(()=>{r(()=>{t.forEach(e=>{n(e)})})})};return{batch:e=>{let n;t++;try{n=e()}finally{t--,t||o()}return n},batchCalls:e=>(...t)=>{a(()=>{e(...t)})},schedule:a,setNotifyFunction:e=>{n=e},setBatchNotifyFunction:e=>{r=e},setScheduler:e=>{i=e}}}var N=ue(),de=new class extends l{#e=!0;#t;#n;constructor(){super(),this.#n=e=>{if(typeof window<`u`&&window.addEventListener){let t=()=>e(!0),n=()=>e(!1);return window.addEventListener(`online`,t,!1),window.addEventListener(`offline`,n,!1),()=>{window.removeEventListener(`online`,t),window.removeEventListener(`offline`,n)}}}}onSubscribe(){this.#t||this.setEventListener(this.#n)}onUnsubscribe(){this.hasListeners()||(this.#t?.(),this.#t=void 0)}setEventListener(e){this.#n=e,this.#t?.(),this.#t=e(this.setOnline.bind(this))}setOnline(e){this.#e!==e&&(this.#e=e,this.listeners.forEach(t=>{t(e)}))}isOnline(){return this.#e}};function fe(e){return Math.min(1e3*2**e,3e4)}function pe(e){return(e??`online`)===`online`?de.isOnline():!0}var me=class extends Error{constructor(e){super(`CancelledError`),this.revert=e?.revert,this.silent=e?.silent}};function he(e){let t=!1,n=0,r,i=M(),a=()=>i.status!==`pending`,o=t=>{if(!a()){let n=new me(t);p(n),e.onCancel?.(n)}},s=()=>{t=!0},c=()=>{t=!1},l=()=>u.isFocused()&&(e.networkMode===`always`||de.isOnline())&&e.canRun(),d=()=>pe(e.networkMode)&&e.canRun(),f=e=>{a()||(r?.(),i.resolve(e))},p=e=>{a()||(r?.(),i.reject(e))},m=()=>new Promise(t=>{r=e=>{(a()||l())&&t(e)},e.onPause?.()}).then(()=>{r=void 0,a()||e.onContinue?.()}),h=()=>{if(a())return;let r,i=n===0?e.initialPromise:void 0;try{r=i??e.fn()}catch(e){r=Promise.reject(e)}Promise.resolve(r).then(f).catch(r=>{if(a())return;let i=e.retry??(ce.isServer()?0:3),o=e.retryDelay??fe,s=typeof o==`function`?o(n,r):o,c=i===!0||typeof i==`number`&&nl()?void 0:m()).then(()=>{t?p(r):h()})})};return{promise:i,status:()=>i.status,cancel:o,continue:()=>(r?.(),i),cancelRetry:s,continueRetry:c,canStart:d,start:()=>(d()?h():m().then(h),i)}}var ge=class{#e;destroy(){this.clearGcTimeout()}scheduleGc(){this.clearGcTimeout(),_(this.gcTime)&&(this.#e=f.setTimeout(()=>{this.optionalRemove()},this.gcTime))}updateGcTime(e){this.gcTime=Math.max(this.gcTime||0,e??(ce.isServer()?1/0:300*1e3))}clearGcTimeout(){this.#e!==void 0&&(f.clearTimeout(this.#e),this.#e=void 0)}};function _e(e){return{onFetch:(t,n)=>{let r=t.options,i=t.fetchOptions?.meta?.fetchMore?.direction,a=t.state.data?.pages||[],o=t.state.data?.pageParams||[],s={pages:[],pageParams:[]},c=0,l=async()=>{let n=!1,l=e=>{se(e,()=>t.signal,()=>n=!0)},u=ae(t.options,t.fetchOptions),d=async(e,r,i)=>{if(n)return Promise.reject(t.signal.reason);if(r==null&&e.pages.length)return Promise.resolve(e);let a=await u((()=>{let e={client:t.client,queryKey:t.queryKey,pageParam:r,direction:i?`backward`:`forward`,meta:t.options.meta};return l(e),e})()),{maxPages:o}=t.options,s=i?ie:A;return{pages:s(e.pages,a,o),pageParams:s(e.pageParams,r,o)}};if(i&&a.length){let e=i===`backward`,t=e?ye:ve,n={pages:a,pageParams:o};s=await d(n,t(r,n),e)}else{let t=e??a.length;do{let e=c===0?o[0]??r.initialPageParam:ve(r,s);if(c>0&&e==null)break;s=await d(s,e),c++}while(ct.options.persister?.(l,{client:t.client,queryKey:t.queryKey,meta:t.options.meta,signal:t.signal},n):t.fetchFn=l}}}function ve(e,{pages:t,pageParams:n}){let r=t.length-1;return t.length>0?e.getNextPageParam(t[r],t,n[r],n):void 0}function ye(e,{pages:t,pageParams:n}){return t.length>0?e.getPreviousPageParam?.(t[0],t,n[0],n):void 0}var be=class extends ge{#e;#t;#n;#r;#i;#a;#o;#s;constructor(e){super(),this.#s=!1,this.#o=e.defaultOptions,this.setOptions(e.options),this.observers=[],this.#i=e.client,this.#r=this.#i.getQueryCache(),this.queryKey=e.queryKey,this.queryHash=e.queryHash,this.#t=Ce(this.options),this.state=e.state??this.#t,this.scheduleGc()}get meta(){return this.options.meta}get queryType(){return this.#e}get promise(){return this.#a?.promise}setOptions(e){if(this.options={...this.#o,...e},e?._type&&(this.#e=e._type),this.updateGcTime(this.options.gcTime),this.state&&this.state.data===void 0){let e=Ce(this.options);e.data!==void 0&&(this.setState(Se(e.data,e.dataUpdatedAt)),this.#t=e)}}optionalRemove(){!this.observers.length&&this.state.fetchStatus===`idle`&&this.#r.remove(this)}setData(e,t){let n=k(this.state.data,e,this.options);return this.#l({data:n,type:`success`,dataUpdatedAt:t?.updatedAt,manual:t?.manual}),n}setState(e){this.#l({type:`setState`,state:e})}cancel(e){let t=this.#a?.promise;return this.#a?.cancel(e),t?t.then(h).catch(h):Promise.resolve()}destroy(){super.destroy(),this.cancel({silent:!0})}get resetState(){return this.#t}reset(){this.destroy(),this.setState(this.resetState)}isActive(){return this.observers.some(e=>b(e.options.enabled,this)!==!1)}isDisabled(){return this.getObserversCount()>0?!this.isActive():this.options.queryFn===j||!this.isFetched()}isFetched(){return this.state.dataUpdateCount+this.state.errorUpdateCount>0}isStatic(){return this.getObserversCount()>0?this.observers.some(e=>y(e.options.staleTime,this)===`static`):!1}isStale(){return this.getObserversCount()>0?this.observers.some(e=>e.getCurrentResult().isStale):this.state.data===void 0||this.state.isInvalidated}isStaleByTime(e=0){return this.state.data===void 0?!0:e===`static`?!1:this.state.isInvalidated?!0:!v(this.state.dataUpdatedAt,e)}onFocus(){this.observers.find(e=>e.shouldFetchOnWindowFocus())?.refetch({cancelRefetch:!1}),this.#a?.continue()}onOnline(){this.observers.find(e=>e.shouldFetchOnReconnect())?.refetch({cancelRefetch:!1}),this.#a?.continue()}addObserver(e){this.observers.includes(e)||(this.observers.push(e),this.clearGcTimeout(),this.#r.notify({type:`observerAdded`,query:this,observer:e}))}removeObserver(e){this.observers.includes(e)&&(this.observers=this.observers.filter(t=>t!==e),this.observers.length||(this.#a&&(this.#s||this.#c()?this.#a.cancel({revert:!0}):this.#a.cancelRetry()),this.scheduleGc()),this.#r.notify({type:`observerRemoved`,query:this,observer:e}))}getObserversCount(){return this.observers.length}#c(){return this.state.fetchStatus===`paused`&&this.state.status===`pending`}invalidate(){this.state.isInvalidated||this.#l({type:`invalidate`})}async fetch(e,t){if(this.state.fetchStatus!==`idle`&&this.#a?.status()!==`rejected`){if(this.state.data!==void 0&&t?.cancelRefetch)this.cancel({silent:!0});else if(this.#a)return this.#a.continueRetry(),this.#a.promise}if(e&&this.setOptions(e),!this.options.queryFn){let e=this.observers.find(e=>e.options.queryFn);e&&this.setOptions(e.options)}let n=new AbortController,r=e=>{Object.defineProperty(e,`signal`,{enumerable:!0,get:()=>(this.#s=!0,n.signal)})},i=()=>{let e=ae(this.options,t),n=(()=>{let e={client:this.#i,queryKey:this.queryKey,meta:this.meta};return r(e),e})();return this.#s=!1,this.options.persister?this.options.persister(e,n,this):e(n)},a=(()=>{let e={fetchOptions:t,options:this.options,queryKey:this.queryKey,client:this.#i,state:this.state,fetchFn:i};return r(e),e})();(this.#e===`infinite`?_e(this.options.pages):this.options.behavior)?.onFetch(a,this),this.#n=this.state,(this.state.fetchStatus===`idle`||this.state.fetchMeta!==a.fetchOptions?.meta)&&this.#l({type:`fetch`,meta:a.fetchOptions?.meta}),this.#a=he({initialPromise:t?.initialPromise,fn:a.fetchFn,onCancel:e=>{e instanceof me&&e.revert&&this.setState({...this.#n,fetchStatus:`idle`}),n.abort()},onFail:(e,t)=>{this.#l({type:`failed`,failureCount:e,error:t})},onPause:()=>{this.#l({type:`pause`})},onContinue:()=>{this.#l({type:`continue`})},retry:a.options.retry,retryDelay:a.options.retryDelay,networkMode:a.options.networkMode,canRun:()=>!0});try{let e=await this.#a.start();if(e===void 0)throw Error(`${this.queryHash} data is undefined`);return this.setData(e),this.#r.config.onSuccess?.(e,this),this.#r.config.onSettled?.(e,this.state.error,this),e}catch(e){if(e instanceof me){if(e.silent)return this.#a.promise;if(e.revert){if(this.state.data===void 0)throw e;return this.state.data}}throw this.#l({type:`error`,error:e}),this.#r.config.onError?.(e,this),this.#r.config.onSettled?.(this.state.data,e,this),e}finally{this.scheduleGc()}}#l(e){let t=t=>{switch(e.type){case`failed`:return{...t,fetchFailureCount:e.failureCount,fetchFailureReason:e.error};case`pause`:return{...t,fetchStatus:`paused`};case`continue`:return{...t,fetchStatus:`fetching`};case`fetch`:return{...t,...xe(t.data,this.options),fetchMeta:e.meta??null};case`success`:let n={...t,...Se(e.data,e.dataUpdatedAt),dataUpdateCount:t.dataUpdateCount+1,...!e.manual&&{fetchStatus:`idle`,fetchFailureCount:0,fetchFailureReason:null}};return this.#n=e.manual?n:void 0,n;case`error`:let r=e.error;return{...t,error:r,errorUpdateCount:t.errorUpdateCount+1,errorUpdatedAt:Date.now(),fetchFailureCount:t.fetchFailureCount+1,fetchFailureReason:r,fetchStatus:`idle`,status:`error`,isInvalidated:!0};case`invalidate`:return{...t,isInvalidated:!0};case`setState`:return{...t,...e.state}}};this.state=t(this.state),N.batch(()=>{this.observers.forEach(e=>{e.onQueryUpdate()}),this.#r.notify({query:this,type:`updated`,action:e})})}};function xe(e,t){return{fetchFailureCount:0,fetchFailureReason:null,fetchStatus:pe(t.networkMode)?`fetching`:`paused`,...e===void 0&&{error:null,status:`pending`}}}function Se(e,t){return{data:e,dataUpdatedAt:t??Date.now(),error:null,isInvalidated:!1,status:`success`}}function Ce(e){let t=typeof e.initialData==`function`?e.initialData():e.initialData,n=t!==void 0,r=n?typeof e.initialDataUpdatedAt==`function`?e.initialDataUpdatedAt():e.initialDataUpdatedAt:0;return{data:t,dataUpdateCount:0,dataUpdatedAt:n?r??Date.now():0,error:null,errorUpdateCount:0,errorUpdatedAt:0,fetchFailureCount:0,fetchFailureReason:null,fetchMeta:null,isInvalidated:!1,status:n?`success`:`pending`,fetchStatus:`idle`}}var we=class extends ge{#e;#t;#n;#r;constructor(e){super(),this.#e=e.client,this.mutationId=e.mutationId,this.#n=e.mutationCache,this.#t=[],this.state=e.state||Te(),this.setOptions(e.options),this.scheduleGc()}setOptions(e){this.options=e,this.updateGcTime(this.options.gcTime)}get meta(){return this.options.meta}addObserver(e){this.#t.includes(e)||(this.#t.push(e),this.clearGcTimeout(),this.#n.notify({type:`observerAdded`,mutation:this,observer:e}))}removeObserver(e){this.#t=this.#t.filter(t=>t!==e),this.scheduleGc(),this.#n.notify({type:`observerRemoved`,mutation:this,observer:e})}optionalRemove(){this.#t.length||(this.state.status===`pending`?this.scheduleGc():this.#n.remove(this))}continue(){return this.#r?.continue()??this.execute(this.state.variables)}async execute(e){let t=()=>{this.#i({type:`continue`})},n={client:this.#e,meta:this.options.meta,mutationKey:this.options.mutationKey};this.#r=he({fn:()=>this.options.mutationFn?this.options.mutationFn(e,n):Promise.reject(Error(`No mutationFn found`)),onFail:(e,t)=>{this.#i({type:`failed`,failureCount:e,error:t})},onPause:()=>{this.#i({type:`pause`})},onContinue:t,retry:this.options.retry??0,retryDelay:this.options.retryDelay,networkMode:this.options.networkMode,canRun:()=>this.#n.canRun(this)});let r=this.state.status===`pending`,i=!this.#r.canStart();try{if(r)t();else{this.#i({type:`pending`,variables:e,isPaused:i}),this.#n.config.onMutate&&await this.#n.config.onMutate(e,this,n);let t=await this.options.onMutate?.(e,n);t!==this.state.context&&this.#i({type:`pending`,context:t,variables:e,isPaused:i})}let a=await this.#r.start();return await this.#n.config.onSuccess?.(a,e,this.state.context,this,n),await this.options.onSuccess?.(a,e,this.state.context,n),await this.#n.config.onSettled?.(a,null,this.state.variables,this.state.context,this,n),await this.options.onSettled?.(a,null,e,this.state.context,n),this.#i({type:`success`,data:a}),a}catch(t){try{await this.#n.config.onError?.(t,e,this.state.context,this,n)}catch(e){Promise.reject(e)}try{await this.options.onError?.(t,e,this.state.context,n)}catch(e){Promise.reject(e)}try{await this.#n.config.onSettled?.(void 0,t,this.state.variables,this.state.context,this,n)}catch(e){Promise.reject(e)}try{await this.options.onSettled?.(void 0,t,e,this.state.context,n)}catch(e){Promise.reject(e)}throw this.#i({type:`error`,error:t}),t}finally{this.#n.runNext(this)}}#i(e){let t=t=>{switch(e.type){case`failed`:return{...t,failureCount:e.failureCount,failureReason:e.error};case`pause`:return{...t,isPaused:!0};case`continue`:return{...t,isPaused:!1};case`pending`:return{...t,context:e.context,data:void 0,failureCount:0,failureReason:null,error:null,isPaused:e.isPaused,status:`pending`,variables:e.variables,submittedAt:Date.now()};case`success`:return{...t,data:e.data,failureCount:0,failureReason:null,error:null,status:`success`,isPaused:!1};case`error`:return{...t,data:void 0,error:e.error,failureCount:t.failureCount+1,failureReason:e.error,isPaused:!1,status:`error`}}};this.state=t(this.state),N.batch(()=>{this.#t.forEach(t=>{t.onMutationUpdate(e)}),this.#n.notify({mutation:this,type:`updated`,action:e})})}};function Te(){return{context:void 0,data:void 0,error:null,failureCount:0,failureReason:null,isPaused:!1,status:`idle`,variables:void 0,submittedAt:0}}var Ee=class extends l{constructor(e={}){super(),this.config=e,this.#e=new Set,this.#t=new Map,this.#n=0}#e;#t;#n;build(e,t,n){let r=new we({client:e,mutationCache:this,mutationId:++this.#n,options:e.defaultMutationOptions(t),state:n});return this.add(r),r}add(e){this.#e.add(e);let t=De(e);if(typeof t==`string`){let n=this.#t.get(t);n?n.push(e):this.#t.set(t,[e])}this.notify({type:`added`,mutation:e})}remove(e){if(this.#e.delete(e)){let t=De(e);if(typeof t==`string`){let n=this.#t.get(t);if(n)if(n.length>1){let t=n.indexOf(e);t!==-1&&n.splice(t,1)}else n[0]===e&&this.#t.delete(t)}}this.notify({type:`removed`,mutation:e})}canRun(e){let t=De(e);if(typeof t==`string`){let n=this.#t.get(t)?.find(e=>e.state.status===`pending`);return!n||n===e}else return!0}runNext(e){let t=De(e);return typeof t==`string`?(this.#t.get(t)?.find(t=>t!==e&&t.state.isPaused))?.continue()??Promise.resolve():Promise.resolve()}clear(){N.batch(()=>{this.#e.forEach(e=>{this.notify({type:`removed`,mutation:e})}),this.#e.clear(),this.#t.clear()})}getAll(){return Array.from(this.#e)}find(e){let t={exact:!0,...e};return this.getAll().find(e=>S(t,e))}findAll(e={}){return this.getAll().filter(t=>S(e,t))}notify(e){N.batch(()=>{this.listeners.forEach(t=>{t(e)})})}resumePausedMutations(){let e=this.getAll().filter(e=>e.state.isPaused);return N.batch(()=>Promise.all(e.map(e=>e.continue().catch(h))))}};function De(e){return e.options.scope?.id}var Oe=class extends l{constructor(e={}){super(),this.config=e,this.#e=new Map}#e;build(e,t,n){let r=t.queryKey,i=t.queryHash??C(r,t),a=this.get(i);return a||(a=new be({client:e,queryKey:r,queryHash:i,options:e.defaultQueryOptions(t),state:n,defaultOptions:e.getQueryDefaults(r)}),this.add(a)),a}add(e){this.#e.has(e.queryHash)||(this.#e.set(e.queryHash,e),this.notify({type:`added`,query:e}))}remove(e){let t=this.#e.get(e.queryHash);t&&(e.destroy(),t===e&&this.#e.delete(e.queryHash),this.notify({type:`removed`,query:e}))}clear(){N.batch(()=>{this.getAll().forEach(e=>{this.remove(e)})})}get(e){return this.#e.get(e)}getAll(){return[...this.#e.values()]}find(e){let t={exact:!0,...e};return this.getAll().find(e=>x(t,e))}findAll(e={}){let t=this.getAll();return Object.keys(e).length>0?t.filter(t=>x(e,t)):t}notify(e){N.batch(()=>{this.listeners.forEach(t=>{t(e)})})}onFocus(){N.batch(()=>{this.getAll().forEach(e=>{e.onFocus()})})}onOnline(){N.batch(()=>{this.getAll().forEach(e=>{e.onOnline()})})}},ke=class{#e;#t;#n;#r;#i;#a;#o;#s;constructor(e={}){this.#e=e.queryCache||new Oe,this.#t=e.mutationCache||new Ee,this.#n=e.defaultOptions||{},this.#r=new Map,this.#i=new Map,this.#a=0}mount(){this.#a++,this.#a===1&&(this.#o=u.subscribe(async e=>{e&&(await this.resumePausedMutations(),this.#e.onFocus())}),this.#s=de.subscribe(async e=>{e&&(await this.resumePausedMutations(),this.#e.onOnline())}))}unmount(){this.#a--,this.#a===0&&(this.#o?.(),this.#o=void 0,this.#s?.(),this.#s=void 0)}isFetching(e){return this.#e.findAll({...e,fetchStatus:`fetching`}).length}isMutating(e){return this.#t.findAll({...e,status:`pending`}).length}getQueryData(e){let t=this.defaultQueryOptions({queryKey:e});return this.#e.get(t.queryHash)?.state.data}ensureQueryData(e){let t=this.defaultQueryOptions(e),n=this.#e.build(this,t),r=n.state.data;return r===void 0?this.fetchQuery(e):(e.revalidateIfStale&&n.isStaleByTime(y(t.staleTime,n))&&this.prefetchQuery(t),Promise.resolve(r))}getQueriesData(e){return this.#e.findAll(e).map(({queryKey:e,state:t})=>[e,t.data])}setQueryData(e,t,n){let r=this.defaultQueryOptions({queryKey:e}),i=this.#e.get(r.queryHash)?.state.data,a=g(t,i);if(a!==void 0)return this.#e.build(this,r).setData(a,{...n,manual:!0})}setQueriesData(e,t,n){return N.batch(()=>this.#e.findAll(e).map(({queryKey:e})=>[e,this.setQueryData(e,t,n)]))}getQueryState(e){let t=this.defaultQueryOptions({queryKey:e});return this.#e.get(t.queryHash)?.state}removeQueries(e){let t=this.#e;N.batch(()=>{t.findAll(e).forEach(e=>{t.remove(e)})})}resetQueries(e,t){let n=this.#e;return N.batch(()=>(n.findAll(e).forEach(e=>{e.reset()}),this.refetchQueries({type:`active`,...e},t)))}cancelQueries(e,t={}){let n={revert:!0,...t},r=N.batch(()=>this.#e.findAll(e).map(e=>e.cancel(n)));return Promise.all(r).then(h).catch(h)}invalidateQueries(e,t={}){return N.batch(()=>(this.#e.findAll(e).forEach(e=>{e.invalidate()}),e?.refetchType===`none`?Promise.resolve():this.refetchQueries({...e,type:e?.refetchType??e?.type??`active`},t)))}refetchQueries(e,t={}){let n={...t,cancelRefetch:t.cancelRefetch??!0},r=N.batch(()=>this.#e.findAll(e).filter(e=>!e.isDisabled()&&!e.isStatic()).map(e=>{let t=e.fetch(void 0,n);return n.throwOnError||(t=t.catch(h)),e.state.fetchStatus===`paused`?Promise.resolve():t}));return Promise.all(r).then(h)}fetchQuery(e){let t=this.defaultQueryOptions(e);t.retry===void 0&&(t.retry=!1);let n=this.#e.build(this,t);return n.isStaleByTime(y(t.staleTime,n))?n.fetch(t):Promise.resolve(n.state.data)}prefetchQuery(e){return this.fetchQuery(e).then(h).catch(h)}fetchInfiniteQuery(e){return e._type=`infinite`,this.fetchQuery(e)}prefetchInfiniteQuery(e){return this.fetchInfiniteQuery(e).then(h).catch(h)}ensureInfiniteQueryData(e){return e._type=`infinite`,this.ensureQueryData(e)}resumePausedMutations(){return de.isOnline()?this.#t.resumePausedMutations():Promise.resolve()}getQueryCache(){return this.#e}getMutationCache(){return this.#t}getDefaultOptions(){return this.#n}setDefaultOptions(e){this.#n=e}setQueryDefaults(e,t){this.#r.set(w(e),{queryKey:e,defaultOptions:t})}getQueryDefaults(e){let t=[...this.#r.values()],n={};return t.forEach(t=>{T(e,t.queryKey)&&Object.assign(n,t.defaultOptions)}),n}setMutationDefaults(e,t){this.#i.set(w(e),{mutationKey:e,defaultOptions:t})}getMutationDefaults(e){let t=[...this.#i.values()],n={};return t.forEach(t=>{T(e,t.mutationKey)&&Object.assign(n,t.defaultOptions)}),n}defaultQueryOptions(e){if(e._defaulted)return e;let t={...this.#n.queries,...this.getQueryDefaults(e.queryKey),...e,_defaulted:!0};return t.queryHash||=C(t.queryKey,t),t.refetchOnReconnect===void 0&&(t.refetchOnReconnect=t.networkMode!==`always`),t.throwOnError===void 0&&(t.throwOnError=!!t.suspense),!t.networkMode&&t.persister&&(t.networkMode=`offlineFirst`),t.queryFn===j&&(t.enabled=!1),t}defaultMutationOptions(e){return e?._defaulted?e:{...this.#n.mutations,...e?.mutationKey&&this.getMutationDefaults(e.mutationKey),...e,_defaulted:!0}}clear(){this.#e.clear(),this.#t.clear()}},Ae=o((e=>{var t=Symbol.for(`react.transitional.element`),n=Symbol.for(`react.portal`),r=Symbol.for(`react.fragment`),i=Symbol.for(`react.strict_mode`),a=Symbol.for(`react.profiler`),o=Symbol.for(`react.consumer`),s=Symbol.for(`react.context`),c=Symbol.for(`react.forward_ref`),l=Symbol.for(`react.suspense`),u=Symbol.for(`react.memo`),d=Symbol.for(`react.lazy`),f=Symbol.for(`react.activity`),p=Symbol.iterator;function m(e){return typeof e!=`object`||!e?null:(e=p&&e[p]||e[`@@iterator`],typeof e==`function`?e:null)}var h={isMounted:function(){return!1},enqueueForceUpdate:function(){},enqueueReplaceState:function(){},enqueueSetState:function(){}},g=Object.assign,_={};function v(e,t,n){this.props=e,this.context=t,this.refs=_,this.updater=n||h}v.prototype.isReactComponent={},v.prototype.setState=function(e,t){if(typeof e!=`object`&&typeof e!=`function`&&e!=null)throw Error(`takes an object of state variables to update or a function which returns an object of state variables.`);this.updater.enqueueSetState(this,e,t,`setState`)},v.prototype.forceUpdate=function(e){this.updater.enqueueForceUpdate(this,e,`forceUpdate`)};function y(){}y.prototype=v.prototype;function b(e,t,n){this.props=e,this.context=t,this.refs=_,this.updater=n||h}var x=b.prototype=new y;x.constructor=b,g(x,v.prototype),x.isPureReactComponent=!0;var S=Array.isArray;function C(){}var w={H:null,A:null,T:null,S:null},T=Object.prototype.hasOwnProperty;function E(e,n,r){var i=r.ref;return{$$typeof:t,type:e,key:n,ref:i===void 0?null:i,props:r}}function ee(e,t){return E(e.type,t,e.props)}function D(e){return typeof e==`object`&&!!e&&e.$$typeof===t}function O(e){var t={"=":`=0`,":":`=2`};return`$`+e.replace(/[=:]/g,function(e){return t[e]})}var te=/\/+/g;function ne(e,t){return typeof e==`object`&&e&&e.key!=null?O(``+e.key):t.toString(36)}function re(e){switch(e.status){case`fulfilled`:return e.value;case`rejected`:throw e.reason;default:switch(typeof e.status==`string`?e.then(C,C):(e.status=`pending`,e.then(function(t){e.status===`pending`&&(e.status=`fulfilled`,e.value=t)},function(t){e.status===`pending`&&(e.status=`rejected`,e.reason=t)})),e.status){case`fulfilled`:return e.value;case`rejected`:throw e.reason}}throw e}function k(e,r,i,a,o){var s=typeof e;(s===`undefined`||s===`boolean`)&&(e=null);var c=!1;if(e===null)c=!0;else switch(s){case`bigint`:case`string`:case`number`:c=!0;break;case`object`:switch(e.$$typeof){case t:case n:c=!0;break;case d:return c=e._init,k(c(e._payload),r,i,a,o)}}if(c)return o=o(e),c=a===``?`.`+ne(e,0):a,S(o)?(i=``,c!=null&&(i=c.replace(te,`$&/`)+`/`),k(o,r,i,``,function(e){return e})):o!=null&&(D(o)&&(o=ee(o,i+(o.key==null||e&&e.key===o.key?``:(``+o.key).replace(te,`$&/`)+`/`)+c)),r.push(o)),1;c=0;var l=a===``?`.`:a+`:`;if(S(e))for(var u=0;u{t.exports=Ae()})),Me=o((e=>{var t=Symbol.for(`react.transitional.element`),n=Symbol.for(`react.fragment`);function r(e,n,r){var i=null;if(r!==void 0&&(i=``+r),n.key!==void 0&&(i=``+n.key),`key`in n)for(var a in r={},n)a!==`key`&&(r[a]=n[a]);else r=n;return n=r.ref,{$$typeof:t,type:e,key:i,ref:n===void 0?null:n,props:r}}e.Fragment=n,e.jsx=r,e.jsxs=r})),Ne=o(((e,t)=>{t.exports=Me()})),P=c(je(),1),F=Ne(),Pe=P.createContext(void 0),Fe=e=>{let t=P.useContext(Pe);if(e)return e;if(!t)throw Error(`No QueryClient set, use QueryClientProvider to set one`);return t},Ie=({client:e,children:t})=>(P.useEffect(()=>(e.mount(),()=>{e.unmount()}),[e]),(0,F.jsx)(Pe.Provider,{value:e,children:t}));function I(e){return e}var Le=P.use,Re=typeof window<`u`?P.useLayoutEffect:P.useEffect;function ze(e){let t=P.useRef({value:e,prev:null}),n=t.current.value;return e!==n&&(t.current={value:e,prev:n}),t.current.prev}function Be(e,t,n={},r={}){P.useEffect(()=>{if(!e.current||r.disabled||typeof IntersectionObserver!=`function`)return;let i=new IntersectionObserver(([e])=>{t(e)},n);return i.observe(e.current),()=>{i.disconnect()}},[t,n,r.disabled,e])}function Ve(e){let t=P.useRef(null);return P.useImperativeHandle(e,()=>t.current,[]),t}function He(e){return e[e.length-1]}function Ue(e){return typeof e==`function`}function We(e,t){return Ue(e)?e(t):e}var Ge=Object.prototype.hasOwnProperty,Ke=Object.prototype.propertyIsEnumerable;function qe(e){for(let t in e)if(Ge.call(e,t))return!0;return!1}var Je=()=>Object.create(null),Ye=(e,t)=>Xe(e,t,Je);function Xe(e,t,n=()=>({}),r=0){if(e===t)return e;if(r>500)return t;let i=t,a=et(e)&&et(i);if(!a&&!(Qe(e)&&Qe(i)))return i;let o=a?e:Ze(e);if(!o)return i;let s=a?i:Ze(i);if(!s)return i;let c=o.length,l=s.length,u=a?Array(l):n(),d=0;for(let t=0;ti||!tt(e[o],t[o],n)))return!1;return i===a}return!1}function nt(e){let t,n,r=new Promise((e,r)=>{t=e,n=r});return r.status=`pending`,r.resolve=n=>{r.status=`resolved`,r.value=n,t(n),e?.(n)},r.reject=e=>{r.status=`rejected`,n(e)},r}function rt(e){return typeof e?.message==`string`?e.message.startsWith(`Failed to fetch dynamically imported module`)||e.message.startsWith(`error loading dynamically imported module`)||e.message.startsWith(`Importing a module script failed`):!1}function it(e){return!!(e&&typeof e==`object`&&typeof e.then==`function`)}function at(e){return e.replace(/[\x00-\x1f\x7f]/g,``)}function ot(e){let t;try{t=decodeURI(e)}catch{t=e.replaceAll(/%[0-9A-F]{2}/gi,e=>{try{return decodeURI(e)}catch{return e}})}return at(t)}var st=[`http:`,`https:`,`mailto:`,`tel:`];function ct(e,t){if(!e)return!1;try{let n=new URL(e);return!t.has(n.protocol)}catch{return!1}}var lt={"&":`\\u0026`,">":`\\u003e`,"<":`\\u003c`,"\u2028":`\\u2028`,"\u2029":`\\u2029`},ut=/[&><\u2028\u2029]/g;function dt(e){return e.replace(ut,e=>lt[e])}function ft(e){if(!e||!/[%\\\x00-\x1f\x7f]/.test(e)&&!e.startsWith(`//`))return{path:e,handledProtocolRelativeURL:!1};let t=/%25|%5C/gi,n=0,r=``,i;for(;(i=t.exec(e))!==null;)r+=ot(e.slice(n,i.index))+i[0],n=t.lastIndex;r+=ot(n?e.slice(n):e);let a=!1;return r.startsWith(`//`)&&(a=!0,r=`/`+r.replace(/^\/+/,``)),{path:r,handledProtocolRelativeURL:a}}function pt(e){return/\s|[^\u0000-\u007F]/.test(e)?e.replace(/\s|[^\u0000-\u007F]/gu,encodeURIComponent):e}function mt(e,t){if(e===t)return!0;if(e.length!==t.length)return!1;for(let n=0;n{e.next&&(e.prev?(e.prev.next=e.next,e.next.prev=e.prev,e.next=void 0,r&&(r.next=e,e.prev=r)):(e.next.prev=void 0,n=e.next,e.next=void 0,r&&(e.prev=r,r.next=e)),r=e)};return{get(e){let n=t.get(e);if(n)return i(n),n.value},set(a,o){if(t.size>=e&&n){let e=n;t.delete(e.key),e.next&&(n=e.next,e.next.prev=void 0),e===r&&(r=void 0)}let s=t.get(a);if(s)s.value=o,i(s);else{let e={key:a,value:o,prev:r};r&&(r.next=e),r=e,n||=e,t.set(a,e)}},clear(){t.clear(),n=void 0,r=void 0}}}var _t=4,vt=5;function yt(e){let t=e.indexOf(`{`);if(t===-1)return null;let n=e.indexOf(`}`,t);return n===-1||t+1>=e.length?null:[t,n]}function bt(e,t,n=new Uint16Array(6)){let r=e.indexOf(`/`,t),i=r===-1?e.length:r,a=e.substring(t,i);if(!a||!a.includes(`$`))return n[0]=0,n[1]=t,n[2]=t,n[3]=i,n[4]=i,n[5]=i,n;if(a===`$`){let r=e.length;return n[0]=2,n[1]=t,n[2]=t,n[3]=r,n[4]=r,n[5]=r,n}if(a.charCodeAt(0)===36)return n[0]=1,n[1]=t,n[2]=t+1,n[3]=i,n[4]=i,n[5]=i,n;let o=yt(a);if(o){let[r,s]=o,c=a.charCodeAt(r+1);if(c===45){if(r+2!e.parse&&e.caseSensitive===f&&e.prefix===p&&e.suffix===m);if(h)o=h;else{let e=Tt(1,n.fullPath??n.from,f,p,m);o=e,e.depth=a,e.parent=i,i.dynamic??=[],i.dynamic.push(e)}break}case 3:{let t=r.substring(u,e[1]),s=r.substring(e[4],d),f=c&&!!(t||s),p=t?f?t:t.toLowerCase():void 0,m=s?f?s:s.toLowerCase():void 0,h=!l&&i.optional?.find(e=>!e.parse&&e.caseSensitive===f&&e.prefix===p&&e.suffix===m);if(h)o=h;else{let e=Tt(3,n.fullPath??n.from,f,p,m);o=e,e.parent=i,e.depth=a,i.optional??=[],i.optional.push(e)}break}case 2:{let t=r.substring(u,e[1]),s=r.substring(e[4],d),l=c&&!!(t||s),f=t?l?t:t.toLowerCase():void 0,p=s?l?s:s.toLowerCase():void 0,m=Tt(2,n.fullPath??n.from,l,f,p);o=m,m.parent=i,m.depth=a,i.wildcard??=[],i.wildcard.push(m)}}i=o}if(l&&n.children&&!n.isRoot&&n.id&&n.id.charCodeAt(n.id.lastIndexOf(`/`)+1)===95){let e=wt(n.fullPath??n.from);e.kind=vt,e.parent=i,a++,e.depth=a,i.pathless??=[],i.pathless.push(e),i=e}let u=(n.path||!n.children)&&!n.isRoot;if(u&&r.endsWith(`/`)){let e=wt(n.fullPath??n.from);e.kind=_t,e.parent=i,a++,e.depth=a,i.index=e,i=e}i.parse=l??null,u&&!i.route&&(i.route=n,i.fullPath=n.fullPath??n.from)}if(n.children)for(let r of n.children)xt(e,t,r,s,i,a,o)}function St(e,t){if(e.parse&&!t.parse)return-1;if(!e.parse&&t.parse)return 1;if(e.prefix&&t.prefix&&e.prefix!==t.prefix){if(e.prefix.startsWith(t.prefix))return-1;if(t.prefix.startsWith(e.prefix))return 1}if(e.suffix&&t.suffix&&e.suffix!==t.suffix){if(e.suffix.endsWith(t.suffix))return-1;if(t.suffix.endsWith(e.suffix))return 1}return e.prefix&&!t.prefix?-1:!e.prefix&&t.prefix?1:e.suffix&&!t.suffix?-1:!e.suffix&&t.suffix?1:e.caseSensitive&&!t.caseSensitive?-1:!e.caseSensitive&&t.caseSensitive?1:0}function Ct(e){if(e.pathless)for(let t of e.pathless)Ct(t);if(e.static)for(let t of e.static.values())Ct(t);if(e.staticInsensitive)for(let t of e.staticInsensitive.values())Ct(t);if(e.dynamic?.length){e.dynamic.sort(St);for(let t of e.dynamic)Ct(t)}if(e.optional?.length){e.optional.sort(St);for(let t of e.optional)Ct(t)}if(e.wildcard?.length){e.wildcard.sort(St);for(let t of e.wildcard)Ct(t)}}function wt(e){return{kind:0,depth:0,pathless:null,index:null,static:null,staticInsensitive:null,dynamic:null,optional:null,wildcard:null,route:null,fullPath:e,parent:null,parse:null}}function Tt(e,t,n,r,i){return{kind:e,depth:0,pathless:null,index:null,static:null,staticInsensitive:null,dynamic:null,optional:null,wildcard:null,route:null,fullPath:t,parent:null,parse:null,caseSensitive:n,prefix:r,suffix:i}}function Et(e,t){let n=wt(`/`),r=new Uint16Array(6);for(let t of e)xt(!1,r,t,1,n,0);Ct(n),t.masksTree=n,t.flatCache=gt(1e3)}function Dt(e,t){e||=`/`;let n=t.flatCache.get(e);if(n)return n;let r=Mt(e,t.masksTree);return t.flatCache.set(e,r),r}function Ot(e,t,n,r,i){e||=`/`,r||=`/`;let a=t?`case\0${e}`:e,o=i.singleCache.get(a);return o||(o=wt(`/`),xt(t,new Uint16Array(6),{from:e},1,o,0),i.singleCache.set(a,o)),Mt(r,o,n)}function kt(e,t,n=!1){let r=n?e:`nofuzz\0${e}`,i=t.matchCache.get(r);if(i!==void 0)return i;e||=`/`;let a;try{a=Mt(e,t.segmentTree,n)}catch(e){if(e instanceof URIError)a=null;else throw e}return a&&(a.branch=Pt(a.route)),t.matchCache.set(r,a),a}function At(e){return e===`/`?e:e.replace(/\/{1,}$/,``)}function jt(e,t=!1,n){let r=wt(e.fullPath),i=new Uint16Array(6),a={},o={},s=0;return xt(t,i,e,1,r,0,e=>{if(n?.(e,s),e.id in a&&ht(),a[e.id]=e,s!==0&&e.path){let t=At(e.fullPath);(!o[t]||e.fullPath.endsWith(`/`))&&(o[t]=e)}s++}),Ct(r),{processedTree:{segmentTree:r,singleCache:gt(1e3),matchCache:gt(1e3),flatCache:null,masksTree:null},routesById:a,routesByPath:o}}function Mt(e,t,n=!1){let r=e.split(`/`),i=It(e,r,t,n);if(!i)return null;let[a]=Nt(e,r,i);return{route:i.node.route,rawParams:a}}function Nt(e,t,n){let r=Ft(n.node),i=null,a=Object.create(null),o=n.extract?.part??0,s=n.extract?.node??0,c=n.extract?.path??0,l=n.extract?.segment??0;for(;s=0;e--){let n=i.wildcard[e],{prefix:r,suffix:a}=n;if(!(r&&(v||!(n.caseSensitive?y:b??=y.toLowerCase()).startsWith(r)))){if(a){if(v)continue;let e=t.slice(u).join(`/`).slice(-a.length);if((n.caseSensitive?e:e.toLowerCase())!==a)continue}s.push({node:n,index:o,skipped:d,depth:f+1,statics:p,dynamics:m,optionals:h,extract:g,rawParams:_})}}if(i.optional){let e=d|1<=0;n--){let r=i.optional[n];s.push({node:r,index:u,skipped:e,depth:t,statics:p,dynamics:m,optionals:h,extract:g,rawParams:_})}if(!v)for(let e=i.optional.length-1;e>=0;e--){let n=i.optional[e],{prefix:r,suffix:a}=n;if(r||a){let e=n.caseSensitive?y:b??=y.toLowerCase();if(r&&!e.startsWith(r)||a&&!e.endsWith(a))continue}s.push({node:n,index:u+1,skipped:d,depth:t,statics:p,dynamics:m,optionals:h+Lt(o,u),extract:g,rawParams:_})}}if(!v&&i.dynamic&&y)for(let e=i.dynamic.length-1;e>=0;e--){let t=i.dynamic[e],{prefix:n,suffix:r}=t;if(n||r){let e=t.caseSensitive?y:b??=y.toLowerCase();if(n&&!e.startsWith(n)||r&&!e.endsWith(r))continue}s.push({node:t,index:u+1,skipped:d,depth:f+1,statics:p,dynamics:m+Lt(o,u),optionals:h,extract:g,rawParams:_})}if(!v&&i.staticInsensitive){let e=i.staticInsensitive.get(b??=y.toLowerCase());e&&s.push({node:e,index:u+1,skipped:d,depth:f+1,statics:p+Lt(o,u),dynamics:m,optionals:h,extract:g,rawParams:_})}if(!v&&i.static){let e=i.static.get(y);e&&s.push({node:e,index:u+1,skipped:d,depth:f+1,statics:p+Lt(o,u),dynamics:m,optionals:h,extract:g,rawParams:_})}if(i.pathless){let e=f+1;for(let t=i.pathless.length-1;t>=0;t--){let n=i.pathless[t];s.push({node:n,index:u,skipped:d,depth:e,statics:p,dynamics:m,optionals:h,extract:g,rawParams:_})}}}if(l)return l;if(r&&c){let n=c.index;for(let e=0;ee.statics||t.statics===e.statics&&(t.dynamics>e.dynamics||t.dynamics===e.dynamics&&(t.optionals>e.optionals||t.optionals===e.optionals&&((t.node.kind===_t)>(e.node.kind===_t)||t.node.kind===_t==(e.node.kind===_t)&&t.depth>e.depth))):!0}function Vt(e){return Ht(e.filter(e=>e!==void 0).join(`/`))}function Ht(e){return e.replace(/\/{2,}/g,`/`)}function Ut(e){return e===`/`?e:e.replace(/^\/{1,}/,``)}function Wt(e){let t=e.length;return t>1&&e[t-1]===`/`?e.replace(/\/{1,}$/,``):e}function Gt(e){return Wt(Ut(e))}function Kt(e,t){return e?.endsWith(`/`)&&e!==`/`&&e!==`${t}/`?e.slice(0,-1):e}function qt(e,t,n){return Kt(e,n)===Kt(t,n)}function Jt({base:e,to:t,trailingSlash:n=`never`,cache:r}){let i=t.startsWith(`/`),a=!i&&t===`.`,o;if(r){o=i?t:a?e:e+`\0`+t;let n=r.get(o);if(n)return n}let s;if(a)s=e.split(`/`);else if(i)s=t.split(`/`);else{for(s=e.split(`/`);s.length>1&&He(s)===``;)s.pop();let n=t.split(`/`);for(let e=0,t=n.length;e1&&(He(s)===``?n===`never`&&s.pop():n===`always`&&s.push(``));let c=Ht(s.join(`/`))||`/`;return o&&r&&r.set(o,c),c}function Yt(e){let t=new Map(e.map(e=>[encodeURIComponent(e),e])),n=Array.from(t.keys()).map(e=>e.replace(/[.*+?^${}()|[\]\\]/g,`\\$&`)).join(`|`),r=new RegExp(n,`g`);return e=>e.replace(r,e=>t.get(e)??e)}function Xt(e,t,n){let r=t[e];return typeof r==`string`?e===`_splat`?/^[a-zA-Z0-9\-._~!/]*$/.test(r)?r:r.split(`/`).map(e=>Qt(e,n)).join(`/`):Qt(r,n):r}function Zt({path:e,params:t,decoder:n,...r}){let i=!1,a=Object.create(null);if(!e||e===`/`)return{interpolatedPath:`/`,usedParams:a,isMissingParams:i};if(!e.includes(`$`))return{interpolatedPath:e,usedParams:a,isMissingParams:i};let o=e.length,s=0,c,l=``;for(;s{t=We(e,t)||t},persist:()=>{try{e.setItem(tn,JSON.stringify(t))}catch{}}}}var rn=nn(),an=e=>e.state.__TSR_key||e.href;function on(e){let t=[],n;for(;n=e.parentNode;)t.push(`${e.tagName}:nth-child(${Array.prototype.indexOf.call(n.children,e)+1})`),e=n;return`${t.reverse().join(` > `)}`.toLowerCase()}var sn=!1,cn=`window`,ln=`data-scroll-restoration-id`;function un(e,t){if(!rn)return;let n=rn;if((t??e.options.scrollRestoration??!1)&&(e.isScrollRestoring=!0),e.isScrollRestorationSetup||!n)return;e.isScrollRestorationSetup=!0,sn=!1;let r=e.options.getScrollRestorationKey||an,i=new Map;window.history.scrollRestoration=`manual`;let a=t=>{if(!(sn||!e.isScrollRestoring))if(t.target===document||t.target===window)i.set(cn,{scrollX:window.scrollX||0,scrollY:window.scrollY||0});else{let e=t.target;i.set(e,{scrollX:e.scrollLeft||0,scrollY:e.scrollTop||0})}},o=t=>{if(!e.isScrollRestoring||!t||i.size===0||!n)return;let r=n.state[t]||={};for(let[e,t]of i){let n;if(e===cn)n=cn;else if(e.isConnected){let t=e.getAttribute(ln);n=t?`[${ln}="${t}"]`:on(e)}n&&(r[n]=t)}};document.addEventListener(`scroll`,a,!0),e.subscribe(`onBeforeLoad`,e=>{o(e.fromLocation?r(e.fromLocation):void 0),i.clear()}),window.addEventListener(`pagehide`,()=>{o(r(e.stores.resolvedLocation.get()??e.stores.location.get())),n.persist()}),e.subscribe(`onRendered`,t=>{let a=r(t.toLocation),o=e.options.scrollRestorationBehavior,s=e.options.scrollToTopSelectors;if(i.clear(),!e.resetNextScroll){e.resetNextScroll=!0;return}if(!(typeof e.options.scrollRestoration==`function`&&!e.options.scrollRestoration({location:e.latestLocation}))){sn=!0;try{let t=e.isScrollRestoring?n.state[a]:void 0,r=!1;if(t)for(let e in t){let n=t[e];if(!Qe(n))continue;let{scrollX:i,scrollY:a}=n;if(!(!Number.isFinite(i)||!Number.isFinite(a))){if(e===cn)window.scrollTo({top:a,left:i,behavior:o}),r=!0;else if(e){let t;try{t=document.querySelector(e)}catch{continue}t&&(t.scrollLeft=i,t.scrollTop=a,r=!0)}}}if(!r){let t=e.history.location.hash.slice(1);if(t){let e=window.history.state?.__hashScrollIntoViewOptions??!0;if(e){let n=document.getElementById(t);n&&n.scrollIntoView(e)}}else{let e={top:0,left:0,behavior:o};if(window.scrollTo(e),s)for(let t of s){if(t===cn)continue;let n=typeof t==`function`?t():document.querySelector(t);n&&n.scrollTo(e)}}}}finally{sn=!1}e.isScrollRestoring&&n.set(e=>(e[a]||={},e))}})}function dn(e,t=String){let n=new URLSearchParams;for(let r in e){let i=e[r];i!==void 0&&n.set(r,t(i))}return n.toString()}function fn(e){return e?e===`false`?!1:e===`true`?!0:e*0==0&&+e+``===e?+e:e:``}function pn(e){let t=new URLSearchParams(e),n=Object.create(null);for(let[e,r]of t.entries()){let t=n[e];t==null?n[e]=fn(r):Array.isArray(t)?t.push(fn(r)):n[e]=[t,fn(r)]}return n}var mn=gn(JSON.parse),hn=_n(JSON.stringify,JSON.parse);function gn(e){return t=>{t[0]===`?`&&(t=t.substring(1));let n=pn(t);for(let t in n){let r=n[t];if(typeof r==`string`)try{n[t]=e(r)}catch{}}return n}}function _n(e,t){let n=typeof t==`function`;function r(r){if(typeof r==`object`&&r)try{return e(r)}catch{}else if(n&&typeof r==`string`)try{return t(r),e(r)}catch{}return r}return e=>{let t=dn(e,r);return t?`?${t}`:``}}var vn=`__root__`;function yn(e){if(e.statusCode=e.statusCode||e.code||307,!e._builtLocation&&!e.reloadDocument&&typeof e.href==`string`)try{new URL(e.href),e.reloadDocument=!0}catch{}let t=new Headers(e.headers);e.href&&t.get(`Location`)===null&&t.set(`Location`,e.href);let n=new Response(null,{status:e.statusCode,headers:t});if(n.options=e,e.throw)throw n;return n}function bn(e){return e instanceof Response&&!!e.options}function xn(e){return{input:({url:t})=>{for(let n of e)t=Cn(n,t);return t},output:({url:t})=>{for(let n=e.length-1;n>=0;n--)t=wn(e[n],t);return t}}}function Sn(e){let t=Gt(e.basepath),n=`/${t}`,r=`${n}/`,i=e.caseSensitive?n:n.toLowerCase(),a=e.caseSensitive?r:r.toLowerCase();return{input:({url:t})=>{let r=e.caseSensitive?t.pathname:t.pathname.toLowerCase();return r===i?t.pathname=`/`:r.startsWith(a)&&(t.pathname=t.pathname.slice(n.length)),t},output:({url:e})=>(e.pathname=Vt([`/`,t,e.pathname]),e)}}function Cn(e,t){let n=e?.input?.({url:t});if(n){if(typeof n==`string`)return new URL(n);if(n instanceof URL)return n}return t}function wn(e,t){let n=e?.output?.({url:t});if(n){if(typeof n==`string`)return new URL(n);if(n instanceof URL)return n}return t}function Tn(e,t){let{createMutableStore:n,createReadonlyStore:r,batch:i,init:a}=t,o=new Map,s=new Map,c=new Map,l=n(e.status),u=n(e.loadedAt),d=n(e.isLoading),f=n(e.isTransitioning),p=n(e.location),m=n(e.resolvedLocation),h=n(e.statusCode),g=n(e.redirect),_=n([]),v=n([]),y=n([]),b=r(()=>En(o,_.get())),x=r(()=>En(s,v.get())),S=r(()=>En(c,y.get())),C=r(()=>_.get()[0]),w=r(()=>_.get().some(e=>o.get(e)?.get().status===`pending`)),T=r(()=>({locationHref:p.get().href,resolvedLocationHref:m.get()?.href,status:l.get()})),E=r(()=>({status:l.get(),loadedAt:u.get(),isLoading:d.get(),isTransitioning:f.get(),matches:b.get(),location:p.get(),resolvedLocation:m.get(),statusCode:h.get(),redirect:g.get()})),ee=gt(64);function D(e){let t=ee.get(e);return t||(t=r(()=>{let t=_.get();for(let n of t){let t=o.get(n);if(t&&t.routeId===e)return t.get()}}),ee.set(e,t)),t}let O={status:l,loadedAt:u,isLoading:d,isTransitioning:f,location:p,resolvedLocation:m,statusCode:h,redirect:g,matchesId:_,pendingIds:v,cachedIds:y,matches:b,pendingMatches:x,cachedMatches:S,firstId:C,hasPending:w,matchRouteDeps:T,matchStores:o,pendingMatchStores:s,cachedMatchStores:c,__store:E,getRouteMatchStore:D,setMatches:te,setPending:ne,setCached:re};te(e.matches),a?.(O);function te(e){Dn(e,o,_,n,i)}function ne(e){Dn(e,s,v,n,i)}function re(e){Dn(e,c,y,n,i)}return O}function En(e,t){let n=[];for(let r of t){let t=e.get(r);t&&n.push(t.get())}return n}function Dn(e,t,n,r,i){let a=e.map(e=>e.id),o=new Set(a);i(()=>{for(let e of t.keys())o.has(e)||t.delete(e);for(let n of e){let e=t.get(n.id);if(!e){let e=r(n);e.routeId=n.routeId,t.set(n.id,e);continue}e.routeId=n.routeId,e.get()!==n&&e.set(n)}mt(n.get(),a)||n.set(a)})}var On=e=>{if(!e.rendered)return e.rendered=!0,e.onReady?.()},kn=e=>e.stores.matchesId.get().some(t=>e.stores.matchStores.get(t)?.get()._forcePending),An=(e,t)=>!!(e.preload&&!e.router.stores.matchStores.has(t)),jn=(e,t,n=!0)=>{let r={...e.router.options.context??{}},i=n?t:t-1;for(let t=0;t<=i;t++){let n=e.matches[t];if(!n)continue;let i=e.router.getMatch(n.id);i&&Object.assign(r,i.__routeContext,i.__beforeLoadContext)}return r},Mn=(e,t)=>{if(!e.matches.length)return;let n=t.routeId,r=e.matches.findIndex(t=>t.routeId===e.router.routeTree.id),i=r>=0?r:0,a=n?e.matches.findIndex(e=>e.routeId===n):e.firstBadMatchIndex??e.matches.length-1;a<0&&(a=i);for(let t=a;t>=0;t--){let n=e.matches[t];if(e.router.looseRoutesById[n.routeId].options.notFoundComponent)return t}return n?a:i},Nn=(e,t,n)=>{if(!(!bn(n)&&!$t(n)))throw bn(n)&&n.redirectHandled&&!n.options.reloadDocument?n:(t&&(t._nonReactive.beforeLoadPromise?.resolve(),t._nonReactive.loaderPromise?.resolve(),t._nonReactive.beforeLoadPromise=void 0,t._nonReactive.loaderPromise=void 0,t._nonReactive.error=n,e.updateMatch(t.id,r=>({...r,status:bn(n)?`redirected`:$t(n)?`notFound`:r.status===`pending`?`success`:r.status,context:jn(e,t.index),isFetching:!1,error:n})),$t(n)&&!n.routeId&&(n.routeId=t.routeId),t._nonReactive.loadPromise?.resolve()),bn(n)&&(e.rendered=!0,n.options._fromLocation=e.location,n.redirectHandled=!0,n=e.router.resolveRedirect(n)),n)},Pn=(e,t)=>{let n=e.router.getMatch(t);return!!(!n||n._nonReactive.dehydrated)},Fn=(e,t,n)=>{let r=jn(e,n);e.updateMatch(t,e=>({...e,context:r}))},In=(e,t,n,r)=>{let{id:i,routeId:a}=e.matches[t],o=e.router.looseRoutesById[a];if(n instanceof Promise)throw n;n.routerCode=r,e.firstBadMatchIndex??=t,Nn(e,e.router.getMatch(i),n);try{o.options.onError?.(n)}catch(t){n=t,Nn(e,e.router.getMatch(i),n)}e.updateMatch(i,e=>(e._nonReactive.beforeLoadPromise?.resolve(),e._nonReactive.beforeLoadPromise=void 0,e._nonReactive.loadPromise?.resolve(),{...e,error:n,status:`error`,isFetching:!1,updatedAt:Date.now(),abortController:new AbortController})),!e.preload&&!bn(n)&&!$t(n)&&(e.serialError??=n)},Ln=(e,t,n,r)=>{if(r._nonReactive.pendingTimeout!==void 0)return;let i=n.options.pendingMs??e.router.options.defaultPendingMs;if(e.onReady&&!An(e,t)&&(n.options.loader||n.options.beforeLoad||Jn(n))&&typeof i==`number`&&i!==1/0&&(n.options.pendingComponent??e.router.options?.defaultPendingComponent)){let t=setTimeout(()=>{On(e)},i);r._nonReactive.pendingTimeout=t}},Rn=(e,t,n)=>{let r=e.router.getMatch(t);if(!r._nonReactive.beforeLoadPromise&&!r._nonReactive.loaderPromise)return;Ln(e,t,n,r);let i=()=>{let n=e.router.getMatch(t);n.preload&&(n.status===`redirected`||n.status===`notFound`)&&Nn(e,n,n.error)};return r._nonReactive.beforeLoadPromise?r._nonReactive.beforeLoadPromise.then(i):i()},zn=(e,t,n,r)=>{let i=e.router.getMatch(t),a=i._nonReactive.loadPromise;i._nonReactive.loadPromise=nt(()=>{a?.resolve(),a=void 0});let{paramsError:o,searchError:s}=i;o&&In(e,n,o,`PARSE_PARAMS`),s&&In(e,n,s,`VALIDATE_SEARCH`),Ln(e,t,r,i);let c=new AbortController,l=!1,u=()=>{l||(l=!0,e.updateMatch(t,e=>({...e,isFetching:`beforeLoad`,fetchCount:e.fetchCount+1,abortController:c})))},d=()=>{i._nonReactive.beforeLoadPromise?.resolve(),i._nonReactive.beforeLoadPromise=void 0,e.updateMatch(t,e=>({...e,isFetching:!1}))};if(!r.options.beforeLoad){e.router.batch(()=>{u(),d()});return}i._nonReactive.beforeLoadPromise=nt();let f={...jn(e,n,!1),...i.__routeContext},{search:p,params:m,cause:h}=i,g=An(e,t),_={search:p,abortController:c,params:m,preload:g,context:f,location:e.location,navigate:t=>e.router.navigate({...t,_fromLocation:e.location}),buildLocation:e.router.buildLocation,cause:g?`preload`:h,matches:e.matches,routeId:r.id,...e.router.options.additionalContext},v=r=>{if(r===void 0){e.router.batch(()=>{u(),d()});return}(bn(r)||$t(r))&&(u(),In(e,n,r,`BEFORE_LOAD`)),e.router.batch(()=>{u(),e.updateMatch(t,e=>({...e,__beforeLoadContext:r})),d()})},y;try{if(y=r.options.beforeLoad(_),it(y))return u(),y.catch(t=>{In(e,n,t,`BEFORE_LOAD`)}).then(v)}catch(t){u(),In(e,n,t,`BEFORE_LOAD`)}v(y)},Bn=(e,t)=>{let{id:n,routeId:r}=e.matches[t],i=e.router.looseRoutesById[r],a=()=>s(),o=()=>zn(e,n,t,i),s=()=>{if(Pn(e,n))return;let t=Rn(e,n,i);return it(t)?t.then(o):o()};return a()},Vn=(e,t,n)=>{let r=e.router.getMatch(t);if(!r||!n.options.head&&!n.options.scripts&&!n.options.headers)return;let i={ssr:e.router.options.ssr,matches:e.matches,match:r,params:r.params,loaderData:r.loaderData};return Promise.all([n.options.head?.(i),n.options.scripts?.(i),n.options.headers?.(i)]).then(([e,t,n])=>({meta:e?.meta,links:e?.links,headScripts:e?.scripts,headers:n,scripts:t,styles:e?.styles}))},Hn=(e,t,n,r,i)=>{let a=t[r-1],{params:o,loaderDeps:s,abortController:c,cause:l}=e.router.getMatch(n),u=jn(e,r),d=An(e,n);return{params:o,deps:s,preload:!!d,parentMatchPromise:a,abortController:c,context:u,location:e.location,navigate:t=>e.router.navigate({...t,_fromLocation:e.location}),cause:d?`preload`:l,route:i,...e.router.options.additionalContext}},Un=async(e,t,n,r,i)=>{try{let a=e.router.getMatch(n);try{qn(i);let o=i.options.loader,s=typeof o==`function`?o:o?.handler,c=s?.(Hn(e,t,n,r,i)),l=!!s&&it(c);if((l||i._lazyPromise||i._componentsPromise||i.options.head||i.options.scripts||i.options.headers||a._nonReactive.minPendingPromise)&&e.updateMatch(n,e=>({...e,isFetching:`loader`})),s){let t=l?await c:c;Nn(e,e.router.getMatch(n),t),t!==void 0&&e.updateMatch(n,e=>({...e,loaderData:t}))}i._lazyPromise&&await i._lazyPromise;let u=a._nonReactive.minPendingPromise;u&&await u,i._componentsPromise&&await i._componentsPromise,e.updateMatch(n,t=>({...t,error:void 0,context:jn(e,r),status:`success`,isFetching:!1,updatedAt:Date.now()}))}catch(t){let o=t;if(o?.name===`AbortError`){if(a.abortController.signal.aborted){a._nonReactive.loaderPromise?.resolve(),a._nonReactive.loaderPromise=void 0;return}e.updateMatch(n,t=>({...t,status:t.status===`pending`?`success`:t.status,isFetching:!1,context:jn(e,r)}));return}let s=a._nonReactive.minPendingPromise;s&&await s,$t(t)&&await i.options.notFoundComponent?.preload?.(),Nn(e,e.router.getMatch(n),t);try{i.options.onError?.(t)}catch(t){o=t,Nn(e,e.router.getMatch(n),t)}!bn(o)&&!$t(o)&&await qn(i,[`errorComponent`]),e.updateMatch(n,t=>({...t,error:o,context:jn(e,r),status:`error`,isFetching:!1}))}}catch(t){let r=e.router.getMatch(n);r&&(r._nonReactive.loaderPromise=void 0),Nn(e,r,t)}},Wn=async(e,t,n)=>{async function r(r,a,c,l,d){let f=Date.now()-a.updatedAt,p=r?d.options.preloadStaleTime??e.router.options.defaultPreloadStaleTime??3e4:d.options.staleTime??e.router.options.defaultStaleTime??0,m=d.options.shouldReload,h=typeof m==`function`?m(Hn(e,t,i,n,d)):m,{status:g,invalid:_}=l,v=f>=p&&(!!e.forceStaleReload||l.cause===`enter`||c!==void 0&&c!==l.id);o=g===`success`&&(_||(h??v)),r&&d.options.preload===!1||(o&&!e.sync&&u?(s=!0,(async()=>{try{await Un(e,t,i,n,d);let r=e.router.getMatch(i);r._nonReactive.loaderPromise?.resolve(),r._nonReactive.loadPromise?.resolve(),r._nonReactive.loaderPromise=void 0,r._nonReactive.loadPromise=void 0}catch(t){bn(t)&&await e.router.navigate(t.options)}})()):g!==`success`||o?await Un(e,t,i,n,d):Fn(e,i,n))}let{id:i,routeId:a}=e.matches[n],o=!1,s=!1,c=e.router.looseRoutesById[a],l=c.options.loader,u=((typeof l==`function`?void 0:l?.staleReloadMode)??e.router.options.defaultStaleReloadMode)!==`blocking`;if(Pn(e,i)){if(!e.router.getMatch(i))return e.matches[n];Fn(e,i,n)}else{let t=e.router.getMatch(i),o=e.router.stores.matchesId.get()[n],s=(o&&e.router.stores.matchStores.get(o)||null)?.routeId===a?o:e.router.stores.matches.get().find(e=>e.routeId===a)?.id,l=An(e,i);if(t._nonReactive.loaderPromise){if(t.status===`success`&&!e.sync&&!t.preload&&u)return t;await t._nonReactive.loaderPromise;let n=e.router.getMatch(i),a=n._nonReactive.error||n.error;a&&Nn(e,n,a),n.status===`pending`&&await r(l,t,s,n,c)}else{let n=l&&!e.router.stores.matchStores.has(i),a=e.router.getMatch(i);a._nonReactive.loaderPromise=nt(),n!==a.preload&&e.updateMatch(i,e=>({...e,preload:n})),await r(l,t,s,a,c)}}let d=e.router.getMatch(i);s||(d._nonReactive.loaderPromise?.resolve(),d._nonReactive.loadPromise?.resolve(),d._nonReactive.loadPromise=void 0),clearTimeout(d._nonReactive.pendingTimeout),d._nonReactive.pendingTimeout=void 0,s||(d._nonReactive.loaderPromise=void 0),d._nonReactive.dehydrated=void 0;let f=s?d.isFetching:!1;return f!==d.isFetching||d.invalid!==!1?(e.updateMatch(i,e=>({...e,isFetching:f,invalid:!1})),e.router.getMatch(i)):d};async function Gn(e){let t=e,n=[];kn(t.router)&&On(t);let r;for(let e=0;e({...e,...a?{status:`success`,globalNotFound:!0,error:void 0}:{status:`notFound`,error:l},isFetching:!1})),u=e,await qn(r,[`notFoundComponent`])}else if(!t.preload){let e=t.matches[0];e.globalNotFound||t.router.getMatch(e.id)?.globalNotFound&&t.updateMatch(e.id,e=>({...e,globalNotFound:!1,error:void 0}))}if(t.serialError&&t.firstBadMatchIndex!==void 0){let e=t.router.looseRoutesById[t.matches[t.firstBadMatchIndex].routeId];await qn(e,[`errorComponent`])}for(let e=0;e<=u;e++){let{id:n,routeId:r}=t.matches[e],i=t.router.looseRoutesById[r];try{let e=Vn(t,n,i);if(e){let r=await e;t.updateMatch(n,e=>({...e,...r}))}}catch(e){console.error(`Error executing head for route ${r}:`,e)}}let d=On(t);if(it(d)&&await d,l)throw l;if(t.serialError&&!t.preload&&!t.onReady)throw t.serialError;return t.matches}function Kn(e,t){let n=t.map(t=>e.options[t]?.preload?.()).filter(Boolean);if(n.length!==0)return Promise.all(n)}function qn(e,t=Yn){!e._lazyLoaded&&e._lazyPromise===void 0&&(e.lazyFn?e._lazyPromise=e.lazyFn().then(t=>{let{id:n,...r}=t.options;Object.assign(e.options,r),e._lazyLoaded=!0,e._lazyPromise=void 0}):e._lazyLoaded=!0);let n=()=>e._componentsLoaded?void 0:t===Yn?(()=>{if(e._componentsPromise===void 0){let t=Kn(e,Yn);t?e._componentsPromise=t.then(()=>{e._componentsLoaded=!0,e._componentsPromise=void 0}):e._componentsLoaded=!0}return e._componentsPromise})():Kn(e,t);return e._lazyPromise?e._lazyPromise.then(n):n()}function Jn(e){for(let t of Yn)if(e.options[t]?.preload)return!0;return!1}var Yn=[`component`,`errorComponent`,`pendingComponent`,`notFoundComponent`],Xn=`__TSR_index`,Zn=`popstate`,Qn=`beforeunload`;function $n(e){let t=e.getLocation(),n=new Set,r=r=>{t=e.getLocation(),n.forEach(e=>e({location:t,action:r}))},i=n=>{e.notifyOnIndexChange??!0?r(n):t=e.getLocation()},a=async({task:n,navigateOpts:r,...i})=>{if(r?.ignoreBlocker??!1){n();return}let a=e.getBlockers?.()??[],o=i.type===`PUSH`||i.type===`REPLACE`;if(typeof document<`u`&&a.length&&o)for(let n of a){let r=rr(i.path,i.state);if(await n.blockerFn({currentLocation:t,nextLocation:r,action:i.type})){e.onBlocked?.();return}}n()};return{get location(){return t},get length(){return e.getLength()},subscribers:n,subscribe:e=>(n.add(e),()=>{n.delete(e)}),push:(n,i,o)=>{let s=t.state[Xn];i=er(s+1,i),a({task:()=>{e.pushState(n,i),r({type:`PUSH`})},navigateOpts:o,type:`PUSH`,path:n,state:i})},replace:(n,i,o)=>{let s=t.state[Xn];i=er(s,i),a({task:()=>{e.replaceState(n,i),r({type:`REPLACE`})},navigateOpts:o,type:`REPLACE`,path:n,state:i})},go:(t,n)=>{a({task:()=>{e.go(t),i({type:`GO`,index:t})},navigateOpts:n,type:`GO`})},back:t=>{a({task:()=>{e.back(t?.ignoreBlocker??!1),i({type:`BACK`})},navigateOpts:t,type:`BACK`})},forward:t=>{a({task:()=>{e.forward(t?.ignoreBlocker??!1),i({type:`FORWARD`})},navigateOpts:t,type:`FORWARD`})},canGoBack:()=>t.state[Xn]!==0,createHref:t=>e.createHref(t),block:t=>{if(!e.setBlockers)return()=>{};let n=e.getBlockers?.()??[];return e.setBlockers([...n,t]),()=>{let n=e.getBlockers?.()??[];e.setBlockers?.(n.filter(e=>e!==t))}},flush:()=>e.flush?.(),destroy:()=>e.destroy?.(),notify:r}}function er(e,t){t||={};let n=ir();return{...t,key:n,__TSR_key:n,[Xn]:e}}function tr(e){let t=e?.window??(typeof document<`u`?window:void 0),n=t.history.pushState,r=t.history.replaceState,i=[],a=()=>i,o=e=>i=e,s=e?.createHref??(e=>e),c=e?.parseLocation??(()=>rr(`${t.location.pathname}${t.location.search}${t.location.hash}`,t.history.state));if(!t.history.state?.__TSR_key&&!t.history.state?.key){let e=ir();t.history.replaceState({[Xn]:0,key:e,__TSR_key:e},``)}let l=c(),u,d=!1,f=!1,p=!1,m=!1,h=()=>l,g,_,v=()=>{g&&(C._ignoreSubscribers=!0,(g.isPush?t.history.pushState:t.history.replaceState)(g.state,``,g.href),C._ignoreSubscribers=!1,g=void 0,_=void 0,u=void 0)},y=(e,t,n)=>{let r=s(t);_||(u=l),l=rr(t,n),g={href:r,state:n,isPush:g?.isPush||e===`push`},_||=Promise.resolve().then(()=>v())},b=e=>{l=c(),C.notify({type:e})},x=async()=>{if(f){f=!1;return}let e=c(),n=e.state[Xn]-l.state[Xn],r=n===1,i=n===-1,o=!r&&!i||d;d=!1;let s=o?`GO`:i?`BACK`:`FORWARD`,u=o?{type:`GO`,index:n}:{type:i?`BACK`:`FORWARD`};if(p)p=!1;else{let n=a();if(typeof document<`u`&&n.length){for(let r of n)if(await r.blockerFn({currentLocation:l,nextLocation:e,action:s})){f=!0,t.history.go(1),C.notify(u);return}}}l=c(),C.notify(u)},S=e=>{if(m){m=!1;return}let t=!1,n=a();if(typeof document<`u`&&n.length)for(let e of n){let n=e.enableBeforeUnload??!0;if(n===!0){t=!0;break}if(typeof n==`function`&&n()===!0){t=!0;break}}if(t)return e.preventDefault(),e.returnValue=``},C=$n({getLocation:h,getLength:()=>t.history.length,pushState:(e,t)=>y(`push`,e,t),replaceState:(e,t)=>y(`replace`,e,t),back:e=>(e&&(p=!0),m=!0,t.history.back()),forward:e=>{e&&(p=!0),m=!0,t.history.forward()},go:e=>{d=!0,t.history.go(e)},createHref:e=>s(e),flush:v,destroy:()=>{t.history.pushState=n,t.history.replaceState=r,t.removeEventListener(Qn,S,{capture:!0}),t.removeEventListener(Zn,x)},onBlocked:()=>{u&&l!==u&&(l=u)},getBlockers:a,setBlockers:o,notifyOnIndexChange:!1});return t.addEventListener(Qn,S,{capture:!0}),t.addEventListener(Zn,x),t.history.pushState=function(...e){let r=n.apply(t.history,e);return C._ignoreSubscribers||b(`PUSH`),r},t.history.replaceState=function(...e){let n=r.apply(t.history,e);return C._ignoreSubscribers||b(`REPLACE`),n},C}function nr(e){let t=e.replace(/[\x00-\x1f\x7f]/g,``);return t.startsWith(`//`)&&(t=`/`+t.replace(/^\/+/,``)),t}function rr(e,t){let n=nr(e),r=n.indexOf(`#`),i=n.indexOf(`?`),a=ir();return{href:n,pathname:n.substring(0,r>0?i>0?Math.min(r,i):r:i>0?i:n.length),hash:r>-1?n.substring(r):``,search:i>-1?n.slice(i,r===-1?void 0:r):``,state:t||{[Xn]:0,key:a,__TSR_key:a}}}function ir(){return(Math.random()+1).toString(36).substring(7)}function ar(e,t){let n=t,r=e;return{fromLocation:n,toLocation:r,pathChanged:n?.pathname!==r.pathname,hrefChanged:n?.href!==r.href,hashChanged:n?.hash!==r.hash}}var or=class{constructor(e,t){this.tempLocationKey=`${Math.round(Math.random()*1e7)}`,this.resetNextScroll=!0,this.shouldViewTransition=void 0,this.isViewTransitionTypesSupported=void 0,this.subscribers=new Set,this.isScrollRestoring=!1,this.isScrollRestorationSetup=!1,this.routeBranchCache=new WeakMap,this.startTransition=e=>e(),this.update=e=>{let t=this.options,n=this.basepath??t?.basepath??`/`,r=this.basepath===void 0,i=t?.rewrite;if(this.options={...t,...e},this.isServer=this.options.isServer??typeof document>`u`,this.protocolAllowlist=new Set(this.options.protocolAllowlist),this.options.pathParamsAllowedCharacters&&(this.pathParamsDecoder=Yt(this.options.pathParamsAllowedCharacters)),(!this.history||this.options.history&&this.options.history!==this.history)&&(this.options.history?this.history=this.options.history:this.history=tr()),this.origin=this.options.origin,this.origin||(window?.origin&&window.origin!==`null`?this.origin=window.origin:this.origin=`http://localhost`),this.history&&this.updateLatestLocation(),this.options.routeTree!==this.routeTree){this.routeTree=this.options.routeTree;let e;this.resolvePathCache=gt(1e3),e=this.buildRouteTree(),this.setRoutes(e)}if(!this.stores&&this.latestLocation){let e=this.getStoreConfig(this);this.batch=e.batch,this.stores=Tn(lr(this.latestLocation),e),un(this)}let a=!1,o=this.options.basepath??`/`,s=this.options.rewrite;if(r||n!==o||i!==s){this.basepath=o;let e=[],t=Gt(o);t&&t!==`/`&&e.push(Sn({basepath:o})),s&&e.push(s),this.rewrite=e.length===0?void 0:e.length===1?e[0]:xn(e),this.history&&this.updateLatestLocation(),a=!0}a&&this.stores&&this.stores.location.set(this.latestLocation),typeof window<`u`&&`CSS`in window&&typeof window.CSS?.supports==`function`&&(this.isViewTransitionTypesSupported=window.CSS.supports(`selector(:active-view-transition-type(a))`))},this.updateLatestLocation=()=>{this.latestLocation=this.parseLocation(this.history.location,this.latestLocation)},this.buildRouteTree=()=>{let e=jt(this.routeTree,this.options.caseSensitive,(e,t)=>{e.init({originalIndex:t})});return this.options.routeMasks&&Et(this.options.routeMasks,e.processedTree),e},this.subscribe=(e,t)=>{let n={eventType:e,fn:t};return this.subscribers.add(n),()=>{this.subscribers.delete(n)}},this.emit=e=>{this.subscribers.forEach(t=>{t.eventType===e.type&&t.fn(e)})},this.parseLocation=(e,t)=>{let n=({pathname:e,search:n,hash:r,href:i,state:a})=>{if(!this.rewrite&&!/[ \x00-\x1f\x7f\u0080-\uffff]/.test(e)){let i=this.options.parseSearch(n),o=this.options.stringifySearch(i);return{href:e+o+r,publicHref:e+o+r,pathname:ft(e).path,external:!1,searchStr:o,search:Ye(t?.search,i),hash:ft(r.slice(1)).path,state:Xe(t?.state,a)}}let o=new URL(i,this.origin),s=Cn(this.rewrite,o),c=this.options.parseSearch(s.search),l=this.options.stringifySearch(c);return s.search=l,{href:s.href.replace(s.origin,``),publicHref:i,pathname:ft(s.pathname).path,external:!!this.rewrite&&s.origin!==this.origin,searchStr:l,search:Ye(t?.search,c),hash:ft(s.hash.slice(1)).path,state:Xe(t?.state,a)}},r=n(e),{__tempLocation:i,__tempKey:a}=r.state;if(i&&(!a||a===this.tempLocationKey)){let e=n(i);return e.state.key=r.state.key,e.state.__TSR_key=r.state.__TSR_key,delete e.state.__tempLocation,{...e,maskedLocation:r}}return r},this.resolvePathWithBase=(e,t)=>Jt({base:e,to:t.includes(`//`)?Ht(t):t,trailingSlash:this.options.trailingSlash,cache:this.resolvePathCache}),this.matchRoutes=(e,t,n)=>typeof e==`string`?this.matchRoutesInternal({pathname:e,search:t},n):this.matchRoutesInternal(e,t),this.getMatchedRoutes=e=>dr({pathname:e,routesById:this.routesById,processedTree:this.processedTree}),this.cancelMatch=e=>{let t=this.getMatch(e);t&&(t.abortController.abort(),clearTimeout(t._nonReactive.pendingTimeout),t._nonReactive.pendingTimeout=void 0)},this.cancelMatches=()=>{this.stores.pendingIds.get().forEach(e=>{this.cancelMatch(e)}),this.stores.matchesId.get().forEach(e=>{if(this.stores.pendingMatchStores.has(e))return;let t=this.stores.matchStores.get(e)?.get();t&&(t.status===`pending`||t.isFetching===`loader`)&&this.cancelMatch(e)})},this.buildLocation=e=>{let t=(t={})=>{let n=t._fromLocation||this.pendingBuiltLocation||this.latestLocation,r=this.matchRoutesLightweight(n);t.from;let i=t.unsafeRelative===`path`?n.pathname:t.from??r.fullPath,a=t.to?`${t.to}`:void 0,o=r.search,s=Object.assign(Object.create(null),r.params),c=a?.charCodeAt(0)===47?`/`:this.resolvePathWithBase(i,`.`),l=a?this.resolvePathWithBase(c,a):c,u=t.params===!1||t.params===null?Object.create(null):(t.params??!0)===!0?s:Object.assign(s,We(t.params,s)),d=this.routesByPath[Wt(l)],f;if(d)f=this.getRouteBranch(d);else if(l.includes(`$`))f=[];else{let e=this.getMatchedRoutes(l);f=e.matchedRoutes,this.options.notFoundRoute&&(!e.foundRoute||e.foundRoute.path!==`/`&&e.routeParams[`**`])&&(f=[...f,this.options.notFoundRoute])}if(f.length&&qe(u))for(let e of f){let t=e.options.params?.stringify??e.options.stringifyParams;if(t)try{Object.assign(u,t(u))}catch{}}let p=e.leaveParams?l:ft(Zt({path:l,params:u,decoder:this.pathParamsDecoder,server:this.isServer}).interpolatedPath).path,m=o;if(e._includeValidateSearch&&this.options.search?.strict){let e={};f.forEach(t=>{if(t.options.validateSearch)try{Object.assign(e,ur(t.options.validateSearch,{...e,...m}))}catch{}}),m=e}m=fr({search:m,dest:t,destRoutes:f,_includeValidateSearch:e._includeValidateSearch}),m=Ye(o,m);let h=this.options.stringifySearch(m),g=t.hash===!0?n.hash:t.hash?We(t.hash,n.hash):void 0,_=g?`#${g}`:``,v=t.state===!0?n.state:t.state?We(t.state,n.state):{};v=Xe(n.state,v);let y=`${p}${h}${_}`,b,x,S=!1;if(this.rewrite){let e=new URL(y,this.origin),t=wn(this.rewrite,e);b=e.href.replace(e.origin,``),t.origin===this.origin?x=t.pathname+t.search+t.hash:(x=t.href,S=!0)}else b=pt(y),x=b;return{publicHref:x,href:b,pathname:p,search:m,searchStr:h,state:v,hash:g??``,external:S,unmaskOnReload:t.unmaskOnReload}},n=(n={},r)=>{let i=t(n),a=r?t(r):void 0;if(!a){let n=Object.create(null);if(this.options.routeMasks){let o=Dt(i.pathname,this.processedTree);if(o){Object.assign(n,o.rawParams);let{from:i,params:s,...c}=o.route,l=s===!1||s===null?Object.create(null):(s??!0)===!0?n:Object.assign(n,We(s,n));r={from:e.from,...c,params:l},a=t(r)}}}return a&&(i.maskedLocation=a),i};return e.mask?n(e,{from:e.from,...e.mask}):n(e)},this.commitLocation=async({viewTransition:e,ignoreBlocker:t,...n})=>{let r=()=>{let e=[`key`,`__TSR_key`,`__TSR_index`,`__hashScrollIntoViewOptions`];e.forEach(e=>{n.state[e]=this.latestLocation.state[e]});let t=tt(n.state,this.latestLocation.state);return e.forEach(e=>{delete n.state[e]}),t},i=Wt(this.latestLocation.href)===Wt(n.href),a=this.commitLocationPromise;if(this.commitLocationPromise=nt(()=>{a?.resolve(),a=void 0}),i&&r())this.load();else{let{maskedLocation:r,hashScrollIntoView:i,...a}=n;r&&(a={...r,state:{...r.state,__tempKey:void 0,__tempLocation:{...a,search:a.searchStr,state:{...a.state,__tempKey:void 0,__tempLocation:void 0,__TSR_key:void 0,key:void 0}}}},(a.unmaskOnReload??this.options.unmaskOnReload??!1)&&(a.state.__tempKey=this.tempLocationKey)),a.state.__hashScrollIntoViewOptions=i??this.options.defaultHashScrollIntoView??!0,this.shouldViewTransition=e,this.history[n.replace?`replace`:`push`](a.publicHref,a.state,{ignoreBlocker:t})}return this.resetNextScroll=n.resetScroll??!0,this.history.subscribers.size||this.load(),this.commitLocationPromise},this.buildAndCommitLocation=({replace:e,resetScroll:t,hashScrollIntoView:n,viewTransition:r,ignoreBlocker:i,href:a,...o}={})=>{if(a){let t=this.history.location.state.__TSR_index,n=rr(a,{__TSR_index:e?t:t+1}),r=new URL(n.pathname,this.origin);o.to=Cn(this.rewrite,r).pathname,o.search=this.options.parseSearch(n.search),o.hash=n.hash.slice(1)}let s=this.buildLocation({...o,_includeValidateSearch:!0});this.pendingBuiltLocation=s;let c=this.commitLocation({...s,viewTransition:r,replace:e,resetScroll:t,hashScrollIntoView:n,ignoreBlocker:i});return Promise.resolve().then(()=>{this.pendingBuiltLocation===s&&(this.pendingBuiltLocation=void 0)}),c},this.navigate=async({to:e,reloadDocument:t,href:n,publicHref:r,...i})=>{let a=!1;if(n)try{new URL(`${n}`),a=!0}catch{}if(a&&!t&&(t=!0),t){if(e!==void 0||!n){let t=this.buildLocation({to:e,...i});n??=t.publicHref,r??=t.publicHref}let t=!a&&r?r:n;if(ct(t,this.protocolAllowlist))return Promise.resolve();if(!i.ignoreBlocker){let e=this.history.getBlockers?.()??[];for(let t of e)if(t?.blockerFn&&await t.blockerFn({currentLocation:this.latestLocation,nextLocation:this.latestLocation,action:`PUSH`}))return Promise.resolve()}return i.replace?window.location.replace(t):window.location.href=t,Promise.resolve()}return this.buildAndCommitLocation({...i,href:n,to:e,_isNavigate:!0})},this.beforeLoad=()=>{this.cancelMatches(),this.updateLatestLocation();let e=this.matchRoutes(this.latestLocation),t=this.stores.cachedMatches.get().filter(t=>!e.some(e=>e.id===t.id));this.batch(()=>{this.stores.status.set(`pending`),this.stores.statusCode.set(200),this.stores.isLoading.set(!0),this.stores.location.set(this.latestLocation),this.stores.setPending(e),this.stores.setCached(t)})},this.load=async e=>{let t,n,r,i=this.stores.resolvedLocation.get()??this.stores.location.get();for(r=new Promise(a=>{this.startTransition(async()=>{try{this.beforeLoad();let t=this.latestLocation,n=ar(t,this.stores.resolvedLocation.get());this.stores.redirect.get()||this.emit({type:`onBeforeNavigate`,...n}),this.emit({type:`onBeforeLoad`,...n}),await Gn({router:this,sync:e?.sync,forceStaleReload:i.href===t.href,matches:this.stores.pendingMatches.get(),location:t,updateMatch:this.updateMatch,onReady:async()=>{this.startTransition(()=>{this.startViewTransition(async()=>{let e=null,t=null,n=null,r=null;this.batch(()=>{let i=this.stores.pendingMatches.get(),a=i.length,o=this.stores.matches.get();e=a?o.filter(e=>!this.stores.pendingMatchStores.has(e.id)):null;let s=new Set;for(let e of this.stores.pendingMatchStores.values())e.routeId&&s.add(e.routeId);let c=new Set;for(let e of this.stores.matchStores.values())e.routeId&&c.add(e.routeId);t=a?o.filter(e=>!s.has(e.routeId)):null,n=a?i.filter(e=>!c.has(e.routeId)):null,r=a?i.filter(e=>c.has(e.routeId)):o,this.stores.isLoading.set(!1),this.stores.loadedAt.set(Date.now()),a&&(this.stores.setMatches(i),this.stores.setPending([]),this.stores.setCached([...this.stores.cachedMatches.get(),...e.filter(e=>e.status!==`error`&&e.status!==`notFound`&&e.status!==`redirected`)]),this.clearExpiredCache())});for(let[e,i]of[[t,`onLeave`],[n,`onEnter`],[r,`onStay`]])if(e)for(let t of e)this.looseRoutesById[t.routeId].options[i]?.(t)})})}})}catch(e){bn(e)?(t=e,this.navigate({...t.options,replace:!0,ignoreBlocker:!0})):$t(e)&&(n=e);let r=t?t.status:n?404:this.stores.matches.get().some(e=>e.status===`error`)?500:200;this.batch(()=>{this.stores.statusCode.set(r),this.stores.redirect.set(t)})}this.latestLoadPromise===r&&(this.commitLocationPromise?.resolve(),this.latestLoadPromise=void 0,this.commitLocationPromise=void 0),a()})}),this.latestLoadPromise=r,await r;this.latestLoadPromise&&r!==this.latestLoadPromise;)await this.latestLoadPromise;let a;this.hasNotFoundMatch()?a=404:this.stores.matches.get().some(e=>e.status===`error`)&&(a=500),a!==void 0&&this.stores.statusCode.set(a)},this.startViewTransition=e=>{let t=this.shouldViewTransition??this.options.defaultViewTransition;if(this.shouldViewTransition=void 0,t&&typeof document<`u`&&`startViewTransition`in document&&typeof document.startViewTransition==`function`){let n;if(typeof t==`object`&&this.isViewTransitionTypesSupported){let r=this.latestLocation,i=this.stores.resolvedLocation.get(),a=typeof t.types==`function`?t.types(ar(r,i)):t.types;if(a===!1){e();return}n={update:e,types:a}}else n=e;document.startViewTransition(n)}else e()},this.updateMatch=(e,t)=>{this.startTransition(()=>{let n=this.stores.pendingMatchStores.get(e);if(n){n.set(t);return}let r=this.stores.matchStores.get(e);if(r){r.set(t);return}let i=this.stores.cachedMatchStores.get(e);if(i){let n=t(i.get());n.status===`redirected`?this.stores.cachedMatchStores.delete(e)&&this.stores.cachedIds.set(t=>t.filter(t=>t!==e)):i.set(n)}})},this.getMatch=e=>this.stores.cachedMatchStores.get(e)?.get()??this.stores.pendingMatchStores.get(e)?.get()??this.stores.matchStores.get(e)?.get(),this.invalidate=e=>{let t=t=>e?.filter?.(t)??!0?{...t,invalid:!0,...e?.forcePending||t.status===`error`||t.status===`notFound`?{status:`pending`,error:void 0}:void 0}:t;return this.batch(()=>{this.stores.setMatches(this.stores.matches.get().map(t)),this.stores.setCached(this.stores.cachedMatches.get().map(t)),this.stores.setPending(this.stores.pendingMatches.get().map(t))}),this.shouldViewTransition=!1,this.load({sync:e?.sync})},this.getParsedLocationHref=e=>e.publicHref||`/`,this.resolveRedirect=e=>{let t=e.headers.get(`Location`);if(!e.options.href||e.options._builtLocation){let t=e.options._builtLocation??this.buildLocation(e.options),n=this.getParsedLocationHref(t);e.options.href=n,e.headers.set(`Location`,n)}else if(t)try{let n=new URL(t);if(this.origin&&n.origin===this.origin){let t=n.pathname+n.search+n.hash;e.options.href=t,e.headers.set(`Location`,t)}}catch{}if(e.options.href&&!e.options._builtLocation&&ct(e.options.href,this.protocolAllowlist))throw Error(`Redirect blocked: unsafe protocol`);return e.headers.get(`Location`)||e.headers.set(`Location`,e.options.href),e},this.clearCache=e=>{let t=e?.filter;t===void 0?this.stores.setCached([]):this.stores.setCached(this.stores.cachedMatches.get().filter(e=>!t(e)))},this.clearExpiredCache=()=>{let e=Date.now();this.clearCache({filter:t=>{let n=this.looseRoutesById[t.routeId];if(!n.options.loader)return!0;let r=(t.preload?n.options.preloadGcTime??this.options.defaultPreloadGcTime:n.options.gcTime??this.options.defaultGcTime)??300*1e3;return t.status===`error`?!0:e-t.updatedAt>=r}})},this.loadRouteChunk=qn,this.preloadRoute=async e=>{let t=e._builtLocation??this.buildLocation(e),n=this.matchRoutes(t,{throwOnError:!0,preload:!0,dest:e}),r=new Set([...this.stores.matchesId.get(),...this.stores.pendingIds.get()]),i=new Set([...r,...this.stores.cachedIds.get()]),a=n.filter(e=>!i.has(e.id));if(a.length){let e=this.stores.cachedMatches.get();this.stores.setCached([...e,...a])}try{return n=await Gn({router:this,matches:n,location:t,preload:!0,updateMatch:(e,t)=>{r.has(e)?n=n.map(n=>n.id===e?t(n):n):this.updateMatch(e,t)}}),n}catch(e){if(bn(e))return e.options.reloadDocument?void 0:await this.preloadRoute({...e.options,_fromLocation:t});$t(e)||console.error(e);return}},this.matchRoute=(e,t)=>{let n={...e,to:e.to?this.resolvePathWithBase(e.from||``,e.to):void 0,params:e.params||{},leaveParams:!0},r=this.buildLocation(n);if(t?.pending&&this.stores.status.get()!==`pending`)return!1;let i=(t?.pending===void 0?!this.stores.isLoading.get():t.pending)?this.latestLocation:this.stores.resolvedLocation.get()||this.stores.location.get(),a=Ot(r.pathname,t?.caseSensitive??!1,t?.fuzzy??!1,i.pathname,this.processedTree);return!a||e.params&&!tt(a.rawParams,e.params,{partial:!0})?!1:t?.includeSearch??!0?tt(i.search,r.search,{partial:!0})?a.rawParams:!1:a.rawParams},this.hasNotFoundMatch=()=>this.stores.matches.get().some(e=>e.status===`notFound`||e.globalNotFound),this.getStoreConfig=t,this.update({defaultPreloadDelay:50,defaultPendingMs:1e3,defaultPendingMinMs:500,context:void 0,...e,caseSensitive:e.caseSensitive??!1,notFoundMode:e.notFoundMode??`fuzzy`,stringifySearch:e.stringifySearch??hn,parseSearch:e.parseSearch??mn,protocolAllowlist:e.protocolAllowlist??st}),typeof document<`u`&&(self.__TSR_ROUTER__=this)}isShell(){return!!this.options.isShell}isPrerendering(){return!!this.options.isPrerendering}get state(){return this.stores.__store.get()}setRoutes({routesById:e,routesByPath:t,processedTree:n}){this.routesById=e,this.routesByPath=t,this.processedTree=n;let r=this.options.notFoundRoute;r&&(r.init({originalIndex:99999999999}),this.routesById[r.id]=r)}getRouteBranch(e){let t=this.routeBranchCache.get(e);return t||(t=Pt(e),this.routeBranchCache.set(e,t)),t}get looseRoutesById(){return this.routesById}getParentContext(e){return e?.id?e.context??this.options.context??void 0:this.options.context??void 0}matchRoutesInternal(e,t){let n=this.getMatchedRoutes(e.pathname),{foundRoute:r,routeParams:i}=n,{matchedRoutes:a}=n,o=!1;(r?r.path!==`/`&&i[`**`]:Wt(e.pathname))&&(this.options.notFoundRoute?a=[...a,this.options.notFoundRoute]:o=!0);let s=o?mr(this.options.notFoundMode,a):void 0,c=Array(a.length),l=new Map;for(let e of this.stores.matchStores.values())e.routeId&&l.set(e.routeId,e.get());for(let n=0;nthis.navigate({...t,_fromLocation:e}),buildLocation:this.buildLocation,cause:n.cause,abortController:n.abortController,preload:!!n.preload,matches:c,routeId:r.id};n.__routeContext=r.options.context(t)??void 0}n.context={...a,...n.__routeContext,...n.__beforeLoadContext}}}return c}matchRoutesLightweight(e){let{matchedRoutes:t,routeParams:n}=this.getMatchedRoutes(e.pathname),r=He(t),i={...e.search};for(let e of t)try{Object.assign(i,ur(e.options.validateSearch,i))}catch{}let a=He(this.stores.matchesId.get()),o=a&&this.stores.matchStores.get(a)?.get(),s=o&&o.routeId===r.id&&o.pathname===e.pathname,c;if(s)c=o.params;else{let e=Object.assign(Object.create(null),n);for(let n of t)try{hr(n,e)}catch{}c=e}return{matchedRoutes:t,fullPath:r.fullPath,search:i,params:c}}},sr=class extends Error{},cr=class extends Error{};function lr(e){return{loadedAt:0,isLoading:!1,isTransitioning:!1,status:`idle`,resolvedLocation:void 0,location:e,matches:[],statusCode:200}}function ur(e,t){if(e==null)return{};if(`~standard`in e){let n=e[`~standard`].validate(t);if(n instanceof Promise)throw new sr(`Async validation not supported`);if(n.issues)throw new sr(JSON.stringify(n.issues,void 0,2),{cause:n});return n.value}return`parse`in e?e.parse(t):typeof e==`function`?e(t):{}}function dr({pathname:e,routesById:t,processedTree:n}){let r=Object.create(null),i=Wt(e),a,o=kt(i,n,!0);return o&&(a=o.route,Object.assign(r,o.rawParams)),{matchedRoutes:o?.branch||[t.__root__],routeParams:r,foundRoute:a}}function fr({search:e,dest:t,destRoutes:n,_includeValidateSearch:r}){return pr(n)(e,t,r??!1)}function pr(e){let t={dest:null,_includeValidateSearch:!1,middlewares:[]};for(let n of e)`search`in n.options?n.options.search?.middlewares&&t.middlewares.push(...n.options.search.middlewares):(n.options.preSearchFilters||n.options.postSearchFilters)&&t.middlewares.push(({search:e,next:t})=>{let r=e;`preSearchFilters`in n.options&&n.options.preSearchFilters&&(r=n.options.preSearchFilters.reduce((e,t)=>t(e),e));let i=t(r);return`postSearchFilters`in n.options&&n.options.postSearchFilters?n.options.postSearchFilters.reduce((e,t)=>t(e),i):i}),n.options.validateSearch&&t.middlewares.push(({search:e,next:r})=>{let i=r(e);if(!t._includeValidateSearch)return i;try{return{...i,...ur(n.options.validateSearch,i)??void 0}}catch{return i}});t.middlewares.push(({search:e})=>{let n=t.dest;return n.search?n.search===!0?e:We(n.search,e):{}});let n=(e,t,r)=>{if(e>=r.length)return t;let i=r[e];return i({search:t,next:t=>n(e+1,t,r)})};return function(e,r,i){return t.dest=r,t._includeValidateSearch=i,n(0,e,t.middlewares)}}function mr(e,t){if(e!==`root`)for(let e=t.length-1;e>=0;e--){let n=t[e];if(n.children)return n.id}return vn}function hr(e,t){let n=e.options.params?.parse??e.options.parseParams;if(n){let e=n(t);if(e===!1)throw Error(`Route params.parse returned false for a matched route`);Object.assign(t,e)}}var gr=`Error preloading route! ☝️`;function _r(e,t){if(e)return typeof e==`string`?e:e[t]}function vr(e){return typeof e==`string`?{href:e,crossOrigin:void 0}:e}function yr(e){if(e.tag!==`link`)return;let t=e.attrs?.rel,n=e.attrs?.href;if(typeof n==`string`&&(typeof t==`string`?t.split(/\s+/):[]).includes(`stylesheet`))return n}function br(e,t){let n=yr(t);return!!n&&e?.inlineCss?.styles[n]!==void 0}var xr=class{get to(){return this._to}get id(){return this._id}get path(){return this._path}get fullPath(){return this._fullPath}constructor(e){if(this.init=e=>{this.originalIndex=e.originalIndex;let t=this.options,n=!t?.path&&!t?.id;this.parentRoute=this.options.getParentRoute?.(),n?this._path=vn:this.parentRoute||ht();let r=n?vn:t?.path;r&&r!==`/`&&(r=Ut(r));let i=t?.id||r,a=n?vn:Vt([this.parentRoute.id===`__root__`?``:this.parentRoute.id,i]);r===`__root__`&&(r=`/`),a!==`__root__`&&(a=Vt([`/`,a]));let o=a===`__root__`?`/`:Vt([this.parentRoute.fullPath,r]);this._path=r,this._id=a,this._fullPath=o,this._to=Wt(o)},this.addChildren=e=>this._addFileChildren(e),this._addFileChildren=e=>(Array.isArray(e)&&(this.children=e),typeof e==`object`&&e&&(this.children=Object.values(e)),this),this._addFileTypes=()=>this,this.updateLoader=e=>(Object.assign(this.options,e),this),this.update=e=>(Object.assign(this.options,e),this),this.lazy=e=>(this.lazyFn=e,this),this.redirect=e=>yn({from:this.fullPath,...e}),this.options=e||{},this.isRoot=!e?.getParentRoute,e?.id&&e?.path)throw Error(`Route cannot have both an 'id' and a 'path' option.`)}},Sr=class extends xr{constructor(e){super(e)}};function Cr(e){if(typeof document<`u`&&document.querySelector){let t=e.stores.location.get(),n=t.state.__hashScrollIntoViewOptions??!0;if(n&&t.hash!==``){let e=document.getElementById(t.hash);e&&e.scrollIntoView(n)}}}function wr(e){let t=e.errorComponent??Er;return(0,F.jsx)(Tr,{getResetKey:e.getResetKey,onCatch:e.onCatch,children:({error:n,reset:r})=>n?P.createElement(t,{error:n,reset:r}):e.children})}var Tr=class extends P.Component{constructor(...e){super(...e),this.state={error:null}}static getDerivedStateFromProps(e,t){let n=e.getResetKey();return t.error&&t.resetKey!==n?{resetKey:n,error:null}:{resetKey:n}}static getDerivedStateFromError(e){return{error:e}}reset(){this.setState({error:null})}componentDidCatch(e,t){this.props.onCatch&&this.props.onCatch(e,t)}render(){return this.props.children({error:this.state.error,reset:()=>{this.reset()}})}};function Er({error:e}){let[t,n]=P.useState(!1);return(0,F.jsxs)(`div`,{style:{padding:`.5rem`,maxWidth:`100%`},children:[(0,F.jsxs)(`div`,{style:{display:`flex`,alignItems:`center`,gap:`.5rem`},children:[(0,F.jsx)(`strong`,{style:{fontSize:`1rem`},children:`Something went wrong!`}),(0,F.jsx)(`button`,{style:{appearance:`none`,fontSize:`.6em`,border:`1px solid currentColor`,padding:`.1rem .2rem`,fontWeight:`bold`,borderRadius:`.25rem`},onClick:()=>n(e=>!e),children:t?`Hide Error`:`Show Error`})]}),(0,F.jsx)(`div`,{style:{height:`.25rem`}}),t?(0,F.jsx)(`div`,{children:(0,F.jsx)(`pre`,{style:{fontSize:`.7em`,border:`1px solid red`,borderRadius:`.25rem`,padding:`.3rem`,color:`red`,overflow:`auto`},children:e.message?(0,F.jsx)(`code`,{children:e.message}):null})}):null]})}function Dr({children:e,fallback:t=null}){return Or()?(0,F.jsx)(P.Fragment,{children:e}):(0,F.jsx)(P.Fragment,{children:t})}function Or(){return P.useSyncExternalStore(kr,()=>!0,()=>!1)}function kr(){return()=>{}}var Ar=P.createContext(null);function jr(e){return P.useContext(Ar)}var Mr=P.createContext(void 0),Nr=P.createContext(void 0),Pr=(e=>(e[e.None=0]=`None`,e[e.Mutable=1]=`Mutable`,e[e.Watching=2]=`Watching`,e[e.RecursedCheck=4]=`RecursedCheck`,e[e.Recursed=8]=`Recursed`,e[e.Dirty=16]=`Dirty`,e[e.Pending=32]=`Pending`,e))(Pr||{});function Fr({update:e,notify:t,unwatched:n}){return{link:r,unlink:i,propagate:a,checkDirty:o,shallowPropagate:s};function r(e,t,n){let r=t.depsTail;if(r!==void 0&&r.dep===e)return;let i=r===void 0?t.deps:r.nextDep;if(i!==void 0&&i.dep===e){i.version=n,t.depsTail=i;return}let a=e.subsTail;if(a!==void 0&&a.version===n&&a.sub===t)return;let o=t.depsTail=e.subsTail={version:n,dep:e,sub:t,prevDep:r,nextDep:i,prevSub:a,nextSub:void 0};i!==void 0&&(i.prevDep=o),r===void 0?t.deps=o:r.nextDep=o,a===void 0?e.subs=o:a.nextSub=o}function i(e,t=e.sub){let r=e.dep,i=e.prevDep,a=e.nextDep,o=e.nextSub,s=e.prevSub;return a===void 0?t.depsTail=i:a.prevDep=i,i===void 0?t.deps=a:i.nextDep=a,o===void 0?r.subsTail=s:o.prevSub=s,s===void 0?(r.subs=o)===void 0&&n(r):s.nextSub=o,a}function a(e){let n=e.nextSub,r;top:do{let i=e.sub,a=i.flags;if(a&60?a&12?a&4?!(a&48)&&c(e,i)?(i.flags=a|40,a&=1):a=0:i.flags=a&-9|32:a=0:i.flags=a|32,a&2&&t(i),a&1){let t=i.subs;if(t!==void 0){let i=(e=t).nextSub;i!==void 0&&(r={value:n,prev:r},n=i);continue}}if((e=n)!==void 0){n=e.nextSub;continue}for(;r!==void 0;)if(e=r.value,r=r.prev,e!==void 0){n=e.nextSub;continue top}break}while(!0)}function o(t,n){let r,i=0,a=!1;top:do{let o=t.dep,c=o.flags;if(n.flags&16)a=!0;else if((c&17)==17){if(e(o)){let e=o.subs;e.nextSub!==void 0&&s(e),a=!0}}else if((c&33)==33){(t.nextSub!==void 0||t.prevSub!==void 0)&&(r={value:t,prev:r}),t=o.deps,n=o,++i;continue}if(!a){let e=t.nextDep;if(e!==void 0){t=e;continue}}for(;i--;){let i=n.subs,o=i.nextSub!==void 0;if(o?(t=r.value,r=r.prev):t=i,a){if(e(n)){o&&s(i),n=t.sub;continue}a=!1}else n.flags&=-33;n=t.sub;let c=t.nextDep;if(c!==void 0){t=c;continue top}}return a}while(!0)}function s(e){do{let n=e.sub,r=n.flags;(r&48)==32&&(n.flags=r|16,(r&6)==2&&t(n))}while((e=e.nextSub)!==void 0)}function c(e,t){let n=t.depsTail;for(;n!==void 0;){if(n===e)return!0;n=n.prevDep}return!1}}function Ir(e,t,n){let r=typeof e==`object`,i=r?e:void 0;return{next:(r?e.next:e)?.bind(i),error:(r?e.error:t)?.bind(i),complete:(r?e.complete:n)?.bind(i)}}var Lr=[],Rr=0,{link:zr,unlink:Br,propagate:Vr,checkDirty:Hr,shallowPropagate:Ur}=Fr({update(e){return e._update()},notify(e){Lr[Gr++]=e,e.flags&=~Pr.Watching},unwatched(e){e.depsTail!==void 0&&(e.depsTail=void 0,e.flags=Pr.Mutable|Pr.Dirty,Yr(e))}}),Wr=0,Gr=0,Kr,qr=0;function Jr(e){try{++qr,e()}finally{--qr||Xr()}}function Yr(e){let t=e.depsTail,n=t===void 0?e.deps:t.nextDep;for(;n!==void 0;)n=Br(n,e)}function Xr(){if(!(qr>0)){for(;Wr{i.get(),n.current?t.next?.(i._snapshot):n.current=!0});return{unsubscribe:()=>{r.stop()}}},_update(e){let a=Kr,o=t?.compare??Object.is;if(n)Kr=i,++Rr,i.depsTail=void 0;else if(e===void 0)return!1;n&&(i.flags=Pr.Mutable|Pr.RecursedCheck);try{let t=i._snapshot,a=typeof e==`function`?e(t):e===void 0&&n?r(t):e;return t===void 0||!o(t,a)?(i._snapshot=a,!0):!1}finally{Kr=a,n&&(i.flags&=~Pr.RecursedCheck),Yr(i)}}};return n?(i.flags=Pr.Mutable|Pr.Dirty,i.get=function(){let e=i.flags;if(e&Pr.Dirty||e&Pr.Pending&&Hr(i.deps,i)){if(i._update()){let e=i.subs;e!==void 0&&Ur(e)}}else e&Pr.Pending&&(i.flags=e&~Pr.Pending);return Kr!==void 0&&zr(i,Kr,Rr),i._snapshot}):i.set=function(e){if(i._update(e)){let e=i.subs;e!==void 0&&(Vr(e),Ur(e),Xr())}},i}function Qr(e){let t=()=>{let t=Kr;Kr=n,++Rr,n.depsTail=void 0,n.flags=Pr.Watching|Pr.RecursedCheck;try{return e()}finally{Kr=t,n.flags&=~Pr.RecursedCheck,Yr(n)}},n={deps:void 0,depsTail:void 0,subs:void 0,subsTail:void 0,flags:Pr.Watching|Pr.RecursedCheck,notify(){let e=this.flags;e&Pr.Dirty||e&Pr.Pending&&Hr(this.deps,this)?t():this.flags=Pr.Watching},stop(){this.flags=Pr.None,this.depsTail=void 0,Yr(this)}};return t(),n}var $r=o((e=>{var t=je();function n(e,t){return e===t&&(e!==0||1/e==1/t)||e!==e&&t!==t}var r=typeof Object.is==`function`?Object.is:n,i=t.useState,a=t.useEffect,o=t.useLayoutEffect,s=t.useDebugValue;function c(e,t){var n=t(),r=i({inst:{value:n,getSnapshot:t}}),c=r[0].inst,u=r[1];return o(function(){c.value=n,c.getSnapshot=t,l(c)&&u({inst:c})},[e,n,t]),a(function(){return l(c)&&u({inst:c}),e(function(){l(c)&&u({inst:c})})},[e]),s(n),n}function l(e){var t=e.getSnapshot;e=e.value;try{var n=t();return!r(e,n)}catch{return!0}}function u(e,t){return t()}var d=typeof window>`u`||window.document===void 0||window.document.createElement===void 0?u:c;e.useSyncExternalStore=t.useSyncExternalStore===void 0?d:t.useSyncExternalStore})),ei=o(((e,t)=>{t.exports=$r()})),ti=o((e=>{var t=je(),n=ei();function r(e,t){return e===t&&(e!==0||1/e==1/t)||e!==e&&t!==t}var i=typeof Object.is==`function`?Object.is:r,a=n.useSyncExternalStore,o=t.useRef,s=t.useEffect,c=t.useMemo,l=t.useDebugValue;e.useSyncExternalStoreWithSelector=function(e,t,n,r,u){var d=o(null);if(d.current===null){var f={hasValue:!1,value:null};d.current=f}else f=d.current;d=c(function(){function e(e){if(!a){if(a=!0,o=e,e=r(e),u!==void 0&&f.hasValue){var t=f.value;if(u(t,e))return s=t}return s=e}if(t=s,i(o,e))return t;var n=r(e);return u!==void 0&&u(t,n)?(o=e,t):(o=e,s=n)}var a=!1,o,s,c=n===void 0?null:n;return[function(){return e(t())},c===null?void 0:function(){return e(c())}]},[t,n,r,u]);var p=a(e,d[0],d[1]);return s(function(){f.hasValue=!0,f.value=p},[p]),l(p),p}})),ni=o(((e,t)=>{t.exports=ti()}))();function ri(e,t){return e===t}function ii(e,t,n=ri){let r=(0,P.useCallback)(t=>{if(!e)return()=>{};let{unsubscribe:n}=e.subscribe(t);return n},[e]),i=(0,P.useCallback)(()=>e?.get(),[e]);return(0,ni.useSyncExternalStoreWithSelector)(r,i,i,t,n)}var ai={get:()=>void 0,subscribe:()=>({unsubscribe:()=>{}})};function oi(e){let t=jr(),n=P.useContext(e.from?Nr:Mr),r=e.from??n,i=r?e.from?t.stores.getRouteMatchStore(r):t.stores.matchStores.get(r):void 0,a=P.useRef(void 0);return ii(i??ai,n=>{if((e.shouldThrow??!0)&&!n&&ht(),n===void 0)return;let r=e.select?e.select(n):n;if(e.structuralSharing??t.options.defaultStructuralSharing){let e=Xe(a.current,r);return a.current=e,e}return r})}function si(e){return oi({from:e.from,strict:e.strict,structuralSharing:e.structuralSharing,select:t=>e.select?e.select(t.loaderData):t.loaderData})}function ci(e){let{select:t,...n}=e;return oi({...n,select:e=>t?t(e.loaderDeps):e.loaderDeps})}function li(e){return oi({from:e.from,shouldThrow:e.shouldThrow,structuralSharing:e.structuralSharing,strict:e.strict,select:t=>{let n=e.strict===!1?t.params:t._strictParams;return e.select?e.select(n):n}})}function ui(e){return oi({from:e.from,strict:e.strict,shouldThrow:e.shouldThrow,structuralSharing:e.structuralSharing,select:t=>e.select?e.select(t.search):t.search})}function di(e){let t=jr();return P.useCallback(n=>t.navigate({...n,from:n.from??e?.from}),[e?.from,t])}function fi(e){return oi({...e,select:t=>e.select?e.select(t.context):t.context})}var pi=o((e=>{var t=je();function n(e){var t=`https://react.dev/errors/`+e;if(1{function n(){if(!(typeof __REACT_DEVTOOLS_GLOBAL_HOOK__>`u`||typeof __REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE!=`function`))try{__REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE(n)}catch(e){console.error(e)}}n(),t.exports=pi()})),hi=c(mi(),1);function gi(e,t){let n=jr(),r=Ve(t),{activeProps:i,inactiveProps:a,activeOptions:o,to:s,preload:c,preloadDelay:l,preloadIntentProximity:u,hashScrollIntoView:d,replace:f,startTransition:p,resetScroll:m,viewTransition:h,children:g,target:_,disabled:v,style:y,className:b,onClick:x,onBlur:S,onFocus:C,onMouseEnter:w,onMouseLeave:T,onTouchStart:E,ignoreBlocker:ee,params:D,search:O,hash:te,state:ne,mask:re,reloadDocument:k,unsafeRelative:A,from:ie,_fromLocation:j,...ae}=e,oe=Or(),se=P.useMemo(()=>e,[n,e.from,e._fromLocation,e.hash,e.to,e.search,e.params,e.state,e.mask,e.unsafeRelative]),ce=ii(n.stores.location,e=>e,(e,t)=>e.href===t.href),M=P.useMemo(()=>{let e={_fromLocation:ce,...se};return n.buildLocation(e)},[n,ce,se]),le=M.maskedLocation?M.maskedLocation.publicHref:M.publicHref,ue=M.maskedLocation?M.maskedLocation.external:M.external,N=P.useMemo(()=>Ti(le,ue,n.history,v),[v,ue,le,n.history]),de=P.useMemo(()=>{if(N?.external)return ct(N.href,n.protocolAllowlist)?void 0:N.href;if(!Ei(s)&&!(typeof s!=`string`||s.indexOf(`:`)===-1))try{return new URL(s),ct(s,n.protocolAllowlist)?void 0:s}catch{}},[s,N,n.protocolAllowlist]),fe=P.useMemo(()=>{if(de)return!1;if(o?.exact){if(!qt(ce.pathname,M.pathname,n.basepath))return!1}else{let e=Kt(ce.pathname,n.basepath),t=Kt(M.pathname,n.basepath);if(!(e.startsWith(t)&&(e.length===t.length||e[t.length]===`/`)))return!1}return(o?.includeSearch??!0)&&!tt(ce.search,M.search,{partial:!o?.exact,ignoreUndefined:!o?.explicitUndefined})?!1:o?.includeHash?oe&&ce.hash===M.hash:!0},[o?.exact,o?.explicitUndefined,o?.includeHash,o?.includeSearch,ce,de,oe,M.hash,M.pathname,M.search,n.basepath]),pe=fe?We(i,{})??vi:_i,me=fe?_i:We(a,{})??_i,he=[b,pe.className,me.className].filter(Boolean).join(` `),ge=(y||pe.style||me.style)&&{...y,...pe.style,...me.style},[_e,ve]=P.useState(!1),ye=P.useRef(!1),be=e.reloadDocument||de?!1:c??n.options.defaultPreload,xe=l??n.options.defaultPreloadDelay??0,Se=P.useCallback(()=>{n.preloadRoute({...se,_builtLocation:M}).catch(e=>{console.warn(e),console.warn(gr)})},[n,se,M]);Be(r,P.useCallback(e=>{e?.isIntersecting&&Se()},[Se]),Ci,{disabled:!!v||be!==`viewport`}),P.useEffect(()=>{ye.current||!v&&be===`render`&&(Se(),ye.current=!0)},[v,Se,be]);let Ce=e=>{let t=e.currentTarget.getAttribute(`target`),r=_===void 0?t:_;if(!v&&!Oi(e)&&!e.defaultPrevented&&(!r||r===`_self`)&&e.button===0){e.preventDefault(),(0,hi.flushSync)(()=>{ve(!0)});let t=n.subscribe(`onResolved`,()=>{t(),ve(!1)});n.navigate({...se,replace:f,resetScroll:m,hashScrollIntoView:d,startTransition:p,viewTransition:h,ignoreBlocker:ee})}};if(de)return{...ae,ref:r,href:de,...g&&{children:g},..._&&{target:_},...v&&{disabled:v},...y&&{style:y},...b&&{className:b},...x&&{onClick:x},...S&&{onBlur:S},...C&&{onFocus:C},...w&&{onMouseEnter:w},...T&&{onMouseLeave:T},...E&&{onTouchStart:E}};let we=e=>{if(v||be!==`intent`)return;if(!xe){Se();return}let t=e.currentTarget;if(Si.has(t))return;let n=setTimeout(()=>{Si.delete(t),Se()},xe);Si.set(t,n)},Te=e=>{v||be!==`intent`||Se()},Ee=e=>{if(v||!be||!xe)return;let t=e.currentTarget,n=Si.get(t);n&&(clearTimeout(n),Si.delete(t))};return{...ae,...pe,...me,href:N?.href,ref:r,onClick:wi([x,Ce]),onBlur:wi([S,Ee]),onFocus:wi([C,we]),onMouseEnter:wi([w,we]),onMouseLeave:wi([T,Ee]),onTouchStart:wi([E,Te]),disabled:!!v,target:_,...ge&&{style:ge},...he&&{className:he},...v&&yi,...fe&&bi,...oe&&_e&&xi}}var _i={},vi={className:`active`},yi={role:`link`,"aria-disabled":!0},bi={"data-status":`active`,"aria-current":`page`},xi={"data-transitioning":`transitioning`},Si=new WeakMap,Ci={rootMargin:`100px`},wi=e=>t=>{for(let n of e)if(n){if(t.defaultPrevented)return;n(t)}};function Ti(e,t,n,r){if(!r)return t?{href:e,external:!0}:{href:n.createHref(e)||`/`,external:!1}}function Ei(e){if(typeof e!=`string`)return!1;let t=e.charCodeAt(0);return t===47?e.charCodeAt(1)!==47:t===46}var Di=P.forwardRef((e,t)=>{let{_asChild:n,...r}=e,{type:i,...a}=gi(r,t),o=typeof r.children==`function`?r.children({isActive:a[`data-status`]===`active`}):r.children;if(!n){let{disabled:e,...t}=a;return P.createElement(`a`,t,o)}return P.createElement(n,a,o)});function Oi(e){return!!(e.metaKey||e.altKey||e.ctrlKey||e.shiftKey)}var ki=class extends xr{constructor(e){super(e),this.useMatch=e=>oi({select:e?.select,from:this.id,structuralSharing:e?.structuralSharing}),this.useRouteContext=e=>fi({...e,from:this.id}),this.useSearch=e=>ui({select:e?.select,structuralSharing:e?.structuralSharing,from:this.id}),this.useParams=e=>li({select:e?.select,structuralSharing:e?.structuralSharing,from:this.id}),this.useLoaderDeps=e=>ci({...e,from:this.id}),this.useLoaderData=e=>si({...e,from:this.id}),this.useNavigate=()=>di({from:this.fullPath}),this.Link=P.forwardRef((e,t)=>(0,F.jsx)(Di,{ref:t,from:this.fullPath,...e}))}};function Ai(e){return new ki(e)}function ji(){return e=>Ni(e)}var Mi=class extends Sr{constructor(e){super(e),this.useMatch=e=>oi({select:e?.select,from:this.id,structuralSharing:e?.structuralSharing}),this.useRouteContext=e=>fi({...e,from:this.id}),this.useSearch=e=>ui({select:e?.select,structuralSharing:e?.structuralSharing,from:this.id}),this.useParams=e=>li({select:e?.select,structuralSharing:e?.structuralSharing,from:this.id}),this.useLoaderDeps=e=>ci({...e,from:this.id}),this.useLoaderData=e=>si({...e,from:this.id}),this.useNavigate=()=>di({from:this.fullPath}),this.Link=P.forwardRef((e,t)=>(0,F.jsx)(Di,{ref:t,from:this.fullPath,...e}))}};function Ni(e){return new Mi(e)}function L(e){return new Pi(e,{silent:!0}).createRoute}var Pi=class{constructor(e,t){this.path=e,this.createRoute=e=>{let t=Ai(e);return t.isRoot=!1,t},this.silent=t?.silent}};function R(e,t){let n,r,i,a,o=()=>(n||=e().then(e=>{n=void 0,r=e[t??`default`]}).catch(e=>{if(i=e,rt(i)&&i instanceof Error&&typeof window<`u`&&typeof sessionStorage<`u`){let e=`tanstack_router_reload:${i.message}`;sessionStorage.getItem(e)||(sessionStorage.setItem(e,`1`),a=!0)}}),n),s=function(e){if(a)throw window.location.reload(),new Promise(()=>{});if(i)throw i;if(!r)if(Le)Le(o());else throw o();return P.createElement(r,e)};return s.preload=o,s}function Fi(e){let t=jr(),n=`not-found-${ii(t.stores.location,e=>e.pathname)}-${ii(t.stores.status,e=>e)}`;return(0,F.jsx)(wr,{getResetKey:()=>n,onCatch:(t,n)=>{if($t(t))e.onCatch?.(t,n);else throw t},errorComponent:({error:t})=>{if($t(t))return e.fallback?.(t);throw t},children:e.children})}function Ii(){return(0,F.jsx)(`p`,{children:`Not Found`})}function Li(e){return(0,F.jsx)(F.Fragment,{children:e.children})}function Ri(e,t,n){return t.options.notFoundComponent?(0,F.jsx)(t.options.notFoundComponent,{...n}):e.options.defaultNotFoundComponent?(0,F.jsx)(e.options.defaultNotFoundComponent,{...n}):(0,F.jsx)(Ii,{})}var zi=P.memo(function({matchId:e}){let t=jr(),n=t.stores.matchStores.get(e);n||ht();let r=ii(t.stores.loadedAt,e=>e),i=ii(n,e=>e);return(0,F.jsx)(Bi,{router:t,matchId:e,resetKey:r,matchState:P.useMemo(()=>{let e=i.routeId,n=t.routesById[e].parentRoute?.id;return{routeId:e,ssr:i.ssr,_displayPending:i._displayPending,parentRouteId:n}},[i._displayPending,i.routeId,i.ssr,t.routesById])})});function Bi({router:e,matchId:t,resetKey:n,matchState:r}){let i=e.routesById[r.routeId],a=i.options.pendingComponent??e.options.defaultPendingComponent,o=a?(0,F.jsx)(a,{}):null,s=i.options.errorComponent??e.options.defaultErrorComponent,c=i.options.onCatch??e.options.defaultOnCatch,l=i.isRoot?i.options.notFoundComponent??e.options.notFoundRoute?.options.component:i.options.notFoundComponent,u=r.ssr===!1||r.ssr===`data-only`,d=(!i.isRoot||i.options.wrapInSuspense||u)&&(i.options.wrapInSuspense??a??(i.options.errorComponent?.preload||u))?P.Suspense:Li,f=s?wr:Li,p=l?Fi:Li;return(0,F.jsxs)(i.isRoot?i.options.shellComponent??Li:Li,{children:[(0,F.jsx)(Mr.Provider,{value:t,children:(0,F.jsx)(d,{fallback:o,children:(0,F.jsx)(f,{getResetKey:()=>n,errorComponent:s||Er,onCatch:(e,t)=>{if($t(e))throw e.routeId??=r.routeId,e;c?.(e,t)},children:(0,F.jsx)(p,{fallback:e=>{if(e.routeId??=r.routeId,!l||e.routeId&&e.routeId!==r.routeId||!e.routeId&&!i.isRoot)throw e;return P.createElement(l,e)},children:u||r._displayPending?(0,F.jsx)(Dr,{fallback:o,children:(0,F.jsx)(Hi,{matchId:t})}):(0,F.jsx)(Hi,{matchId:t})})})})}),r.parentRouteId===`__root__`?(0,F.jsxs)(F.Fragment,{children:[(0,F.jsx)(Vi,{resetKey:n}),(e.options.scrollRestoration,null)]}):null]})}function Vi({resetKey:e}){let t=jr(),n=P.useRef(void 0);return Re(()=>{let e=t.latestLocation.href;(n.current===void 0||n.current!==e)&&(t.emit({type:`onRendered`,...ar(t.stores.location.get(),t.stores.resolvedLocation.get())}),n.current=e)},[t.latestLocation.state.__TSR_key,e,t]),null}var Hi=P.memo(function({matchId:e}){let t=jr(),n=(e,n)=>t.getMatch(e.id)?._nonReactive[n]??e._nonReactive[n],r=t.stores.matchStores.get(e);r||ht();let i=ii(r,e=>e),a=i.routeId,o=t.routesById[a],s=P.useMemo(()=>{let e=(t.routesById[a].options.remountDeps??t.options.defaultRemountDeps)?.({routeId:a,loaderDeps:i.loaderDeps,params:i._strictParams,search:i._strictSearch});return e?JSON.stringify(e):void 0},[a,i.loaderDeps,i._strictParams,i._strictSearch,t.options.defaultRemountDeps,t.routesById]),c=P.useMemo(()=>{let e=o.options.component??t.options.defaultComponent;return e?(0,F.jsx)(e,{},s):(0,F.jsx)(Ui,{})},[s,o.options.component,t.options.defaultComponent]);if(i._displayPending)throw n(i,`displayPendingPromise`);if(i._forcePending)throw n(i,`minPendingPromise`);if(i.status===`pending`){let e=o.options.pendingMinMs??t.options.defaultPendingMinMs;if(e){let n=t.getMatch(i.id);if(n&&!n._nonReactive.minPendingPromise){let t=nt();n._nonReactive.minPendingPromise=t,setTimeout(()=>{t.resolve(),n._nonReactive.minPendingPromise=void 0},e)}}throw n(i,`loadPromise`)}if(i.status===`notFound`)return $t(i.error)||ht(),Ri(t,o,i.error);if(i.status===`redirected`)throw bn(i.error)||ht(),n(i,`loadPromise`);if(i.status===`error`)throw i.error;return c}),Ui=P.memo(function(){let e=jr(),t=P.useContext(Mr),n,r=!1,i;{let a=t?e.stores.matchStores.get(t):void 0;[n,r]=ii(a,e=>[e?.routeId,e?.globalNotFound??!1]),i=ii(e.stores.matchesId,e=>e[e.findIndex(e=>e===t)+1])}let a=n?e.routesById[n]:void 0,o=e.options.defaultPendingComponent?(0,F.jsx)(e.options.defaultPendingComponent,{}):null;if(r)return a||ht(),Ri(e,a,void 0);if(!i)return null;let s=(0,F.jsx)(zi,{matchId:i});return n===`__root__`?(0,F.jsx)(P.Suspense,{fallback:o,children:s}):s});function Wi(){let e=jr(),t=P.useRef({router:e,mounted:!1}),[n,r]=P.useState(!1),i=ii(e.stores.isLoading,e=>e),a=ii(e.stores.hasPending,e=>e),o=ze(i),s=i||n||a,c=ze(s),l=i||a,u=ze(l);return e.startTransition=e=>{r(!0),P.startTransition(()=>{e(),r(!1)})},P.useEffect(()=>{let t=e.history.subscribe(e.load),n=e.buildLocation({to:e.latestLocation.pathname,search:!0,params:!0,hash:!0,state:!0,_includeValidateSearch:!0});return Wt(e.latestLocation.publicHref)!==Wt(n.publicHref)&&e.commitLocation({...n,replace:!0}),()=>{t()}},[e,e.history]),Re(()=>{typeof window<`u`&&e.ssr||t.current.router===e&&t.current.mounted||(t.current={router:e,mounted:!0},(async()=>{try{await e.load()}catch(e){console.error(e)}})())},[e]),Re(()=>{o&&!i&&e.emit({type:`onLoad`,...ar(e.stores.location.get(),e.stores.resolvedLocation.get())})},[o,e,i]),Re(()=>{u&&!l&&e.emit({type:`onBeforeRouteMount`,...ar(e.stores.location.get(),e.stores.resolvedLocation.get())})},[l,u,e]),Re(()=>{if(c&&!s){let t=ar(e.stores.location.get(),e.stores.resolvedLocation.get());e.emit({type:`onResolved`,...t}),Jr(()=>{e.stores.status.set(`idle`),e.stores.resolvedLocation.set(e.stores.location.get())}),t.hrefChanged&&Cr(e)}},[s,c,e]),null}function Gi(){let e=jr(),t=e.routesById.__root__.options.pendingComponent??e.options.defaultPendingComponent,n=t?(0,F.jsx)(t,{}):null,r=(0,F.jsxs)(typeof document<`u`&&e.ssr?Li:P.Suspense,{fallback:n,children:[(0,F.jsx)(Wi,{}),(0,F.jsx)(Ki,{})]});return e.options.InnerWrap?(0,F.jsx)(e.options.InnerWrap,{children:r}):r}function Ki(){let e=jr(),t=ii(e.stores.firstId,e=>e),n=ii(e.stores.loadedAt,e=>e),r=t?(0,F.jsx)(zi,{matchId:t}):null;return(0,F.jsx)(Mr.Provider,{value:t,children:e.options.disableGlobalCatchBoundary?r:(0,F.jsx)(wr,{getResetKey:()=>n,errorComponent:Er,onCatch:void 0,children:r})})}var qi=e=>({createMutableStore:Zr,createReadonlyStore:Zr,batch:Jr}),Ji=e=>new Yi(e),Yi=class extends or{constructor(e){super(e,qi)}};function Xi({router:e,children:t,...n}){qe(n)&&e.update({...e.options,...n,context:{...e.options.context,...n.context}});let r=(0,F.jsx)(Ar.Provider,{value:e,children:t});return e.options.Wrap?(0,F.jsx)(e.options.Wrap,{children:r}):r}function Zi({router:e,...t}){return(0,F.jsx)(Xi,{router:e,...t,children:(0,F.jsx)(Gi,{})})}function Qi(e){let t=jr(),n=(0,P.useRef)(void 0);return ii(t.stores.location,r=>{let i=e?.select?e.select(r):r;if(e?.structuralSharing??t.options.defaultStructuralSharing){let e=Xe(n.current,i);return n.current=e,e}return i})}function $i(e){let{attrs:t,children:n,nonce:r}=e;switch(e.tag){case`title`:return(0,F.jsx)(`title`,{...t,suppressHydrationWarning:!0,children:n});case`meta`:return(0,F.jsx)(`meta`,{...t,suppressHydrationWarning:!0});case`link`:return(0,F.jsx)(`link`,{...t,precedence:t?.precedence??(t?.rel===`stylesheet`?`default`:void 0),nonce:r,suppressHydrationWarning:!0});case`style`:return e.inlineCss,(0,F.jsx)(`style`,{...t,dangerouslySetInnerHTML:{__html:n},nonce:r});case`script`:return(0,F.jsx)(ea,{attrs:t,children:n});default:return null}}function ea({attrs:e,children:t}){jr();let n=Or(),r=typeof e?.type==`string`&&e.type!==``&&e.type!==`text/javascript`&&e.type!==`module`;if(P.useEffect(()=>{if(!r){if(e?.src){let t=(()=>{try{let t=document.baseURI||window.location.href;return new URL(e.src,t).href}catch{return e.src}})();if(Array.from(document.querySelectorAll(`script[src]`)).find(e=>e.src===t))return;let n=document.createElement(`script`);for(let[t,r]of Object.entries(e))t!==`suppressHydrationWarning`&&r!==void 0&&r!==!1&&n.setAttribute(t,typeof r==`boolean`?``:String(r));return document.head.appendChild(n),()=>{n.parentNode&&n.parentNode.removeChild(n)}}if(typeof t==`string`){let n=typeof e?.type==`string`?e.type:`text/javascript`,r=typeof e?.nonce==`string`?e.nonce:void 0;if(Array.from(document.querySelectorAll(`script:not([src])`)).find(e=>{if(!(e instanceof HTMLScriptElement))return!1;let i=e.getAttribute(`type`)??`text/javascript`,a=e.getAttribute(`nonce`)??void 0;return e.textContent===t&&i===n&&a===r}))return;let i=document.createElement(`script`);if(i.textContent=t,e)for(let[t,n]of Object.entries(e))t!==`suppressHydrationWarning`&&n!==void 0&&n!==!1&&i.setAttribute(t,typeof n==`boolean`?``:String(n));return document.head.appendChild(i),()=>{i.parentNode&&i.parentNode.removeChild(i)}}}},[e,t,r]),r&&typeof t==`string`)return(0,F.jsx)(`script`,{...e,suppressHydrationWarning:!0,dangerouslySetInnerHTML:{__html:t}});if(!n){if(e?.src)return(0,F.jsx)(`script`,{...e,suppressHydrationWarning:!0});if(typeof t==`string`)return(0,F.jsx)(`script`,{...e,dangerouslySetInnerHTML:{__html:t},suppressHydrationWarning:!0})}return null}var ta=e=>{let t=jr(),n=t.options.ssr?.nonce,r=ii(t.stores.matches,e=>e.map(e=>e.meta).filter(Boolean),tt),i=P.useMemo(()=>{let e=[],t={},i;for(let a=r.length-1;a>=0;a--){let o=r[a];for(let r=o.length-1;r>=0;r--){let a=o[r];if(a)if(a.title)i||={tag:`title`,children:a.title};else if(`script:ld+json`in a)try{let t=JSON.stringify(a[`script:ld+json`]);e.push({tag:`script`,attrs:{type:`application/ld+json`},children:dt(t)})}catch{}else{let r=a.name??a.property;if(r){if(t[r])continue;t[r]=!0}e.push({tag:`meta`,attrs:{...a,nonce:n}})}}}return i&&e.push(i),n&&e.push({tag:`meta`,attrs:{property:`csp-nonce`,content:n}}),e.reverse(),e},[r,n]),a=ii(t.stores.matches,r=>{let i=r.map(e=>e.links).filter(Boolean).flat(1).map(e=>({tag:`link`,attrs:{...e,nonce:n}})),a=t.ssr?.manifest,o=r.map(e=>a?.routes[e.routeId]?.assets??[]).filter(Boolean).flat(1).flatMap(t=>t.tag===`link`?br(a,t)?[]:[{tag:`link`,attrs:{...t.attrs,crossOrigin:_r(e,`stylesheet`)??t.attrs?.crossOrigin,suppressHydrationWarning:!0,nonce:n}}]:t.tag===`style`?[{tag:`style`,attrs:{...t.attrs,nonce:n},children:t.children,...t.inlineCss?{inlineCss:!0}:{}}]:[]);return[...i,...o]},tt),o=ii(t.stores.matches,r=>{let i=[];return r.map(e=>t.looseRoutesById[e.routeId]).forEach(r=>t.ssr?.manifest?.routes[r.id]?.preloads?.filter(Boolean).forEach(t=>{let r=vr(t);i.push({tag:`link`,attrs:{rel:`modulepreload`,href:r.href,crossOrigin:_r(e,`modulepreload`)??r.crossOrigin,nonce:n}})})),i},tt),s=ii(t.stores.matches,e=>e.map(e=>e.styles).flat(1).filter(Boolean).map(({children:e,...t})=>({tag:`style`,attrs:{...t,nonce:n},children:e})),tt),c=ii(t.stores.matches,e=>e.map(e=>e.headScripts).flat(1).filter(Boolean).map(({children:e,...t})=>({tag:`script`,attrs:{...t,nonce:n},children:e})),tt);return na([...i,...o,...a,...s,...c],e=>JSON.stringify(e))};function na(e,t){let n=new Set;return e.filter(e=>{let r=t(e);return n.has(r)?!1:(n.add(r),!0)})}function ra(e){let t=ta(e.assetCrossOrigin),n=jr().options.ssr?.nonce;return(0,F.jsx)(F.Fragment,{children:t.map(e=>(0,P.createElement)($i,{...e,key:`tsr-meta-${JSON.stringify(e)}`,nonce:n}))})}var ia=o((e=>{function t(e,t){var n=e.length;e.push(t);a:for(;0>>1,a=e[r];if(0>>1;ri(c,n))li(u,c)?(e[r]=u,e[l]=n,r=l):(e[r]=c,e[s]=n,r=s);else if(li(u,n))e[r]=u,e[l]=n,r=l;else break a}}return t}function i(e,t){var n=e.sortIndex-t.sortIndex;return n===0?e.id-t.id:n}if(e.unstable_now=void 0,typeof performance==`object`&&typeof performance.now==`function`){var a=performance;e.unstable_now=function(){return a.now()}}else{var o=Date,s=o.now();e.unstable_now=function(){return o.now()-s}}var c=[],l=[],u=1,d=null,f=3,p=!1,m=!1,h=!1,g=!1,_=typeof setTimeout==`function`?setTimeout:null,v=typeof clearTimeout==`function`?clearTimeout:null,y=typeof setImmediate<`u`?setImmediate:null;function b(e){for(var i=n(l);i!==null;){if(i.callback===null)r(l);else if(i.startTime<=e)r(l),i.sortIndex=i.expirationTime,t(c,i);else break;i=n(l)}}function x(e){if(h=!1,b(e),!m)if(n(c)!==null)m=!0,S||(S=!0,D());else{var t=n(l);t!==null&&ne(x,t.startTime-e)}}var S=!1,C=-1,w=5,T=-1;function E(){return g?!0:!(e.unstable_now()-Tt&&E());){var o=d.callback;if(typeof o==`function`){d.callback=null,f=d.priorityLevel;var s=o(d.expirationTime<=t);if(t=e.unstable_now(),typeof s==`function`){d.callback=s,b(t),i=!0;break b}d===n(c)&&r(c),b(t)}else r(c);d=n(c)}if(d!==null)i=!0;else{var u=n(l);u!==null&&ne(x,u.startTime-t),i=!1}}break a}finally{d=null,f=a,p=!1}i=void 0}}finally{i?D():S=!1}}}var D;if(typeof y==`function`)D=function(){y(ee)};else if(typeof MessageChannel<`u`){var O=new MessageChannel,te=O.port2;O.port1.onmessage=ee,D=function(){te.postMessage(null)}}else D=function(){_(ee,0)};function ne(t,n){C=_(function(){t(e.unstable_now())},n)}e.unstable_IdlePriority=5,e.unstable_ImmediatePriority=1,e.unstable_LowPriority=4,e.unstable_NormalPriority=3,e.unstable_Profiling=null,e.unstable_UserBlockingPriority=2,e.unstable_cancelCallback=function(e){e.callback=null},e.unstable_forceFrameRate=function(e){0>e||125o?(r.sortIndex=a,t(l,r),n(c)===null&&r===n(l)&&(h?(v(C),C=-1):h=!0,ne(x,a-o))):(r.sortIndex=s,t(c,r),m||p||(m=!0,S||(S=!0,D()))),r},e.unstable_shouldYield=E,e.unstable_wrapCallback=function(e){var t=f;return function(){var n=f;f=t;try{return e.apply(this,arguments)}finally{f=n}}}})),aa=o(((e,t)=>{t.exports=ia()})),oa=o((e=>{var t=aa(),n=je(),r=mi();function i(e){var t=`https://react.dev/errors/`+e;if(1ae||(e.current=j[ae],j[ae]=null,ae--)}function ce(e,t){ae++,j[ae]=e.current,e.current=t}var M=oe(null),le=oe(null),ue=oe(null),N=oe(null);function de(e,t){switch(ce(ue,t),ce(le,e),ce(M,null),t.nodeType){case 9:case 11:e=(e=t.documentElement)&&(e=e.namespaceURI)?ef(e):0;break;default:if(e=t.tagName,t=t.namespaceURI)t=ef(t),e=tf(t,e);else switch(e){case`svg`:e=1;break;case`math`:e=2;break;default:e=0}}se(M),ce(M,e)}function fe(){se(M),se(le),se(ue)}function pe(e){e.memoizedState!==null&&ce(N,e);var t=M.current,n=tf(t,e.type);t!==n&&(ce(le,e),ce(M,n))}function me(e){le.current===e&&(se(M),se(le)),N.current===e&&(se(N),dp._currentValue=ie)}var he,ge;function _e(e){if(he===void 0)try{throw Error()}catch(e){var t=e.stack.trim().match(/\n( *(at )?)/);he=t&&t[1]||``,ge=-1i.map(i=>d[i]);
+import{i as e,n as t,o as n,r,t as i}from"./compiler-runtime.js";import{t as a}from"./queryOptions.js";import{A as o,B as s,C as c,D as l,E as u,F as d,G as f,I as p,L as m,M as h,N as g,O as _,P as v,R as y,S as b,T as x,V as S,W as C,_ as w,a as T,b as E,c as D,d as O,f as k,g as A,h as j,i as M,j as ee,k as N,l as P,m as te,n as ne,o as re,r as ie,s as ae,t as F,v as oe,w as se,x as I,y as ce,z as le}from"./useStore.js";import{i as ue,n as de,r as fe,t as pe}from"./matchContext.js";import{n as me,t as he}from"./redirect.js";import{B as ge,Bn as _e,Bt as ve,Cr as L,Fn as ye,G as be,Gn as xe,Gt as Se,H as Ce,Hn as we,Ht as Te,In as Ee,J as De,Jn as Oe,Jt as ke,K as Ae,Kn as je,Kt as Me,Ln as Ne,Nn as Pe,Or as Fe,Pn as Ie,Q as Le,Rn as Re,Tr as R,U as ze,Un as Be,Ut as Ve,V as He,Vn as Ue,Vt as We,W as Ge,Wn as Ke,X as qe,Xt as Je,Y as Ye,Yn as Xe,Yt as Ze,Z as Qe,_r as $e,_t as et,a as tt,b as nt,br as z,bt as rt,en as it,ft as at,gr as ot,gt as st,h as ct,ht as lt,i as ut,kr as dt,kt as ft,m as pt,mr as mt,mt as ht,n as gt,o as _t,pt as vt,q as yt,qn as bt,qt as xt,r as St,s as Ct,t as wt,tt as Tt,vt as Et,y as Dt,yt as Ot,zn as kt,zt as At}from"./awsClient.js";import{a as jt,c as Mt,f as Nt,l as Pt,n as Ft,o as It,s as Lt,t as Rt,u as zt}from"./dist.js";import{C as Bt,T as Vt,a as Ht,i as Ut,l as Wt,t as Gt,u as Kt,w as qt}from"./SignatureV4.js";import{C as Jt,D as Yt,E as Xt,S as Zt,T as Qt,_ as $t,b as en,d as tn,f as nn,g as rn,h as an,l as on,m as sn,n as cn,o as B,p as ln,r as un,s as dn,t as fn,u as pn,v as mn,w as hn,x as gn,y as V}from"./button.js";import{C as _n,S as vn,a as yn,b as bn,f as xn,g as Sn,h as Cn,i as wn,m as Tn,o as En,p as Dn,r as On,s as kn,t as An,v as jn,x as Mn,y as H}from"./createBaseUIEventDetails.js";(function(){let e=document.createElement(`link`).relList;if(e&&e.supports&&e.supports(`modulepreload`))return;for(let e of document.querySelectorAll(`link[rel="modulepreload"]`))n(e);new MutationObserver(e=>{for(let t of e)if(t.type===`childList`)for(let e of t.addedNodes)e.tagName===`LINK`&&e.rel===`modulepreload`&&n(e)}).observe(document,{childList:!0,subtree:!0});function t(e){let t={};return e.integrity&&(t.integrity=e.integrity),e.referrerPolicy&&(t.referrerPolicy=e.referrerPolicy),e.crossOrigin===`use-credentials`?t.credentials=`include`:e.crossOrigin===`anonymous`?t.credentials=`omit`:t.credentials=`same-origin`,t}function n(e){if(e.ep)return;e.ep=!0;let n=t(e);fetch(e.href,n)}})();var Nn=class{constructor(){this.listeners=new Set,this.subscribe=this.subscribe.bind(this)}subscribe(e){return this.listeners.add(e),this.onSubscribe(),()=>{this.listeners.delete(e),this.onUnsubscribe()}}hasListeners(){return this.listeners.size>0}onSubscribe(){}onUnsubscribe(){}},Pn=new class extends Nn{#e;#t;#n;constructor(){super(),this.#n=e=>{if(typeof window<`u`&&window.addEventListener){let t=()=>e();return window.addEventListener(`visibilitychange`,t,!1),()=>{window.removeEventListener(`visibilitychange`,t)}}}}onSubscribe(){this.#t||this.setEventListener(this.#n)}onUnsubscribe(){this.hasListeners()||(this.#t?.(),this.#t=void 0)}setEventListener(e){this.#n=e,this.#t?.(),this.#t=e(e=>{typeof e==`boolean`?this.setFocused(e):this.onFocus()})}setFocused(e){this.#e!==e&&(this.#e=e,this.onFocus())}onFocus(){let e=this.isFocused();this.listeners.forEach(t=>{t(e)})}isFocused(){return typeof this.#e==`boolean`?this.#e:globalThis.document?.visibilityState!==`hidden`}},Fn={setTimeout:(e,t)=>setTimeout(e,t),clearTimeout:e=>clearTimeout(e),setInterval:(e,t)=>setInterval(e,t),clearInterval:e=>clearInterval(e)},In=new class{#e=Fn;setTimeoutProvider(e){this.#e=e}setTimeout(e,t){return this.#e.setTimeout(e,t)}clearTimeout(e){this.#e.clearTimeout(e)}setInterval(e,t){return this.#e.setInterval(e,t)}clearInterval(e){this.#e.clearInterval(e)}};function Ln(e){setTimeout(e,0)}var Rn=typeof window>`u`||`Deno`in globalThis;function zn(){}function Bn(e,t){return typeof e==`function`?e(t):e}function Vn(e){return typeof e==`number`&&e>=0&&e!==1/0}function Hn(e,t){return Math.max(e+(t||0)-Date.now(),0)}function Un(e,t){return typeof e==`function`?e(t):e}function Wn(e,t){return typeof e==`function`?e(t):e}function Gn(e,t){let{type:n=`all`,exact:r,fetchStatus:i,predicate:a,queryKey:o,stale:s}=e;if(o){if(r){if(t.queryHash!==qn(o,t.options))return!1}else if(!Yn(t.queryKey,o))return!1}if(n!==`all`){let e=t.isActive();if(n===`active`&&!e||n===`inactive`&&e)return!1}return!(typeof s==`boolean`&&t.isStale()!==s||i&&i!==t.state.fetchStatus||a&&!a(t))}function Kn(e,t){let{exact:n,status:r,predicate:i,mutationKey:a}=e;if(a){if(!t.options.mutationKey)return!1;if(n){if(Jn(t.options.mutationKey)!==Jn(a))return!1}else if(!Yn(t.options.mutationKey,a))return!1}return!(r&&t.state.status!==r||i&&!i(t))}function qn(e,t){return(t?.queryKeyHashFn||Jn)(e)}function Jn(e){return JSON.stringify(e,(e,t)=>er(t)?Object.keys(t).sort().reduce((e,n)=>(e[n]=t[n],e),{}):t)}function Yn(e,t){return e===t?!0:typeof e==typeof t&&e&&t&&typeof e==`object`&&typeof t==`object`?Object.keys(t).every(n=>Yn(e[n],t[n])):!1}var Xn=Object.prototype.hasOwnProperty;function Zn(e,t,n=0){if(e===t)return e;if(n>500)return t;let r=$n(e)&&$n(t);if(!r&&!(er(e)&&er(t)))return t;let i=(r?e:Object.keys(e)).length,a=r?t:Object.keys(t),o=a.length,s=r?Array(o):{},c=0;for(let l=0;l{In.setTimeout(t,e)})}function rr(e,t,n){return typeof n.structuralSharing==`function`?n.structuralSharing(e,t):n.structuralSharing===!1?t:Zn(e,t)}function ir(e,t,n=0){let r=[...e,t];return n&&r.length>n?r.slice(1):r}function ar(e,t,n=0){let r=[t,...e];return n&&r.length>n?r.slice(0,-1):r}var or=Symbol();function sr(e,t){return!e.queryFn&&t?.initialPromise?()=>t.initialPromise:!e.queryFn||e.queryFn===or?()=>Promise.reject(Error(`Missing queryFn: '${e.queryHash}'`)):e.queryFn}function cr(e,t){return typeof e==`function`?e(...t):!!e}function lr(e,t,n){let r=!1,i;return Object.defineProperty(e,"signal",{enumerable:!0,get:()=>(i??=t(),r?i:(r=!0,i.aborted?n():i.addEventListener(`abort`,n,{once:!0}),i))}),e}var ur=(()=>{let e=()=>Rn;return{isServer(){return e()},setIsServer(t){e=t}}})();function dr(){let e,t,n=new Promise((n,r)=>{e=n,t=r});n.status=`pending`,n.catch(()=>{});function r(e){Object.assign(n,e),delete n.resolve,delete n.reject}return n.resolve=t=>{r({status:`fulfilled`,value:t}),e(t)},n.reject=e=>{r({status:`rejected`,reason:e}),t(e)},n}var fr=Ln;function pr(){let e=[],t=0,n=e=>{e()},r=e=>{e()},i=fr,a=r=>{t?e.push(r):i(()=>{n(r)})},o=()=>{let t=e;e=[],t.length&&i(()=>{r(()=>{t.forEach(e=>{n(e)})})})};return{batch:e=>{let n;t++;try{n=e()}finally{t--,t||o()}return n},batchCalls:e=>(...t)=>{a(()=>{e(...t)})},schedule:a,setNotifyFunction:e=>{n=e},setBatchNotifyFunction:e=>{r=e},setScheduler:e=>{i=e}}}var mr=pr(),hr=new class extends Nn{#e=!0;#t;#n;constructor(){super(),this.#n=e=>{if(typeof window<`u`&&window.addEventListener){let t=()=>e(!0),n=()=>e(!1);return window.addEventListener(`online`,t,!1),window.addEventListener(`offline`,n,!1),()=>{window.removeEventListener(`online`,t),window.removeEventListener(`offline`,n)}}}}onSubscribe(){this.#t||this.setEventListener(this.#n)}onUnsubscribe(){this.hasListeners()||(this.#t?.(),this.#t=void 0)}setEventListener(e){this.#n=e,this.#t?.(),this.#t=e(this.setOnline.bind(this))}setOnline(e){this.#e!==e&&(this.#e=e,this.listeners.forEach(t=>{t(e)}))}isOnline(){return this.#e}};function gr(e){return Math.min(1e3*2**e,3e4)}function _r(e){return(e??`online`)===`online`?hr.isOnline():!0}var vr=class extends Error{constructor(e){super(`CancelledError`),this.revert=e?.revert,this.silent=e?.silent}};function yr(e){let t=!1,n=0,r,i=dr(),a=()=>i.status!==`pending`,o=t=>{if(!a()){let n=new vr(t);f(n),e.onCancel?.(n)}},s=()=>{t=!0},c=()=>{t=!1},l=()=>Pn.isFocused()&&(e.networkMode===`always`||hr.isOnline())&&e.canRun(),u=()=>_r(e.networkMode)&&e.canRun(),d=e=>{a()||(r?.(),i.resolve(e))},f=e=>{a()||(r?.(),i.reject(e))},p=()=>new Promise(t=>{r=e=>{(a()||l())&&t(e)},e.onPause?.()}).then(()=>{r=void 0,a()||e.onContinue?.()}),m=()=>{if(a())return;let r,i=n===0?e.initialPromise:void 0;try{r=i??e.fn()}catch(e){r=Promise.reject(e)}Promise.resolve(r).then(d).catch(r=>{if(a())return;let i=e.retry??(ur.isServer()?0:3),o=e.retryDelay??gr,s=typeof o==`function`?o(n,r):o,c=i===!0||typeof i==`number`&&nl()?void 0:p()).then(()=>{t?f(r):m()})})};return{promise:i,status:()=>i.status,cancel:o,continue:()=>(r?.(),i),cancelRetry:s,continueRetry:c,canStart:u,start:()=>(u()?m():p().then(m),i)}}var br=class{#e;destroy(){this.clearGcTimeout()}scheduleGc(){this.clearGcTimeout(),Vn(this.gcTime)&&(this.#e=In.setTimeout(()=>{this.optionalRemove()},this.gcTime))}updateGcTime(e){this.gcTime=Math.max(this.gcTime||0,e??(ur.isServer()?1/0:300*1e3))}clearGcTimeout(){this.#e!==void 0&&(In.clearTimeout(this.#e),this.#e=void 0)}};function xr(e){return{onFetch:(t,n)=>{let r=t.options,i=t.fetchOptions?.meta?.fetchMore?.direction,a=t.state.data?.pages||[],o=t.state.data?.pageParams||[],s={pages:[],pageParams:[]},c=0,l=async()=>{let n=!1,l=e=>{lr(e,()=>t.signal,()=>n=!0)},u=sr(t.options,t.fetchOptions),d=async(e,r,i)=>{if(n)return Promise.reject(t.signal.reason);if(r==null&&e.pages.length)return Promise.resolve(e);let a=await u((()=>{let e={client:t.client,queryKey:t.queryKey,pageParam:r,direction:i?`backward`:`forward`,meta:t.options.meta};return l(e),e})()),{maxPages:o}=t.options,s=i?ar:ir;return{pages:s(e.pages,a,o),pageParams:s(e.pageParams,r,o)}};if(i&&a.length){let e=i===`backward`,t=e?Cr:Sr,n={pages:a,pageParams:o};s=await d(n,t(r,n),e)}else{let t=e??a.length;do{let e=c===0?o[0]??r.initialPageParam:Sr(r,s);if(c>0&&e==null)break;s=await d(s,e),c++}while(ct.options.persister?.(l,{client:t.client,queryKey:t.queryKey,meta:t.options.meta,signal:t.signal},n):t.fetchFn=l}}}function Sr(e,{pages:t,pageParams:n}){let r=t.length-1;return t.length>0?e.getNextPageParam(t[r],t,n[r],n):void 0}function Cr(e,{pages:t,pageParams:n}){return t.length>0?e.getPreviousPageParam?.(t[0],t,n[0],n):void 0}var wr=class extends br{#e;#t;#n;#r;#i;#a;#o;#s;constructor(e){super(),this.#s=!1,this.#o=e.defaultOptions,this.setOptions(e.options),this.observers=[],this.#i=e.client,this.#r=this.#i.getQueryCache(),this.queryKey=e.queryKey,this.queryHash=e.queryHash,this.#t=Dr(this.options),this.state=e.state??this.#t,this.scheduleGc()}get meta(){return this.options.meta}get queryType(){return this.#e}get promise(){return this.#a?.promise}setOptions(e){if(this.options={...this.#o,...e},e?._type&&(this.#e=e._type),this.updateGcTime(this.options.gcTime),this.state&&this.state.data===void 0){let e=Dr(this.options);e.data!==void 0&&(this.setState(Er(e.data,e.dataUpdatedAt)),this.#t=e)}}optionalRemove(){!this.observers.length&&this.state.fetchStatus===`idle`&&this.#r.remove(this)}setData(e,t){let n=rr(this.state.data,e,this.options);return this.#l({data:n,type:`success`,dataUpdatedAt:t?.updatedAt,manual:t?.manual}),n}setState(e){this.#l({type:`setState`,state:e})}cancel(e){let t=this.#a?.promise;return this.#a?.cancel(e),t?t.then(zn).catch(zn):Promise.resolve()}destroy(){super.destroy(),this.cancel({silent:!0})}get resetState(){return this.#t}reset(){this.destroy(),this.setState(this.resetState)}isActive(){return this.observers.some(e=>Wn(e.options.enabled,this)!==!1)}isDisabled(){return this.getObserversCount()>0?!this.isActive():this.options.queryFn===or||!this.isFetched()}isFetched(){return this.state.dataUpdateCount+this.state.errorUpdateCount>0}isStatic(){return this.getObserversCount()>0?this.observers.some(e=>Un(e.options.staleTime,this)===`static`):!1}isStale(){return this.getObserversCount()>0?this.observers.some(e=>e.getCurrentResult().isStale):this.state.data===void 0||this.state.isInvalidated}isStaleByTime(e=0){return this.state.data===void 0?!0:e===`static`?!1:this.state.isInvalidated?!0:!Hn(this.state.dataUpdatedAt,e)}onFocus(){this.observers.find(e=>e.shouldFetchOnWindowFocus())?.refetch({cancelRefetch:!1}),this.#a?.continue()}onOnline(){this.observers.find(e=>e.shouldFetchOnReconnect())?.refetch({cancelRefetch:!1}),this.#a?.continue()}addObserver(e){this.observers.includes(e)||(this.observers.push(e),this.clearGcTimeout(),this.#r.notify({type:`observerAdded`,query:this,observer:e}))}removeObserver(e){this.observers.includes(e)&&(this.observers=this.observers.filter(t=>t!==e),this.observers.length||(this.#a&&(this.#s||this.#c()?this.#a.cancel({revert:!0}):this.#a.cancelRetry()),this.scheduleGc()),this.#r.notify({type:`observerRemoved`,query:this,observer:e}))}getObserversCount(){return this.observers.length}#c(){return this.state.fetchStatus===`paused`&&this.state.status===`pending`}invalidate(){this.state.isInvalidated||this.#l({type:`invalidate`})}async fetch(e,t){if(this.state.fetchStatus!==`idle`&&this.#a?.status()!==`rejected`){if(this.state.data!==void 0&&t?.cancelRefetch)this.cancel({silent:!0});else if(this.#a)return this.#a.continueRetry(),this.#a.promise}if(e&&this.setOptions(e),!this.options.queryFn){let e=this.observers.find(e=>e.options.queryFn);e&&this.setOptions(e.options)}let n=new AbortController,r=e=>{Object.defineProperty(e,"signal",{enumerable:!0,get:()=>(this.#s=!0,n.signal)})},i=()=>{let e=sr(this.options,t),n=(()=>{let e={client:this.#i,queryKey:this.queryKey,meta:this.meta};return r(e),e})();return this.#s=!1,this.options.persister?this.options.persister(e,n,this):e(n)},a=(()=>{let e={fetchOptions:t,options:this.options,queryKey:this.queryKey,client:this.#i,state:this.state,fetchFn:i};return r(e),e})();(this.#e===`infinite`?xr(this.options.pages):this.options.behavior)?.onFetch(a,this),this.#n=this.state,(this.state.fetchStatus===`idle`||this.state.fetchMeta!==a.fetchOptions?.meta)&&this.#l({type:`fetch`,meta:a.fetchOptions?.meta}),this.#a=yr({initialPromise:t?.initialPromise,fn:a.fetchFn,onCancel:e=>{e instanceof vr&&e.revert&&this.setState({...this.#n,fetchStatus:`idle`}),n.abort()},onFail:(e,t)=>{this.#l({type:`failed`,failureCount:e,error:t})},onPause:()=>{this.#l({type:`pause`})},onContinue:()=>{this.#l({type:`continue`})},retry:a.options.retry,retryDelay:a.options.retryDelay,networkMode:a.options.networkMode,canRun:()=>!0});try{let e=await this.#a.start();if(e===void 0)throw Error(`${this.queryHash} data is undefined`);return this.setData(e),this.#r.config.onSuccess?.(e,this),this.#r.config.onSettled?.(e,this.state.error,this),e}catch(e){if(e instanceof vr){if(e.silent)return this.#a.promise;if(e.revert){if(this.state.data===void 0)throw e;return this.state.data}}throw this.#l({type:`error`,error:e}),this.#r.config.onError?.(e,this),this.#r.config.onSettled?.(this.state.data,e,this),e}finally{this.scheduleGc()}}#l(e){let t=t=>{switch(e.type){case`failed`:return{...t,fetchFailureCount:e.failureCount,fetchFailureReason:e.error};case`pause`:return{...t,fetchStatus:`paused`};case`continue`:return{...t,fetchStatus:`fetching`};case`fetch`:return{...t,...Tr(t.data,this.options),fetchMeta:e.meta??null};case`success`:let n={...t,...Er(e.data,e.dataUpdatedAt),dataUpdateCount:t.dataUpdateCount+1,...!e.manual&&{fetchStatus:`idle`,fetchFailureCount:0,fetchFailureReason:null}};return this.#n=e.manual?n:void 0,n;case`error`:let r=e.error;return{...t,error:r,errorUpdateCount:t.errorUpdateCount+1,errorUpdatedAt:Date.now(),fetchFailureCount:t.fetchFailureCount+1,fetchFailureReason:r,fetchStatus:`idle`,status:`error`,isInvalidated:!0};case`invalidate`:return{...t,isInvalidated:!0};case`setState`:return{...t,...e.state}}};this.state=t(this.state),mr.batch(()=>{this.observers.forEach(e=>{e.onQueryUpdate()}),this.#r.notify({query:this,type:`updated`,action:e})})}};function Tr(e,t){return{fetchFailureCount:0,fetchFailureReason:null,fetchStatus:_r(t.networkMode)?`fetching`:`paused`,...e===void 0&&{error:null,status:`pending`}}}function Er(e,t){return{data:e,dataUpdatedAt:t??Date.now(),error:null,isInvalidated:!1,status:`success`}}function Dr(e){let t=typeof e.initialData==`function`?e.initialData():e.initialData,n=t!==void 0,r=n?typeof e.initialDataUpdatedAt==`function`?e.initialDataUpdatedAt():e.initialDataUpdatedAt:0;return{data:t,dataUpdateCount:0,dataUpdatedAt:n?r??Date.now():0,error:null,errorUpdateCount:0,errorUpdatedAt:0,fetchFailureCount:0,fetchFailureReason:null,fetchMeta:null,isInvalidated:!1,status:n?`success`:`pending`,fetchStatus:`idle`}}var Or=class extends br{#e;#t;#n;#r;constructor(e){super(),this.#e=e.client,this.mutationId=e.mutationId,this.#n=e.mutationCache,this.#t=[],this.state=e.state||kr(),this.setOptions(e.options),this.scheduleGc()}setOptions(e){this.options=e,this.updateGcTime(this.options.gcTime)}get meta(){return this.options.meta}addObserver(e){this.#t.includes(e)||(this.#t.push(e),this.clearGcTimeout(),this.#n.notify({type:`observerAdded`,mutation:this,observer:e}))}removeObserver(e){this.#t=this.#t.filter(t=>t!==e),this.scheduleGc(),this.#n.notify({type:`observerRemoved`,mutation:this,observer:e})}optionalRemove(){this.#t.length||(this.state.status===`pending`?this.scheduleGc():this.#n.remove(this))}continue(){return this.#r?.continue()??this.execute(this.state.variables)}async execute(e){let t=()=>{this.#i({type:`continue`})},n={client:this.#e,meta:this.options.meta,mutationKey:this.options.mutationKey};this.#r=yr({fn:()=>this.options.mutationFn?this.options.mutationFn(e,n):Promise.reject(Error(`No mutationFn found`)),onFail:(e,t)=>{this.#i({type:`failed`,failureCount:e,error:t})},onPause:()=>{this.#i({type:`pause`})},onContinue:t,retry:this.options.retry??0,retryDelay:this.options.retryDelay,networkMode:this.options.networkMode,canRun:()=>this.#n.canRun(this)});let r=this.state.status===`pending`,i=!this.#r.canStart();try{if(r)t();else{this.#i({type:`pending`,variables:e,isPaused:i}),this.#n.config.onMutate&&await this.#n.config.onMutate(e,this,n);let t=await this.options.onMutate?.(e,n);t!==this.state.context&&this.#i({type:`pending`,context:t,variables:e,isPaused:i})}let a=await this.#r.start();return await this.#n.config.onSuccess?.(a,e,this.state.context,this,n),await this.options.onSuccess?.(a,e,this.state.context,n),await this.#n.config.onSettled?.(a,null,this.state.variables,this.state.context,this,n),await this.options.onSettled?.(a,null,e,this.state.context,n),this.#i({type:`success`,data:a}),a}catch(t){try{await this.#n.config.onError?.(t,e,this.state.context,this,n)}catch(e){Promise.reject(e)}try{await this.options.onError?.(t,e,this.state.context,n)}catch(e){Promise.reject(e)}try{await this.#n.config.onSettled?.(void 0,t,this.state.variables,this.state.context,this,n)}catch(e){Promise.reject(e)}try{await this.options.onSettled?.(void 0,t,e,this.state.context,n)}catch(e){Promise.reject(e)}throw this.#i({type:`error`,error:t}),t}finally{this.#n.runNext(this)}}#i(e){let t=t=>{switch(e.type){case`failed`:return{...t,failureCount:e.failureCount,failureReason:e.error};case`pause`:return{...t,isPaused:!0};case`continue`:return{...t,isPaused:!1};case`pending`:return{...t,context:e.context,data:void 0,failureCount:0,failureReason:null,error:null,isPaused:e.isPaused,status:`pending`,variables:e.variables,submittedAt:Date.now()};case`success`:return{...t,data:e.data,failureCount:0,failureReason:null,error:null,status:`success`,isPaused:!1};case`error`:return{...t,data:void 0,error:e.error,failureCount:t.failureCount+1,failureReason:e.error,isPaused:!1,status:`error`}}};this.state=t(this.state),mr.batch(()=>{this.#t.forEach(t=>{t.onMutationUpdate(e)}),this.#n.notify({mutation:this,type:`updated`,action:e})})}};function kr(){return{context:void 0,data:void 0,error:null,failureCount:0,failureReason:null,isPaused:!1,status:`idle`,variables:void 0,submittedAt:0}}var Ar=class extends Nn{constructor(e={}){super(),this.config=e,this.#e=new Set,this.#t=new Map,this.#n=0}#e;#t;#n;build(e,t,n){let r=new Or({client:e,mutationCache:this,mutationId:++this.#n,options:e.defaultMutationOptions(t),state:n});return this.add(r),r}add(e){this.#e.add(e);let t=jr(e);if(typeof t==`string`){let n=this.#t.get(t);n?n.push(e):this.#t.set(t,[e])}this.notify({type:`added`,mutation:e})}remove(e){if(this.#e.delete(e)){let t=jr(e);if(typeof t==`string`){let n=this.#t.get(t);if(n)if(n.length>1){let t=n.indexOf(e);t!==-1&&n.splice(t,1)}else n[0]===e&&this.#t.delete(t)}}this.notify({type:`removed`,mutation:e})}canRun(e){let t=jr(e);if(typeof t==`string`){let n=this.#t.get(t)?.find(e=>e.state.status===`pending`);return!n||n===e}else return!0}runNext(e){let t=jr(e);return typeof t==`string`?(this.#t.get(t)?.find(t=>t!==e&&t.state.isPaused))?.continue()??Promise.resolve():Promise.resolve()}clear(){mr.batch(()=>{this.#e.forEach(e=>{this.notify({type:`removed`,mutation:e})}),this.#e.clear(),this.#t.clear()})}getAll(){return Array.from(this.#e)}find(e){let t={exact:!0,...e};return this.getAll().find(e=>Kn(t,e))}findAll(e={}){return this.getAll().filter(t=>Kn(e,t))}notify(e){mr.batch(()=>{this.listeners.forEach(t=>{t(e)})})}resumePausedMutations(){let e=this.getAll().filter(e=>e.state.isPaused);return mr.batch(()=>Promise.all(e.map(e=>e.continue().catch(zn))))}};function jr(e){return e.options.scope?.id}var Mr=class extends Nn{constructor(e={}){super(),this.config=e,this.#e=new Map}#e;build(e,t,n){let r=t.queryKey,i=t.queryHash??qn(r,t),a=this.get(i);return a||(a=new wr({client:e,queryKey:r,queryHash:i,options:e.defaultQueryOptions(t),state:n,defaultOptions:e.getQueryDefaults(r)}),this.add(a)),a}add(e){this.#e.has(e.queryHash)||(this.#e.set(e.queryHash,e),this.notify({type:`added`,query:e}))}remove(e){let t=this.#e.get(e.queryHash);t&&(e.destroy(),t===e&&this.#e.delete(e.queryHash),this.notify({type:`removed`,query:e}))}clear(){mr.batch(()=>{this.getAll().forEach(e=>{this.remove(e)})})}get(e){return this.#e.get(e)}getAll(){return[...this.#e.values()]}find(e){let t={exact:!0,...e};return this.getAll().find(e=>Gn(t,e))}findAll(e={}){let t=this.getAll();return Object.keys(e).length>0?t.filter(t=>Gn(e,t)):t}notify(e){mr.batch(()=>{this.listeners.forEach(t=>{t(e)})})}onFocus(){mr.batch(()=>{this.getAll().forEach(e=>{e.onFocus()})})}onOnline(){mr.batch(()=>{this.getAll().forEach(e=>{e.onOnline()})})}},Nr=class{#e;#t;#n;#r;#i;#a;#o;#s;constructor(e={}){this.#e=e.queryCache||new Mr,this.#t=e.mutationCache||new Ar,this.#n=e.defaultOptions||{},this.#r=new Map,this.#i=new Map,this.#a=0}mount(){this.#a++,this.#a===1&&(this.#o=Pn.subscribe(async e=>{e&&(await this.resumePausedMutations(),this.#e.onFocus())}),this.#s=hr.subscribe(async e=>{e&&(await this.resumePausedMutations(),this.#e.onOnline())}))}unmount(){this.#a--,this.#a===0&&(this.#o?.(),this.#o=void 0,this.#s?.(),this.#s=void 0)}isFetching(e){return this.#e.findAll({...e,fetchStatus:`fetching`}).length}isMutating(e){return this.#t.findAll({...e,status:`pending`}).length}getQueryData(e){let t=this.defaultQueryOptions({queryKey:e});return this.#e.get(t.queryHash)?.state.data}ensureQueryData(e){let t=this.defaultQueryOptions(e),n=this.#e.build(this,t),r=n.state.data;return r===void 0?this.fetchQuery(e):(e.revalidateIfStale&&n.isStaleByTime(Un(t.staleTime,n))&&this.prefetchQuery(t),Promise.resolve(r))}getQueriesData(e){return this.#e.findAll(e).map(({queryKey:e,state:t})=>[e,t.data])}setQueryData(e,t,n){let r=this.defaultQueryOptions({queryKey:e}),i=this.#e.get(r.queryHash)?.state.data,a=Bn(t,i);if(a!==void 0)return this.#e.build(this,r).setData(a,{...n,manual:!0})}setQueriesData(e,t,n){return mr.batch(()=>this.#e.findAll(e).map(({queryKey:e})=>[e,this.setQueryData(e,t,n)]))}getQueryState(e){let t=this.defaultQueryOptions({queryKey:e});return this.#e.get(t.queryHash)?.state}removeQueries(e){let t=this.#e;mr.batch(()=>{t.findAll(e).forEach(e=>{t.remove(e)})})}resetQueries(e,t){let n=this.#e;return mr.batch(()=>(n.findAll(e).forEach(e=>{e.reset()}),this.refetchQueries({type:`active`,...e},t)))}cancelQueries(e,t={}){let n={revert:!0,...t},r=mr.batch(()=>this.#e.findAll(e).map(e=>e.cancel(n)));return Promise.all(r).then(zn).catch(zn)}invalidateQueries(e,t={}){return mr.batch(()=>(this.#e.findAll(e).forEach(e=>{e.invalidate()}),e?.refetchType===`none`?Promise.resolve():this.refetchQueries({...e,type:e?.refetchType??e?.type??`active`},t)))}refetchQueries(e,t={}){let n={...t,cancelRefetch:t.cancelRefetch??!0},r=mr.batch(()=>this.#e.findAll(e).filter(e=>!e.isDisabled()&&!e.isStatic()).map(e=>{let t=e.fetch(void 0,n);return n.throwOnError||(t=t.catch(zn)),e.state.fetchStatus===`paused`?Promise.resolve():t}));return Promise.all(r).then(zn)}fetchQuery(e){let t=this.defaultQueryOptions(e);t.retry===void 0&&(t.retry=!1);let n=this.#e.build(this,t);return n.isStaleByTime(Un(t.staleTime,n))?n.fetch(t):Promise.resolve(n.state.data)}prefetchQuery(e){return this.fetchQuery(e).then(zn).catch(zn)}fetchInfiniteQuery(e){return e._type=`infinite`,this.fetchQuery(e)}prefetchInfiniteQuery(e){return this.fetchInfiniteQuery(e).then(zn).catch(zn)}ensureInfiniteQueryData(e){return e._type=`infinite`,this.ensureQueryData(e)}resumePausedMutations(){return hr.isOnline()?this.#t.resumePausedMutations():Promise.resolve()}getQueryCache(){return this.#e}getMutationCache(){return this.#t}getDefaultOptions(){return this.#n}setDefaultOptions(e){this.#n=e}setQueryDefaults(e,t){this.#r.set(Jn(e),{queryKey:e,defaultOptions:t})}getQueryDefaults(e){let t=[...this.#r.values()],n={};return t.forEach(t=>{Yn(e,t.queryKey)&&Object.assign(n,t.defaultOptions)}),n}setMutationDefaults(e,t){this.#i.set(Jn(e),{mutationKey:e,defaultOptions:t})}getMutationDefaults(e){let t=[...this.#i.values()],n={};return t.forEach(t=>{Yn(e,t.mutationKey)&&Object.assign(n,t.defaultOptions)}),n}defaultQueryOptions(e){if(e._defaulted)return e;let t={...this.#n.queries,...this.getQueryDefaults(e.queryKey),...e,_defaulted:!0};return t.queryHash||=qn(t.queryKey,t),t.refetchOnReconnect===void 0&&(t.refetchOnReconnect=t.networkMode!==`always`),t.throwOnError===void 0&&(t.throwOnError=!!t.suspense),!t.networkMode&&t.persister&&(t.networkMode=`offlineFirst`),t.queryFn===or&&(t.enabled=!1),t}defaultMutationOptions(e){return e?._defaulted?e:{...this.#n.mutations,...e?.mutationKey&&this.getMutationDefaults(e.mutationKey),...e,_defaulted:!0}}clear(){this.#e.clear(),this.#t.clear()}},U=n(r(),1),W=t(),Pr=U.createContext(void 0),Fr=e=>{let t=U.useContext(Pr);if(e)return e;if(!t)throw Error(`No QueryClient set, use QueryClientProvider to set one`);return t},Ir=({client:e,children:t})=>(U.useEffect(()=>(e.mount(),()=>{e.unmount()}),[e]),(0,W.jsx)(Pr.Provider,{value:e,children:t}));function Lr(e,t=String){let n=new URLSearchParams;for(let r in e){let i=e[r];i!==void 0&&n.set(r,t(i))}return n.toString()}function Rr(e){return e?e===`false`?!1:e===`true`?!0:e*0==0&&+e+``===e?+e:e:``}function zr(e){let t=new URLSearchParams(e),n=Object.create(null);for(let[e,r]of t.entries()){let t=n[e];t==null?n[e]=Rr(r):Array.isArray(t)?t.push(Rr(r)):n[e]=[t,Rr(r)]}return n}var Br=Hr(JSON.parse),Vr=Ur(JSON.stringify,JSON.parse);function Hr(e){return t=>{t[0]===`?`&&(t=t.substring(1));let n=zr(t);for(let t in n){let r=n[t];if(typeof r==`string`)try{n[t]=e(r)}catch{}}return n}}function Ur(e,t){let n=typeof t==`function`;function r(r){if(typeof r==`object`&&r)try{return e(r)}catch{}else if(n&&typeof r==`string`)try{return t(r),e(r)}catch{}return r}return e=>{let t=Lr(e,r);return t?`?${t}`:``}}var Wr=e=>{if(!e.rendered)return e.rendered=!0,e.onReady?.()},Gr=e=>e.stores.matchesId.get().some(t=>e.stores.matchStores.get(t)?.get()._forcePending),Kr=(e,t)=>!!(e.preload&&!e.router.stores.matchStores.has(t)),qr=(e,t,n=!0)=>{let r={...e.router.options.context??{}},i=n?t:t-1;for(let t=0;t<=i;t++){let n=e.matches[t];if(!n)continue;let i=e.router.getMatch(n.id);i&&Object.assign(r,i.__routeContext,i.__beforeLoadContext)}return r},Jr=(e,t)=>{if(!e.matches.length)return;let n=t.routeId,r=e.matches.findIndex(t=>t.routeId===e.router.routeTree.id),i=r>=0?r:0,a=n?e.matches.findIndex(e=>e.routeId===n):e.firstBadMatchIndex??e.matches.length-1;a<0&&(a=i);for(let t=a;t>=0;t--){let n=e.matches[t];if(e.router.looseRoutesById[n.routeId].options.notFoundComponent)return t}return n?a:i},Yr=(e,t,n)=>{if(!(!he(n)&&!ue(n)))throw he(n)&&n.redirectHandled&&!n.options.reloadDocument?n:(t&&(t._nonReactive.beforeLoadPromise?.resolve(),t._nonReactive.loaderPromise?.resolve(),t._nonReactive.beforeLoadPromise=void 0,t._nonReactive.loaderPromise=void 0,t._nonReactive.error=n,e.updateMatch(t.id,r=>({...r,status:he(n)?`redirected`:ue(n)?`notFound`:r.status===`pending`?`success`:r.status,context:qr(e,t.index),isFetching:!1,error:n})),ue(n)&&!n.routeId&&(n.routeId=t.routeId),t._nonReactive.loadPromise?.resolve()),he(n)&&(e.rendered=!0,n.options._fromLocation=e.location,n.redirectHandled=!0,n=e.router.resolveRedirect(n)),n)},Xr=(e,t)=>{let n=e.router.getMatch(t);return!!(!n||n._nonReactive.dehydrated)},Zr=(e,t,n)=>{let r=qr(e,n);e.updateMatch(t,e=>({...e,context:r}))},Qr=(e,t,n)=>{let{id:r,routeId:i}=e.matches[t],a=e.router.looseRoutesById[i];if(n instanceof Promise)throw n;e.firstBadMatchIndex??=t,Yr(e,e.router.getMatch(r),n);try{a.options.onError?.(n)}catch(t){n=t,Yr(e,e.router.getMatch(r),n)}e.updateMatch(r,e=>(e._nonReactive.beforeLoadPromise?.resolve(),e._nonReactive.beforeLoadPromise=void 0,e._nonReactive.loadPromise?.resolve(),{...e,error:n,status:`error`,isFetching:!1,updatedAt:Date.now(),abortController:new AbortController})),!e.preload&&!he(n)&&!ue(n)&&(e.serialError??=n)},$r=(e,t,n,r)=>{if(r._nonReactive.pendingTimeout!==void 0)return;let i=n.options.pendingMs??e.router.options.defaultPendingMs;if(e.onReady&&!Kr(e,t)&&(n.options.loader||n.options.beforeLoad||ui(n))&&typeof i==`number`&&i!==1/0&&(n.options.pendingComponent??e.router.options?.defaultPendingComponent)){let t=setTimeout(()=>{Wr(e)},i);r._nonReactive.pendingTimeout=t}},ei=(e,t,n)=>{let r=e.router.getMatch(t);if(!r._nonReactive.beforeLoadPromise&&!r._nonReactive.loaderPromise)return;$r(e,t,n,r);let i=()=>{let n=e.router.getMatch(t);n.preload&&(n.status===`redirected`||n.status===`notFound`)&&Yr(e,n,n.error)};return r._nonReactive.beforeLoadPromise?r._nonReactive.beforeLoadPromise.then(i):i()},ti=(e,t,n,r)=>{let i=e.router.getMatch(t),a=i._nonReactive.loadPromise;i._nonReactive.loadPromise=_(()=>{a?.resolve(),a=void 0});let{paramsError:o,searchError:s}=i;o&&Qr(e,n,o),s&&Qr(e,n,s),$r(e,t,r,i);let c=new AbortController,l=!1,u=()=>{l||(l=!0,e.updateMatch(t,e=>({...e,isFetching:`beforeLoad`,fetchCount:e.fetchCount+1,abortController:c})))},d=()=>{i._nonReactive.beforeLoadPromise?.resolve(),i._nonReactive.beforeLoadPromise=void 0,e.updateMatch(t,e=>({...e,isFetching:!1}))};if(!r.options.beforeLoad){e.router.batch(()=>{u(),d()});return}i._nonReactive.beforeLoadPromise=_();let f={...qr(e,n,!1),...i.__routeContext},{search:p,params:h,cause:g}=i,v=Kr(e,t),y={search:p,abortController:c,params:h,preload:v,context:f,location:e.location,navigate:t=>e.router.navigate({...t,_fromLocation:e.location}),buildLocation:e.router.buildLocation,cause:v?`preload`:g,matches:e.matches,routeId:r.id,...e.router.options.additionalContext},b=r=>{if(r===void 0){e.router.batch(()=>{u(),d()});return}(he(r)||ue(r))&&(u(),Qr(e,n,r)),e.router.batch(()=>{u(),e.updateMatch(t,e=>({...e,__beforeLoadContext:r})),d()})},x;try{if(x=r.options.beforeLoad(y),m(x))return u(),x.catch(t=>{Qr(e,n,t)}).then(b)}catch(t){u(),Qr(e,n,t)}b(x)},ni=(e,t)=>{let{id:n,routeId:r}=e.matches[t],i=e.router.looseRoutesById[r],a=()=>s(),o=()=>ti(e,n,t,i),s=()=>{if(Xr(e,n))return;let t=ei(e,n,i);return m(t)?t.then(o):o()};return a()},ri=(e,t,n)=>{let r=e.router.getMatch(t);if(!r||!n.options.head&&!n.options.scripts&&!n.options.headers)return;let i={ssr:e.router.options.ssr,matches:e.matches,match:r,params:r.params,loaderData:r.loaderData};return Promise.all([n.options.head?.(i),n.options.scripts?.(i),n.options.headers?.(i)]).then(([e,t,n])=>({meta:e?.meta,links:e?.links,headScripts:e?.scripts,headers:n,scripts:t,styles:e?.styles}))},ii=(e,t,n,r,i)=>{let a=t[r-1],{params:o,loaderDeps:s,abortController:c,cause:l}=e.router.getMatch(n),u=qr(e,r),d=Kr(e,n);return{params:o,deps:s,preload:!!d,parentMatchPromise:a,abortController:c,context:u,location:e.location,navigate:t=>e.router.navigate({...t,_fromLocation:e.location}),cause:d?`preload`:l,route:i,...e.router.options.additionalContext}},ai=async(e,t,n,r,i)=>{try{let a=e.router.getMatch(n);try{li(i);let o=i.options.loader,s=typeof o==`function`?o:o?.handler,c=s?.(ii(e,t,n,r,i)),l=!!s&&m(c);if((l||i._lazyPromise||i._componentsPromise||i.options.head||i.options.scripts||i.options.headers||a._nonReactive.minPendingPromise)&&e.updateMatch(n,e=>({...e,isFetching:`loader`})),s){let t=l?await c:c;Yr(e,e.router.getMatch(n),t),t!==void 0&&e.updateMatch(n,e=>({...e,loaderData:t}))}i._lazyPromise&&await i._lazyPromise;let u=a._nonReactive.minPendingPromise;u&&await u,i._componentsPromise&&await i._componentsPromise,e.updateMatch(n,t=>({...t,error:void 0,context:qr(e,r),status:`success`,isFetching:!1,updatedAt:Date.now()}))}catch(t){let o=t;if(o?.name===`AbortError`){if(a.abortController.signal.aborted){a._nonReactive.loaderPromise?.resolve(),a._nonReactive.loaderPromise=void 0;return}e.updateMatch(n,t=>({...t,status:t.status===`pending`?`success`:t.status,isFetching:!1,context:qr(e,r)}));return}let s=a._nonReactive.minPendingPromise;s&&await s,ue(t)&&await i.options.notFoundComponent?.preload?.(),Yr(e,e.router.getMatch(n),t);try{i.options.onError?.(t)}catch(t){o=t,Yr(e,e.router.getMatch(n),t)}!he(o)&&!ue(o)&&await li(i,[`errorComponent`]),e.updateMatch(n,t=>({...t,error:o,context:qr(e,r),status:`error`,isFetching:!1}))}}catch(t){let r=e.router.getMatch(n);r&&(r._nonReactive.loaderPromise=void 0),Yr(e,r,t)}},oi=async(e,t,n)=>{async function r(r,a,c,l,d){let f=Date.now()-a.updatedAt,p=r?d.options.preloadStaleTime??e.router.options.defaultPreloadStaleTime??3e4:d.options.staleTime??e.router.options.defaultStaleTime??0,m=d.options.shouldReload,h=typeof m==`function`?m(ii(e,t,i,n,d)):m,{status:g,invalid:_}=l,v=f>=p&&(!!e.forceStaleReload||l.cause===`enter`||c!==void 0&&c!==l.id);o=g===`success`&&(_||(h??v)),r&&d.options.preload===!1||(o&&!e.sync&&u?(s=!0,(async()=>{try{await ai(e,t,i,n,d);let r=e.router.getMatch(i);r._nonReactive.loaderPromise?.resolve(),r._nonReactive.loadPromise?.resolve(),r._nonReactive.loaderPromise=void 0,r._nonReactive.loadPromise=void 0}catch(t){he(t)&&await e.router.navigate(t.options)}})()):g!==`success`||o?await ai(e,t,i,n,d):Zr(e,i,n))}let{id:i,routeId:a}=e.matches[n],o=!1,s=!1,c=e.router.looseRoutesById[a],l=c.options.loader,u=((typeof l==`function`?void 0:l?.staleReloadMode)??e.router.options.defaultStaleReloadMode)!==`blocking`;if(Xr(e,i)){if(!e.router.getMatch(i))return e.matches[n];Zr(e,i,n)}else{let t=e.router.getMatch(i),o=e.router.stores.matchesId.get()[n],s=(o&&e.router.stores.matchStores.get(o)||null)?.routeId===a?o:e.router.stores.matches.get().find(e=>e.routeId===a)?.id,l=Kr(e,i);if(t._nonReactive.loaderPromise){if(t.status===`success`&&!e.sync&&!t.preload&&u)return t;await t._nonReactive.loaderPromise;let n=e.router.getMatch(i),a=n._nonReactive.error||n.error;a&&Yr(e,n,a),n.status===`pending`&&await r(l,t,s,n,c)}else{let n=l&&!e.router.stores.matchStores.has(i),a=e.router.getMatch(i);a._nonReactive.loaderPromise=_(),n!==a.preload&&e.updateMatch(i,e=>({...e,preload:n})),await r(l,t,s,a,c)}}let d=e.router.getMatch(i);s||(d._nonReactive.loaderPromise?.resolve(),d._nonReactive.loadPromise?.resolve(),d._nonReactive.loadPromise=void 0),clearTimeout(d._nonReactive.pendingTimeout),d._nonReactive.pendingTimeout=void 0,s||(d._nonReactive.loaderPromise=void 0),d._nonReactive.dehydrated=void 0;let f=s?d.isFetching:!1;return f!==d.isFetching||d.invalid!==!1?(e.updateMatch(i,e=>({...e,isFetching:f,invalid:!1})),e.router.getMatch(i)):d};async function si(e){let t=e,n=[];Gr(t.router)&&Wr(t);let r;for(let e=0;e({...e,...a?{status:`success`,globalNotFound:!0,error:void 0}:{status:`notFound`,error:l},isFetching:!1})),u=e,await li(r,[`notFoundComponent`])}else if(!t.preload){let e=t.matches[0];e.globalNotFound||t.router.getMatch(e.id)?.globalNotFound&&t.updateMatch(e.id,e=>({...e,globalNotFound:!1,error:void 0}))}if(t.serialError&&t.firstBadMatchIndex!==void 0){let e=t.router.looseRoutesById[t.matches[t.firstBadMatchIndex].routeId];await li(e,[`errorComponent`])}for(let e=0;e<=u;e++){let{id:n,routeId:r}=t.matches[e],i=t.router.looseRoutesById[r];try{let e=ri(t,n,i);if(e){let r=await e;t.updateMatch(n,e=>({...e,...r}))}}catch(e){console.error(`Error executing head for route ${r}:`,e)}}let d=Wr(t);if(m(d)&&await d,l)throw l;if(t.serialError&&!t.preload&&!t.onReady)throw t.serialError;return t.matches}function ci(e,t){let n=t.map(t=>e.options[t]?.preload?.()).filter(Boolean);if(n.length!==0)return Promise.all(n)}function li(e,t=di){!e._lazyLoaded&&e._lazyPromise===void 0&&(e.lazyFn?e._lazyPromise=e.lazyFn().then(t=>{let{id:n,...r}=t.options;Object.assign(e.options,r),e._lazyLoaded=!0,e._lazyPromise=void 0}):e._lazyLoaded=!0);let n=()=>e._componentsLoaded?void 0:t===di?(()=>{if(e._componentsPromise===void 0){let t=ci(e,di);t?e._componentsPromise=t.then(()=>{e._componentsLoaded=!0,e._componentsPromise=void 0}):e._componentsLoaded=!0}return e._componentsPromise})():ci(e,t);return e._lazyPromise?e._lazyPromise.then(n):n()}function ui(e){for(let t of di)if(e.options[t]?.preload)return!0;return!1}var di=[`component`,`errorComponent`,`pendingComponent`,`notFoundComponent`];function fi(e){return{input:({url:t})=>{for(let n of e)t=mi(n,t);return t},output:({url:t})=>{for(let n=e.length-1;n>=0;n--)t=hi(e[n],t);return t}}}function pi(e){let t=j(e.basepath),n=`/${t}`,r=e.caseSensitive?n:n.toLowerCase(),i=`${r}/`;return{input:({url:t})=>{let a=e.caseSensitive?t.pathname:t.pathname.toLowerCase();return a===r?t.pathname=`/`:a.startsWith(i)&&(t.pathname=t.pathname.slice(n.length)),t},output:({url:e})=>(e.pathname=k([`/`,t,e.pathname]),e)}}function mi(e,t){let n=e?.input?.({url:t});if(n){if(typeof n==`string`)return new URL(n);if(n instanceof URL)return n}return t}function hi(e,t){let n=e?.output?.({url:t});if(n){if(typeof n==`string`)return new URL(n);if(n instanceof URL)return n}return t}function gi(e,t){let{createMutableStore:n,createReadonlyStore:r,batch:i,init:a}=t,o=new Map,s=new Map,c=new Map,l=n(e.status),u=n(e.loadedAt),d=n(e.isLoading),f=n(e.isTransitioning),p=n(e.location),m=n(e.resolvedLocation),h=n(e.statusCode),g=n(e.redirect),_=n([]),v=n([]),y=n([]),b=r(()=>_i(o,_.get())),x=r(()=>_i(s,v.get())),S=r(()=>_i(c,y.get())),C=r(()=>_.get()[0]),w=r(()=>_.get().some(e=>o.get(e)?.get().status===`pending`)),T=r(()=>({locationHref:p.get().href,resolvedLocationHref:m.get()?.href,status:l.get()})),E=r(()=>({status:l.get(),loadedAt:u.get(),isLoading:d.get(),isTransitioning:f.get(),matches:b.get(),location:p.get(),resolvedLocation:m.get(),statusCode:h.get(),redirect:g.get()})),D=se(64);function O(e){let t=D.get(e);return t||(t=r(()=>{let t=_.get();for(let n of t){let t=o.get(n);if(t&&t.routeId===e)return t.get()}}),D.set(e,t)),t}let k={status:l,loadedAt:u,isLoading:d,isTransitioning:f,location:p,resolvedLocation:m,statusCode:h,redirect:g,matchesId:_,pendingIds:v,cachedIds:y,matches:b,pendingMatches:x,cachedMatches:S,firstId:C,hasPending:w,matchRouteDeps:T,matchStores:o,pendingMatchStores:s,cachedMatchStores:c,__store:E,getRouteMatchStore:O,setMatches:A,setPending:j,setCached:M};A(e.matches),a?.(k);function A(e){vi(e,o,_,n,i)}function j(e){vi(e,s,v,n,i)}function M(e){vi(e,c,y,n,i)}return k}function _i(e,t){let n=[];for(let r of t){let t=e.get(r);t&&n.push(t.get())}return n}function vi(e,t,n,r,i){let a=e.map(e=>e.id),o=new Set(a);i(()=>{for(let e of t.keys())o.has(e)||t.delete(e);for(let n of e){let e=t.get(n.id);if(!e){let e=r(n);e.routeId=n.routeId,t.set(n.id,e);continue}e.routeId=n.routeId,e.get()!==n&&e.set(n)}l(n.get(),a)||n.set(a)})}var yi=`__TSR_index`,bi=`popstate`,xi=`beforeunload`;function Si(e){let t=e.getLocation(),n=new Set,r=r=>{t=e.getLocation(),n.forEach(e=>e({location:t,action:r}))},i=n=>{e.notifyOnIndexChange??!0?r(n):t=e.getLocation()},a=async({task:n,navigateOpts:r,...i})=>{if(r?.ignoreBlocker??!1){n();return}let a=e.getBlockers?.()??[],o=i.type===`PUSH`||i.type===`REPLACE`;if(typeof document<`u`&&a.length&&o)for(let n of a){let r=Ei(i.path,i.state);if(await n.blockerFn({currentLocation:t,nextLocation:r,action:i.type})){e.onBlocked?.();return}}n()};return{get location(){return t},get length(){return e.getLength()},subscribers:n,subscribe:e=>(n.add(e),()=>{n.delete(e)}),push:(n,i,o)=>{let s=t.state[yi];i=Ci(s+1,i),a({task:()=>{e.pushState(n,i),r({type:`PUSH`})},navigateOpts:o,type:`PUSH`,path:n,state:i})},replace:(n,i,o)=>{let s=t.state[yi];i=Ci(s,i),a({task:()=>{e.replaceState(n,i),r({type:`REPLACE`})},navigateOpts:o,type:`REPLACE`,path:n,state:i})},go:(t,n)=>{a({task:()=>{e.go(t),i({type:`GO`,index:t})},navigateOpts:n,type:`GO`})},back:t=>{a({task:()=>{e.back(t?.ignoreBlocker??!1),i({type:`BACK`})},navigateOpts:t,type:`BACK`})},forward:t=>{a({task:()=>{e.forward(t?.ignoreBlocker??!1),i({type:`FORWARD`})},navigateOpts:t,type:`FORWARD`})},canGoBack:()=>t.state[yi]!==0,createHref:t=>e.createHref(t),block:t=>{if(!e.setBlockers)return()=>{};let n=e.getBlockers?.()??[];return e.setBlockers([...n,t]),()=>{let n=e.getBlockers?.()??[];e.setBlockers?.(n.filter(e=>e!==t))}},flush:()=>e.flush?.(),destroy:()=>e.destroy?.(),notify:r}}function Ci(e,t){t||={};let n=Di();return{...t,key:n,__TSR_key:n,[yi]:e}}function wi(e){let t=e?.window??(typeof document<`u`?window:void 0),n=t.history.pushState,r=t.history.replaceState,i=[],a=()=>i,o=e=>i=e,s=e?.createHref??(e=>e),c=e?.parseLocation??(()=>Ei(`${t.location.pathname}${t.location.search}${t.location.hash}`,t.history.state));if(!t.history.state?.__TSR_key&&!t.history.state?.key){let e=Di();t.history.replaceState({[yi]:0,key:e,__TSR_key:e},``)}let l=c(),u,d=!1,f=!1,p=!1,m=!1,h=()=>l,g,_,v=()=>{g&&(C._ignoreSubscribers=!0,(g.isPush?t.history.pushState:t.history.replaceState)(g.state,``,g.href),C._ignoreSubscribers=!1,g=void 0,_=void 0,u=void 0)},y=(e,t,n)=>{let r=s(t);_||(u=l),l=Ei(t,n),g={href:r,state:n,isPush:g?.isPush||e===`push`},_||=Promise.resolve().then(()=>v())},b=e=>{l=c(),C.notify({type:e})},x=async()=>{if(f){f=!1;return}let e=c(),n=e.state[yi]-l.state[yi],r=n===1,i=n===-1,o=!r&&!i||d;d=!1;let s=o?`GO`:i?`BACK`:`FORWARD`,u=o?{type:`GO`,index:n}:{type:i?`BACK`:`FORWARD`};if(p)p=!1;else{let n=a();if(typeof document<`u`&&n.length){for(let r of n)if(await r.blockerFn({currentLocation:l,nextLocation:e,action:s})){f=!0,t.history.go(1),C.notify(u);return}}}l=c(),C.notify(u)},S=e=>{if(m){m=!1;return}let t=!1,n=a();if(typeof document<`u`&&n.length)for(let e of n){let n=e.enableBeforeUnload??!0;if(n===!0){t=!0;break}if(typeof n==`function`&&n()===!0){t=!0;break}}if(t)return e.preventDefault(),e.returnValue=``},C=Si({getLocation:h,getLength:()=>t.history.length,pushState:(e,t)=>y(`push`,e,t),replaceState:(e,t)=>y(`replace`,e,t),back:e=>(e&&(p=!0),m=!0,t.history.back()),forward:e=>{e&&(p=!0),m=!0,t.history.forward()},go:e=>{d=!0,t.history.go(e)},createHref:e=>s(e),flush:v,destroy:()=>{t.history.pushState=n,t.history.replaceState=r,t.removeEventListener(xi,S,{capture:!0}),t.removeEventListener(bi,x)},onBlocked:()=>{u&&l!==u&&(l=u)},getBlockers:a,setBlockers:o,notifyOnIndexChange:!1});return t.addEventListener(xi,S,{capture:!0}),t.addEventListener(bi,x),t.history.pushState=function(...e){let r=n.apply(t.history,e);return C._ignoreSubscribers||b(`PUSH`),r},t.history.replaceState=function(...e){let n=r.apply(t.history,e);return C._ignoreSubscribers||b(`REPLACE`),n},C}function Ti(e){let t=e.replace(/[\x00-\x1f\x7f]/g,``);return t.startsWith(`//`)&&(t=`/`+t.replace(/^\/+/,``)),t}function Ei(e,t){let n=Ti(e),r=n.indexOf(`#`),i=n.indexOf(`?`),a=Di();return{href:n,pathname:n.substring(0,r>0?i>0?Math.min(r,i):r:i>0?i:n.length),hash:r>-1?n.substring(r):``,search:i>-1?n.slice(i,r===-1?void 0:r):``,state:t||{[yi]:0,key:a,__TSR_key:a}}}function Di(){return(Math.random()+1).toString(36).substring(7)}function G(e,t){let n=t,r=e;return{fromLocation:n,toLocation:r,pathChanged:n?.pathname!==r.pathname,hrefChanged:n?.href!==r.href,hashChanged:n?.hash!==r.hash}}var Oi=new WeakMap,ki=class{constructor(e,t){this.tempLocationKey=`${Math.round(Math.random()*1e7)}`,this.resetNextScroll=!0,this.shouldViewTransition=void 0,this.isViewTransitionTypesSupported=void 0,this.subscribers=new Set,this.isScrollRestoring=!1,this.isScrollRestorationSetup=!1,this.routeBranchCache=new WeakMap,this.startTransition=e=>e(),this.update=e=>{let t=this.options,n=this.basepath??t?.basepath??`/`,r=this.basepath===void 0,i=t?.rewrite;if(this.options={...t,...e},this.isServer=this.options.isServer??typeof document>`u`,this.protocolAllowlist=new Set(this.options.protocolAllowlist),this.options.pathParamsAllowedCharacters&&(this.pathParamsDecoder=P(this.options.pathParamsAllowedCharacters)),(!this.history||this.options.history&&this.options.history!==this.history)&&(this.options.history?this.history=this.options.history:this.history=wi()),this.origin=this.options.origin,this.origin||(window?.origin&&window.origin!==`null`?this.origin=window.origin:this.origin=`http://localhost`),this.history&&this.updateLatestLocation(),this.options.routeTree!==this.routeTree){this.routeTree=this.options.routeTree;let e;this.resolvePathCache=se(1e3),e=this.buildRouteTree(),this.setRoutes(e)}if(!this.stores&&this.latestLocation){let e=this.getStoreConfig(this);this.batch=e.batch,this.stores=gi(Mi(this.latestLocation),e),Qi(this)}let a=!1,o=this.options.basepath??`/`,s=this.options.rewrite;if(r||n!==o||i!==s){this.basepath=o;let e=[],t=j(o);t&&t!==`/`&&e.push(pi({basepath:o})),s&&e.push(s),this.rewrite=e.length===0?void 0:e.length===1?e[0]:fi(e),this.history&&this.updateLatestLocation(),a=!0}a&&this.stores&&this.stores.location.set(this.latestLocation),typeof window<`u`&&`CSS`in window&&typeof window.CSS?.supports==`function`&&(this.isViewTransitionTypesSupported=window.CSS.supports(`selector(:active-view-transition-type(a))`))},this.updateLatestLocation=()=>{this.latestLocation=this.parseLocation(this.history.location,this.latestLocation)},this.buildRouteTree=()=>{let e=c(this.routeTree,this.options.caseSensitive,(e,t)=>{e.init({originalIndex:t})});return this.options.routeMasks&&b(this.options.routeMasks,e.processedTree),e},this.subscribe=(e,t)=>{let n={eventType:e,fn:t};return this.subscribers.add(n),()=>{this.subscribers.delete(n)}},this.emit=e=>{this.subscribers.forEach(t=>{t.eventType===e.type&&t.fn(e)})},this.parseLocation=(e,t)=>{let n=({pathname:e,search:n,hash:r,href:i,state:a})=>{if(!this.rewrite&&!/[ \x00-\x1f\x7f\u0080-\uffff]/.test(e)){let i=this.options.parseSearch(n),o=this.options.stringifySearch(i);return{href:e+o+r,publicHref:e+o+r,pathname:N(e).path,external:!1,searchStr:o,search:le(t?.search,i),hash:N(r.slice(1)).path,state:s(t?.state,a)}}let o=new URL(i,this.origin),c=mi(this.rewrite,o),l=this.options.parseSearch(c.search),u=this.options.stringifySearch(l);return c.search=u,{href:c.href.replace(c.origin,``),publicHref:i,pathname:N(c.pathname).path,external:!!this.rewrite&&c.origin!==this.origin,searchStr:u,search:le(t?.search,l),hash:N(c.hash.slice(1)).path,state:s(t?.state,a)}},r=n(e),{__tempLocation:i,__tempKey:a}=r.state;if(i&&(!a||a===this.tempLocationKey)){let e=n(i);return e.state.key=r.state.key,e.state.__TSR_key=r.state.__TSR_key,delete e.state.__tempLocation,{...e,maskedLocation:r}}return r},this.resolvePathWithBase=(e,t)=>te({base:e,to:t.includes(`//`)?D(t):t,trailingSlash:this.options.trailingSlash,cache:this.resolvePathCache}),this.matchRoutes=(e,t,n)=>typeof e==`string`?this.matchRoutesInternal({pathname:e,search:t},n):this.matchRoutesInternal(e,t),this.getMatchedRoutes=e=>Pi({pathname:e,routesById:this.routesById,processedTree:this.processedTree}),this.cancelMatch=e=>{let t=this.getMatch(e);t&&(t.abortController.abort(),clearTimeout(t._nonReactive.pendingTimeout),t._nonReactive.pendingTimeout=void 0)},this.cancelMatches=()=>{this.stores.pendingIds.get().forEach(e=>{this.cancelMatch(e)}),this.stores.matchesId.get().forEach(e=>{if(this.stores.pendingMatchStores.has(e))return;let t=this.stores.matchStores.get(e)?.get();t&&(t.status===`pending`||t.isFetching===`loader`)&&this.cancelMatch(e)})},this.buildLocation=e=>{let t=(t={})=>{let n=t._fromLocation||this.pendingBuiltLocation||this.latestLocation,r=this.matchRoutesLightweight(n);t.from;let i=t.unsafeRelative===`path`?n.pathname:t.from??r.fullPath,a=t.to?`${t.to}`:void 0,o=r.search,c=Object.assign(Object.create(null),r.params),l=a?.charCodeAt(0)===47?`/`:this.resolvePathWithBase(i,`.`),u=a?this.resolvePathWithBase(l,a):l,d=t.params===!1||t.params===null?Object.create(null):(t.params??!0)===!0?c:Object.assign(c,g(t.params,c)),f=this.routesByPath[w(u)],p;if(f)p=this.getRouteBranch(f);else if(u.includes(`$`))p=[];else{let e=this.getMatchedRoutes(u);p=e.matchedRoutes,this.options.notFoundRoute&&(!e.foundRoute||e.foundRoute.path!==`/`&&e.routeParams[`**`])&&(p=[...p,this.options.notFoundRoute])}if(p.length&&v(d))for(let e of p){let t=e.options.params?.stringify??e.options.stringifyParams;if(t)try{Object.assign(d,t(d))}catch{}}let m=e.leaveParams?u:N(O({path:u,params:d,decoder:this.pathParamsDecoder,server:this.isServer}).interpolatedPath).path,h=o;if(e._includeValidateSearch&&this.options.search?.strict){let e={};p.forEach(t=>{if(t.options.validateSearch)try{Object.assign(e,Ni(t.options.validateSearch,{...e,...h}))}catch{}}),h=e}h=Fi({search:h,dest:t,destRoutes:p,_includeValidateSearch:e._includeValidateSearch}),h=le(o,h);let _=this.options.stringifySearch(h),y=t.hash===!0?n.hash:t.hash?g(t.hash,n.hash):void 0,b=y?`#${y}`:``,x=t.state===!0?n.state:t.state?g(t.state,n.state):{};x=s(n.state,x);let S=`${m}${_}${b}`,C,T,E=!1;if(this.rewrite){let e=new URL(S,this.origin),t=hi(this.rewrite,e);C=e.href.replace(e.origin,``),t.origin===this.origin?T=t.pathname+t.search+t.hash:(T=t.href,E=!0)}else C=ee(S),T=C;return{publicHref:T,href:C,pathname:m,search:h,searchStr:_,state:x,hash:y??``,external:E,unmaskOnReload:t.unmaskOnReload}},n=(n={},r)=>{let i=t(n),a=r?t(r):void 0;if(!a){let n=Object.create(null);if(this.options.routeMasks){let o=ce(i.pathname,this.processedTree);if(o){Object.assign(n,o.rawParams);let{from:i,params:s,...c}=o.route,l=s===!1||s===null?Object.create(null):(s??!0)===!0?n:Object.assign(n,g(s,n));r={from:e.from,...c,params:l},a=t(r)}}}return a&&(i.maskedLocation=a),i};return e.mask?n(e,{from:e.from,...e.mask}):n(e)},this.commitLocation=async({viewTransition:e,ignoreBlocker:t,...n})=>{let r,i=()=>{let e=[`key`,`__TSR_key`,`__TSR_index`,`__hashScrollIntoViewOptions`];e.forEach(e=>{n.state[e]=this.latestLocation.state[e]});let t=o(n.state,this.latestLocation.state);return e.forEach(e=>{delete n.state[e]}),t},a=w(this.latestLocation.href)===w(n.href),s=this.commitLocationPromise;if(this.commitLocationPromise=_(()=>{s?.resolve(),s=void 0}),a&&i())this.load();else{let{maskedLocation:i,hashScrollIntoView:a,...o}=n;i&&(o={...i,state:{...i.state,__tempKey:void 0,__tempLocation:{...o,search:o.searchStr,state:{...o.state,__tempKey:void 0,__tempLocation:void 0,__TSR_key:void 0,key:void 0}}}},(o.unmaskOnReload??this.options.unmaskOnReload??!1)&&(o.state.__tempKey=this.tempLocationKey)),o.state.__hashScrollIntoViewOptions=a??this.options.defaultHashScrollIntoView??!0,this.shouldViewTransition=e,r=n.replace?`REPLACE`:`PUSH`,this.history[r===`REPLACE`?`replace`:`push`](o.publicHref,o.state,{ignoreBlocker:t})}return this.resetNextScroll=n.resetScroll??!0,this.history.subscribers.size||this.load(r?{action:{type:r}}:void 0),this.commitLocationPromise},this.buildAndCommitLocation=({replace:e,resetScroll:t,hashScrollIntoView:n,viewTransition:r,ignoreBlocker:i,href:a,...o}={})=>{if(a){let t=this.history.location.state.__TSR_index,n=Ei(a,{__TSR_index:e?t:t+1}),r=new URL(n.pathname,this.origin);o.to=mi(this.rewrite,r).pathname,o.search=this.options.parseSearch(n.search),o.hash=n.hash.slice(1)}let s=this.buildLocation({...o,_includeValidateSearch:!0});this.pendingBuiltLocation=s;let c=this.commitLocation({...s,viewTransition:r,replace:e,resetScroll:t,hashScrollIntoView:n,ignoreBlocker:i});return Promise.resolve().then(()=>{this.pendingBuiltLocation===s&&(this.pendingBuiltLocation=void 0)}),c},this.navigate=async({to:e,reloadDocument:t,href:n,publicHref:r,...i})=>{let a=!1;if(n)try{new URL(`${n}`),a=!0}catch{}if(a&&!t&&(t=!0),t){if(e!==void 0||!n){let t=this.buildLocation({to:e,...i});n??=t.publicHref,r??=t.publicHref}let t=!a&&r?r:n;if(d(t,this.protocolAllowlist))return Promise.resolve();if(!i.ignoreBlocker){let e=this.history.getBlockers?.()??[];for(let t of e)if(t?.blockerFn&&await t.blockerFn({currentLocation:this.latestLocation,nextLocation:this.latestLocation,action:`PUSH`}))return Promise.resolve()}return i.replace?window.location.replace(t):window.location.href=t,Promise.resolve()}return this.buildAndCommitLocation({...i,href:n,to:e,_isNavigate:!0})},this.beforeLoad=()=>{this.cancelMatches(),this.updateLatestLocation();let e=this.matchRoutes(this.latestLocation),t=this.stores.cachedMatches.get().filter(t=>!e.some(e=>e.id===t.id));this.batch(()=>{this.stores.status.set(`pending`),this.stores.statusCode.set(200),this.stores.isLoading.set(!0),this.stores.location.set(this.latestLocation),this.stores.setPending(e),this.stores.setCached(t)})},this.load=async e=>{let t=e?.action?.type,n,r,i,a=this.stores.resolvedLocation.get()??this.stores.location.get();for(i=new Promise(o=>{this.startTransition(async()=>{try{this.beforeLoad(),t?Oi.set(this.latestLocation,t):Oi.delete(this.latestLocation);let n=this.latestLocation,r=G(n,this.stores.resolvedLocation.get());this.stores.redirect.get()||this.emit({type:`onBeforeNavigate`,...r}),this.emit({type:`onBeforeLoad`,...r}),await si({router:this,sync:e?.sync,forceStaleReload:a.href===n.href,matches:this.stores.pendingMatches.get(),location:n,updateMatch:this.updateMatch,onReady:async()=>{this.startTransition(()=>{this.startViewTransition(async()=>{let e=null,t=null,n=null,r=null;this.batch(()=>{let i=this.stores.pendingMatches.get(),a=i.length,o=this.stores.matches.get();e=a?o.filter(e=>!this.stores.pendingMatchStores.has(e.id)):null;let s=new Set;for(let e of this.stores.pendingMatchStores.values())e.routeId&&s.add(e.routeId);let c=new Set;for(let e of this.stores.matchStores.values())e.routeId&&c.add(e.routeId);t=a?o.filter(e=>!s.has(e.routeId)):null,n=a?i.filter(e=>!c.has(e.routeId)):null,r=a?i.filter(e=>c.has(e.routeId)):o,this.stores.isLoading.set(!1),this.stores.loadedAt.set(Date.now()),a&&(this.stores.setMatches(i),this.stores.setPending([]),this.stores.setCached([...this.stores.cachedMatches.get(),...e.filter(e=>e.status!==`error`&&e.status!==`notFound`&&e.status!==`redirected`)]),this.clearExpiredCache())});for(let[e,i]of[[t,`onLeave`],[n,`onEnter`],[r,`onStay`]])if(e)for(let t of e)this.looseRoutesById[t.routeId].options[i]?.(t)})})}})}catch(e){he(e)?(n=e,this.navigate({...n.options,replace:!0,ignoreBlocker:!0})):ue(e)&&(r=e);let t=n?n.status:r?404:this.stores.matches.get().some(e=>e.status===`error`)?500:200;this.batch(()=>{this.stores.statusCode.set(t),this.stores.redirect.set(n)})}this.latestLoadPromise===i&&(this.commitLocationPromise?.resolve(),this.latestLoadPromise=void 0,this.commitLocationPromise=void 0),o()})}),this.latestLoadPromise=i,await i;this.latestLoadPromise&&i!==this.latestLoadPromise;)await this.latestLoadPromise;let o;this.hasNotFoundMatch()?o=404:this.stores.matches.get().some(e=>e.status===`error`)&&(o=500),o!==void 0&&this.stores.statusCode.set(o)},this.startViewTransition=e=>{let t=this.shouldViewTransition??this.options.defaultViewTransition;if(this.shouldViewTransition=void 0,t&&typeof document<`u`&&`startViewTransition`in document&&typeof document.startViewTransition==`function`){let n;if(typeof t==`object`&&this.isViewTransitionTypesSupported){let r=this.latestLocation,i=this.stores.resolvedLocation.get(),a=typeof t.types==`function`?t.types(G(r,i)):t.types;if(a===!1){e();return}n={update:e,types:a}}else n=e;document.startViewTransition(n)}else e()},this.updateMatch=(e,t)=>{this.startTransition(()=>{let n=this.stores.pendingMatchStores.get(e);if(n){n.set(t);return}let r=this.stores.matchStores.get(e);if(r){r.set(t);return}let i=this.stores.cachedMatchStores.get(e);if(i){let n=t(i.get());n.status===`redirected`?this.stores.cachedMatchStores.delete(e)&&this.stores.cachedIds.set(t=>t.filter(t=>t!==e)):i.set(n)}})},this.getMatch=e=>this.stores.cachedMatchStores.get(e)?.get()??this.stores.pendingMatchStores.get(e)?.get()??this.stores.matchStores.get(e)?.get(),this.invalidate=e=>{let t=t=>e?.filter?.(t)??!0?{...t,invalid:!0,...e?.forcePending||t.status===`error`||t.status===`notFound`?{status:`pending`,error:void 0}:void 0}:t;return this.batch(()=>{this.stores.setMatches(this.stores.matches.get().map(t)),this.stores.setCached(this.stores.cachedMatches.get().map(t)),this.stores.setPending(this.stores.pendingMatches.get().map(t))}),this.shouldViewTransition=!1,this.load({sync:e?.sync})},this.getParsedLocationHref=e=>e.publicHref||`/`,this.resolveRedirect=e=>{let t=e.headers.get(`Location`);if(!e.options.href||e.options._builtLocation){let t=e.options._builtLocation??this.buildLocation(e.options),n=this.getParsedLocationHref(t);e.options.href=n,e.headers.set(`Location`,n)}else if(t)try{let n=new URL(t);if(this.origin&&n.origin===this.origin){let t=n.pathname+n.search+n.hash;e.options.href=t,e.headers.set(`Location`,t)}}catch{}if(e.options.href&&!e.options._builtLocation&&d(e.options.href,this.protocolAllowlist))throw Error(`Redirect blocked: unsafe protocol`);return e.headers.get(`Location`)||e.headers.set(`Location`,e.options.href),e},this.clearCache=e=>{let t=e?.filter;t===void 0?this.stores.setCached([]):this.stores.setCached(this.stores.cachedMatches.get().filter(e=>!t(e)))},this.clearExpiredCache=()=>{let e=Date.now();this.clearCache({filter:t=>{let n=this.looseRoutesById[t.routeId];if(!n.options.loader)return!0;let r=(t.preload?n.options.preloadGcTime??this.options.defaultPreloadGcTime:n.options.gcTime??this.options.defaultGcTime)??300*1e3;return t.status===`error`?!0:e-t.updatedAt>=r}})},this.loadRouteChunk=li,this.preloadRoute=async e=>{let t=e._builtLocation??this.buildLocation(e),n=this.matchRoutes(t,{throwOnError:!0,preload:!0,dest:e}),r=new Set([...this.stores.matchesId.get(),...this.stores.pendingIds.get()]),i=new Set([...r,...this.stores.cachedIds.get()]),a=n.filter(e=>!i.has(e.id));if(a.length){let e=this.stores.cachedMatches.get();this.stores.setCached([...e,...a])}try{return n=await si({router:this,matches:n,location:t,preload:!0,updateMatch:(e,t)=>{r.has(e)?n=n.map(n=>n.id===e?t(n):n):this.updateMatch(e,t)}}),n}catch(e){if(he(e))return e.options.reloadDocument?void 0:await this.preloadRoute({...e.options,_fromLocation:t});ue(e)||console.error(e);return}},this.matchRoute=(e,t)=>{let n={...e,to:e.to?this.resolvePathWithBase(e.from||``,e.to):void 0,params:e.params||{},leaveParams:!0},r=this.buildLocation(n);if(t?.pending&&this.stores.status.get()!==`pending`)return!1;let i=(t?.pending===void 0?!this.stores.isLoading.get():t.pending)?this.latestLocation:this.stores.resolvedLocation.get()||this.stores.location.get(),a=I(r.pathname,t?.caseSensitive??!1,t?.fuzzy??!1,i.pathname,this.processedTree);return!a||e.params&&!o(a.rawParams,e.params,{partial:!0})?!1:t?.includeSearch??!0?o(i.search,r.search,{partial:!0})?a.rawParams:!1:a.rawParams},this.hasNotFoundMatch=()=>this.stores.matches.get().some(e=>e.status===`notFound`||e.globalNotFound),this.getStoreConfig=t,this.update({defaultPreloadDelay:50,defaultPendingMs:1e3,defaultPendingMinMs:500,context:void 0,...e,caseSensitive:e.caseSensitive??!1,notFoundMode:e.notFoundMode??`fuzzy`,stringifySearch:e.stringifySearch??Vr,parseSearch:e.parseSearch??Br,protocolAllowlist:e.protocolAllowlist??u}),typeof document<`u`&&(self.__TSR_ROUTER__=this)}isShell(){return!!this.options.isShell}isPrerendering(){return!!this.options.isPrerendering}get state(){return this.stores.__store.get()}setRoutes({routesById:e,routesByPath:t,processedTree:n}){this.routesById=e,this.routesByPath=t,this.processedTree=n;let r=this.options.notFoundRoute;r&&(r.init({originalIndex:99999999999}),this.routesById[r.id]=r)}getRouteBranch(e){let t=this.routeBranchCache.get(e);return t||(t=oe(e),this.routeBranchCache.set(e,t)),t}get looseRoutesById(){return this.routesById}getParentContext(e){return e?.id?e.context??this.options.context??void 0:this.options.context??void 0}matchRoutesInternal(e,t){let n=this.getMatchedRoutes(e.pathname),{foundRoute:r,routeParams:i}=n,{matchedRoutes:a}=n,o=!1;(r?r.path!==`/`&&i[`**`]:w(e.pathname))&&(this.options.notFoundRoute?a=[...a,this.options.notFoundRoute]:o=!0);let c=o?Li(this.options.notFoundMode,a):void 0,l=Array(a.length),u=new Map;for(let e of this.stores.matchStores.values())e.routeId&&u.set(e.routeId,e.get());for(let n=0;nthis.navigate({...t,_fromLocation:e}),buildLocation:this.buildLocation,cause:n.cause,abortController:n.abortController,preload:!!n.preload,matches:l,routeId:r.id};n.__routeContext=r.options.context(t)??void 0}n.context={...a,...n.__routeContext,...n.__beforeLoadContext}}}return l}matchRoutesLightweight(e){let{matchedRoutes:t,routeParams:n}=this.getMatchedRoutes(e.pathname),r=y(t),i={...e.search};for(let e of t)try{Object.assign(i,Ni(e.options.validateSearch,i))}catch{}let a=y(this.stores.matchesId.get()),o=a&&this.stores.matchStores.get(a)?.get(),s=o&&o.routeId===r.id&&o.pathname===e.pathname,c;if(s)c=o.params;else{let e=Object.assign(Object.create(null),n);for(let n of t)try{Ri(n,e)}catch{}c=e}return{matchedRoutes:t,fullPath:r.fullPath,search:i,params:c}}},Ai=class extends Error{},ji=class extends Error{};function Mi(e){return{loadedAt:0,isLoading:!1,isTransitioning:!1,status:`idle`,resolvedLocation:void 0,location:e,matches:[],statusCode:200}}function Ni(e,t){if(e==null)return{};if(`~standard`in e){let n=e[`~standard`].validate(t);if(n instanceof Promise)throw new Ai(`Async validation not supported`);if(n.issues)throw new Ai(JSON.stringify(n.issues,void 0,2),{cause:n});return n.value}return`parse`in e?e.parse(t):typeof e==`function`?e(t):{}}function Pi({pathname:e,routesById:t,processedTree:n}){let r=Object.create(null),i=w(e),a,o=E(i,n,!0);return o&&(a=o.route,Object.assign(r,o.rawParams)),{matchedRoutes:o?.branch||[t.__root__],routeParams:r,foundRoute:a}}function Fi({search:e,dest:t,destRoutes:n,_includeValidateSearch:r}){return Ii(n)(e,t,r??!1)}function Ii(e){let t,n,r=[];for(let t of e){let e=t.options;`search`in e?e.search?.middlewares&&r.push(...e.search.middlewares):(e.preSearchFilters||e.postSearchFilters)&&r.push(({search:t,next:n})=>{let r=n(e.preSearchFilters?e.preSearchFilters.reduce((e,t)=>t(e),t):t);return e.postSearchFilters?e.postSearchFilters.reduce((e,t)=>t(e),r):r});let i=e.validateSearch;i&&r.push(({search:e,next:t,meta:r})=>{let a=t(e);if(n)try{let e=Ni(i,a);if(r&&e)for(let t in e)t in a||(r.defaulted||=new Map).set(t,e[t]);return{...a,...e}}catch{}return a})}let i=(e,n,a)=>e>=r.length?t.search?t.search===!0?n:g(t.search,n):{}:r[e]({search:n,next:(t,n)=>{if(n){let n=a||{};return{search:i(e+1,t,n),meta:n}}return i(e+1,t,a)},meta:a});return function(e,r,a){return t=r,n=a,i(0,e)}}function Li(e,t){if(e!==`root`)for(let e=t.length-1;e>=0;e--){let n=t[e];if(n.children)return n.id}return fe}function Ri(e,t){let n=e.options.params?.parse??e.options.parseParams;if(n){let e=n(t);if(e===!1)throw Error(`Route params.parse returned false for a matched route`);Object.assign(t,e)}}function zi(){try{return sessionStorage}catch{return}}var Bi=`tsr-scroll-restoration-v1_3`,Vi=zi();function Hi(){try{return JSON.parse(Vi?.getItem(`tsr-scroll-restoration-v1_3`)||`{}`)}catch{return{}}}function Ui(){try{Vi?.setItem(Bi,JSON.stringify(Wi))}catch{}}var Wi=Hi(),Gi=`data-scroll-restoration-id`,Ki=e=>e.state.__TSR_key||e.href;function qi(e){let t=e.getAttribute(Gi);if(t)return`[${Gi}="${t}"]`;let n=``,r=e,i;for(;i=r.parentNode;){let e=1,t=r;for(;t=t.previousElementSibling;)e++;let a=`${r.localName}:nth-child(${e})`;n=n?`${a} > ${n}`:a,r=i}return n}var Ji=!1,Yi=`window`;function Xi(e){try{return typeof e==`function`?e():document.querySelector(e)}catch{}}function Zi(e){let t=[];for(let n of e){if(n===Yi)continue;let e=Xi(n);e&&t.push(e)}return t}function Qi(e,t){if((t??e.options.scrollRestoration)&&(e.isScrollRestoring=!0),e.isScrollRestorationSetup)return;e.isScrollRestorationSetup=!0,Ji=!1;let n=e.options.getScrollRestorationKey||Ki,r=new Map,i=(e,t,n)=>{let i=r.get(e)||{};i.scrollX=t,i.scrollY=n,r.set(e,i)};history.scrollRestoration=`manual`;let a=t=>{if(!(Ji||!e.isScrollRestoring))if(t.target===document)i(Yi,scrollX,scrollY);else{let e=t.target;i(e,e.scrollLeft,e.scrollTop)}},o=t=>{if(!e.isScrollRestoring)return;let n=Wi[t]||={};for(let[e,t]of r)e===Yi?n[Yi]=t:e.isConnected&&(n[qi(e)]=t)};document.addEventListener(`scroll`,a,!0),e.subscribe(`onBeforeLoad`,e=>{e.fromLocation&&o(n(e.fromLocation)),r.clear()}),addEventListener(`pagehide`,()=>{o(n(e.stores.resolvedLocation.get()??e.stores.location.get())),Ui()}),e.subscribe(`onRendered`,t=>{let i=e.options.scrollRestorationBehavior,a=e.options.scrollToTopSelectors,o=e.resetNextScroll,s;if(r.clear(),o||(e.resetNextScroll=!0),typeof e.options.scrollRestoration==`function`&&!e.options.scrollRestoration({location:e.latestLocation}))return;let c=n(t.toLocation),l=t.fromLocation&&n(t.fromLocation);if(e.isScrollRestoring&&l&&l!==c){let e=Wi[l];if(e){let t=Wi[c];for(let n in e){if(n===Yi){if(o)continue}else{let e=Xi(n);if(!e||o&&a&&(s??=Zi(a),s.includes(e)))continue}t||=Wi[c]={},t[n]??=e[n]}}}Ji=!0;try{let n=t.toLocation.hash,r=t.toLocation.state.__hashScrollIntoViewOptions??!0,l=!1;if(o){let o=Oi.get(t.toLocation),u=n&&r&&(o===`PUSH`||o===`REPLACE`),d=e.isScrollRestoring?Wi[c]:void 0;if(d)for(let e in d){let{scrollX:t,scrollY:n}=d[e];if(e===Yi){if(u)continue;scrollTo({top:n,left:t,behavior:i}),l=!0}else{let r=Xi(e);r&&(r.scrollLeft=t,r.scrollTop=n)}}if(!l&&!n){let e={top:0,left:0,behavior:i};if(scrollTo(e),a){s??=Zi(a);for(let t of s)t.scrollTo(e)}}}!l&&n&&r&&document.getElementById(n)?.scrollIntoView(r)}finally{Ji=!1}})}function $i(e,t){if(e)return typeof e==`string`?e:e[t]}function ea(e){return e?.scriptFormat??`module`}function ta(e,t,n){let r=na(t),i=$i(n,`script`)??r.crossOrigin;return{...ea(e)===`iife`?{rel:`preload`,as:`script`}:{rel:`modulepreload`},href:r.href,...i?{crossOrigin:i}:{}}}function na(e){return typeof e==`string`?{href:e,crossOrigin:void 0}:e}function ra(e,t){if(t.length===0)return;if(t.length===1){e.push(t[0]);return}let n=new Set;for(let r of t){let t=JSON.stringify(r);n.has(t)||(n.add(t),e.push(r))}}function ia(e){return typeof e==`string`?{href:e,crossOrigin:void 0}:e}var aa=class{get to(){return this._to}get id(){return this._id}get path(){return this._path}get fullPath(){return this._fullPath}constructor(e){if(this.init=e=>{this.originalIndex=e.originalIndex;let t=this.options,n=!t?.path&&!t?.id;this.parentRoute=this.options.getParentRoute?.(),n?this._path=fe:this.parentRoute||x();let r=n?fe:t?.path;r&&r!==`/`&&(r=A(r));let i=t?.id||r,a=n?fe:k([this.parentRoute.id===`__root__`?``:this.parentRoute.id,i]);r===`__root__`&&(r=`/`),a!==`__root__`&&(a=k([`/`,a]));let o=a===`__root__`?`/`:k([this.parentRoute.fullPath,r]);this._path=r,this._id=a,this._fullPath=o,this._to=w(o)},this.addChildren=e=>this._addFileChildren(e),this._addFileChildren=e=>(Array.isArray(e)&&(this.children=e),typeof e==`object`&&e&&(this.children=Object.values(e)),this),this._addFileTypes=()=>this,this.updateLoader=e=>(Object.assign(this.options,e),this),this.update=e=>(Object.assign(this.options,e),this),this.lazy=e=>(this.lazyFn=e,this),this.redirect=e=>me({from:this.fullPath,...e}),this.options=e||{},this.isRoot=!e?.getParentRoute,e?.id&&e?.path)throw Error(`Route cannot have both an 'id' and a 'path' option.`)}},oa=class extends aa{constructor(e){super(e)}};function sa(e){let t=e.errorComponent??la;return(0,W.jsx)(ca,{getResetKey:e.getResetKey,onCatch:e.onCatch,children:({error:n,reset:r})=>n?U.createElement(t,{error:n,reset:r}):e.children})}var ca=class extends U.Component{constructor(...e){super(...e),this.state={error:null}}static getDerivedStateFromProps(e,t){let n=e.getResetKey();return t.error&&t.resetKey!==n?{resetKey:n,error:null}:{resetKey:n}}static getDerivedStateFromError(e){return{error:e}}reset(){this.setState({error:null})}componentDidCatch(e,t){this.props.onCatch&&this.props.onCatch(e,t)}render(){return this.props.children({error:this.state.error,reset:()=>{this.reset()}})}};function la({error:e}){let[t,n]=U.useState(!1);return(0,W.jsxs)(`div`,{style:{padding:`.5rem`,maxWidth:`100%`},children:[(0,W.jsxs)(`div`,{style:{display:`flex`,alignItems:`center`,gap:`.5rem`},children:[(0,W.jsx)(`strong`,{style:{fontSize:`1rem`},children:`Something went wrong!`}),(0,W.jsx)(`button`,{style:{appearance:`none`,fontSize:`.6em`,border:`1px solid currentColor`,padding:`.1rem .2rem`,fontWeight:`bold`,borderRadius:`.25rem`},onClick:()=>n(e=>!e),children:t?`Hide Error`:`Show Error`})]}),(0,W.jsx)(`div`,{style:{height:`.25rem`}}),t?(0,W.jsx)(`div`,{children:(0,W.jsx)(`pre`,{style:{fontSize:`.7em`,border:`1px solid red`,borderRadius:`.25rem`,padding:`.3rem`,color:`red`,overflow:`auto`},children:e.message?(0,W.jsx)(`code`,{children:e.message}):null})}):null]})}var ua=(e=>(e[e.None=0]=`None`,e[e.Mutable=1]=`Mutable`,e[e.Watching=2]=`Watching`,e[e.RecursedCheck=4]=`RecursedCheck`,e[e.Recursed=8]=`Recursed`,e[e.Dirty=16]=`Dirty`,e[e.Pending=32]=`Pending`,e))(ua||{});function da({update:e,notify:t,unwatched:n}){return{link:r,unlink:i,propagate:a,checkDirty:o,shallowPropagate:s};function r(e,t,n){let r=t.depsTail;if(r!==void 0&&r.dep===e)return;let i=r===void 0?t.deps:r.nextDep;if(i!==void 0&&i.dep===e){i.version=n,t.depsTail=i;return}let a=e.subsTail;if(a!==void 0&&a.version===n&&a.sub===t)return;let o=t.depsTail=e.subsTail={version:n,dep:e,sub:t,prevDep:r,nextDep:i,prevSub:a,nextSub:void 0};i!==void 0&&(i.prevDep=o),r===void 0?t.deps=o:r.nextDep=o,a===void 0?e.subs=o:a.nextSub=o}function i(e,t=e.sub){let r=e.dep,i=e.prevDep,a=e.nextDep,o=e.nextSub,s=e.prevSub;return a===void 0?t.depsTail=i:a.prevDep=i,i===void 0?t.deps=a:i.nextDep=a,o===void 0?r.subsTail=s:o.prevSub=s,s===void 0?(r.subs=o)===void 0&&n(r):s.nextSub=o,a}function a(e){let n=e.nextSub,r;top:do{let i=e.sub,a=i.flags;if(a&60?a&12?a&4?!(a&48)&&c(e,i)?(i.flags=a|40,a&=1):a=0:i.flags=a&-9|32:a=0:i.flags=a|32,a&2&&t(i),a&1){let t=i.subs;if(t!==void 0){let i=(e=t).nextSub;i!==void 0&&(r={value:n,prev:r},n=i);continue}}if((e=n)!==void 0){n=e.nextSub;continue}for(;r!==void 0;)if(e=r.value,r=r.prev,e!==void 0){n=e.nextSub;continue top}break}while(!0)}function o(t,n){let r,i=0,a=!1;top:do{let o=t.dep,c=o.flags;if(n.flags&16)a=!0;else if((c&17)==17){if(e(o)){let e=o.subs;e.nextSub!==void 0&&s(e),a=!0}}else if((c&33)==33){(t.nextSub!==void 0||t.prevSub!==void 0)&&(r={value:t,prev:r}),t=o.deps,n=o,++i;continue}if(!a){let e=t.nextDep;if(e!==void 0){t=e;continue}}for(;i--;){let i=n.subs,o=i.nextSub!==void 0;if(o?(t=r.value,r=r.prev):t=i,a){if(e(n)){o&&s(i),n=t.sub;continue}a=!1}else n.flags&=-33;n=t.sub;let c=t.nextDep;if(c!==void 0){t=c;continue top}}return a}while(!0)}function s(e){do{let n=e.sub,r=n.flags;(r&48)==32&&(n.flags=r|16,(r&6)==2&&t(n))}while((e=e.nextSub)!==void 0)}function c(e,t){let n=t.depsTail;for(;n!==void 0;){if(n===e)return!0;n=n.prevDep}return!1}}function fa(e,t,n){let r=typeof e==`object`,i=r?e:void 0;return{next:(r?e.next:e)?.bind(i),error:(r?e.error:t)?.bind(i),complete:(r?e.complete:n)?.bind(i)}}var pa=[],ma=0,{link:ha,unlink:ga,propagate:_a,checkDirty:va,shallowPropagate:ya}=da({update(e){return e._update()},notify(e){pa[xa++]=e,e.flags&=~ua.Watching},unwatched(e){e.depsTail!==void 0&&(e.depsTail=void 0,e.flags=ua.Mutable|ua.Dirty,Ta(e))}}),ba=0,xa=0,Sa,Ca=0;function wa(e){try{++Ca,e()}finally{--Ca||Ea()}}function Ta(e){let t=e.depsTail,n=t===void 0?e.deps:t.nextDep;for(;n!==void 0;)n=ga(n,e)}function Ea(){if(!(Ca>0)){for(;ba{i.get(),n.current?t.next?.(i._snapshot):n.current=!0});return{unsubscribe:()=>{r.stop()}}},_update(e){let a=Sa,o=t?.compare??Object.is;if(n)Sa=i,++ma,i.depsTail=void 0;else if(e===void 0)return!1;n&&(i.flags=ua.Mutable|ua.RecursedCheck);try{let t=i._snapshot,a=typeof e==`function`?e(t):e===void 0&&n?r(t):e;return t===void 0||!o(t,a)?(i._snapshot=a,!0):!1}finally{Sa=a,n&&(i.flags&=~ua.RecursedCheck),Ta(i)}}};return n?(i.flags=ua.Mutable|ua.Dirty,i.get=function(){let e=i.flags;if(e&ua.Dirty||e&ua.Pending&&va(i.deps,i)){if(i._update()){let e=i.subs;e!==void 0&&ya(e)}}else e&ua.Pending&&(i.flags=e&~ua.Pending);return Sa!==void 0&&ha(i,Sa,ma),i._snapshot}):i.set=function(e){if(i._update(e)){let e=i.subs;e!==void 0&&(_a(e),ya(e),Ea())}},i}function Oa(e){let t=()=>{let t=Sa;Sa=n,++ma,n.depsTail=void 0,n.flags=ua.Watching|ua.RecursedCheck;try{return e()}finally{Sa=t,n.flags&=~ua.RecursedCheck,Ta(n)}},n={deps:void 0,depsTail:void 0,subs:void 0,subsTail:void 0,flags:ua.Watching|ua.RecursedCheck,notify(){let e=this.flags;e&ua.Dirty||e&ua.Pending&&va(this.deps,this)?t():this.flags=ua.Watching},stop(){this.flags=ua.None,this.depsTail=void 0,Ta(this)}};return t(),n}var ka={get:()=>void 0,subscribe:()=>({unsubscribe:()=>{}})};function Aa(e){let t=M(),n=U.useContext(e.from?pe:de),r=e.from??n,i=r?e.from?t.stores.getRouteMatchStore(r):t.stores.matchStores.get(r):void 0,a=U.useRef(void 0);return F(i??ka,n=>{if((e.shouldThrow??!0)&&!n&&x(),n===void 0)return;let r=e.select?e.select(n):n;if(e.structuralSharing??t.options.defaultStructuralSharing){let e=s(a.current,r);return a.current=e,e}return r})}function ja(e){return Aa({from:e.from,strict:e.strict,structuralSharing:e.structuralSharing,select:t=>e.select?e.select(t.loaderData):t.loaderData})}function Ma(e){let{select:t,...n}=e;return Aa({...n,select:e=>t?t(e.loaderDeps):e.loaderDeps})}function Na(e){return Aa({from:e.from,shouldThrow:e.shouldThrow,structuralSharing:e.structuralSharing,strict:e.strict,select:t=>{let n=e.strict===!1?t.params:t._strictParams;return e.select?e.select(n):n}})}function Pa(e){return Aa({from:e.from,strict:e.strict,shouldThrow:e.shouldThrow,structuralSharing:e.structuralSharing,select:t=>e.select?e.select(t.search):t.search})}function Fa(e){let t=M();return U.useCallback(n=>t.navigate({...n,from:n.from??e?.from}),[e?.from,t])}function Ia(e){return Aa({...e,select:t=>e.select?e.select(t.context):t.context})}var La=class extends aa{constructor(e){super(e),this.useMatch=e=>Aa({select:e?.select,from:this.id,structuralSharing:e?.structuralSharing}),this.useRouteContext=e=>Ia({...e,from:this.id}),this.useSearch=e=>Pa({select:e?.select,structuralSharing:e?.structuralSharing,from:this.id}),this.useParams=e=>Na({select:e?.select,structuralSharing:e?.structuralSharing,from:this.id}),this.useLoaderDeps=e=>Ma({...e,from:this.id}),this.useLoaderData=e=>ja({...e,from:this.id}),this.useNavigate=()=>Fa({from:this.fullPath}),this.Link=U.forwardRef((e,t)=>(0,W.jsx)(Fe,{ref:t,from:this.fullPath,...e}))}};function Ra(e){return new La(e)}function za(){return e=>Va(e)}var Ba=class extends oa{constructor(e){super(e),this.useMatch=e=>Aa({select:e?.select,from:this.id,structuralSharing:e?.structuralSharing}),this.useRouteContext=e=>Ia({...e,from:this.id}),this.useSearch=e=>Pa({select:e?.select,structuralSharing:e?.structuralSharing,from:this.id}),this.useParams=e=>Na({select:e?.select,structuralSharing:e?.structuralSharing,from:this.id}),this.useLoaderDeps=e=>Ma({...e,from:this.id}),this.useLoaderData=e=>ja({...e,from:this.id}),this.useNavigate=()=>Fa({from:this.fullPath}),this.Link=U.forwardRef((e,t)=>(0,W.jsx)(Fe,{ref:t,from:this.fullPath,...e}))}};function Va(e){return new Ba(e)}function K(e){return new Ha(e,{silent:!0}).createRoute}var Ha=class{constructor(e,t){this.path=e,this.createRoute=e=>{let t=Ra(e);return t.isRoot=!1,t},this.silent=t?.silent}};function q(e,t){let n,r,i,a,o=()=>(n||=e().then(e=>{n=void 0,r=e[t??`default`]}).catch(e=>{if(i=e,p(i)&&i instanceof Error&&typeof window<`u`&&typeof sessionStorage<`u`){let e=`tanstack_router_reload:${i.message}`;sessionStorage.getItem(e)||(sessionStorage.setItem(e,`1`),a=!0)}}),n),s=function(e){if(a)throw window.location.reload(),new Promise(()=>{});if(i)throw i;if(!r)if(S)S(o());else throw o();return U.createElement(r,e)};return s.preload=o,s}function Ua(e){let t=M(),n=`not-found-${F(t.stores.location,e=>e.pathname)}-${F(t.stores.status,e=>e)}`;return(0,W.jsx)(sa,{getResetKey:()=>n,onCatch:(t,n)=>{if(ue(t))e.onCatch?.(t,n);else throw t},errorComponent:({error:t})=>{if(ue(t))return e.fallback?.(t);throw t},children:e.children})}function Wa(){return(0,W.jsx)(`p`,{children:`Not Found`})}function Ga(e){return(0,W.jsx)(W.Fragment,{children:e.children})}function Ka(e,t,n){return t.options.notFoundComponent?(0,W.jsx)(t.options.notFoundComponent,{...n}):e.options.defaultNotFoundComponent?(0,W.jsx)(e.options.defaultNotFoundComponent,{...n}):(0,W.jsx)(Wa,{})}var qa=U.memo(function({matchId:e}){let t=M(),n=t.stores.matchStores.get(e);n||x();let r=F(t.stores.loadedAt,e=>e),i=F(n,e=>e);return(0,W.jsx)(Ja,{router:t,matchId:e,resetKey:r,matchState:U.useMemo(()=>{let e=i.routeId,n=t.routesById[e].parentRoute?.id;return{routeId:e,ssr:i.ssr,_displayPending:i._displayPending,parentRouteId:n}},[i._displayPending,i.routeId,i.ssr,t.routesById])})});function Ja({router:e,matchId:t,resetKey:n,matchState:r}){let i=e.routesById[r.routeId],a=i.options.pendingComponent??e.options.defaultPendingComponent,o=a?(0,W.jsx)(a,{}):null,s=i.options.errorComponent??e.options.defaultErrorComponent,c=i.options.onCatch??e.options.defaultOnCatch,l=i.isRoot?i.options.notFoundComponent??e.options.notFoundRoute?.options.component:i.options.notFoundComponent,u=r.ssr===!1||r.ssr===`data-only`,d=(!i.isRoot||i.options.wrapInSuspense||u)&&(i.options.wrapInSuspense??a??(i.options.errorComponent?.preload||u))?U.Suspense:Ga,f=s?sa:Ga,p=l?Ua:Ga;return(0,W.jsxs)(i.isRoot?i.options.shellComponent??Ga:Ga,{children:[(0,W.jsx)(de.Provider,{value:t,children:(0,W.jsx)(d,{fallback:o,children:(0,W.jsx)(f,{getResetKey:()=>n,errorComponent:s||la,onCatch:(e,t)=>{if(ue(e))throw e.routeId??=r.routeId,e;c?.(e,t)},children:(0,W.jsx)(p,{fallback:e=>{if(e.routeId??=r.routeId,!l||e.routeId&&e.routeId!==r.routeId||!e.routeId&&!i.isRoot)throw e;return U.createElement(l,e)},children:u||r._displayPending?(0,W.jsx)(re,{fallback:o,children:(0,W.jsx)(Xa,{matchId:t})}):(0,W.jsx)(Xa,{matchId:t})})})})}),r.parentRouteId===`__root__`?(0,W.jsxs)(W.Fragment,{children:[(0,W.jsx)(Ya,{resetKey:n}),(e.options.scrollRestoration,null)]}):null]})}function Ya({resetKey:e}){let t=M(),n=U.useRef(void 0);return C(()=>{let e=t.latestLocation.href;(n.current===void 0||n.current!==e)&&(t.emit({type:`onRendered`,...G(t.stores.location.get(),t.stores.resolvedLocation.get())}),n.current=e)},[t.latestLocation.state.__TSR_key,e,t]),null}var Xa=U.memo(function({matchId:e}){let t=M(),n=(e,n)=>t.getMatch(e.id)?._nonReactive[n]??e._nonReactive[n],r=t.stores.matchStores.get(e);r||x();let i=F(r,e=>e),a=i.routeId,o=t.routesById[a],s=U.useMemo(()=>{let e=(t.routesById[a].options.remountDeps??t.options.defaultRemountDeps)?.({routeId:a,loaderDeps:i.loaderDeps,params:i._strictParams,search:i._strictSearch});return e?JSON.stringify(e):void 0},[a,i.loaderDeps,i._strictParams,i._strictSearch,t.options.defaultRemountDeps,t.routesById]),c=U.useMemo(()=>{let e=o.options.component??t.options.defaultComponent;return e?(0,W.jsx)(e,{},s):(0,W.jsx)(Za,{})},[s,o.options.component,t.options.defaultComponent]);if(i._displayPending)throw n(i,`displayPendingPromise`);if(i._forcePending)throw n(i,`minPendingPromise`);if(i.status===`pending`){let e=o.options.pendingMinMs??t.options.defaultPendingMinMs;if(e){let n=t.getMatch(i.id);if(n&&!n._nonReactive.minPendingPromise){let t=_();n._nonReactive.minPendingPromise=t,setTimeout(()=>{t.resolve(),n._nonReactive.minPendingPromise=void 0},e)}}throw n(i,`loadPromise`)}if(i.status===`notFound`)return ue(i.error)||x(),Ka(t,o,i.error);if(i.status===`redirected`)throw he(i.error)||x(),n(i,`loadPromise`);if(i.status===`error`)throw i.error;return c}),Za=U.memo(function(){let e=M(),t=U.useContext(de),n,r=!1,i;{let a=t?e.stores.matchStores.get(t):void 0;[n,r]=F(a,e=>[e?.routeId,e?.globalNotFound??!1]),i=F(e.stores.matchesId,e=>e[e.findIndex(e=>e===t)+1])}let a=n?e.routesById[n]:void 0,o=e.options.defaultPendingComponent?(0,W.jsx)(e.options.defaultPendingComponent,{}):null;if(r)return a||x(),Ka(e,a,void 0);if(!i)return null;let s=(0,W.jsx)(qa,{matchId:i});return n===`__root__`?(0,W.jsx)(U.Suspense,{fallback:o,children:s}):s});function Qa(){let e=M(),t=U.useRef({router:e,mounted:!1}),[n,r]=U.useState(!1),i=F(e.stores.isLoading,e=>e),a=F(e.stores.hasPending,e=>e),o=f(i),s=i||n||a,c=f(s),l=i||a,u=f(l);return e.startTransition=e=>{r(!0),U.startTransition(()=>{e(),r(!1)})},U.useEffect(()=>{let t=e.history.subscribe(e.load),n=e.buildLocation({to:e.latestLocation.pathname,search:!0,params:!0,hash:!0,state:!0,_includeValidateSearch:!0});return w(e.latestLocation.publicHref)!==w(n.publicHref)&&e.commitLocation({...n,replace:!0}),()=>{t()}},[e,e.history]),C(()=>{typeof window<`u`&&e.ssr||t.current.router===e&&t.current.mounted||(t.current={router:e,mounted:!0},(async()=>{try{await e.load()}catch(e){console.error(e)}})())},[e]),C(()=>{o&&!i&&e.emit({type:`onLoad`,...G(e.stores.location.get(),e.stores.resolvedLocation.get())})},[o,e,i]),C(()=>{u&&!l&&e.emit({type:`onBeforeRouteMount`,...G(e.stores.location.get(),e.stores.resolvedLocation.get())})},[l,u,e]),C(()=>{if(c&&!s){let t=G(e.stores.location.get(),e.stores.resolvedLocation.get());e.emit({type:`onResolved`,...t}),wa(()=>{e.stores.status.set(`idle`),e.stores.resolvedLocation.set(e.stores.location.get())})}},[s,c,e]),null}function $a(){let e=M(),t=e.routesById.__root__.options.pendingComponent??e.options.defaultPendingComponent,n=t?(0,W.jsx)(t,{}):null,r=(0,W.jsxs)(typeof document<`u`&&e.ssr?Ga:U.Suspense,{fallback:n,children:[(0,W.jsx)(Qa,{}),(0,W.jsx)(eo,{})]});return e.options.InnerWrap?(0,W.jsx)(e.options.InnerWrap,{children:r}):r}function eo(){let e=M(),t=F(e.stores.firstId,e=>e),n=F(e.stores.loadedAt,e=>e),r=t?(0,W.jsx)(qa,{matchId:t}):null;return(0,W.jsx)(de.Provider,{value:t,children:e.options.disableGlobalCatchBoundary?r:(0,W.jsx)(sa,{getResetKey:()=>n,errorComponent:la,onCatch:void 0,children:r})})}var to=e=>({createMutableStore:Da,createReadonlyStore:Da,batch:wa}),no=e=>new ro(e),ro=class extends ki{constructor(e){super(e,to)}};function J({router:e,children:t,...n}){v(n)&&e.update({...e.options,...n,context:{...e.options.context,...n.context}});let r=(0,W.jsx)(T.Provider,{value:e,children:t});return e.options.Wrap?(0,W.jsx)(e.options.Wrap,{children:r}):r}function io({router:e,...t}){return(0,W.jsx)(J,{router:e,...t,children:(0,W.jsx)($a,{})})}function ao(e){let t=M(),n=(0,U.useRef)(void 0);return F(t.stores.location,r=>{let i=e?.select?e.select(r):r;if(e?.structuralSharing??t.options.defaultStructuralSharing){let e=s(n.current,i);return n.current=e,e}return i})}function oo(e,t){if(t)for(let[n,r]of Object.entries(t))n!==`suppressHydrationWarning`&&r!==void 0&&r!==!1&&e.setAttribute(n,typeof r==`boolean`?``:String(r))}function so(e){let{attrs:t,children:n,nonce:r,preventScriptHoist:i}=e;switch(e.tag){case`title`:return(0,W.jsx)(`title`,{...t,suppressHydrationWarning:!0,children:n});case`meta`:return(0,W.jsx)(`meta`,{...t,suppressHydrationWarning:!0});case`link`:return(0,W.jsx)(`link`,{...t,precedence:t?.precedence??(t?.rel===`stylesheet`?`default`:void 0),nonce:r,suppressHydrationWarning:!0});case`style`:return e.inlineCss,(0,W.jsx)(`style`,{...t,dangerouslySetInnerHTML:{__html:n},nonce:r});case`script`:return(0,W.jsx)(co,{attrs:t,preventScriptHoist:i,children:n});default:return null}}function co({attrs:e,children:t,preventScriptHoist:n}){M();let r=ae(),i=typeof e?.type==`string`&&e.type!==``&&e.type!==`text/javascript`&&e.type!==`module`;if(U.useEffect(()=>{if(!i){if(e?.src){let t=(()=>{try{let t=document.baseURI||window.location.href;return new URL(e.src,t).href}catch{return e.src}})();for(let e of document.querySelectorAll(`script[src]`))if(e.src===t)return;let n=document.createElement(`script`);return oo(n,e),document.head.appendChild(n),()=>n.remove()}if(typeof t==`string`){let n=typeof e?.type==`string`?e.type:`text/javascript`,r=typeof e?.nonce==`string`?e.nonce:void 0;for(let e of document.querySelectorAll(`script:not([src])`)){if(!(e instanceof HTMLScriptElement))continue;let i=e.getAttribute(`type`)??`text/javascript`,a=e.getAttribute(`nonce`)??void 0;if(e.textContent===t&&i===n&&a===r)return}let i=document.createElement(`script`);return i.textContent=t,oo(i,e),document.head.appendChild(i),()=>i.remove()}}},[e,t,i]),i&&typeof t==`string`)return(0,W.jsx)(`script`,{...e,suppressHydrationWarning:!0,dangerouslySetInnerHTML:{__html:t}});if(!r){if(e?.src)return(0,W.jsx)(`script`,{...e,suppressHydrationWarning:!0});if(typeof t==`string`)return(0,W.jsx)(`script`,{...e,dangerouslySetInnerHTML:{__html:t},suppressHydrationWarning:!0})}return null}var lo=e=>{let t=M(),n=t.options.ssr?.nonce,r=F(t.stores.matches,e=>e.map(e=>e.meta).filter(e=>e!==void 0),o),i=U.useMemo(()=>{let e=[],t={},i;for(let a=r.length-1;a>=0;a--){let o=r[a];for(let r=o.length-1;r>=0;r--){let a=o[r];if(a)if(a.title)i||={tag:`title`,children:a.title};else if(`script:ld+json`in a)try{let t=JSON.stringify(a[`script:ld+json`]);e.push({tag:`script`,attrs:{type:`application/ld+json`},children:h(t)})}catch{}else{let r=a.name??a.property;if(r){if(t[r])continue;t[r]=!0}e.push({tag:`meta`,attrs:{...a,nonce:n}})}}}return i&&e.push(i),n&&e.push({tag:`meta`,attrs:{property:`csp-nonce`,content:n}}),e.reverse(),e},[r,n]),a=F(t.stores.matches,e=>e.flatMap(e=>e.links??[]).filter(e=>e!==void 0).map(e=>({tag:`link`,attrs:{...e,nonce:n}})),o),s=F(t.stores.matches,r=>{let i=t.ssr?.manifest,a=[];return i?(r.forEach(t=>{i.routes[t.routeId]?.css?.forEach(t=>{let r=ia(t);a.push({tag:`link`,attrs:{rel:`stylesheet`,...r,crossOrigin:$i(e,`stylesheet`)??r.crossOrigin,suppressHydrationWarning:!0,nonce:n}})})}),i.inlineStyle&&a.push({tag:`style`,attrs:{...i.inlineStyle.attrs,nonce:n},children:i.inlineStyle.children,inlineCss:!0}),a):a},o),c=F(t.stores.matches,r=>{let i=[],a=t.ssr?.manifest;return a&&r.forEach(t=>{a.routes[t.routeId]?.preloads?.forEach(t=>{i.push({tag:`link`,attrs:{...ta(a,t,e),nonce:n}})})}),i},o),l=F(t.stores.matches,e=>e.flatMap(e=>e.styles??[]).filter(e=>e!==void 0).map(({children:e,...t})=>({tag:`style`,attrs:{...t,nonce:n},children:e})),o),u=F(t.stores.matches,e=>e.flatMap(e=>e.headScripts??[]).filter(e=>e!==void 0).map(({children:e,...t})=>({tag:`script`,attrs:{...t,nonce:n},children:e})),o),d=[];return ra(d,i),d.push(...c),ra(d,a),d.push(...s),ra(d,l),ra(d,u),d};function uo(e){let t=lo(e.assetCrossOrigin),n=M().options.ssr?.nonce;return(0,W.jsx)(W.Fragment,{children:t.map(e=>(0,U.createElement)(so,{...e,key:`tsr-meta-${JSON.stringify(e)}`,nonce:n}))})}var fo=e((e=>{function t(e,t){var n=e.length;e.push(t);a:for(;0>>1,a=e[r];if(0>>1;ri(c,n))li(u,c)?(e[r]=u,e[l]=n,r=l):(e[r]=c,e[s]=n,r=s);else if(li(u,n))e[r]=u,e[l]=n,r=l;else break a}}return t}function i(e,t){var n=e.sortIndex-t.sortIndex;return n===0?e.id-t.id:n}if(e.unstable_now=void 0,typeof performance==`object`&&typeof performance.now==`function`){var a=performance;e.unstable_now=function(){return a.now()}}else{var o=Date,s=o.now();e.unstable_now=function(){return o.now()-s}}var c=[],l=[],u=1,d=null,f=3,p=!1,m=!1,h=!1,g=!1,_=typeof setTimeout==`function`?setTimeout:null,v=typeof clearTimeout==`function`?clearTimeout:null,y=typeof setImmediate<`u`?setImmediate:null;function b(e){for(var i=n(l);i!==null;){if(i.callback===null)r(l);else if(i.startTime<=e)r(l),i.sortIndex=i.expirationTime,t(c,i);else break;i=n(l)}}function x(e){if(h=!1,b(e),!m)if(n(c)!==null)m=!0,S||(S=!0,O());else{var t=n(l);t!==null&&j(x,t.startTime-e)}}var S=!1,C=-1,w=5,T=-1;function E(){return g?!0:!(e.unstable_now()-Tt&&E());){var o=d.callback;if(typeof o==`function`){d.callback=null,f=d.priorityLevel;var s=o(d.expirationTime<=t);if(t=e.unstable_now(),typeof s==`function`){d.callback=s,b(t),i=!0;break b}d===n(c)&&r(c),b(t)}else r(c);d=n(c)}if(d!==null)i=!0;else{var u=n(l);u!==null&&j(x,u.startTime-t),i=!1}}break a}finally{d=null,f=a,p=!1}i=void 0}}finally{i?O():S=!1}}}var O;if(typeof y==`function`)O=function(){y(D)};else if(typeof MessageChannel<`u`){var k=new MessageChannel,A=k.port2;k.port1.onmessage=D,O=function(){A.postMessage(null)}}else O=function(){_(D,0)};function j(t,n){C=_(function(){t(e.unstable_now())},n)}e.unstable_IdlePriority=5,e.unstable_ImmediatePriority=1,e.unstable_LowPriority=4,e.unstable_NormalPriority=3,e.unstable_Profiling=null,e.unstable_UserBlockingPriority=2,e.unstable_cancelCallback=function(e){e.callback=null},e.unstable_forceFrameRate=function(e){0>e||125o?(r.sortIndex=a,t(l,r),n(c)===null&&r===n(l)&&(h?(v(C),C=-1):h=!0,j(x,a-o))):(r.sortIndex=s,t(c,r),m||p||(m=!0,S||(S=!0,O()))),r},e.unstable_shouldYield=E,e.unstable_wrapCallback=function(e){var t=f;return function(){var n=f;f=t;try{return e.apply(this,arguments)}finally{f=n}}}})),po=e(((e,t)=>{t.exports=fo()})),mo=e((e=>{var t=po(),n=r(),i=dt();function a(e){var t=`https://react.dev/errors/`+e;if(1re||(e.current=ne[re],ne[re]=null,re--)}function F(e,t){re++,ne[re]=e.current,e.current=t}var oe=ie(null),se=ie(null),I=ie(null),ce=ie(null);function le(e,t){switch(F(I,t),F(se,e),F(oe,null),t.nodeType){case 9:case 11:e=(e=t.documentElement)&&(e=e.namespaceURI)?Ud(e):0;break;default:if(e=t.tagName,t=t.namespaceURI)t=Ud(t),e=Wd(t,e);else switch(e){case`svg`:e=1;break;case`math`:e=2;break;default:e=0}}ae(oe),F(oe,e)}function ue(){ae(oe),ae(se),ae(I)}function de(e){e.memoizedState!==null&&F(ce,e);var t=oe.current,n=Wd(t,e.type);t!==n&&(F(se,e),F(oe,n))}function fe(e){se.current===e&&(ae(oe),ae(se)),ce.current===e&&(ae(ce),$f._currentValue=te)}var pe,me;function he(e){if(pe===void 0)try{throw Error()}catch(e){var t=e.stack.trim().match(/\n( *(at )?)/);pe=t&&t[1]||``,me=-1)`:-1i||c[r]!==l[i]){var u=`
-`+c[r].replace(` at new `,` at `);return e.displayName&&u.includes(``)&&(u=u.replace(``,e.displayName)),u}while(1<=r&&0<=i);break}}}finally{ve=!1,Error.prepareStackTrace=n}return(n=e?e.displayName||e.name:``)?_e(n):``}function be(e,t){switch(e.tag){case 26:case 27:case 5:return _e(e.type);case 16:return _e(`Lazy`);case 13:return e.child!==t&&t!==null?_e(`Suspense Fallback`):_e(`Suspense`);case 19:return _e(`SuspenseList`);case 0:case 15:return ye(e.type,!1);case 11:return ye(e.type.render,!1);case 1:return ye(e.type,!0);case 31:return _e(`Activity`);default:return``}}function xe(e){try{var t=``,n=null;do t+=be(e,n),n=e,e=e.return;while(e);return t}catch(e){return`
+`+c[r].replace(` at new `,` at `);return e.displayName&&u.includes(``)&&(u=u.replace(``,e.displayName)),u}while(1<=r&&0<=i);break}}}finally{ge=!1,Error.prepareStackTrace=n}return(n=e?e.displayName||e.name:``)?he(n):``}function ve(e,t){switch(e.tag){case 26:case 27:case 5:return he(e.type);case 16:return he(`Lazy`);case 13:return e.child!==t&&t!==null?he(`Suspense Fallback`):he(`Suspense`);case 19:return he(`SuspenseList`);case 0:case 15:return _e(e.type,!1);case 11:return _e(e.type.render,!1);case 1:return _e(e.type,!0);case 31:return he(`Activity`);default:return``}}function L(e){try{var t=``,n=null;do t+=ve(e,n),n=e,e=e.return;while(e);return t}catch(e){return`
Error generating stack: `+e.message+`
-`+e.stack}}var Se=Object.prototype.hasOwnProperty,Ce=t.unstable_scheduleCallback,we=t.unstable_cancelCallback,Te=t.unstable_shouldYield,Ee=t.unstable_requestPaint,De=t.unstable_now,Oe=t.unstable_getCurrentPriorityLevel,ke=t.unstable_ImmediatePriority,Ae=t.unstable_UserBlockingPriority,Me=t.unstable_NormalPriority,Ne=t.unstable_LowPriority,P=t.unstable_IdlePriority,F=t.log,Pe=t.unstable_setDisableYieldValue,Fe=null,Ie=null;function I(e){if(typeof F==`function`&&Pe(e),Ie&&typeof Ie.setStrictMode==`function`)try{Ie.setStrictMode(Fe,e)}catch{}}var Le=Math.clz32?Math.clz32:Be,Re=Math.log,ze=Math.LN2;function Be(e){return e>>>=0,e===0?32:31-(Re(e)/ze|0)|0}var Ve=256,He=262144,Ue=4194304;function We(e){var t=e&42;if(t!==0)return t;switch(e&-e){case 1:return 1;case 2:return 2;case 4:return 4;case 8:return 8;case 16:return 16;case 32:return 32;case 64:return 64;case 128:return 128;case 256:case 512:case 1024:case 2048:case 4096:case 8192:case 16384:case 32768:case 65536:case 131072:return e&261888;case 262144:case 524288:case 1048576:case 2097152:return e&3932160;case 4194304:case 8388608:case 16777216:case 33554432:return e&62914560;case 67108864:return 67108864;case 134217728:return 134217728;case 268435456:return 268435456;case 536870912:return 536870912;case 1073741824:return 0;default:return e}}function Ge(e,t,n){var r=e.pendingLanes;if(r===0)return 0;var i=0,a=e.suspendedLanes,o=e.pingedLanes;e=e.warmLanes;var s=r&134217727;return s===0?(s=r&~a,s===0?o===0?n||(n=r&~e,n!==0&&(i=We(n))):i=We(o):i=We(s)):(r=s&~a,r===0?(o&=s,o===0?n||(n=s&~e,n!==0&&(i=We(n))):i=We(o)):i=We(r)),i===0?0:t!==0&&t!==i&&(t&a)===0&&(a=i&-i,n=t&-t,a>=n||a===32&&n&4194048)?t:i}function Ke(e,t){return(e.pendingLanes&~(e.suspendedLanes&~e.pingedLanes)&t)===0}function qe(e,t){switch(e){case 1:case 2:case 4:case 8:case 64:return t+250;case 16:case 32:case 128:case 256:case 512:case 1024:case 2048:case 4096:case 8192:case 16384:case 32768:case 65536:case 131072:case 262144:case 524288:case 1048576:case 2097152:return t+5e3;case 4194304:case 8388608:case 16777216:case 33554432:return-1;case 67108864:case 134217728:case 268435456:case 536870912:case 1073741824:return-1;default:return-1}}function Je(){var e=Ue;return Ue<<=1,!(Ue&62914560)&&(Ue=4194304),e}function Ye(e){for(var t=[],n=0;31>n;n++)t.push(e);return t}function Xe(e,t){e.pendingLanes|=t,t!==268435456&&(e.suspendedLanes=0,e.pingedLanes=0,e.warmLanes=0)}function Ze(e,t,n,r,i,a){var o=e.pendingLanes;e.pendingLanes=n,e.suspendedLanes=0,e.pingedLanes=0,e.warmLanes=0,e.expiredLanes&=n,e.entangledLanes&=n,e.errorRecoveryDisabledLanes&=n,e.shellSuspendCounter=0;var s=e.entanglements,c=e.expirationTimes,l=e.hiddenUpdates;for(n=o&~n;0`u`||window.document===void 0||window.document.createElement===void 0),un=!1;if(ln)try{var dn={};Object.defineProperty(dn,`passive`,{get:function(){un=!0}}),window.addEventListener(`test`,dn,dn),window.removeEventListener(`test`,dn,dn)}catch{un=!1}var fn=null,pn=null,mn=null;function hn(){if(mn)return mn;var e,t=pn,n=t.length,r,i=`value`in fn?fn.value:fn.textContent,a=i.length;for(e=0;e=qn),Xn=` `,Zn=!1;function Qn(e,t){switch(e){case`keyup`:return Gn.indexOf(t.keyCode)!==-1;case`keydown`:return t.keyCode!==229;case`keypress`:case`mousedown`:case`focusout`:return!0;default:return!1}}function $n(e){return e=e.detail,typeof e==`object`&&`data`in e?e.data:null}var er=!1;function tr(e,t){switch(e){case`compositionend`:return $n(t);case`keypress`:return t.which===32?(Zn=!0,Xn):null;case`textInput`:return e=t.data,e===Xn&&Zn?null:e;default:return null}}function nr(e,t){if(er)return e===`compositionend`||!Kn&&Qn(e,t)?(e=hn(),mn=pn=fn=null,er=!1,e):null;switch(e){case`paste`:return null;case`keypress`:if(!(t.ctrlKey||t.altKey||t.metaKey)||t.ctrlKey&&t.altKey){if(t.char&&1=t)return{node:n,offset:t-e};e=r}a:{for(;n;){if(n.nextSibling){n=n.nextSibling;break a}n=n.parentNode}n=void 0}n=wr(n)}}function Er(e,t){return e&&t?e===t?!0:e&&e.nodeType===3?!1:t&&t.nodeType===3?Er(e,t.parentNode):`contains`in e?e.contains(t):e.compareDocumentPosition?!!(e.compareDocumentPosition(t)&16):!1:!1}function Dr(e){e=e!=null&&e.ownerDocument!=null&&e.ownerDocument.defaultView!=null?e.ownerDocument.defaultView:window;for(var t=It(e.document);t instanceof e.HTMLIFrameElement;){try{var n=typeof t.contentWindow.location.href==`string`}catch{n=!1}if(n)e=t.contentWindow;else break;t=It(e.document)}return t}function Or(e){var t=e&&e.nodeName&&e.nodeName.toLowerCase();return t&&(t===`input`&&(e.type===`text`||e.type===`search`||e.type===`tel`||e.type===`url`||e.type===`password`)||t===`textarea`||e.contentEditable===`true`)}var kr=ln&&`documentMode`in document&&11>=document.documentMode,Ar=null,jr=null,Mr=null,Nr=!1;function Pr(e,t,n){var r=n.window===n?n.document:n.nodeType===9?n:n.ownerDocument;Nr||Ar==null||Ar!==It(r)||(r=Ar,`selectionStart`in r&&Or(r)?r={start:r.selectionStart,end:r.selectionEnd}:(r=(r.ownerDocument&&r.ownerDocument.defaultView||window).getSelection(),r={anchorNode:r.anchorNode,anchorOffset:r.anchorOffset,focusNode:r.focusNode,focusOffset:r.focusOffset}),Mr&&Cr(Mr,r)||(Mr=r,r=Rd(jr,`onSelect`),0>=o,i-=o,Di=1<<32-Le(t)+i|n<m?(h=d,d=null):h=d.sibling;var g=p(i,d,s[m],c);if(g===null){d===null&&(d=h);break}e&&d&&g.alternate===null&&t(i,d),a=o(g,a,m),u===null?l=g:u.sibling=g,u=g,d=h}if(m===s.length)return n(i,d),R&&ki(i,m),l;if(d===null){for(;mh?(g=m,m=null):g=m.sibling;var y=p(a,m,v.value,l);if(y===null){m===null&&(m=g);break}e&&m&&y.alternate===null&&t(a,m),s=o(y,s,h),d===null?u=y:d.sibling=y,d=y,m=g}if(v.done)return n(a,m),R&&ki(a,h),u;if(m===null){for(;!v.done;h++,v=c.next())v=f(a,v.value,l),v!==null&&(s=o(v,s,h),d===null?u=v:d.sibling=v,d=v);return R&&ki(a,h),u}for(m=r(m);!v.done;h++,v=c.next())v=_(m,a,h,v.value,l),v!==null&&(e&&v.alternate!==null&&m.delete(v.key===null?h:v.key),s=o(v,s,h),d===null?u=v:d.sibling=v,d=v);return e&&m.forEach(function(e){return t(a,e)}),R&&ki(a,h),u}function x(e,r,o,c){if(typeof o==`object`&&o&&o.type===g&&o.key===null&&(o=o.props.children),typeof o==`object`&&o){switch(o.$$typeof){case m:a:{for(var l=o.key;r!==null;){if(r.key===l){if(l=o.type,l===g){if(r.tag===7){n(e,r.sibling),c=a(r,o.props.children),c.return=e,e=c;break a}}else if(r.elementType===l||typeof l==`object`&&l&&l.$$typeof===T&&ka(l)===r.type){n(e,r.sibling),c=a(r,o.props),Ia(c,o),c.return=e,e=c;break a}n(e,r);break}else t(e,r);r=r.sibling}o.type===g?(c=pi(o.props.children,e.mode,c,o.key),c.return=e,e=c):(c=fi(o.type,o.key,o.props,null,e.mode,c),Ia(c,o),c.return=e,e=c)}return s(e);case h:a:{for(l=o.key;r!==null;){if(r.key===l)if(r.tag===4&&r.stateNode.containerInfo===o.containerInfo&&r.stateNode.implementation===o.implementation){n(e,r.sibling),c=a(r,o.children||[]),c.return=e,e=c;break a}else{n(e,r);break}else t(e,r);r=r.sibling}c=_i(o,e.mode,c),c.return=e,e=c}return s(e);case T:return o=ka(o),x(e,r,o,c)}if(re(o))return v(e,r,o,c);if(O(o)){if(l=O(o),typeof l!=`function`)throw Error(i(150));return o=l.call(o),y(e,r,o,c)}if(typeof o.then==`function`)return x(e,r,Fa(o),c);if(o.$$typeof===b)return x(e,r,na(e,o),c);La(e,o)}return typeof o==`string`&&o!==``||typeof o==`number`||typeof o==`bigint`?(o=``+o,r!==null&&r.tag===6?(n(e,r.sibling),c=a(r,o),c.return=e,e=c):(n(e,r),c=hi(o,e.mode,c),c.return=e,e=c),s(e)):n(e,r)}return function(e,t,n,r){try{Pa=0;var i=x(e,t,n,r);return Na=null,i}catch(t){if(t===Ca||t===Ta)throw t;var a=ci(29,t,null,e.mode);return a.lanes=r,a.return=e,a}}}var za=Ra(!0),Ba=Ra(!1),Va=!1;function Ha(e){e.updateQueue={baseState:e.memoizedState,firstBaseUpdate:null,lastBaseUpdate:null,shared:{pending:null,lanes:0,hiddenCallbacks:null},callbacks:null}}function Ua(e,t){e=e.updateQueue,t.updateQueue===e&&(t.updateQueue={baseState:e.baseState,firstBaseUpdate:e.firstBaseUpdate,lastBaseUpdate:e.lastBaseUpdate,shared:e.shared,callbacks:null})}function Wa(e){return{lane:e,tag:0,payload:null,callback:null,next:null}}function Ga(e,t,n){var r=e.updateQueue;if(r===null)return null;if(r=r.shared,Wl&2){var i=r.pending;return i===null?t.next=t:(t.next=i.next,i.next=t),r.pending=t,t=ai(e),ii(e,null,n),t}return ti(e,r,t,n),ai(e)}function Ka(e,t,n){if(t=t.updateQueue,t!==null&&(t=t.shared,n&4194048)){var r=t.lanes;r&=e.pendingLanes,n|=r,t.lanes=n,$e(e,n)}}function qa(e,t){var n=e.updateQueue,r=e.alternate;if(r!==null&&(r=r.updateQueue,n===r)){var i=null,a=null;if(n=n.firstBaseUpdate,n!==null){do{var o={lane:n.lane,tag:n.tag,payload:n.payload,callback:null,next:null};a===null?i=a=o:a=a.next=o,n=n.next}while(n!==null);a===null?i=a=t:a=a.next=t}else i=a=t;n={baseState:r.baseState,firstBaseUpdate:i,lastBaseUpdate:a,shared:r.shared,callbacks:r.callbacks},e.updateQueue=n;return}e=n.lastBaseUpdate,e===null?n.firstBaseUpdate=t:e.next=t,n.lastBaseUpdate=t}var Ja=!1;function Ya(){if(Ja){var e=ma;if(e!==null)throw e}}function Xa(e,t,n,r){Ja=!1;var i=e.updateQueue;Va=!1;var a=i.firstBaseUpdate,o=i.lastBaseUpdate,s=i.shared.pending;if(s!==null){i.shared.pending=null;var c=s,l=c.next;c.next=null,o===null?a=l:o.next=l,o=c;var u=e.alternate;u!==null&&(u=u.updateQueue,s=u.lastBaseUpdate,s!==o&&(s===null?u.firstBaseUpdate=l:s.next=l,u.lastBaseUpdate=c))}if(a!==null){var d=i.baseState;o=0,u=l=c=null,s=a;do{var p=s.lane&-536870913,m=p!==s.lane;if(m?(ql&p)===p:(r&p)===p){p!==0&&p===pa&&(Ja=!0),u!==null&&(u=u.next={lane:0,tag:s.tag,payload:s.payload,callback:null,next:null});a:{var h=e,g=s;p=t;var _=n;switch(g.tag){case 1:if(h=g.payload,typeof h==`function`){d=h.call(_,d,p);break a}d=h;break a;case 3:h.flags=h.flags&-65537|128;case 0:if(h=g.payload,p=typeof h==`function`?h.call(_,d,p):h,p==null)break a;d=f({},d,p);break a;case 2:Va=!0}}p=s.callback,p!==null&&(e.flags|=64,m&&(e.flags|=8192),m=i.callbacks,m===null?i.callbacks=[p]:m.push(p))}else m={lane:p,tag:s.tag,payload:s.payload,callback:s.callback,next:null},u===null?(l=u=m,c=d):u=u.next=m,o|=p;if(s=s.next,s===null){if(s=i.shared.pending,s===null)break;m=s,s=m.next,m.next=null,i.lastBaseUpdate=m,i.shared.pending=null}}while(1);u===null&&(c=d),i.baseState=c,i.firstBaseUpdate=l,i.lastBaseUpdate=u,a===null&&(i.shared.lanes=0),tu|=o,e.lanes=o,e.memoizedState=d}}function Za(e,t){if(typeof e!=`function`)throw Error(i(191,e));e.call(t)}function Qa(e,t){var n=e.callbacks;if(n!==null)for(e.callbacks=null,e=0;ea?a:8;var o=k.T,s={};k.T=s,zs(e,!1,t,n);try{var c=i(),l=k.S;l!==null&&l(s,c),typeof c==`object`&&c&&typeof c.then==`function`?Rs(e,t,_a(c,r),Cu(e)):Rs(e,t,r,Cu(e))}catch(n){Rs(e,t,{then:function(){},status:`rejected`,reason:n},Cu())}finally{A.p=a,o!==null&&s.types!==null&&(o.types=s.types),k.T=o}}function Os(){}function ks(e,t,n,r){if(e.tag!==5)throw Error(i(476));var a=As(e).queue;Ds(e,a,t,ie,n===null?Os:function(){return js(e),n(r)})}function As(e){var t=e.memoizedState;if(t!==null)return t;t={memoizedState:ie,baseState:ie,baseQueue:null,queue:{pending:null,lanes:0,dispatch:null,lastRenderedReducer:Bo,lastRenderedState:ie},next:null};var n={};return t.next={memoizedState:n,baseState:n,baseQueue:null,queue:{pending:null,lanes:0,dispatch:null,lastRenderedReducer:Bo,lastRenderedState:n},next:null},e.memoizedState=t,e=e.alternate,e!==null&&(e.memoizedState=t),t}function js(e){var t=As(e);t.next===null&&(t=e.alternate.memoizedState),Rs(e,t.next.queue,{},Cu())}function Ms(){return ta(dp)}function Ns(){return Fo().memoizedState}function Ps(){return Fo().memoizedState}function Fs(e){for(var t=e.return;t!==null;){switch(t.tag){case 24:case 3:var n=Cu();e=Wa(n);var r=Ga(t,e,n);r!==null&&(Tu(r,t,n),Ka(r,t,n)),t={cache:la()},e.payload=t;return}t=t.return}}function Is(e,t,n){var r=Cu();n={lane:r,revertLane:0,gesture:null,action:n,hasEagerState:!1,eagerState:null,next:null},Bs(e)?Vs(t,n):(n=ni(e,t,n,r),n!==null&&(Tu(n,e,r),Hs(n,t,r)))}function Ls(e,t,n){Rs(e,t,n,Cu())}function Rs(e,t,n,r){var i={lane:r,revertLane:0,gesture:null,action:n,hasEagerState:!1,eagerState:null,next:null};if(Bs(e))Vs(t,i);else{var a=e.alternate;if(e.lanes===0&&(a===null||a.lanes===0)&&(a=t.lastRenderedReducer,a!==null))try{var o=t.lastRenderedState,s=a(o,n);if(i.hasEagerState=!0,i.eagerState=s,Sr(s,o))return ti(e,t,i,0),Gl===null&&ei(),!1}catch{}if(n=ni(e,t,i,r),n!==null)return Tu(n,e,r),Hs(n,t,r),!0}return!1}function zs(e,t,n,r){if(r={lane:2,revertLane:Sd(),gesture:null,action:r,hasEagerState:!1,eagerState:null,next:null},Bs(e)){if(t)throw Error(i(479))}else t=ni(e,n,r,2),t!==null&&Tu(t,e,2)}function Bs(e){var t=e.alternate;return e===ho||t!==null&&t===ho}function Vs(e,t){yo=vo=!0;var n=e.pending;n===null?t.next=t:(t.next=n.next,n.next=t),e.pending=t}function Hs(e,t,n){if(n&4194048){var r=t.lanes;r&=e.pendingLanes,n|=r,t.lanes=n,$e(e,n)}}var Us={readContext:ta,use:Ro,useCallback:To,useContext:To,useEffect:To,useImperativeHandle:To,useLayoutEffect:To,useInsertionEffect:To,useMemo:To,useReducer:To,useRef:To,useState:To,useDebugValue:To,useDeferredValue:To,useTransition:To,useSyncExternalStore:To,useId:To,useHostTransitionStatus:To,useFormState:To,useActionState:To,useOptimistic:To,useMemoCache:To,useCacheRefresh:To};Us.useEffectEvent=To;var Ws={readContext:ta,use:Ro,useCallback:function(e,t){return Po().memoizedState=[e,t===void 0?null:t],e},useContext:ta,useEffect:ms,useImperativeHandle:function(e,t,n){n=n==null?null:n.concat([e]),fs(4194308,4,bs.bind(null,t,e),n)},useLayoutEffect:function(e,t){return fs(4194308,4,e,t)},useInsertionEffect:function(e,t){fs(4,2,e,t)},useMemo:function(e,t){var n=Po();t=t===void 0?null:t;var r=e();if(bo){I(!0);try{e()}finally{I(!1)}}return n.memoizedState=[r,t],r},useReducer:function(e,t,n){var r=Po();if(n!==void 0){var i=n(t);if(bo){I(!0);try{n(t)}finally{I(!1)}}}else i=t;return r.memoizedState=r.baseState=i,e={pending:null,lanes:0,dispatch:null,lastRenderedReducer:e,lastRenderedState:i},r.queue=e,e=e.dispatch=Is.bind(null,ho,e),[r.memoizedState,e]},useRef:function(e){var t=Po();return e={current:e},t.memoizedState=e},useState:function(e){e=Xo(e);var t=e.queue,n=Ls.bind(null,ho,t);return t.dispatch=n,[e.memoizedState,n]},useDebugValue:Ss,useDeferredValue:function(e,t){return Ts(Po(),e,t)},useTransition:function(){var e=Xo(!1);return e=Ds.bind(null,ho,e.queue,!0,!1),Po().memoizedState=e,[!1,e]},useSyncExternalStore:function(e,t,n){var r=ho,a=Po();if(R){if(n===void 0)throw Error(i(407));n=n()}else{if(n=t(),Gl===null)throw Error(i(349));ql&127||Go(r,t,n)}a.memoizedState=n;var o={value:n,getSnapshot:t};return a.queue=o,ms(qo.bind(null,r,o,e),[e]),r.flags|=2048,us(9,{destroy:void 0},Ko.bind(null,r,o,n,t),null),n},useId:function(){var e=Po(),t=Gl.identifierPrefix;if(R){var n=Oi,r=Di;n=(r&~(1<<32-Le(r)-1)).toString(32)+n,t=`_`+t+`R_`+n,n=xo++,0<\/script>`,o=o.removeChild(o.firstChild);break;case`select`:o=typeof r.is==`string`?s.createElement(`select`,{is:r.is}):s.createElement(`select`),r.multiple?o.multiple=!0:r.size&&(o.size=r.size);break;default:o=typeof r.is==`string`?s.createElement(a,{is:r.is}):s.createElement(a)}}o[ot]=t,o[st]=r;a:for(s=t.child;s!==null;){if(s.tag===5||s.tag===6)o.appendChild(s.stateNode);else if(s.tag!==4&&s.tag!==27&&s.child!==null){s.child.return=s,s=s.child;continue}if(s===t)break a;for(;s.sibling===null;){if(s.return===null||s.return===t)break a;s=s.return}s.sibling.return=s.return,s=s.sibling}t.stateNode=o;a:switch(qd(o,a,r),a){case`button`:case`input`:case`select`:case`textarea`:r=!!r.autoFocus;break a;case`img`:r=!0;break a;default:r=!1}r&&Lc(t)}}return Vc(t),Rc(t,t.type,e===null?null:e.memoizedProps,t.pendingProps,n),null;case 6:if(e&&t.stateNode!=null)e.memoizedProps!==r&&Lc(t);else{if(typeof r!=`string`&&t.stateNode===null)throw Error(i(166));if(e=ue.current,Vi(t)){if(e=t.stateNode,n=t.memoizedProps,r=null,a=L,a!==null)switch(a.tag){case 27:case 5:r=a.memoizedProps}e[ot]=t,e=!!(e.nodeValue===n||r!==null&&!0===r.suppressHydrationWarning||Wd(e.nodeValue,n)),e||Ri(t,!0)}else e=$d(e).createTextNode(r),e[ot]=t,t.stateNode=e}return Vc(t),null;case 31:if(n=t.memoizedState,e===null||e.memoizedState!==null){if(r=Vi(t),n!==null){if(e===null){if(!r)throw Error(i(318));if(e=t.memoizedState,e=e===null?null:e.dehydrated,!e)throw Error(i(557));e[ot]=t}else Hi(),!(t.flags&128)&&(t.memoizedState=null),t.flags|=4;Vc(t),e=!1}else n=Ui(),e!==null&&e.memoizedState!==null&&(e.memoizedState.hydrationErrors=n),e=!0;if(!e)return t.flags&256?(uo(t),t):(uo(t),null);if(t.flags&128)throw Error(i(558))}return Vc(t),null;case 13:if(r=t.memoizedState,e===null||e.memoizedState!==null&&e.memoizedState.dehydrated!==null){if(a=Vi(t),r!==null&&r.dehydrated!==null){if(e===null){if(!a)throw Error(i(318));if(a=t.memoizedState,a=a===null?null:a.dehydrated,!a)throw Error(i(317));a[ot]=t}else Hi(),!(t.flags&128)&&(t.memoizedState=null),t.flags|=4;Vc(t),a=!1}else a=Ui(),e!==null&&e.memoizedState!==null&&(e.memoizedState.hydrationErrors=a),a=!0;if(!a)return t.flags&256?(uo(t),t):(uo(t),null)}return uo(t),t.flags&128?(t.lanes=n,t):(n=r!==null,e=e!==null&&e.memoizedState!==null,n&&(r=t.child,a=null,r.alternate!==null&&r.alternate.memoizedState!==null&&r.alternate.memoizedState.cachePool!==null&&(a=r.alternate.memoizedState.cachePool.pool),o=null,r.memoizedState!==null&&r.memoizedState.cachePool!==null&&(o=r.memoizedState.cachePool.pool),o!==a&&(r.flags|=2048)),n!==e&&n&&(t.child.flags|=8192),zc(t,t.updateQueue),Vc(t),null);case 4:return fe(),e===null&&Pd(t.stateNode.containerInfo),Vc(t),null;case 10:return Yi(t.type),Vc(t),null;case 19:if(se(fo),r=t.memoizedState,r===null)return Vc(t),null;if(a=(t.flags&128)!=0,o=r.rendering,o===null)if(a)Bc(r,!1);else{if(eu!==0||e!==null&&e.flags&128)for(e=t.child;e!==null;){if(o=po(e),o!==null){for(t.flags|=128,Bc(r,!1),e=o.updateQueue,t.updateQueue=e,zc(t,e),t.subtreeFlags=0,e=n,n=t.child;n!==null;)di(n,e),n=n.sibling;return ce(fo,fo.current&1|2),R&&ki(t,r.treeForkCount),t.child}e=e.sibling}r.tail!==null&&De()>du&&(t.flags|=128,a=!0,Bc(r,!1),t.lanes=4194304)}else{if(!a)if(e=po(o),e!==null){if(t.flags|=128,a=!0,e=e.updateQueue,t.updateQueue=e,zc(t,e),Bc(r,!0),r.tail===null&&r.tailMode===`hidden`&&!o.alternate&&!R)return Vc(t),null}else 2*De()-r.renderingStartTime>du&&n!==536870912&&(t.flags|=128,a=!0,Bc(r,!1),t.lanes=4194304);r.isBackwards?(o.sibling=t.child,t.child=o):(e=r.last,e===null?t.child=o:e.sibling=o,r.last=o)}return r.tail===null?(Vc(t),null):(e=r.tail,r.rendering=e,r.tail=e.sibling,r.renderingStartTime=De(),e.sibling=null,n=fo.current,ce(fo,a?n&1|2:n&1),R&&ki(t,r.treeForkCount),e);case 22:case 23:return uo(t),ro(),r=t.memoizedState!==null,e===null?r&&(t.flags|=8192):e.memoizedState!==null!==r&&(t.flags|=8192),r?n&536870912&&!(t.flags&128)&&(Vc(t),t.subtreeFlags&6&&(t.flags|=8192)):Vc(t),n=t.updateQueue,n!==null&&zc(t,n.retryQueue),n=null,e!==null&&e.memoizedState!==null&&e.memoizedState.cachePool!==null&&(n=e.memoizedState.cachePool.pool),r=null,t.memoizedState!==null&&t.memoizedState.cachePool!==null&&(r=t.memoizedState.cachePool.pool),r!==n&&(t.flags|=2048),e!==null&&se(ya),null;case 24:return n=null,e!==null&&(n=e.memoizedState.cache),t.memoizedState.cache!==n&&(t.flags|=2048),Yi(ca),Vc(t),null;case 25:return null;case 30:return null}throw Error(i(156,t.tag))}function Uc(e,t){switch(Mi(t),t.tag){case 1:return e=t.flags,e&65536?(t.flags=e&-65537|128,t):null;case 3:return Yi(ca),fe(),e=t.flags,e&65536&&!(e&128)?(t.flags=e&-65537|128,t):null;case 26:case 27:case 5:return me(t),null;case 31:if(t.memoizedState!==null){if(uo(t),t.alternate===null)throw Error(i(340));Hi()}return e=t.flags,e&65536?(t.flags=e&-65537|128,t):null;case 13:if(uo(t),e=t.memoizedState,e!==null&&e.dehydrated!==null){if(t.alternate===null)throw Error(i(340));Hi()}return e=t.flags,e&65536?(t.flags=e&-65537|128,t):null;case 19:return se(fo),null;case 4:return fe(),null;case 10:return Yi(t.type),null;case 22:case 23:return uo(t),ro(),e!==null&&se(ya),e=t.flags,e&65536?(t.flags=e&-65537|128,t):null;case 24:return Yi(ca),null;case 25:return null;default:return null}}function Wc(e,t){switch(Mi(t),t.tag){case 3:Yi(ca),fe();break;case 26:case 27:case 5:me(t);break;case 4:fe();break;case 31:t.memoizedState!==null&&uo(t);break;case 13:uo(t);break;case 19:se(fo);break;case 10:Yi(t.type);break;case 22:case 23:uo(t),ro(),e!==null&&se(ya);break;case 24:Yi(ca)}}function Gc(e,t){try{var n=t.updateQueue,r=n===null?null:n.lastEffect;if(r!==null){var i=r.next;n=i;do{if((n.tag&e)===e){r=void 0;var a=n.create,o=n.inst;r=a(),o.destroy=r}n=n.next}while(n!==i)}}catch(e){td(t,t.return,e)}}function Kc(e,t,n){try{var r=t.updateQueue,i=r===null?null:r.lastEffect;if(i!==null){var a=i.next;r=a;do{if((r.tag&e)===e){var o=r.inst,s=o.destroy;if(s!==void 0){o.destroy=void 0,i=t;var c=n,l=s;try{l()}catch(e){td(i,c,e)}}}r=r.next}while(r!==a)}}catch(e){td(t,t.return,e)}}function qc(e){var t=e.updateQueue;if(t!==null){var n=e.stateNode;try{Qa(t,n)}catch(t){td(e,e.return,t)}}}function Jc(e,t,n){n.props=Zs(e.type,e.memoizedProps),n.state=e.memoizedState;try{n.componentWillUnmount()}catch(n){td(e,t,n)}}function Yc(e,t){try{var n=e.ref;if(n!==null){switch(e.tag){case 26:case 27:case 5:var r=e.stateNode;break;case 30:r=e.stateNode;break;default:r=e.stateNode}typeof n==`function`?e.refCleanup=n(r):n.current=r}}catch(n){td(e,t,n)}}function Xc(e,t){var n=e.ref,r=e.refCleanup;if(n!==null)if(typeof r==`function`)try{r()}catch(n){td(e,t,n)}finally{e.refCleanup=null,e=e.alternate,e!=null&&(e.refCleanup=null)}else if(typeof n==`function`)try{n(null)}catch(n){td(e,t,n)}else n.current=null}function Zc(e){var t=e.type,n=e.memoizedProps,r=e.stateNode;try{a:switch(t){case`button`:case`input`:case`select`:case`textarea`:n.autoFocus&&r.focus();break a;case`img`:n.src?r.src=n.src:n.srcSet&&(r.srcset=n.srcSet)}}catch(t){td(e,e.return,t)}}function Qc(e,t,n){try{var r=e.stateNode;Jd(r,e.type,n,t),r[st]=t}catch(t){td(e,e.return,t)}}function $c(e){return e.tag===5||e.tag===3||e.tag===26||e.tag===27&&df(e.type)||e.tag===4}function el(e){a:for(;;){for(;e.sibling===null;){if(e.return===null||$c(e.return))return null;e=e.return}for(e.sibling.return=e.return,e=e.sibling;e.tag!==5&&e.tag!==6&&e.tag!==18;){if(e.tag===27&&df(e.type)||e.flags&2||e.child===null||e.tag===4)continue a;e.child.return=e,e=e.child}if(!(e.flags&2))return e.stateNode}}function tl(e,t,n){var r=e.tag;if(r===5||r===6)e=e.stateNode,t?(n.nodeType===9?n.body:n.nodeName===`HTML`?n.ownerDocument.body:n).insertBefore(e,t):(t=n.nodeType===9?n.body:n.nodeName===`HTML`?n.ownerDocument.body:n,t.appendChild(e),n=n._reactRootContainer,n!=null||t.onclick!==null||(t.onclick=$t));else if(r!==4&&(r===27&&df(e.type)&&(n=e.stateNode,t=null),e=e.child,e!==null))for(tl(e,t,n),e=e.sibling;e!==null;)tl(e,t,n),e=e.sibling}function nl(e,t,n){var r=e.tag;if(r===5||r===6)e=e.stateNode,t?n.insertBefore(e,t):n.appendChild(e);else if(r!==4&&(r===27&&df(e.type)&&(n=e.stateNode),e=e.child,e!==null))for(nl(e,t,n),e=e.sibling;e!==null;)nl(e,t,n),e=e.sibling}function rl(e){var t=e.stateNode,n=e.memoizedProps;try{for(var r=e.type,i=t.attributes;i.length;)t.removeAttributeNode(i[0]);qd(t,r,n),t[ot]=e,t[st]=n}catch(t){td(e,e.return,t)}}var il=!1,al=!1,ol=!1,sl=typeof WeakSet==`function`?WeakSet:Set,cl=null;function ll(e,t){if(e=e.containerInfo,Zd=bp,e=Dr(e),Or(e)){if(`selectionStart`in e)var n={start:e.selectionStart,end:e.selectionEnd};else a:{n=(n=e.ownerDocument)&&n.defaultView||window;var r=n.getSelection&&n.getSelection();if(r&&r.rangeCount!==0){n=r.anchorNode;var a=r.anchorOffset,o=r.focusNode;r=r.focusOffset;try{n.nodeType,o.nodeType}catch{n=null;break a}var s=0,c=-1,l=-1,u=0,d=0,f=e,p=null;b:for(;;){for(var m;f!==n||a!==0&&f.nodeType!==3||(c=s+a),f!==o||r!==0&&f.nodeType!==3||(l=s+r),f.nodeType===3&&(s+=f.nodeValue.length),(m=f.firstChild)!==null;)p=f,f=m;for(;;){if(f===e)break b;if(p===n&&++u===a&&(c=s),p===o&&++d===r&&(l=s),(m=f.nextSibling)!==null)break;f=p,p=f.parentNode}f=m}n=c===-1||l===-1?null:{start:c,end:l}}else n=null}n||={start:0,end:0}}else n=null;for(Qd={focusedElem:e,selectionRange:n},bp=!1,cl=t;cl!==null;)if(t=cl,e=t.child,t.subtreeFlags&1028&&e!==null)e.return=t,cl=e;else for(;cl!==null;){switch(t=cl,o=t.alternate,e=t.flags,t.tag){case 0:if(e&4&&(e=t.updateQueue,e=e===null?null:e.events,e!==null))for(n=0;n title`))),qd(o,r,n),o[ot]=e,yt(o),r=o;break a;case`link`:var s=ep(`link`,`href`,a).get(r+(n.href||``));if(s){for(var c=0;cg&&(o=g,g=h,h=o);var _=Tr(s,h),v=Tr(s,g);if(_&&v&&(p.rangeCount!==1||p.anchorNode!==_.node||p.anchorOffset!==_.offset||p.focusNode!==v.node||p.focusOffset!==v.offset)){var y=d.createRange();y.setStart(_.node,_.offset),p.removeAllRanges(),h>g?(p.addRange(y),p.extend(v.node,v.offset)):(y.setEnd(v.node,v.offset),p.addRange(y))}}}}for(d=[],p=s;p=p.parentNode;)p.nodeType===1&&d.push({element:p,left:p.scrollLeft,top:p.scrollTop});for(typeof s.focus==`function`&&s.focus(),s=0;sn?32:n,k.T=null,n=yu,yu=null;var o=hu,s=_u;if(mu=0,gu=hu=null,_u=0,Wl&6)throw Error(i(331));var c=Wl;if(Wl|=4,zl(o.current),jl(o,o.current,s,n),Wl=c,hd(0,!1),Ie&&typeof Ie.onPostCommitFiberRoot==`function`)try{Ie.onPostCommitFiberRoot(Fe,o)}catch{}return!0}finally{A.p=a,k.T=r,Zu(e,t)}}function ed(e,t,n){t=yi(n,t),t=rc(e.stateNode,t,2),e=Ga(e,t,2),e!==null&&(Xe(e,2),md(e))}function td(e,t,n){if(e.tag===3)ed(e,e,n);else for(;t!==null;){if(t.tag===3){ed(t,e,n);break}else if(t.tag===1){var r=t.stateNode;if(typeof t.type.getDerivedStateFromError==`function`||typeof r.componentDidCatch==`function`&&(pu===null||!pu.has(r))){e=yi(n,e),n=ic(2),r=Ga(t,n,2),r!==null&&(ac(n,r,t,e),Xe(r,2),md(r));break}}t=t.return}}function nd(e,t,n){var r=e.pingCache;if(r===null){r=e.pingCache=new Ul;var i=new Set;r.set(t,i)}else i=r.get(t),i===void 0&&(i=new Set,r.set(t,i));i.has(n)||(Ql=!0,i.add(n),e=rd.bind(null,e,t,n),t.then(e,e))}function rd(e,t,n){var r=e.pingCache;r!==null&&r.delete(t),e.pingedLanes|=e.suspendedLanes&n,e.warmLanes&=~n,Gl===e&&(ql&n)===n&&(eu===4||eu===3&&(ql&62914560)===ql&&300>De()-lu?!(Wl&2)&&Mu(e,0):ru|=n,au===ql&&(au=0)),md(e)}function id(e,t){t===0&&(t=Je()),e=ri(e,t),e!==null&&(Xe(e,t),md(e))}function ad(e){var t=e.memoizedState,n=0;t!==null&&(n=t.retryLane),id(e,n)}function od(e,t){var n=0;switch(e.tag){case 31:case 13:var r=e.stateNode,a=e.memoizedState;a!==null&&(n=a.retryLane);break;case 19:r=e.stateNode;break;case 22:r=e.stateNode._retryCache;break;default:throw Error(i(314))}r!==null&&r.delete(t),id(e,n)}function sd(e,t){return Ce(e,t)}var cd=null,ld=null,ud=!1,dd=!1,fd=!1,pd=0;function md(e){e!==ld&&e.next===null&&(ld===null?cd=ld=e:ld=ld.next=e),dd=!0,ud||(ud=!0,xd())}function hd(e,t){if(!fd&&dd){fd=!0;do for(var n=!1,r=cd;r!==null;){if(!t)if(e!==0){var i=r.pendingLanes;if(i===0)var a=0;else{var o=r.suspendedLanes,s=r.pingedLanes;a=(1<<31-Le(42|e)+1)-1,a&=i&~(o&~s),a=a&201326741?a&201326741|1:a?a|2:0}a!==0&&(n=!0,bd(r,a))}else a=ql,a=Ge(r,r===Gl?a:0,r.cancelPendingCommit!==null||r.timeoutHandle!==-1),!(a&3)||Ke(r,a)||(n=!0,bd(r,a));r=r.next}while(n);fd=!1}}function gd(){_d()}function _d(){dd=ud=!1;var e=0;pd!==0&&af()&&(e=pd);for(var t=De(),n=null,r=cd;r!==null;){var i=r.next,a=vd(r,t);a===0?(r.next=null,n===null?cd=i:n.next=i,i===null&&(ld=n)):(n=r,(e!==0||a&3)&&(dd=!0)),r=i}mu!==0&&mu!==5||hd(e,!1),pd!==0&&(pd=0)}function vd(e,t){for(var n=e.suspendedLanes,r=e.pingedLanes,i=e.expirationTimes,a=e.pendingLanes&-62914561;0s)break;var u=c.transferSize,d=c.initiatorType;u&&Yd(d)&&(c=c.responseEnd,o+=u*(c`u`?null:document;function Pf(e,t,n){var r=Nf;if(r&&typeof t==`string`&&t){var i=Rt(t);i=`link[rel="`+e+`"][href="`+i+`"]`,typeof n==`string`&&(i+=`[crossorigin="`+n+`"]`),Of.has(i)||(Of.add(i),e={rel:e,crossOrigin:n,href:t},r.querySelector(i)===null&&(t=r.createElement(`link`),qd(t,`link`,e),yt(t),r.head.appendChild(t)))}}function Ff(e){Af.D(e),Pf(`dns-prefetch`,e,null)}function If(e,t){Af.C(e,t),Pf(`preconnect`,e,t)}function Lf(e,t,n){Af.L(e,t,n);var r=Nf;if(r&&e&&t){var i=`link[rel="preload"][as="`+Rt(t)+`"]`;t===`image`&&n&&n.imageSrcSet?(i+=`[imagesrcset="`+Rt(n.imageSrcSet)+`"]`,typeof n.imageSizes==`string`&&(i+=`[imagesizes="`+Rt(n.imageSizes)+`"]`)):i+=`[href="`+Rt(e)+`"]`;var a=i;switch(t){case`style`:a=Uf(e);break;case`script`:a=qf(e)}Df.has(a)||(e=f({rel:`preload`,href:t===`image`&&n&&n.imageSrcSet?void 0:e,as:t},n),Df.set(a,e),r.querySelector(i)!==null||t===`style`&&r.querySelector(Wf(a))||t===`script`&&r.querySelector(Jf(a))||(t=r.createElement(`link`),qd(t,`link`,e),yt(t),r.head.appendChild(t)))}}function Rf(e,t){Af.m(e,t);var n=Nf;if(n&&e){var r=t&&typeof t.as==`string`?t.as:`script`,i=`link[rel="modulepreload"][as="`+Rt(r)+`"][href="`+Rt(e)+`"]`,a=i;switch(r){case`audioworklet`:case`paintworklet`:case`serviceworker`:case`sharedworker`:case`worker`:case`script`:a=qf(e)}if(!Df.has(a)&&(e=f({rel:`modulepreload`,href:e},t),Df.set(a,e),n.querySelector(i)===null)){switch(r){case`audioworklet`:case`paintworklet`:case`serviceworker`:case`sharedworker`:case`worker`:case`script`:if(n.querySelector(Jf(a)))return}r=n.createElement(`link`),qd(r,`link`,e),yt(r),n.head.appendChild(r)}}}function zf(e,t,n){Af.S(e,t,n);var r=Nf;if(r&&e){var i=vt(r).hoistableStyles,a=Uf(e);t||=`default`;var o=i.get(a);if(!o){var s={loading:0,preload:null};if(o=r.querySelector(Wf(a)))s.loading=5;else{e=f({rel:`stylesheet`,href:e,"data-precedence":t},n),(n=Df.get(a))&&Zf(e,n);var c=o=r.createElement(`link`);yt(c),qd(c,`link`,e),c._p=new Promise(function(e,t){c.onload=e,c.onerror=t}),c.addEventListener(`load`,function(){s.loading|=1}),c.addEventListener(`error`,function(){s.loading|=2}),s.loading|=4,Xf(o,t,r)}o={type:`stylesheet`,instance:o,count:1,state:s},i.set(a,o)}}}function Bf(e,t){Af.X(e,t);var n=Nf;if(n&&e){var r=vt(n).hoistableScripts,i=qf(e),a=r.get(i);a||(a=n.querySelector(Jf(i)),a||(e=f({src:e,async:!0},t),(t=Df.get(i))&&Qf(e,t),a=n.createElement(`script`),yt(a),qd(a,`link`,e),n.head.appendChild(a)),a={type:`script`,instance:a,count:1,state:null},r.set(i,a))}}function Vf(e,t){Af.M(e,t);var n=Nf;if(n&&e){var r=vt(n).hoistableScripts,i=qf(e),a=r.get(i);a||(a=n.querySelector(Jf(i)),a||(e=f({src:e,async:!0,type:`module`},t),(t=Df.get(i))&&Qf(e,t),a=n.createElement(`script`),yt(a),qd(a,`link`,e),n.head.appendChild(a)),a={type:`script`,instance:a,count:1,state:null},r.set(i,a))}}function Hf(e,t,n,r){var a=(a=ue.current)?kf(a):null;if(!a)throw Error(i(446));switch(e){case`meta`:case`title`:return null;case`style`:return typeof n.precedence==`string`&&typeof n.href==`string`?(t=Uf(n.href),n=vt(a).hoistableStyles,r=n.get(t),r||(r={type:`style`,instance:null,count:0,state:null},n.set(t,r)),r):{type:`void`,instance:null,count:0,state:null};case`link`:if(n.rel===`stylesheet`&&typeof n.href==`string`&&typeof n.precedence==`string`){e=Uf(n.href);var o=vt(a).hoistableStyles,s=o.get(e);if(s||(a=a.ownerDocument||a,s={type:`stylesheet`,instance:null,count:0,state:{loading:0,preload:null}},o.set(e,s),(o=a.querySelector(Wf(e)))&&!o._p&&(s.instance=o,s.state.loading=5),Df.has(e)||(n={rel:`preload`,as:`style`,href:n.href,crossOrigin:n.crossOrigin,integrity:n.integrity,media:n.media,hrefLang:n.hrefLang,referrerPolicy:n.referrerPolicy},Df.set(e,n),o||Kf(a,e,n,s.state))),t&&r===null)throw Error(i(528,``));return s}if(t&&r!==null)throw Error(i(529,``));return null;case`script`:return t=n.async,n=n.src,typeof n==`string`&&t&&typeof t!=`function`&&typeof t!=`symbol`?(t=qf(n),n=vt(a).hoistableScripts,r=n.get(t),r||(r={type:`script`,instance:null,count:0,state:null},n.set(t,r)),r):{type:`void`,instance:null,count:0,state:null};default:throw Error(i(444,e))}}function Uf(e){return`href="`+Rt(e)+`"`}function Wf(e){return`link[rel="stylesheet"][`+e+`]`}function Gf(e){return f({},e,{"data-precedence":e.precedence,precedence:null})}function Kf(e,t,n,r){e.querySelector(`link[rel="preload"][as="style"][`+t+`]`)?r.loading=1:(t=e.createElement(`link`),r.preload=t,t.addEventListener(`load`,function(){return r.loading|=1}),t.addEventListener(`error`,function(){return r.loading|=2}),qd(t,`link`,n),yt(t),e.head.appendChild(t))}function qf(e){return`[src="`+Rt(e)+`"]`}function Jf(e){return`script[async]`+e}function Yf(e,t,n){if(t.count++,t.instance===null)switch(t.type){case`style`:var r=e.querySelector(`style[data-href~="`+Rt(n.href)+`"]`);if(r)return t.instance=r,yt(r),r;var a=f({},n,{"data-href":n.href,"data-precedence":n.precedence,href:null,precedence:null});return r=(e.ownerDocument||e).createElement(`style`),yt(r),qd(r,`style`,a),Xf(r,n.precedence,e),t.instance=r;case`stylesheet`:a=Uf(n.href);var o=e.querySelector(Wf(a));if(o)return t.state.loading|=4,t.instance=o,yt(o),o;r=Gf(n),(a=Df.get(a))&&Zf(r,a),o=(e.ownerDocument||e).createElement(`link`),yt(o);var s=o;return s._p=new Promise(function(e,t){s.onload=e,s.onerror=t}),qd(o,`link`,r),t.state.loading|=4,Xf(o,n.precedence,e),t.instance=o;case`script`:return o=qf(n.src),(a=e.querySelector(Jf(o)))?(t.instance=a,yt(a),a):(r=n,(a=Df.get(o))&&(r=f({},n),Qf(r,a)),e=e.ownerDocument||e,a=e.createElement(`script`),yt(a),qd(a,`link`,r),e.head.appendChild(a),t.instance=a);case`void`:return null;default:throw Error(i(443,t.type))}else t.type===`stylesheet`&&!(t.state.loading&4)&&(r=t.instance,t.state.loading|=4,Xf(r,n.precedence,e));return t.instance}function Xf(e,t,n){for(var r=n.querySelectorAll(`link[rel="stylesheet"][data-precedence],style[data-precedence]`),i=r.length?r[r.length-1]:null,a=i,o=0;o title`):null)}function np(e,t,n){if(n===1||t.itemProp!=null)return!1;switch(e){case`meta`:case`title`:return!0;case`style`:if(typeof t.precedence!=`string`||typeof t.href!=`string`||t.href===``)break;return!0;case`link`:if(typeof t.rel!=`string`||typeof t.href!=`string`||t.href===``||t.onLoad||t.onError)break;switch(t.rel){case`stylesheet`:return e=t.disabled,typeof t.precedence==`string`&&e==null;default:return!0}case`script`:if(t.async&&typeof t.async!=`function`&&typeof t.async!=`symbol`&&!t.onLoad&&!t.onError&&t.src&&typeof t.src==`string`)return!0}return!1}function rp(e){return!(e.type===`stylesheet`&&!(e.state.loading&3))}function ip(e,t,n,r){if(n.type===`stylesheet`&&(typeof r.media!=`string`||!1!==matchMedia(r.media).matches)&&!(n.state.loading&4)){if(n.instance===null){var i=Uf(r.href),a=t.querySelector(Wf(i));if(a){t=a._p,typeof t==`object`&&t&&typeof t.then==`function`&&(e.count++,e=sp.bind(e),t.then(e,e)),n.state.loading|=4,n.instance=a,yt(a);return}a=t.ownerDocument||t,r=Gf(r),(i=Df.get(i))&&Zf(r,i),a=a.createElement(`link`),yt(a);var o=a;o._p=new Promise(function(e,t){o.onload=e,o.onerror=t}),qd(a,`link`,r),n.instance=a}e.stylesheets===null&&(e.stylesheets=new Map),e.stylesheets.set(n,t),(t=n.state.preload)&&!(n.state.loading&3)&&(e.count++,n=sp.bind(e),t.addEventListener(`load`,n),t.addEventListener(`error`,n))}}var ap=0;function op(e,t){return e.stylesheets&&e.count===0&&lp(e,e.stylesheets),0ap?50:800)+t);return e.unsuspend=n,function(){e.unsuspend=null,clearTimeout(r),clearTimeout(i)}}:null}function sp(){if(this.count--,this.count===0&&(this.imgCount===0||!this.waitingForImages)){if(this.stylesheets)lp(this,this.stylesheets);else if(this.unsuspend){var e=this.unsuspend;this.unsuspend=null,e()}}}var cp=null;function lp(e,t){e.stylesheets=null,e.unsuspend!==null&&(e.count++,cp=new Map,t.forEach(up,e),cp=null,sp.call(e))}function up(e,t){if(!(t.state.loading&4)){var n=cp.get(e);if(n)var r=n.get(null);else{n=new Map,cp.set(e,n);for(var i=e.querySelectorAll(`link[data-precedence],style[data-precedence]`),a=0;a{function n(){if(!(typeof __REACT_DEVTOOLS_GLOBAL_HOOK__>`u`||typeof __REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE!=`function`))try{__REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE(n)}catch(e){console.error(e)}}n(),t.exports=oa()})),ca=o((e=>{var t=je().__CLIENT_INTERNALS_DO_NOT_USE_OR_WARN_USERS_THEY_CANNOT_UPGRADE;e.c=function(e){return t.H.useMemoCache(e)}})),la=o(((e,t)=>{t.exports=ca()})),ua=c(sa(),1),da=la(),fa=(0,P.createContext)(void 0);function pa(e,t){try{let t=localStorage.getItem(e);if(t===`light`||t===`dark`||t===`system`)return t}catch{}return t}function ma(e){let t=(0,da.c)(14),{children:n,defaultTheme:r,storageKey:i}=e,a=r===void 0?`dark`:r,o=i===void 0?`ui-theme`:i,s;t[0]!==a||t[1]!==o?(s=()=>pa(o,a),t[0]=a,t[1]=o,t[2]=s):s=t[2];let[c,l]=(0,P.useState)(s),u,d;t[3]===c?(u=t[4],d=t[5]):(u=()=>{let e=window.document.documentElement;if(e.classList.remove(`light`,`dark`),c===`system`){let t=window.matchMedia(`(prefers-color-scheme: dark)`).matches?`dark`:`light`;e.classList.add(t);return}e.classList.add(c)},d=[c],t[3]=c,t[4]=u,t[5]=d),(0,P.useEffect)(u,d);let f;t[6]===o?f=t[7]:(f=e=>{try{localStorage.setItem(o,e)}catch{}l(e)},t[6]=o,t[7]=f);let p;t[8]!==f||t[9]!==c?(p={theme:c,setTheme:f},t[8]=f,t[9]=c,t[10]=p):p=t[10];let m=p,h;return t[11]!==n||t[12]!==m?(h=(0,F.jsx)(fa.Provider,{value:m,children:n}),t[11]=n,t[12]=m,t[13]=h):h=t[13],h}function ha(){let e=(0,P.useContext)(fa);if(e===void 0)throw Error(`useTheme must be used within a ThemeProvider`);return e}function ga(e,t){if(e&&!t)return e;if(!e&&t)return t;if(e||t)return{...e,...t}}var _a={};function va(e,t,n,r,i){if(!n&&!r&&!i&&!e)return ba(t);let a=ba(e);return t&&(a=xa(a,t)),n&&(a=xa(a,n)),r&&(a=xa(a,r)),i&&(a=xa(a,i)),a}function ya(e){if(e.length===0)return _a;if(e.length===1)return ba(e[0]);let t=ba(e[0]);for(let n=1;n=65&&i<=90&&(typeof t==`function`||t===void 0)}function Ta(e){return typeof e==`function`}function Ea(e,t){return Ta(e)?e(t):e??_a}function Da(e,t){return t?e?(...n)=>{let r=n[0];if(ja(r)){let i=r;ka(i);let a=t(...n);return i.baseUIHandlerPrevented||e?.(...n),a}let i=t(...n);return e?.(...n),i}:Oa(t):e}function Oa(e){return e&&((...t)=>{let n=t[0];return ja(n)&&ka(n),e(...t)})}function ka(e){return e.preventBaseUIHandler=()=>{e.baseUIHandlerPrevented=!0},e}function Aa(e,t){return t?e?t+` `+e:t:e}function ja(e){return typeof e==`object`&&!!e&&`nativeEvent`in e}function Ma(e,t){return function(n,...r){let i=new URL(e);return i.searchParams.set(`code`,n.toString()),r.forEach(e=>i.searchParams.append(`args[]`,e)),`${t} error #${n}; visit ${i} for the full message.`}}var Na=Ma(`https://base-ui.com/production-error`,`Base UI`),Pa={};function Fa(e,t){let n=P.useRef(Pa);return n.current===Pa&&(n.current=e(t)),n}function Ia(e,t,n,r){let i=Fa(Ra).current;return za(i,e,t,n,r)&&Va(i,[e,t,n,r]),i.callback}function La(e){let t=Fa(Ra).current;return Ba(t,e)&&Va(t,e),t.callback}function Ra(){return{callback:null,cleanup:null,refs:[]}}function za(e,t,n,r,i){return e.refs[0]!==t||e.refs[1]!==n||e.refs[2]!==r||e.refs[3]!==i}function Ba(e,t){return e.refs.length!==t.length||e.refs.some((e,n)=>e!==t[n])}function Va(e,t){if(e.refs=t,t.every(e=>e==null)){e.callback=null;return}e.callback=n=>{if(e.cleanup&&=(e.cleanup(),null),n!=null){let r=Array(t.length).fill(null);for(let e=0;e{for(let e=0;e=e}function Wa(e){if(!P.isValidElement(e))return null;let t=e,n=t.props;return(Ua(19)?n?.ref:t.ref)??null}function Ga(){}var Ka=Object.freeze([]),qa=Object.freeze({});function Ja(e,t){let n={};for(let r in e){let i=e[r];if(t?.hasOwnProperty(r)){let e=t[r](i);e!=null&&Object.assign(n,e);continue}i===!0?n[`data-${r.toLowerCase()}`]=``:i&&(n[`data-${r.toLowerCase()}`]=i.toString())}return n}function Ya(e,t){return typeof e==`function`?e(t):e}function Xa(e,t){return typeof e==`function`?e(t):e}function Za(e,t,n={}){let r=t.render,i=Qa(t,n);return n.enabled===!1?null:to(e,r,i,n.state??qa)}function Qa(e,t={}){let{className:n,style:r,render:i}=e,{state:a=qa,ref:o,props:s,stateAttributesMapping:c,enabled:l=!0}=t,u=l?Ya(n,a):void 0,d=l?Xa(r,a):void 0,f=l?Ja(a,c):qa,p=l&&s?$a(s):void 0,m=l?ga(f,p)??{}:qa;return typeof document<`u`&&(l?Array.isArray(o)?m.ref=La([m.ref,Wa(i),...o]):m.ref=Ia(m.ref,Wa(i),o):Ia(null,null)),l?(u!==void 0&&(m.className=Aa(m.className,u)),d!==void 0&&(m.style=ga(m.style,d)),m):qa}function $a(e){return Array.isArray(e)?ya(e):va(void 0,e)}var eo=Symbol.for(`react.lazy`);function to(e,t,n,r){if(t){if(typeof t==`function`)return t(n,r);let e=va(n,t.props);e.ref=n.ref;let i=t;return i?.$$typeof===eo&&(i=P.Children.toArray(t)[0]),P.cloneElement(i,e)}if(e&&typeof e==`string`)return no(e,n);throw Error(Na(8))}function no(e,t){return e===`button`?(0,P.createElement)(`button`,{type:`button`,...t,key:t.key}):e===`img`?(0,P.createElement)(`img`,{alt:``,...t,key:t.key}):P.createElement(e,t)}function ro(e){return Za(e.defaultTagName??`div`,e,e)}function io(e){var t,n,r=``;if(typeof e==`string`||typeof e==`number`)r+=e;else if(typeof e==`object`)if(Array.isArray(e)){var i=e.length;for(t=0;ttypeof e==`boolean`?`${e}`:e===0?`0`:e,so=ao,co=(e,t)=>n=>{if(t?.variants==null)return so(e,n?.class,n?.className);let{variants:r,defaultVariants:i}=t,a=Object.keys(r).map(e=>{let t=n?.[e],a=i?.[e];if(t===null)return null;let o=oo(t)||oo(a);return r[e][o]}),o=n&&Object.entries(n).reduce((e,t)=>{let[n,r]=t;return r===void 0||(e[n]=r),e},{});return so(e,a,t?.compoundVariants?.reduce((e,t)=>{let{class:n,className:r,...a}=t;return Object.entries(a).every(e=>{let[t,n]=e;return Array.isArray(n)?n.includes({...i,...o}[t]):{...i,...o}[t]===n})?[...e,n,r]:e},[]),n?.class,n?.className)},lo=(...e)=>e.filter((e,t,n)=>!!e&&e.trim()!==``&&n.indexOf(e)===t).join(` `).trim(),uo=e=>e.replace(/([a-z0-9])([A-Z])/g,`$1-$2`).toLowerCase(),fo=e=>e.replace(/^([A-Z])|[\s-_]+(\w)/g,(e,t,n)=>n?n.toUpperCase():t.toLowerCase()),po=e=>{let t=fo(e);return t.charAt(0).toUpperCase()+t.slice(1)},mo={xmlns:`http://www.w3.org/2000/svg`,width:24,height:24,viewBox:`0 0 24 24`,fill:`none`,stroke:`currentColor`,strokeWidth:2,strokeLinecap:`round`,strokeLinejoin:`round`},ho=e=>{for(let t in e)if(t.startsWith(`aria-`)||t===`role`||t===`title`)return!0;return!1},go=(0,P.createContext)({}),_o=()=>(0,P.useContext)(go),vo=(0,P.forwardRef)(({color:e,size:t,strokeWidth:n,absoluteStrokeWidth:r,className:i=``,children:a,iconNode:o,...s},c)=>{let{size:l=24,strokeWidth:u=2,absoluteStrokeWidth:d=!1,color:f=`currentColor`,className:p=``}=_o()??{},m=r??d?Number(n??u)*24/Number(t??l):n??u;return(0,P.createElement)(`svg`,{ref:c,...mo,width:t??l??mo.width,height:t??l??mo.height,stroke:e??f,strokeWidth:m,className:lo(`lucide`,p,i),...!a&&!ho(s)&&{"aria-hidden":`true`},...s},[...o.map(([e,t])=>(0,P.createElement)(e,t)),...Array.isArray(a)?a:[a]])}),yo=(e,t)=>{let n=(0,P.forwardRef)(({className:n,...r},i)=>(0,P.createElement)(vo,{ref:i,iconNode:t,className:lo(`lucide-${uo(po(e))}`,`lucide-${e}`,n),...r}));return n.displayName=po(e),n},bo=yo(`activity`,[[`path`,{d:`M22 12h-2.48a2 2 0 0 0-1.93 1.46l-2.35 8.36a.25.25 0 0 1-.48 0L9.24 2.18a.25.25 0 0 0-.48 0l-2.35 8.36A2 2 0 0 1 4.49 12H2`,key:`169zse`}]]),xo=yo(`book-open`,[[`path`,{d:`M12 7v14`,key:`1akyts`}],[`path`,{d:`M3 18a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1h5a4 4 0 0 1 4 4 4 4 0 0 1 4-4h5a1 1 0 0 1 1 1v13a1 1 0 0 1-1 1h-6a3 3 0 0 0-3 3 3 3 0 0 0-3-3z`,key:`ruj8y`}]]),So=yo(`camera`,[[`path`,{d:`M13.997 4a2 2 0 0 1 1.76 1.05l.486.9A2 2 0 0 0 18.003 7H20a2 2 0 0 1 2 2v9a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V9a2 2 0 0 1 2-2h1.997a2 2 0 0 0 1.759-1.048l.489-.904A2 2 0 0 1 10.004 4z`,key:`18u6gg`}],[`circle`,{cx:`12`,cy:`13`,r:`3`,key:`1vg3eu`}]]),Co=yo(`circle-alert`,[[`circle`,{cx:`12`,cy:`12`,r:`10`,key:`1mglay`}],[`line`,{x1:`12`,x2:`12`,y1:`8`,y2:`12`,key:`1pkeuh`}],[`line`,{x1:`12`,x2:`12.01`,y1:`16`,y2:`16`,key:`4dfq90`}]]),wo=yo(`crosshair`,[[`circle`,{cx:`12`,cy:`12`,r:`10`,key:`1mglay`}],[`line`,{x1:`22`,x2:`18`,y1:`12`,y2:`12`,key:`l9bcsi`}],[`line`,{x1:`6`,x2:`2`,y1:`12`,y2:`12`,key:`13hhkx`}],[`line`,{x1:`12`,x2:`12`,y1:`6`,y2:`2`,key:`10w3f3`}],[`line`,{x1:`12`,x2:`12`,y1:`22`,y2:`18`,key:`15g9kq`}]]),To=yo(`globe`,[[`circle`,{cx:`12`,cy:`12`,r:`10`,key:`1mglay`}],[`path`,{d:`M12 2a14.5 14.5 0 0 0 0 20 14.5 14.5 0 0 0 0-20`,key:`13o1zl`}],[`path`,{d:`M2 12h20`,key:`9i4pu4`}]]),Eo=yo(`hard-drive`,[[`path`,{d:`M10 16h.01`,key:`1bzywj`}],[`path`,{d:`M2.212 11.577a2 2 0 0 0-.212.896V18a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-5.527a2 2 0 0 0-.212-.896L18.55 5.11A2 2 0 0 0 16.76 4H7.24a2 2 0 0 0-1.79 1.11z`,key:`18tbho`}],[`path`,{d:`M21.946 12.013H2.054`,key:`zqlbp7`}],[`path`,{d:`M6 16h.01`,key:`1pmjb7`}]]),Do=yo(`house`,[[`path`,{d:`M15 21v-8a1 1 0 0 0-1-1h-4a1 1 0 0 0-1 1v8`,key:`5wwlr5`}],[`path`,{d:`M3 10a2 2 0 0 1 .709-1.528l7-6a2 2 0 0 1 2.582 0l7 6A2 2 0 0 1 21 10v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z`,key:`r6nss1`}]]),Oo=yo(`image`,[[`rect`,{width:`18`,height:`18`,x:`3`,y:`3`,rx:`2`,ry:`2`,key:`1m3agn`}],[`circle`,{cx:`9`,cy:`9`,r:`2`,key:`af1f0g`}],[`path`,{d:`m21 15-3.086-3.086a2 2 0 0 0-2.828 0L6 21`,key:`1xmnt7`}]]),ko=yo(`key`,[[`path`,{d:`m15.5 7.5 2.3 2.3a1 1 0 0 0 1.4 0l2.1-2.1a1 1 0 0 0 0-1.4L19 4`,key:`g0fldk`}],[`path`,{d:`m21 2-9.6 9.6`,key:`1j0ho8`}],[`circle`,{cx:`7.5`,cy:`15.5`,r:`5.5`,key:`yqb3hr`}]]),Ao=yo(`layers`,[[`path`,{d:`M12.83 2.18a2 2 0 0 0-1.66 0L2.6 6.08a1 1 0 0 0 0 1.83l8.58 3.91a2 2 0 0 0 1.66 0l8.58-3.9a1 1 0 0 0 0-1.83z`,key:`zw3jo`}],[`path`,{d:`M2 12a1 1 0 0 0 .58.91l8.6 3.91a2 2 0 0 0 1.65 0l8.58-3.9A1 1 0 0 0 22 12`,key:`1wduqc`}],[`path`,{d:`M2 17a1 1 0 0 0 .58.91l8.6 3.91a2 2 0 0 0 1.65 0l8.58-3.9A1 1 0 0 0 22 17`,key:`kqbvx6`}]]),jo=yo(`layout-grid`,[[`rect`,{width:`7`,height:`7`,x:`3`,y:`3`,rx:`1`,key:`1g98yp`}],[`rect`,{width:`7`,height:`7`,x:`14`,y:`3`,rx:`1`,key:`6d4xhi`}],[`rect`,{width:`7`,height:`7`,x:`14`,y:`14`,rx:`1`,key:`nxv5o0`}],[`rect`,{width:`7`,height:`7`,x:`3`,y:`14`,rx:`1`,key:`1bb6yr`}]]),Mo=yo(`log-out`,[[`path`,{d:`m16 17 5-5-5-5`,key:`1bji2h`}],[`path`,{d:`M21 12H9`,key:`dn1m92`}],[`path`,{d:`M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4`,key:`1uf3rs`}]]),No=yo(`map-pin`,[[`path`,{d:`M20 10c0 4.993-5.539 10.193-7.399 11.799a1 1 0 0 1-1.202 0C9.539 20.193 4 14.993 4 10a8 8 0 0 1 16 0`,key:`1r0f0z`}],[`circle`,{cx:`12`,cy:`10`,r:`3`,key:`ilqhr7`}]]),Po=yo(`moon`,[[`path`,{d:`M20.985 12.486a9 9 0 1 1-9.473-9.472c.405-.022.617.46.402.803a6 6 0 0 0 8.268 8.268c.344-.215.825-.004.803.401`,key:`kfwtm`}]]),Fo=yo(`network`,[[`rect`,{x:`16`,y:`16`,width:`6`,height:`6`,rx:`1`,key:`4q2zg0`}],[`rect`,{x:`2`,y:`16`,width:`6`,height:`6`,rx:`1`,key:`8cvhb9`}],[`rect`,{x:`9`,y:`2`,width:`6`,height:`6`,rx:`1`,key:`1egb70`}],[`path`,{d:`M5 16v-3a1 1 0 0 1 1-1h12a1 1 0 0 1 1 1v3`,key:`1jsf9p`}],[`path`,{d:`M12 12V8`,key:`2874zd`}]]),Io=yo(`panel-left`,[[`rect`,{width:`18`,height:`18`,x:`3`,y:`3`,rx:`2`,key:`afitv7`}],[`path`,{d:`M9 3v18`,key:`fh3hqa`}]]),Lo=yo(`route`,[[`circle`,{cx:`6`,cy:`19`,r:`3`,key:`1kj8tv`}],[`path`,{d:`M9 19h8.5a3.5 3.5 0 0 0 0-7h-11a3.5 3.5 0 0 1 0-7H15`,key:`1d8sl`}],[`circle`,{cx:`18`,cy:`5`,r:`3`,key:`gq8acd`}]]),Ro=yo(`router`,[[`rect`,{width:`20`,height:`8`,x:`2`,y:`14`,rx:`2`,key:`w68u3i`}],[`path`,{d:`M6.01 18H6`,key:`19vcac`}],[`path`,{d:`M10.01 18H10`,key:`uamcmx`}],[`path`,{d:`M15 10v4`,key:`qjz1xs`}],[`path`,{d:`M17.84 7.17a4 4 0 0 0-5.66 0`,key:`1rif40`}],[`path`,{d:`M20.66 4.34a8 8 0 0 0-11.31 0`,key:`6a5xfq`}]]),zo=yo(`server`,[[`rect`,{width:`20`,height:`8`,x:`2`,y:`2`,rx:`2`,ry:`2`,key:`ngkwjq`}],[`rect`,{width:`20`,height:`8`,x:`2`,y:`14`,rx:`2`,ry:`2`,key:`iecqi9`}],[`line`,{x1:`6`,x2:`6.01`,y1:`6`,y2:`6`,key:`16zg32`}],[`line`,{x1:`6`,x2:`6.01`,y1:`18`,y2:`18`,key:`nzw8ys`}]]),Bo=yo(`shield-check`,[[`path`,{d:`M20 13c0 5-3.5 7.5-7.66 8.95a1 1 0 0 1-.67-.01C7.5 20.5 4 18 4 13V6a1 1 0 0 1 1-1c2 0 4.5-1.2 6.24-2.72a1.17 1.17 0 0 1 1.52 0C14.51 3.81 17 5 19 5a1 1 0 0 1 1 1z`,key:`oel41y`}],[`path`,{d:`m9 12 2 2 4-4`,key:`dzmm74`}]]),Vo=yo(`shield`,[[`path`,{d:`M20 13c0 5-3.5 7.5-7.66 8.95a1 1 0 0 1-.67-.01C7.5 20.5 4 18 4 13V6a1 1 0 0 1 1-1c2 0 4.5-1.2 6.24-2.72a1.17 1.17 0 0 1 1.52 0C14.51 3.81 17 5 19 5a1 1 0 0 1 1 1z`,key:`oel41y`}]]),Ho=yo(`sun`,[[`circle`,{cx:`12`,cy:`12`,r:`4`,key:`4exip2`}],[`path`,{d:`M12 2v2`,key:`tus03m`}],[`path`,{d:`M12 20v2`,key:`1lh1kg`}],[`path`,{d:`m4.93 4.93 1.41 1.41`,key:`149t6j`}],[`path`,{d:`m17.66 17.66 1.41 1.41`,key:`ptbguv`}],[`path`,{d:`M2 12h2`,key:`1t8f8n`}],[`path`,{d:`M20 12h2`,key:`1q8mjw`}],[`path`,{d:`m6.34 17.66-1.41 1.41`,key:`1m8zz5`}],[`path`,{d:`m19.07 4.93-1.41 1.41`,key:`1shlcs`}]]),Uo=yo(`users`,[[`path`,{d:`M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2`,key:`1yyitq`}],[`path`,{d:`M16 3.128a4 4 0 0 1 0 7.744`,key:`16gr8j`}],[`path`,{d:`M22 21v-2a4 4 0 0 0-3-3.87`,key:`kshegd`}],[`circle`,{cx:`9`,cy:`7`,r:`4`,key:`nufk8`}]]),Wo=yo(`waypoints`,[[`path`,{d:`m10.586 5.414-5.172 5.172`,key:`4mc350`}],[`path`,{d:`m18.586 13.414-5.172 5.172`,key:`8c96vv`}],[`path`,{d:`M6 12h12`,key:`8npq4p`}],[`circle`,{cx:`12`,cy:`20`,r:`2`,key:`144qzu`}],[`circle`,{cx:`12`,cy:`4`,r:`2`,key:`muu5ef`}],[`circle`,{cx:`20`,cy:`12`,r:`2`,key:`1xzzfp`}],[`circle`,{cx:`4`,cy:`12`,r:`2`,key:`1hvhnz`}]]),Go=yo(`x`,[[`path`,{d:`M18 6 6 18`,key:`1bl5f8`}],[`path`,{d:`m6 6 12 12`,key:`d8bk6v`}]]);function Ko(){return typeof window<`u`}function qo(e){return Xo(e)?(e.nodeName||``).toLowerCase():`#document`}function Jo(e){var t;return(e==null||(t=e.ownerDocument)==null?void 0:t.defaultView)||window}function Yo(e){return((Xo(e)?e.ownerDocument:e.document)||window.document)?.documentElement}function Xo(e){return Ko()?e instanceof Node||e instanceof Jo(e).Node:!1}function Zo(e){return Ko()?e instanceof Element||e instanceof Jo(e).Element:!1}function Qo(e){return Ko()?e instanceof HTMLElement||e instanceof Jo(e).HTMLElement:!1}function $o(e){return!Ko()||typeof ShadowRoot>`u`?!1:e instanceof ShadowRoot||e instanceof Jo(e).ShadowRoot}function es(e){let{overflow:t,overflowX:n,overflowY:r,display:i}=ds(e);return/auto|scroll|overlay|hidden|clip/.test(t+r+n)&&i!==`inline`&&i!==`contents`}function ts(e){return/^(table|td|th)$/.test(qo(e))}function ns(e){try{if(e.matches(`:popover-open`))return!0}catch{}try{return e.matches(`:modal`)}catch{return!1}}var rs=/transform|translate|scale|rotate|perspective|filter/,is=/paint|layout|strict|content/,as=e=>!!e&&e!==`none`,os;function ss(e){let t=Zo(e)?ds(e):e;return as(t.transform)||as(t.translate)||as(t.scale)||as(t.rotate)||as(t.perspective)||!ls()&&(as(t.backdropFilter)||as(t.filter))||rs.test(t.willChange||``)||is.test(t.contain||``)}function cs(e){let t=ps(e);for(;Qo(t)&&!us(t);){if(ss(t))return t;if(ns(t))return null;t=ps(t)}return null}function ls(){return os??=typeof CSS<`u`&&CSS.supports&&CSS.supports(`-webkit-backdrop-filter`,`none`),os}function us(e){return/^(html|body|#document)$/.test(qo(e))}function ds(e){return Jo(e).getComputedStyle(e)}function fs(e){return Zo(e)?{scrollLeft:e.scrollLeft,scrollTop:e.scrollTop}:{scrollLeft:e.scrollX,scrollTop:e.scrollY}}function ps(e){if(qo(e)===`html`)return e;let t=e.assignedSlot||e.parentNode||$o(e)&&e.host||Yo(e);return $o(t)?t.host:t}function ms(e){let t=ps(e);return us(t)?e.ownerDocument?e.ownerDocument.body:e.body:Qo(t)&&es(t)?t:ms(t)}function hs(e,t,n){t===void 0&&(t=[]),n===void 0&&(n=!0);let r=ms(e),i=r===e.ownerDocument?.body,a=Jo(r);if(i){let e=gs(a);return t.concat(a,a.visualViewport||[],es(r)?r:[],e&&n?hs(e):[])}else return t.concat(r,hs(r,[],n))}function gs(e){return e.parent&&Object.getPrototypeOf(e.parent)?e.frameElement:null}var _s=P[`useInsertionEffect${Math.random().toFixed(1)}`.slice(0,-3)],vs=_s&&_s!==P.useLayoutEffect?_s:e=>e();function ys(e){let t=Fa(bs).current;return t.next=e,vs(t.effect),t.trampoline}function bs(){let e={next:void 0,callback:xs,trampoline:(...t)=>e.callback?.(...t),effect:()=>{e.callback=e.next}};return e}function xs(){}var Ss={...P},Cs=typeof document<`u`?P.useLayoutEffect:()=>{},ws=P.createContext(void 0);function Ts(e=!1){let t=P.useContext(ws);if(t===void 0&&!e)throw Error(Na(16));return t}function Es(e){let{focusableWhenDisabled:t,disabled:n,composite:r=!1,tabIndex:i=0,isNativeButton:a}=e,o=r&&t!==!1,s=r&&t===!1;return{props:P.useMemo(()=>{let e={onKeyDown(e){n&&t&&e.key!==`Tab`&&e.preventDefault()}};return r||(e.tabIndex=i,!a&&n&&(e.tabIndex=t?i:-1)),(a&&(t||o)||!a&&n)&&(e[`aria-disabled`]=n),a&&(!t||s)&&(e.disabled=n),e},[r,n,t,o,s,a,i])}}function Ds(e={}){let{disabled:t=!1,focusableWhenDisabled:n,tabIndex:r=0,native:i=!0,composite:a}=e,o=P.useRef(null),s=Ts(!0),c=a??s!==void 0,{props:l}=Es({focusableWhenDisabled:n,disabled:t,composite:c,tabIndex:r,isNativeButton:i}),u=P.useCallback(()=>{let e=o.current;Os(e)&&c&&t&&l.disabled===void 0&&e.disabled&&(e.disabled=!1)},[t,l.disabled,c]);return Cs(u,[u]),{getButtonProps:P.useCallback((e={})=>{let{onClick:n,onMouseDown:r,onKeyUp:a,onKeyDown:o,onPointerDown:s,...u}=e;return va({type:i?`button`:void 0,onClick(e){if(t){e.preventDefault();return}n?.(e)},onMouseDown(e){t||r?.(e)},onKeyDown(e){if(t||(ka(e),o?.(e),e.baseUIHandlerPrevented))return;let r=e.target===e.currentTarget,a=e.currentTarget,s=Os(a),l=!i&&ks(a),u=r&&(i?s:!l),d=e.key===`Enter`,f=e.key===` `,p=a.getAttribute(`role`),m=p?.startsWith(`menuitem`)||p===`option`||p===`gridcell`;if(r&&c&&f){if(e.defaultPrevented&&m)return;e.preventDefault(),l||i&&s?(a.click(),e.preventBaseUIHandler()):u&&(n?.(e),e.preventBaseUIHandler());return}u&&(!i&&(f||d)&&e.preventDefault(),!i&&d&&n?.(e))},onKeyUp(e){if(!t){if(ka(e),a?.(e),e.target===e.currentTarget&&i&&c&&Os(e.currentTarget)&&e.key===` `){e.preventDefault();return}e.baseUIHandlerPrevented||e.target===e.currentTarget&&!i&&!c&&e.key===` `&&n?.(e)}},onPointerDown(e){if(t){e.preventDefault();return}s?.(e)}},i?void 0:{role:`button`},l,u)},[t,l,c,i]),buttonRef:ys(e=>{o.current=e,u()})}}function Os(e){return Qo(e)&&e.tagName===`BUTTON`}function ks(e){return!!(e?.tagName===`A`&&e?.href)}var As=P.forwardRef(function(e,t){let{render:n,className:r,disabled:i=!1,focusableWhenDisabled:a=!1,nativeButton:o=!0,style:s,...c}=e,{getButtonProps:l,buttonRef:u}=Ds({disabled:i,focusableWhenDisabled:a,native:o});return Za(`button`,e,{state:{disabled:i},ref:[t,u],props:[c,l]})}),js=(e,t)=>{let n=Array(e.length+t.length);for(let t=0;t