Compare commits

..

5 Commits

Author SHA1 Message Date
Julian Sommer 96e62ac213 chore: centre the search dialog 2026-11-02 10:47:56 +10:00
Julian Sommer 02bba41669 chore: title case headings on ui 2026-11-02 10:45:25 +10:00
Josh Siviour 8fa33f07ce feat(eks): inject IRSA credentials into pods of annotated ServiceAccounts (#1396)
* test: pod identity webhook mutation, publisher and webhook config contracts

* feat: pod identity webhook mutator, CA bundle publisher and webhook configuration

* feat(eks): run the pod identity webhook on every control-plane server

Add the eks-pod-identity-webhook binary and its OpenRC service. It listens on
127.0.0.1:8444 with a serving certificate signed by the k3s server CA, authenticates
to the API server with a client certificate signed by the k3s client CA, and runs
under its own least-privilege ClusterRole. Each server stages the RBAC and the
MutatingWebhookConfiguration (caBundle is the server CA) into the k3s manifests
directory, so server-join nodes get them too.

Match upstream's container skip rule: skip only when the regional STS key is
present, and add the token mount otherwise.

* fix(eks): issue pod identity webhook certs for one year and renew in the running process

Both the serving and client certificates were valid for ten years. Kubernetes
cannot revoke a client certificate, so a copy taken off a control-plane disk
would stay a valid identity for a decade. Issue both for one year with the
existing 30-day renew window.

The process loaded the serving certificate once. A daily check now exits
with ErrCertRenewal when either certificate enters the renew window, so the
supervisor restarts it and EnsureCerts reissues.

Add tests for KubeClient against a TLS fake API server and for Run, which the
package had none of.

* test: e2e covers pod-identity webhook and unmodified LBC chart

IRSAPod now uses a ServiceAccount annotation only and asserts the injected
env and volumes plus an untouched unannotated pod. LBCChart installs the
upstream chart 1.11.0 and reconciles an Ingress into an active ALB.

* test: pass the tagging endpoint to the LBC chart and mark Shield/WAF flags Spinifex-only

* fix: make the pod identity webhook init script executable in the eks-node image

setup.sh chmods each shipped init script to 0755 explicitly, and the new
service was missing from the list. rc-update then refused it, and
eks-node-role, running under set -eu, aborted before starting k3s.

Add a guard test that every init script in INSTALL_FILES is chmod'd.

* fix: start the pod identity webhook last and keep its failures non-fatal

eks-node-role runs under set -eu, so a fault enabling or starting the
optional webhook aborted the control plane boot before k3s-first-boot,
state-report and addon-sync. Enable and start it at the end of the server
and server-join roles and log a warning on failure instead.

The role test now asserts the webhook is the last call and that an enable
or start failure still exits 0 and finishes the role.

* fix: move the pod identity webhook off the token webhook readiness port

eks-token-webhook already serves its plain HTTP readiness endpoint on 8444,
which the API server target group health-checks. The pod identity webhook
bound the same port, failed with address already in use and respawned
forever, so no pod was ever injected.

Listen on 127.0.0.1:8445 and add a test that the two ports differ.

* docs: describe pod identity webhook for user workloads in the EKS guide

* fix: split STUB_FAIL assignment from export in the role test
2026-11-02 10:42:38 +10:00
Josh Siviour 3c192b3558 feat(iam): evict cached account status on change via an accounts watcher (#1411) 2026-11-02 10:29:02 +10:00
Ben Duncan 8cded67776 feat(eks): DeleteCluster returns DELETING and converges the teardown in the background (#1394)
* feat(eks): converge DeleteCluster's teardown behind the recorded DELETING

DeleteCluster ran the whole cluster teardown inside the NATS request. Each
control-plane VM that ignores ACPI costs 40 s (20 s graceful powerdown, then
20 s waiting for the PID file), so a two-member cluster overran the 30 s
budget: the caller got a 500, and its retry queued behind the first handler
on the serial subscription and got a 404.

The request now validates, answers a DELETING cluster from its record,
records DELETING and returns. The teardown runs in a background converger
started by the request and backed by the deleting reaper:

- An in-process per-cluster gate sits in front of the teardown lease, which
  re-adopts a key its own node holds; one per-node bound covers kicks and
  reaper hand-offs.
- The lease renews on the service context, not the request's, and the
  converger stops at a step boundary if it is lost.
- The converger acts only on a record that is DELETING when re-read, refuses
  to remove a record that still has a node group, deletes the cluster's
  child records before the meta, and deletes the meta only if no ownership
  ref appeared that its pass did not tear down.
- DELETING is sticky for every status writer, and the create launch's meta
  writes add their refs to a DELETING record instead of overwriting it, and
  never recreate a swept one.
- The reaper hands work to the converger instead of running it inline,
  retries at a backoff capped at 30 minutes without ever abandoning a
  record, and resumes records an older build flagged exhausted.
- A stuck delete is surfaced in DescribeCluster as one INTERNAL_FAILURE
  issue naming the step, never the internal error.

* fix(eks): refuse DeleteCluster while node groups are attached

AWS answers ResourceInUseException ("Cluster has nodegroups attached") when
managed node groups remain. DeleteCluster went ahead instead, and its sweep
erased the node group records, leaving their workers running in the
customer account with no record to own them and no control plane.

The request now decides that error before recording anything. The e2e
suites, the terraform workbooks and the account teardown engine all delete
node groups first; the console shows the refusal's message.

* fix(eks): refuse new add-ons and access entries on a deleting cluster

CreateAddon and CreateAccessEntry checked only that the cluster record
existed, so a dependant could be written to a cluster whose delete was
already recorded, and the teardown would then sweep it with the record.
Both now answer ResourceInUseException for a DELETING cluster, a code both
operations declare in the EKS model.

* test(e2e): DeleteCluster returns DELETING at once and never reads ACTIVE after

The first delete-cluster must return within 5 s with status DELETING, as
AWS does. While the cluster is still listed every DescribeCluster must read
DELETING, and a repeat delete must answer DELETING from the record; once it
is gone a further delete answers ResourceNotFoundException.

* docs(eks): record the cluster delete lifecycle contract

The contract ADR-0003 S1 asks for before a conformance claim: what the
request decides, the recorded obligation, how the teardown converges and
recovers, and the S8 and S7 tests that pin each item, with the remaining
gaps listed.

* test(eks): wait for the delete request before counting terminates

On a build whose DeleteCluster is synchronous the request goroutine is still
recording terminates when the assertions read, so the tests must wait for it
to return as well as for the background teardown.

* test(eks): classify every eks request subject by its handler's work

The ADR-0003 S8 subject table for EKS. Every eks.* subject the gateway sends
and the daemon serves is classified in eksSubjects, and the table fails for
one that is not, so a new subject is classified deliberately rather than by
inheriting the 30 s default. DeleteCluster is request-scaled. Four handlers
still hold the request for work that grows with the resource and are named
as not conformant: CreateCluster's FAILED-name reclaim, DeleteNodegroup,
UpdateNodegroupConfig and RestoreSnapshot. Their budget stays 30 s, since a
larger one would hide the defect.

* fix(eks): leave no child record behind a launch overtaken by a delete

A create launch still running when its cluster was deleted and swept could
seed the creator's access entry afterwards, leaving it under a cluster with
no record. The launch now re-checks the record after seeding and, finding it
gone, removes the cluster's child keys with the infra it holds. The teardown
also sweeps child keys again after deleting the meta, for a write that lands
between its two deletes.

* fix(eks): fence the create launch on its generation and keep its infra owned

Review findings on the async delete, each with a test that fails before:

- The launch's meta write checked only the status, so a launch overtaken by
  a delete could overwrite a newer cluster of the same name and then release
  by name. Writes are now fenced on CreatedAt and the ARN; a superseded
  launch releases only the instances it started, by ID.
- A launch whose record was swept released its infra once, best effort. It
  now recreates the record as DELETING with its refs, so the teardown and
  the reaper own and retry the release.
- A record marked FAILED under a running launch was treated as owned by a
  teardown; the launch now merges its refs and releases what it built.
- Child records: a fresh claim clears keys an earlier cluster of the name
  left, the teardown stops the cluster's bootstrap subscriber, bootstrap
  refuses to write into a record that is gone or DELETING, the creator's
  access entry and the GPU device add-on are gated on the record.
- Deleting the last node group of a DELETING cluster resumes its delete,
  and waiting on node groups no longer counts as a failed pass.
- One converger run is bounded at 15 minutes, so a hung step cannot hold
  the gate and lease for ever; the timed-out pass is recorded.
- The FAILED-name reclaim purge runs under the teardown gate and lease.
- The meta-last test drives the converger with a failing KV instead of
  DeleteClusterPrefix, which production no longer called and is removed.
- The S7 test asserts the teardown issues no volume delete and terminates
  no worker.

* fix(eks): keep a deleted cluster's record while its create launch runs

Second review of the async delete, each behavioural change with a test that
fails before:

- A create launch now heartbeats its owner and time on the record, and the
  teardown keeps the record while that heartbeat is fresh, so DescribeCluster
  never answers NotFound and then DELETING again when an overtaken launch
  recreates the record. The launch hands its refs over at its next write and
  starts the teardown when it returns; a heartbeat silent for 90 s is taken
  as a crashed launch, and recreating a swept record stays as the backstop.
- The launch's OIDC keypair writes are fenced on the record's generation, so
  an overtaken launch cannot replace a newer same-name cluster's signing key.
- Re-creating a FAILED cluster's name is no longer refused by the per-node
  teardown bound.
- A deadline reached while removing the record is still recorded; an exited
  bootstrap subscriber removes its own entry; a record that is not DELETING
  is refused as not recorded even when it has node groups.
2026-11-01 18:28:05 -06:00
112 changed files with 6771 additions and 821 deletions
+32
View File
@@ -0,0 +1,32 @@
// Command eks-pod-identity-webhook is the IRSA mutating admission webhook run
// on every EKS control-plane server; the implementation is in
// agents/eks/podidentity. It listens on loopback only and is called by the
// local kube-apiserver.
package main
import (
"context"
"log/slog"
"os"
"os/signal"
"syscall"
_ "github.com/mulgadc/bluebottle/pkg/fipsboot"
"github.com/mulgadc/spinifex/spinifex/agents/eks/podidentity"
)
func main() {
log := slog.New(slog.NewTextHandler(os.Stderr, nil))
cfg, err := podidentity.ParseServiceConfig(os.Args[1:], os.Getenv, os.Stderr)
if err != nil {
log.Error("invalid configuration", "error", err)
os.Exit(2)
}
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
defer stop()
if err := podidentity.Run(ctx, cfg, log); err != nil {
log.Error("pod identity webhook stopped", "error", err)
os.Exit(1)
}
}
+2 -2
View File
@@ -489,8 +489,8 @@ Enforcement: the owner decided the layering test is blocking now, with a ratchet
- `domains/rds/instance` → `domains/rds/parametergroup` (legacy edges): `parameterGroupReferences` binds `Dependants` by scanning instance records; this clears when the RDS instance gains its own owner and answers `Dependants` from it. `tags.go` writes the meta record through its generic tag registry; this clears when tagging invokes a parameter-group-owned tag mutation capability, and moving the tagging adapter to `domains/rds/awsapi` alone does not clear it. The Modify handler still calls `propagateParameterGroup` after the owner's write; propagation is instance-side realization and stays legacy until it is designed as asynchronous realization (group generation, instance staleness, retry). `parameterGroupValues` returns the persisted `Record` and overrides to instance code; when the instance owner is extracted, replace them with a consumer-owned resolved-parameters projection.
- `domains/ecs` → `domains/ecs/taskdefinition` (owner bypass): `tags.go` reads and writes the revision record through its generic tag registry; this clears when ECS tagging invokes an owner-provided tag mutation capability, and moving `tags.go` to `domains/ecs/awsapi` alone does not clear it. `taskDefinitionResolver` returns the persisted `Record` to task and service launch code; when the task and service owners are extracted, replace the record crossing that boundary with a consumer-owned resolved task-definition (launch-spec) projection. Moving packages has not made the record private.
- `domains/ecs/taskdefinition` (deliberate duplication, not debt): private `getJSON`, `putJSON` and `keysWithPrefix` copy the raw-KV helpers in `domains/ecs` so the extraction keeps ECS's get/put/list error and recovery semantics. Revisit only if a later, tested state abstraction proves it preserves those semantics; do not migrate ECS to `kvstore` merely to remove them.
- `domains/eks/cluster` → `domains/eks/access` (legacy edges): `ResolveTokenReview` opens the EKS bucket in the gateway process and reads records through the exported `access.Get`; this clears when token review asks the owner across a contract rather than opening the bucket itself. `DeleteClusterPrefix` erases access-entry keys as part of its blanket cluster sweep; this clears when cluster delete invokes an owner-provided removal of the cluster's entries. The owner returns the persisted `Record` to the AWS adapter and token review; replace it with consumer-owned projections when those consumers are extracted.
- `domains/eks/cluster` → `domains/eks/addon` (legacy edges): `DeleteClusterPrefix` erases add-on records and manifests in its blanket cluster sweep; this clears when cluster delete invokes an add-on-owned teardown capability. `stageGPUDeviceAddon` is a temporary node-group-to-add-on orchestration edge: best-effort, errors logged and swallowed, never undone on node-group delete; it clears when the node-group owner is extracted and the dependency is designed. `ClusterReconciler` hosts the add-on status subscription; it clears when add-on delivery observation has its own host under the lifecycle correction. Cluster existence is checked in the handler, not through an owner-declared capability; it moves into the owner when cluster admission is defined (today status is ignored). Guest wire types (`AddonStatusReport`, `AddonDeliveryPhase`, `AddonStatusSubject`, `StagedAddonManifest`, `InternalAddonsResponse`) are owned by `contracts/eks/v1`, which `domains/eks/cluster`, `gateway/eks` and `agents/eks/gatewayfetch` import directly.
- `domains/eks/cluster` → `domains/eks/access` (legacy edges): `ResolveTokenReview` opens the EKS bucket in the gateway process and reads records through the exported `access.Get`; this clears when token review asks the owner across a contract rather than opening the bucket itself. `DeleteClusterChildren` erases access-entry keys as part of its blanket cluster sweep; this clears when cluster delete invokes an owner-provided removal of the cluster's entries. The owner returns the persisted `Record` to the AWS adapter and token review; replace it with consumer-owned projections when those consumers are extracted.
- `domains/eks/cluster` → `domains/eks/addon` (legacy edges): `DeleteClusterChildren` erases add-on records and manifests in its blanket cluster sweep; this clears when cluster delete invokes an add-on-owned teardown capability. `stageGPUDeviceAddon` is a temporary node-group-to-add-on orchestration edge: best-effort, errors logged and swallowed, never undone on node-group delete; it clears when the node-group owner is extracted and the dependency is designed. `ClusterReconciler` hosts the add-on status subscription; it clears when add-on delivery observation has its own host under the lifecycle correction. Cluster existence is checked in the handler, not through an owner-declared capability; it moves into the owner when cluster admission is defined (today status is ignored). Guest wire types (`AddonStatusReport`, `AddonDeliveryPhase`, `AddonStatusSubject`, `StagedAddonManifest`, `InternalAddonsResponse`) are owned by `contracts/eks/v1`, which `domains/eks/cluster`, `gateway/eks` and `agents/eks/gatewayfetch` import directly.
- `domains/eks/cluster` and `domains/eks/awsapi` → `domains/eks/addon` (further edges): the AWS adapter reads the persisted `Record` through `addon.Get`/`addon.List`; this clears when the owner returns a view and the add-on adapter moves to `domains/eks/awsapi`. The guest route reaches the owner through the `handlers_eks` NATS RPC (`ListStagedAddonManifests`); this clears with per-resource commands behind EKS registration. The add-on ARN is derived in both the owner and `domains/eks/awsapi/authz.go`; this clears when authorization obtains the ARN from the owner, which an incarnation suffix would require. The owner's `Manifest`/`Report` beside the `contracts/eks/v1` types is deliberate duplication, not debt.
- `agents/rds/agent` (dependency, narrowed): engine metadata now arrives through the agent-declared `EngineCatalog`, but the agent still imports `domains/rds/instance` for the command protocol, wire and record types (`Command*`, `Parameter`, `EngineHealth*`, `BootstrapMode*`) and API output types. This clears when those move behind a guest/controller wire contract.
- `domains/rds/awsapi` → `domains/rds/instance` (`InstanceSizing`): the gateway obtains its `Sizing` through the RDS handler package's adapter rather than its own composition root. This clears when the gateway's RDS composition moves to `runtime/roles/awsgw` and builds `Sizing` there.
+47
View File
@@ -96,6 +96,53 @@ Limits:
The load balancer VM reports target health through its own host's gateway, so while that gateway is down the unhealthy control plane stays in rotation.
- Not measured: more than one control plane per host, a physical-node failure instead of a stopped gateway, and the target health state as `DescribeTargetHealth` reports it, because the load balancer and control-plane VMs belong to the system account.
### IAM roles for service accounts in your workloads
A pod gets an IAM role by running under a ServiceAccount that carries the role ARN in an annotation.
No pod-spec wiring is needed.
```yaml
apiVersion: v1
kind: ServiceAccount
metadata:
name: app
annotations:
eks.amazonaws.com/role-arn: arn:aws:iam::123456789012:role/app-role
```
Every control plane runs a mutating webhook that acts on pod creation.
When the pod's ServiceAccount has the annotation, the webhook adds these to each container and init container:
- `AWS_ROLE_ARN` and `AWS_WEB_IDENTITY_TOKEN_FILE`, with a projected token volume named `aws-iam-token` mounted at `/var/run/secrets/eks.amazonaws.com/serviceaccount`.
- `AWS_REGION`, `AWS_DEFAULT_REGION` and `AWS_STS_REGIONAL_ENDPOINTS`.
- `AWS_ENDPOINT_URL_STS`, so the SDK calls the Spinifex STS endpoint.
- `AWS_CA_BUNDLE`, pointing at `/etc/spinifex/ca-bundle/ca.pem`, a ConfigMap named `spinifex-ca-bundle` that is created in every namespace and holds the system roots plus the gateway CA.
- `SSL_CERT_FILE` with the same path, only when the container does not set it. Clients such as the AWS Load Balancer Controller read this variable instead of `AWS_CA_BUNDLE`.
Your workload still sets `AWS_ENDPOINT_URL` (or the per-service variable) for every service other than STS.
The token audience defaults to `sts.amazonaws.com`.
The expiry defaults to a value between 79200 and 86400 seconds and is clamped to 600 to 86400 seconds.
Set them with the `eks.amazonaws.com/audience` and `eks.amazonaws.com/token-expiration` annotations on the ServiceAccount.
The `token-expiration` annotation on the pod wins over the ServiceAccount.
A container is left alone when it already has the role, region and regional STS settings, which is how the managed add-ons and your own explicit wiring are skipped.
When some are present, the missing ones and the mount are added.
Containers named in the pod's `eks.amazonaws.com/skip-containers` annotation (comma separated) are not touched.
A pod whose ServiceAccount has no role annotation is not changed.
The webhook fails open: if it cannot decide, the pod is created without injection.
Each injection, skip and failure is logged with the namespace, pod and reason in the control-plane console log.
Because it fails open, a pod that needs a role but starts without credentials is the symptom to look for, and the console log says why.
The webhook listens on `127.0.0.1:8445` of each control plane, and each API server calls its own.
Port 8444 is a different listener, the readiness check described above.
Existing clusters get the webhook only when their control planes are replaced by the current `eks-node` image.
No user-facing operation replaces a control plane, and automatic replacement does not currently work when the replacement reuses the failed member's IP.
Recreate the cluster to adopt it.
User-workload injection through a cluster created with an older image is not measured.
## Prerequisites
> [!IMPORTANT]
@@ -16,16 +16,16 @@ Create, update, delete and policy association complete synchronously within the
|---|---|---|
| Identity | Cluster name plus principal ARN. Key and ARN suffix use `sha256hex(principalARN)` (`domains/eks/access/store.go` `PrincipalARNHash`). ARN `arn:aws:eks:{region}:{account}:access-entry/{cluster}/{hash}` (`foundation/aws/arn/eks.go`); the AWS ARN shape differs and is not verified. | `handlers/eks/service_impl_test.go` `TestAccessEntry_CreateDescribeListDelete`, `domains/eks/access/store_test.go` `TestKeyPaths`, `gateway/eks/authz_test.go` `TestResourceARNsAccessEntryMatchesHandler` |
| Scope | Account and Region, within one cluster: keys live under `clusters/{cluster}/access-entries/` in the `eks-account-{account}` bucket. | `handlers/eks/access_lifecycle_test.go` `TestAccessEntryPersistedLayout_TopLevelAndNestedFieldNames`, `TestAccessEntryRecord_DecodesHandWrittenJSON`, `handlers/eks/create_cluster_test.go` `TestCreateCluster_SeedsCreatorAdminAccessEntry` |
| Durable owner and record | `domains/eks/access` (`Owner`) is the only writer of `Record` with nested `AssociatedPolicy` and `Scope`: Create, SeedCreatorAdmin (cluster launch), Update, Associate, Disassociate and Delete. Keys and field names are unchanged by the move. `DeleteClusterPrefix` (`handlers/eks/cluster_state.go`) still erases entries with the cluster, and the gateway process reads records through the exported `access.Get` in `ResolveTokenReview` (`handlers/eks/token_review.go`). | `handlers/eks/access_lifecycle_test.go` `TestAccessEntryPersistedLayout_TopLevelAndNestedFieldNames`, `TestAccessEntryRecord_DecodesHandWrittenJSON`, `domains/eks/access/owner_test.go`, `domains/eks/access/store_test.go`, `handlers/eks/create_cluster_test.go` `TestCreateCluster_SeedsCreatorAdminAccessEntry` |
| Durable owner and record | `domains/eks/access` (`Owner`) is the only writer of `Record` with nested `AssociatedPolicy` and `Scope`: Create, SeedCreatorAdmin (cluster launch), Update, Associate, Disassociate and Delete. Keys and field names are unchanged by the move. `DeleteClusterChildren` (`domains/eks/cluster/cluster_state.go`) still erases entries with the cluster, and the gateway process reads records through the exported `access.Get` in `ResolveTokenReview` (`handlers/eks/token_review.go`). | `handlers/eks/access_lifecycle_test.go` `TestAccessEntryPersistedLayout_TopLevelAndNestedFieldNames`, `TestAccessEntryRecord_DecodesHandWrittenJSON`, `domains/eks/access/owner_test.go`, `domains/eks/access/store_test.go`, `handlers/eks/create_cluster_test.go` `TestCreateCluster_SeedsCreatorAdminAccessEntry` |
| AWS adapter | `handlers/eks` keeps request validation (entry type, supported policy, scope), the cluster existence check (`acctKVForCluster`), awserrors mapping and the AWS projection. `gateway/eks/authz.go` uses `access.PrincipalARNHash`. | `handlers/eks/service_impl_test.go` access cases, `gateway/eks/authz_test.go` `TestResourceARNsAccessEntryMatchesHandler` |
| Desired state | Principal, Kubernetes username and groups, type (`STANDARD` only), tags (set at create only) and cluster-scoped access policy associations. | `handlers/eks/service_impl_test.go` `TestAccessEntry_RejectsNodeType`, `TestAccessPolicy_AssociateRejectsUnsupportedPolicyAndScope`, `TestAccessPolicy_AssociateRejectsNamespaceScope`, `handlers/eks/access_lifecycle_test.go` `TestAccessEntry_UpdateWithNoFieldsKeepsUsernameAndGroups` |
| Generation | None. Update, associate and disassociate retry against the KV revision; create and delete do not. | |
| Observed state | Not tracked; the record is the only state. | |
| Readiness | Usable by token review as soon as the write returns. | `handlers/eks/token_review_test.go` `TestResolveTokenReview_AuthenticatesViaVerifyAndKV`, `TestResolveTokenReview_NoAccessEntryDenies`, `handlers/eks/service_impl_test.go` `TestAccessPolicy_DisassociateRemovesProjectedGroup` |
| Idempotency | `clientRequestToken` is ignored. Update always advances `modifiedAt`, even when nothing changes. A duplicate create returns `ResourceInUseException`; a repeated delete returns `ResourceNotFoundException`; disassociating an unbound policy succeeds without change. Internal NATS redelivery of create returns `ResourceInUseException` to the redelivered request. | `handlers/eks/service_impl_test.go` `TestAccessEntry_CreateDescribeListDelete`, `TestAccessEntry_DescribeDeleteMissingIsNotFound`, `handlers/eks/access_lifecycle_test.go` `TestAccessPolicy_DisassociateUnassociatedLeavesRecordUnchanged`, `TestAccessPolicy_AssociateWithNoEntryIsNotFound` |
| Dependencies | Every action requires only that the cluster's meta record exists (`acctKVForCluster`); cluster status is not read, so actions succeed on a `CREATING`, `FAILED` or `DELETING` cluster. Token review does not check the cluster at all. The principal is not checked against IAM. | `handlers/eks/service_impl_test.go` `TestAccessEntry_UnknownClusterIsNotFound`, `handlers/eks/access_lifecycle_test.go` `TestAccessEntryActionsIgnoreClusterLifecycleStatus`, `TestResolveTokenReview_AuthenticatesWithoutClusterMeta` |
| Dependencies | Every action requires that the cluster's meta record exists (`acctKVForCluster`). `CreateAccessEntry` also refuses a `DELETING` cluster with `ResourceInUseException`, since a recorded delete admits no new dependant; no other status is read, so actions succeed on a `CREATING` or `FAILED` cluster. Token review does not check the cluster at all. The principal is not checked against IAM. | `handlers/eks/service_impl_test.go` `TestAccessEntry_UnknownClusterIsNotFound`, `handlers/eks/access_lifecycle_test.go` `TestAccessEntryActionsIgnoreFailedClusterStatus`, `delete_cluster_async_test.go` `TestS2_EKSDeletingClusterAdmitsNoNewDependants`, `TestResolveTokenReview_AuthenticatesWithoutClusterMeta` |
| Authorization | Gateway authz derives the access-entry ARN with `PrincipalARNHash` (`gateway/eks/authz.go`). No `iam:PassRole`. The STS step of token review is the existing `eks.VerifyToken` NATS contract served by awsgw. | `gateway/eks/authz_test.go` `TestResourceARNsAccessEntryMatchesHandler` |
| Deletion ordering | Delete is get-then-delete, not conditional. Cluster delete erases entries in the prefix purge only after all infrastructure teardown succeeds; on failure they survive while the cluster stays `DELETING`. | `handlers/eks/access_lifecycle_test.go` `TestDeleteClusterPrefix_SweepsAccessEntriesScopedToCluster` |
| Deletion ordering | Delete is get-then-delete, not conditional. Cluster delete erases entries with the cluster's other child records, before the meta, only after all infrastructure teardown succeeds; on failure they survive while the cluster stays `DELETING`. | `handlers/eks/access_lifecycle_test.go` `TestDeleteClusterChildren_SweepsAccessEntriesScopedToCluster` |
| Restart and interruption | The prefix purge deletes keys in listing order; an interruption after the meta key is gone leaves entries that a new cluster of the same name inherits. Reclaiming a `FAILED` cluster and a failed launch purge with the meta kept, so the recreated cluster inherits the failed attempt's entries. | none |
| End-to-end | `tests/e2e/eks/eks_test.go` `AccessEntry` and `GetToken`; `tests/integration/cross_tenant_test.go`. No Terraform apply/destroy evidence for `aws_eks_access_entry` or `aws_eks_access_policy_association`. | |
@@ -33,7 +33,7 @@ Create, update, delete and policy association complete synchronously within the
1. Create is get-then-put with no exclusive create, so concurrent creates for one principal both succeed and the later write wins.
2. The creator-admin seed at cluster launch is an unconditional put after the cluster meta is final, so it replaces an entry for the same principal created while the cluster was `CREATING`, including its policy associations.
3. Access actions do not check cluster status (see dependencies row), and opening the bucket creates it for an account that has none.
3. Access actions other than `CreateAccessEntry` do not check cluster status (see dependencies row), and opening the bucket creates it for an account that has none.
4. Entries can outlive their cluster (interrupted prefix purge) or carry over to a recreated cluster (reclaim and failed launch keep the meta and do not purge entries separately).
5. Token review authenticates against an entry without checking the cluster exists or its status.
6. `ResolveTokenReview` opens the EKS bucket and reads the record in the gateway process through the exported `access.Get`, outside the owner.
+4 -4
View File
@@ -16,9 +16,9 @@ There is no generation, so the record mixes desired fields with the latest obser
| Item | Present behaviour | Evidence |
|---|---|---|
| Identity | Cluster name plus add-on name; one add-on per name per cluster. ARN `arn:aws:eks:{region}:{account}:addon/{cluster}/{addon}` (`foundation/aws/arn/eks.go` `FormatEKSAddon`), deterministic by name, with no per-incarnation suffix. The gateway authorizer derives the same ARN (`domains/eks/awsapi/authz.go` `addonARN`). | `handlers/eks/addon_lifecycle_test.go` `TestAddonPersistedLayout_RecordAndManifestKeysAndFieldNames`, `domains/eks/awsapi/authz_test.go` `TestResourceARNsFidelity` |
| Scope | Account and Region, within one cluster: keys live under `clusters/{cluster}/addons/` in the `eks-account-{account}` bucket. | `TestAddonPersistedLayout_RecordAndManifestKeysAndFieldNames`, `TestDeleteClusterPrefix_SweepsAddonRecordsAndManifestsScopedToCluster` |
| Scope | Account and Region, within one cluster: keys live under `clusters/{cluster}/addons/` in the `eks-account-{account}` bucket. | `TestAddonPersistedLayout_RecordAndManifestKeysAndFieldNames`, `TestDeleteClusterChildren_SweepsAddonRecordsAndManifestsScopedToCluster` |
| Durable record | `clusters/{cluster}/addons/{addon}` holds `Record` (`domains/eks/addon/record.go`): `addonName`, `addonVersion`, `status`, `serviceAccountRoleArn`, `configurationValues`, `health`, `arn`, `tags`, `createdAt`, `modifiedAt` (the optional ones `omitempty`). `clusters/{cluster}/addons/{addon}/manifest` holds `Manifest` (`domains/eks/addon/record.go`): `addonName`, `addonVersion`, `serviceAccountRoleArn`, `configurationValues`. Listing skips the manifest sub-key by segment count. | `TestAddonPersistedLayout_RecordAndManifestKeysAndFieldNames`, `TestAddonRecord_DecodesHandWrittenJSON`, `domains/eks/addon/store_test.go` `TestRecord_RoundTrip`, `TestList_SkipsManifestSubKeys` |
| Writers | Record: the owner in `domains/eks/addon/owner.go`, reached from the `CreateAddon`, `UpdateAddon` and `DeleteAddon` handlers, the cluster reconciler's report relay and the GPU node group launch: `Owner.Create` (unconditional put), `Owner.Update` and its failure marking (revision CAS), `Owner.Delete` (get then delete), `Owner.ApplyReport` (revision CAS), `Owner.EnsureGPUDevicePlugin` through `Owner.Create`; and the cluster prefix purge (`DeleteClusterPrefix`). Manifest: `StagingInstaller.Install` (unconditional put on create and update), `StagingInstaller.Uninstall` (`domains/eks/addon/delivery.go`), `Owner.Delete` and the prefix purge. | `TestAddonPersistedLayout_RecordAndManifestKeysAndFieldNames`, `TestUpdateAddon_ReportsSuccessfulWhileRecordIsUpdating`, `TestDeleteAddon_ReturnsDeletingAfterErasingRecordAndManifest`, `TestDeleteClusterPrefix_SweepsAddonRecordsAndManifestsScopedToCluster`, `domains/eks/addon/store_test.go` `TestCasUpdate`, `TestDeleteRecord_RemovesRecordAndManifest` |
| Writers | Record: the owner in `domains/eks/addon/owner.go`, reached from the `CreateAddon`, `UpdateAddon` and `DeleteAddon` handlers, the cluster reconciler's report relay and the GPU node group launch: `Owner.Create` (unconditional put), `Owner.Update` and its failure marking (revision CAS), `Owner.Delete` (get then delete), `Owner.ApplyReport` (revision CAS), `Owner.EnsureGPUDevicePlugin` through `Owner.Create`; and the cluster prefix purge (`DeleteClusterChildren`). Manifest: `StagingInstaller.Install` (unconditional put on create and update), `StagingInstaller.Uninstall` (`domains/eks/addon/delivery.go`), `Owner.Delete` and the prefix purge. | `TestAddonPersistedLayout_RecordAndManifestKeysAndFieldNames`, `TestUpdateAddon_ReportsSuccessfulWhileRecordIsUpdating`, `TestDeleteAddon_ReturnsDeletingAfterErasingRecordAndManifest`, `TestDeleteClusterChildren_SweepsAddonRecordsAndManifestsScopedToCluster`, `domains/eks/addon/store_test.go` `TestCasUpdate`, `TestDeleteRecord_RemovesRecordAndManifest` |
| Readers | `ListAddons`, `DescribeAddon`, `CreateAddon` (existence check) and the reconciler read the record. `ListStagedAddonManifests` reads every `/manifest` key for the cluster (`addon.ListManifests`); it is served on `eks.ListStagedAddonManifests` (`daemon/daemon.go`) and reached only through the gateway's internal-addons route. | `handlers/eks/addons_test.go` `TestAddon_DescribeListDelete`, `TestListStagedAddonManifests` |
| Catalogue | Static in-binary catalogue (`domains/eks/addon/catalog.go`): `aws-load-balancer-controller`, `argocd`, `aws-ebs-csi-driver`, and hidden-but-creatable `nvidia-device-plugin` and `spinifex-noop`; one version each, the first being the default. Create and update reject unknown names and versions with `InvalidParameterValue`. | `domains/eks/addon/catalog_test.go`, `handlers/eks/addons_test.go` `TestDescribeAddonVersions_ReturnsCatalog`, `TestCreateAddon_UnknownAddonRejected`, `TestCreateAddon_UnknownVersionRejected`, `TestUpdateAddon_UnknownVersionRejected` |
| Desired state | Version, `serviceAccountRoleArn`, `configurationValues` and create-time tags. `resolveConflicts`, `podIdentityAssociations` and `clientRequestToken` are accepted and dropped; `preserve` on delete is ignored. `configurationValues` reaches the guest but the agent discards it (`mulga-eks-addon-sync.sh` `sync_once`), so it has no effect. | `TestAddonPersistedLayout_RecordAndManifestKeysAndFieldNames`, `TestDeleteAddon_ReturnsDeletingAfterErasingRecordAndManifest` |
@@ -37,9 +37,9 @@ There is no generation, so the record mixes desired fields with the latest obser
| Cross-resource dependency | A GPU node group's launch calls `stageGPUDeviceAddon`, which checks the cluster exists and calls `Owner.EnsureGPUDevicePlugin` for `nvidia-device-plugin`; an existing record is a no-op and every other error is logged only. Deleting the node group never unstages it, and an existing record in any status, including `CREATE_FAILED`, blocks re-staging. | `handlers/eks/nodegroup_test.go` `TestCreateNodegroup_GPUNodegroupStagesDevicePluginAddon`, `domains/eks/addon/owner_test.go` `TestOwnerEnsureGPUDevicePlugin_StagesDefaultVersionOnceAndKeepsAnyExistingRecord`, `TestCreateNodegroup_NonGPUNodegroupDoesNotStageDevicePluginAddon`, `tests/e2e/gpu/eks_gpu_test.go` `TestEKSGPUPodExposure` |
| Idempotency | `clientRequestToken` is ignored. A duplicate create, including an SDK retry with the same token, returns `ResourceInUseException`. A repeated update re-stages and returns another `Successful` with the same id. A repeated delete returns `ResourceNotFoundException`. | `TestCreateAddon_RepeatedClientRequestTokenIsResourceInUse`, `TestUpdateAddon_ReportsSuccessfulWhileRecordIsUpdating`, `TestDeleteAddon_ReturnsDeletingAfterErasingRecordAndManifest`, `handlers/eks/addons_test.go` `TestCreateAddon_CreatesStagingAndDefaultsVersion` |
| Absent resource | An unknown cluster returns `ResourceNotFoundException` for every action, including the internal manifest listing. A missing add-on returns `ResourceNotFoundException` from describe, update and delete. | `handlers/eks/addons_test.go` `TestCreateAddon_UnknownClusterIsNotFound`, `TestDescribeDeleteAddon_MissingIsNotFound`, `TestUpdateAddon_MissingIsNotFound`, `TestListStagedAddonManifests` |
| Dependencies | Only the cluster meta's existence is checked; cluster status is not read, so add-ons can be created on a `DELETING` cluster. | `TestAddonActionsIgnoreClusterLifecycleStatus` |
| Dependencies | `CreateAddon` checks the cluster meta exists and refuses a `DELETING` cluster with `ResourceInUseException`, since a recorded delete admits no new dependant; other statuses are not read. | `TestCreateAddonRefusesOnlyADeletingCluster` |
| Authorization | Gateway policy uses the add-on ARN above. `serviceAccountRoleArn` requires `iam:PassRole` on `CreateAddon` and `UpdateAddon` (`domains/eks/awsapi/passrole.go`), the same check `CreateCluster`/`CreateNodegroup` apply, including that the role exists. Tag actions on an add-on ARN return `NotImplemented`. | `domains/eks/awsapi/gateway_wire_test.go` `TestPassedRoleARNs_AddonServiceAccountRoleIsChecked`, `domains/eks/awsapi/gateway_passrole_test.go` `TestEKSCreateAddon_PassRole`, `TestEKSUpdateAddon_PassRole`, `domains/eks/awsapi/authz_test.go` `TestResourceARNsFidelity` |
| Cluster deletion | Add-on records and manifests are erased only by the cluster prefix purge, after the cluster's infrastructure teardown succeeds; there is no per-add-on delete. Reclaiming a failed cluster keeps the meta and does not purge, so the recreated cluster inherits the add-ons. | `TestDeleteClusterPrefix_SweepsAddonRecordsAndManifestsScopedToCluster`, `handlers/eks/cluster_state_test.go` `TestDeleteClusterPrefix_SweepsEveryKey` |
| Cluster deletion | Add-on records and manifests are erased only by the cluster prefix purge, after the cluster's infrastructure teardown succeeds; there is no per-add-on delete. Reclaiming a failed cluster keeps the meta and does not purge, so the recreated cluster inherits the add-ons. | `TestDeleteClusterChildren_SweepsAddonRecordsAndManifestsScopedToCluster`, `handlers/eks/cluster_state_test.go` `TestDeleteClusterChildren_SweepsEveryKey` |
| Restart and interruption | Create interrupted between the record put and staging leaves a `CREATING` record with no manifest that nothing re-stages and that blocks a retry. Delete interrupted after unstaging leaves a record in its previous status while the guest removes the software. An update interrupted after the CAS leaves `UPDATING` with the previous manifest. A daemon restart loses nothing durable, but reports sent while no reconciler runs are lost until the next tick. | none |
| End-to-end | `tests/e2e/eks/eks_test.go` `TestEKS/AddonDelivery` (create to `ACTIVE`, objects present, delete removes the namespace) and `tests/e2e/gpu/eks_gpu_test.go` `TestEKSGPUPodExposure`; `tests/integration/cross_tenant_test.go` `TestCrossTenant_EveryScopedActionIsAccountedFor`. No E2E for update, failure, configuration or the role. No Terraform apply/destroy evidence for `aws_eks_addon`. | |
@@ -0,0 +1,42 @@
# EKS cluster: delete lifecycle contract
Contract under ADR-0003 S1 for the delete half of an EKS cluster's lifecycle, the half ADR-0003 S8 constrains.
Create, update and the control-plane reconciler are out of its scope and keep the observed gaps recorded elsewhere.
Paths are relative to `spinifex/` unless they start with `tests/`, which is relative to the spinifex repo root.
Test names without a path are in `domains/eks/cluster/`.
## Scope
A cluster is a composite resource: its record at `clusters/{name}/meta` in the `eks-account-{account}` bucket names the dependants a delete must remove.
In the system account those are the control-plane VMs and their root volumes, the endpoint load balancer with its target groups, the managed control-plane VPC and its network, and the spread placement group.
In the customer account they are the egress address, the private-endpoint interface and security group, the cluster security groups, a cluster-owned internet gateway, and load balancers the in-cluster controller created.
Node group workers and CSI-provisioned EBS volumes are never removed by a cluster delete.
## Contract
| Item | Behaviour | Evidence |
|---|---|---|
| Request | `DeleteCluster` validates, answers `ResourceNotFoundException` for an absent cluster, answers a `DELETING` cluster from its record, refuses a cluster with node groups with `ResourceInUseException`, records `DELETING` and returns. It waits for no teardown step. | `TestS8_EKSDeleteClusterReturnsBeforeTheTeardownCompletes`, `TestRLC1_EKSDeleteClusterNotFoundOnAbsent`, `TestS8_EKSDeleteClusterRefusesWhileNodegroupsExist`, `tests/e2e/eks` `TestEKS/DeleteCluster` |
| Obligation | `DELETING` with `DeletingSince` on the record is the tenant's decision. No status writer moves a record off it, and a create launch still running adds its refs to it instead of overwriting it. | `TestS2_EKSDeletingIsStickyForStatusWriters`, `TestS2_EKSLaunchWriteAfterDeleteNeitherRevertsNorRecreates` |
| Retry | A repeat delete answers `DELETING` from the record and starts nothing. | `TestS8_EKSSecondDeleteAnswersFromTheRecordWithoutASecondTeardown` |
| Convergence | `convergeClusterDeletion` (`domains/eks/cluster/cluster_teardown.go`), started by the request and backed by `EKSDeletingReaper`, runs under a per-cluster in-process gate and the cross-node teardown lease, renewed on the service context. It acts only on a record that is `DELETING` when re-read. | `TestS8_EKSKickAndSameNodeSweepRunOneTeardown`, `TestS8_EKSTeardownLeaseOutlivesTheRequest`, `TestS7_EKSTeardownActsOnlyOnARecordedDelete` |
| Retained cleanup | A failed step keeps the record `DELETING` with its refs and the error. The record is removed last, after its child records, and only if no ref appeared that the pass did not tear down. A record with a node group is never removed; a record that is not `DELETING` is refused before anything else is checked. | `TestRLC2_EKSBillableTeardownBeforeKVSweep`, `TestS2_EKSTeardownRemovesTheMetaLast`, `TestS2_EKSTeardownDoesNotSweepARecordChangedDuringTeardown`, `TestS7_EKSTeardownNeverRemovesAClusterWithNodegroupWorkers` |
| Recovery | A teardown interrupted by a crash or a lost lease is finished by the reaper on any node. Failed passes back off from 90 s, doubling to a 30 min cap, and are never abandoned; `DeleteReapExhausted` only flags a long failure for an operator. | `TestS2_EKSTeardownResumesAfterAnInterruptedConverge`, `TestRLC4_DeletingReaperReDrivesWedgedTeardown`, `TestDeletingReaper_KeepsRetryingPastTheOldAttemptLimit`, `TestDeletingReaper_ResumesAnAlreadyExhaustedRecord` |
| Visibility | `DescribeCluster` and `ListClusters` show the cluster `DELETING` until the record is removed, then `ResourceNotFoundException`. A failed pass shows one `INTERNAL_FAILURE` health issue naming the step, never the internal error. | `TestDescribeCluster_StuckDeleteShowsTheStepNotTheError`, `TestRLC2_EKSBillableTeardownBeforeKVSweep` |
| New dependants | `CreateNodegroup` requires `ACTIVE`; `CreateAddon` and `CreateAccessEntry` refuse a `DELETING` cluster with `ResourceInUseException`. | `TestCreateAddonRefusesOnlyADeletingCluster`, `TestS2_EKSDeletingClusterAdmitsNoNewDependants` |
| Subject budgets | Every `eks.*` request subject is classified by its handler's work in `eksSubjects` (`domains/eks/cluster/service_nats.go`) and keeps the 30 s budget; `eks.DeleteCluster` is request-scaled. A subject the gateway sends or the daemon serves that is not classified fails the table. | `TestS8_EKSSubjectBudgets` |
| Late child records | No child record outlives the cluster or leaks into a later one of the same name. The teardown sweeps child keys again after removing the meta and stops the cluster's bootstrap subscriber, bootstrap refuses to write into a record that is gone or `DELETING`, the launch re-checks its record around seeding the creator's access entry, and a fresh claim clears whatever an earlier cluster left under the name. | `TestS2_EKSLaunchOvertakenByDeleteLeavesNoChildRecords`, `TestS2_EKSBootstrapWritesNothingIntoADeletingCluster`, `TestS2_EKSFreshClaimInheritsNoStaleChildRecords` |
| Launch generation | The create launch's writes are fenced on the record's generation (`CreatedAt` and ARN). An overtaken launch never writes a newer cluster of the same name and releases only the instances it started, by ID. One whose record was swept recreates it as `DELETING` with its refs, so the teardown and reaper own the release. One whose record was marked `FAILED` merges its refs and releases what it built. | `TestS2_EKSOvertakenLaunchNeverWritesANewerClusterOfTheSameName`, `TestS2_EKSOvertakenLaunchHandsItsInfraToTheTeardown`, `TestS2_EKSLaunchMarkedFailedReleasesWhatItBuilt` |
| Running launch | A create launch heartbeats its owner and time on the record (`LaunchOwner`, `LaunchHeartbeat`). The teardown keeps the record while that heartbeat is fresh, so the cluster reads `DELETING` throughout and never `ResourceNotFoundException` then `DELETING` again; the launch hands its refs over at its next write and starts the teardown when it returns. A heartbeat older than 90 s is taken as a crashed launch and no longer waited for, and recreating a swept record stays as the backstop. The launch's OIDC keypair writes are fenced on the record's generation. | `TestS2_EKSDeletedClusterNeverReadsMissingWhileItsLaunchRuns`, `TestS2_EKSCrashedLaunchDoesNotPinTheDelete`, `TestS2_EKSOvertakenLaunchCannotReplaceANewerClustersSigningKey` |
| Bounded passes | One converger run is bounded at 15 minutes, so a hung step cannot hold the gate and lease; the timed-out pass is recorded and backed off. Waiting on node groups is not counted as a failed pass, and deleting the last node group resumes the delete. | `TestS8_EKSHungTeardownStepReleasesTheGate`, `TestS2_EKSLastNodegroupDeletedResumesTheClusterDelete` |
| FAILED reclaim | Re-creating a `FAILED` cluster's name tears the old attempt down under the same gate and lease as a delete, and answers `ResourceInUseException` while another holder has it; the per-node teardown bound does not apply to it. | `TestS2_EKSFailedClusterReclaimRunsUnderTheTeardownLease`, `TestS2_EKSFailedClusterReclaimIsNotRefusedForCapacity` |
| Tenant data | The teardown never deletes a CSI-provisioned volume or terminates a node group worker. | `TestS7_EKSTeardownNeverDeletesCSIVolumesOrWorkers`, `TestS7_EKSTeardownNeverRemovesAClusterWithNodegroupWorkers` |
## Gaps
1. Four `eks.*` handlers still hold their request for work that grows with the resource, and the subject table names them as not conformant: `CreateCluster` (reclaiming a `FAILED` cluster of the same name tears its infra down inline), `DeleteNodegroup` (one awaited terminate per worker), `UpdateNodegroupConfig` (one awaited launch or terminate per worker added or removed) and `RestoreSnapshot` (launches a control-plane VM and fences the old one inline).
2. AWS's answer to a repeat delete on a `DELETING` cluster has not been observed; `DELETING` from the record is the chosen behaviour.
3. Control-plane, load balancer and VPC resources in the system account are keyed by the bare cluster name, so a teardown by name can reach another tenant's same-named cluster.
4. The teardown deletes load balancers the in-cluster controller created in the customer account, which AWS leaves behind.
5. An overtaken launch's late write can still land under a newer cluster of the same name in the instant between that cluster's claim sweep and the old launch's generation check; it is bounded by one write, since the old launch stops at its next fenced write.
6. The `FAILED`-name reclaim purge still runs inside the `CreateCluster` request (gap 1).
+1 -1
View File
@@ -129,7 +129,7 @@ The client reports on its serial console because the runner has no route into th
### `eks`, `ecs`, `ecr` — container services
`eks`: cluster create/delete, access entries, addon delivery, `GetToken`, kubeconfig artifacts, the EBS CSI volume path, an IRSA web-identity exchange under the stock AWS trust policy (`StringEquals` on the issuer host-path `:sub`/`:aud`, a mismatched `:sub` denied with `AccessDenied`, a `StringLike` `:sub` granted), and an IRSA pod wired with a projected SA token. Worker IMDS is covered too: a managed nodegroup worker reports IMDSv2 required with hop limit 2, a pod with no IRSA resolves to the node role through IMDS, and a nodegroup built from a launch template takes the template's metadata options. Control-plane isolation is covered as well: no control-plane host registers as a Node, a DaemonSet tolerating every taint runs on workers only, metrics-server is Ready, and logs work for every kube-system pod. `ecs`: clusters, container instances, the three network modes (`awsvpc`, `bridge`, `host`), task-role credentials fetched from the container credentials endpoint, a service behind an ELB, and ENI release on terminate. `ecr`: control-plane CRUD plus a real OCI data plane — docker login/build/push/pull round trips, multi-layer images, and raw blob HTTP.
`eks`: cluster create/delete, access entries, addon delivery, `GetToken`, kubeconfig artifacts, the EBS CSI volume path, an IRSA web-identity exchange under the stock AWS trust policy (`StringEquals` on the issuer host-path `:sub`/`:aud`, a mismatched `:sub` denied with `AccessDenied`, a `StringLike` `:sub` granted), and an IRSA pod whose spec names only a ServiceAccount with the role-arn annotation, where the pod-identity webhook injects the token volume, `AWS_*` environment, STS endpoint and CA bundle and the pod assumes the role, while a pod of an unannotated ServiceAccount is left untouched. The unmodified aws-load-balancer-controller chart 1.11.0 installs with the values an AWS user passes plus the gateway endpoint override, the Shield and WAF switches and the single-subnet feature gate, and reconciles an Ingress into an active ALB. Worker IMDS is covered too: a managed nodegroup worker reports IMDSv2 required with hop limit 2, a pod with no IRSA resolves to the node role through IMDS, and a nodegroup built from a launch template takes the template's metadata options. Control-plane isolation is covered as well: no control-plane host registers as a Node, a DaemonSet tolerating every taint runs on workers only, metrics-server is Ready, and logs work for every kube-system pod. `ecs`: clusters, container instances, the three network modes (`awsvpc`, `bridge`, `host`), task-role credentials fetched from the container credentials endpoint, a service behind an ELB, and ENI release on terminate. `ecr`: control-plane CRUD plus a real OCI data plane — docker login/build/push/pull round trips, multi-layer images, and raw blob HTTP.
### `rds` — managed databases
+17 -6
View File
@@ -11,13 +11,14 @@ set -eu
# cron can branch without re-parsing, then disables itself so it runs exactly
# once.
#
# server → eks-token-webhook, k3s (server), k3s-first-boot (bootstrap
# publish), mulga-eks-state-report, mulga-eks-node-gc
# server → eks-token-webhook, k3s (server), eks-pod-identity-webhook,
# k3s-first-boot (bootstrap publish), mulga-eks-state-report,
# mulga-eks-node-gc
# server-join → eks-token-webhook, k3s (server, joins the first server's etcd
# quorum), mulga-eks-state-report, mulga-eks-node-gc. NO
# k3s-first-boot: the first server already publishes the
# cluster-identical bootstrap artifacts; a join re-publish only
# races the bootstrap bus.
# quorum), eks-pod-identity-webhook, mulga-eks-state-report,
# mulga-eks-node-gc. NO k3s-first-boot: the first server
# already publishes the cluster-identical bootstrap artifacts;
# a join re-publish only races the bootstrap bus.
# agent → k3s-agent
#
# Paths are overridable via env so the selection logic is unit-testable (bats)
@@ -81,6 +82,14 @@ svc_start() {
esac
}
# IRSA injector: every server runs one, called by its own apiserver. It is
# optional (failurePolicy Ignore), so it goes last and a fault only costs
# injection, never the control plane.
svc_optional() {
svc_enable "$1" && svc_start "$1" ||
log "WARN: optional service $1 failed to enable or start; continuing without it"
}
svc_disable() {
case "${INIT_SYSTEM}" in
openrc) rc-update del "$1" default 2>/dev/null || true ;;
@@ -150,6 +159,7 @@ case "${ROLE}" in
svc_start mulga-eks-state-report
svc_start mulga-eks-addon-sync
svc_start mulga-eks-node-gc
svc_optional eks-pod-identity-webhook
;;
server-join)
log "configuring server-join role"
@@ -168,6 +178,7 @@ case "${ROLE}" in
svc_start konnectivity-server
svc_start mulga-eks-state-report
svc_start mulga-eks-node-gc
svc_optional eks-pod-identity-webhook
;;
agent)
log "configuring agent role"
+34 -1
View File
@@ -22,6 +22,7 @@ for tool in rc-update rc-service; do
cat > "${STUB_OPENRC}/${tool}" <<EOF
#!/bin/sh
echo "${tool} \$*" >> "\${CALLS}"
case "\$*" in *"\${STUB_FAIL:-@none@}"*) exit 1 ;; esac
exit 0
EOF
chmod +x "${STUB_OPENRC}/${tool}"
@@ -32,6 +33,7 @@ mkdir -p "${STUB_SYSTEMD}"
cat > "${STUB_SYSTEMD}/systemctl" <<'EOF'
#!/bin/sh
echo "systemctl $*" >> "${CALLS}"
case "$*" in *"${STUB_FAIL:-@none@}"*) exit 1 ;; esac
exit 0
EOF
chmod +x "${STUB_SYSTEMD}/systemctl"
@@ -129,6 +131,7 @@ for INIT in openrc systemd; do
check "server(${INIT}): enables k3s-first-boot" "yes" "$(present "${CALLS}" "$(enable_cmd "${INIT}" k3s-first-boot)")"
check "server(${INIT}): starts k3s" "yes" "$(present "${CALLS}" "$(start_cmd "${INIT}" k3s)")"
check "server(${INIT}): enables konnectivity-server" "yes" "$(present "${CALLS}" "$(enable_cmd "${INIT}" konnectivity-server)")"
check "server(${INIT}): enables pod-identity-webhook" "yes" "$(present "${CALLS}" "$(enable_cmd "${INIT}" eks-pod-identity-webhook)")"
check "server(${INIT}): enables k3s-recovery" "yes" "$(present "${CALLS}" "$(enable_cmd "${INIT}" mulga-eks-k3s-recovery)")"
# Recovery is enabled (so it also runs pre-k3s on later boots) AND started
# inline now — a restore-snapshot DR seed's directive must apply on this
@@ -145,6 +148,34 @@ for INIT in openrc systemd; do
check "server(${INIT}): starts node-gc" "yes" "$(present "${CALLS}" "$(start_cmd "${INIT}" mulga-eks-node-gc)")"
check "server(${INIT}): no k3s-agent" "no" "$(present "${CALLS}" "$(enable_cmd "${INIT}" k3s-agent)")"
check "server(${INIT}): self-disables" "yes" "$(present "${CALLS}" "$(disable_cmd "${INIT}" eks-node-role)")"
check "server(${INIT}): pod-identity-webhook is the last call" "$(start_cmd "${INIT}" eks-pod-identity-webhook)" "$(grep -v "$(disable_cmd "${INIT}" eks-node-role)" "${CALLS}" | tail -n 1)"
check "server(${INIT}): pod-identity-webhook enabled after addon-sync start" "yes" "$(
awk -v a="$(start_cmd "${INIT}" mulga-eks-addon-sync)" -v w="$(enable_cmd "${INIT}" eks-pod-identity-webhook)" '
index($0, a) == 1 { an = NR }
index($0, w) == 1 { wn = NR }
END { print (an > 0 && wn > an) ? "yes" : "no" }
' "${CALLS}"
)"
# A broken optional webhook must leave the control plane booting.
for FAILING in enable start; do
run_case "${INIT}" server_${FAILING}_fail
printf 'SPINIFEX_K3S_ROLE=server\nEKS_CLUSTER_NAME=alpha\n' > "${EKS_NODE_ENVFILE}"
if [ "${FAILING}" = enable ]; then
STUB_FAIL="$(enable_cmd "${INIT}" eks-pod-identity-webhook)"
export STUB_FAIL
STUB_FAIL="${STUB_FAIL#rc-update add }"; STUB_FAIL="${STUB_FAIL#systemctl enable }"
else
STUB_FAIL="$(start_cmd "${INIT}" eks-pod-identity-webhook)"
export STUB_FAIL
STUB_FAIL="${STUB_FAIL#rc-service }"; STUB_FAIL="${STUB_FAIL#systemctl start --no-block }"
fi
invoke
unset STUB_FAIL
check "server(${INIT}): webhook ${FAILING} failure still exits 0" 0 "${rc}"
check "server(${INIT}): webhook ${FAILING} failure still starts mulga-eks-addon-sync" "yes" "$(present "${CALLS}" "$(start_cmd "${INIT}" mulga-eks-addon-sync)")"
check "server(${INIT}): webhook ${FAILING} failure still self-disables" "yes" "$(present "${CALLS}" "$(disable_cmd "${INIT}" eks-node-role)")"
done
# --- server-join ---
run_case "${INIT}" server_join
@@ -158,11 +189,13 @@ for INIT in openrc systemd; do
check "server-join(${INIT}): enables node-gc" "yes" "$(present "${CALLS}" "$(enable_cmd "${INIT}" mulga-eks-node-gc)")"
check "server-join(${INIT}): starts node-gc" "yes" "$(present "${CALLS}" "$(start_cmd "${INIT}" mulga-eks-node-gc)")"
check "server-join(${INIT}): enables konnectivity-server" "yes" "$(present "${CALLS}" "$(enable_cmd "${INIT}" konnectivity-server)")"
check "server-join(${INIT}): enables pod-identity-webhook" "yes" "$(present "${CALLS}" "$(enable_cmd "${INIT}" eks-pod-identity-webhook)")"
check "server-join(${INIT}): enables k3s-recovery" "yes" "$(present "${CALLS}" "$(enable_cmd "${INIT}" mulga-eks-k3s-recovery)")"
check "server-join(${INIT}): starts k3s-recovery" "yes" "$(present "${CALLS}" "$(start_cmd "${INIT}" mulga-eks-k3s-recovery)")"
# Join servers must NOT re-publish bootstrap: the first server already
# published the cluster-identical artifacts, and a join re-publish would
# only race the bootstrap bus. This is deliberate, not an oversight.
check "server-join(${INIT}): pod-identity-webhook is the last call" "$(start_cmd "${INIT}" eks-pod-identity-webhook)" "$(grep -v "$(disable_cmd "${INIT}" eks-node-role)" "${CALLS}" | tail -n 1)"
check "server-join(${INIT}): no k3s-first-boot" "yes" "$(absent_all "${CALLS}" "k3s-first-boot")"
check "server-join(${INIT}): no k3s-agent" "yes" "$(absent_all "${CALLS}" "$(enable_cmd "${INIT}" k3s-agent)")"
check "server-join(${INIT}): self-disables" "yes" "$(present "${CALLS}" "$(disable_cmd "${INIT}" eks-node-role)")"
@@ -174,7 +207,7 @@ for INIT in openrc systemd; do
check "agent(${INIT}): exit 0" 0 "${rc}"
check "agent(${INIT}): role file" "agent" "$(cat "${ROLE_FILE}")"
check "agent(${INIT}): enables k3s-agent" "yes" "$(present "${CALLS}" "$(enable_cmd "${INIT}" k3s-agent)")"
check "agent(${INIT}): no server services" "yes" "$(absent_all "${CALLS}" "eks-token-webhook" "k3s-first-boot" "mulga-eks-node-gc" "$(enable_cmd "${INIT}" k3s)")"
check "agent(${INIT}): no server services" "yes" "$(absent_all "${CALLS}" "eks-token-webhook" "eks-pod-identity-webhook" "k3s-first-boot" "mulga-eks-node-gc" "$(enable_cmd "${INIT}" k3s)")"
# --- agent (inferred from agent.env, no explicit role) ---
run_case "${INIT}" agent_inferred
@@ -0,0 +1,26 @@
#!/sbin/openrc-run
# eks-pod-identity-webhook — IRSA mutating admission webhook. Binds 127.0.0.1:8445
# only; the local kube-apiserver is the sole client. Supervised and respawned so
# it can wait out k3s writing its CAs without ever delaying k3s or the token
# webhook: it exits until the CAs exist and supervise-daemon retries.
description="EKS pod identity webhook (Mulga)"
command="/usr/local/bin/eks-pod-identity-webhook"
command_args="--addr=127.0.0.1:8445"
pidfile="/run/eks-pod-identity-webhook.pid"
supervisor="supervise-daemon"
respawn_delay=5
respawn_max=0
output_log="/dev/console"
error_log="/dev/console"
if [ -f /etc/spinifex-eks/first-boot.env ]; then
# shellcheck disable=SC1091
. /etc/spinifex-eks/first-boot.env
export EKS_REGION EKS_ADDON_GATEWAY_URL EKS_GATEWAY_CA
fi
depend() {
need net
after k3s
}
+3 -2
View File
@@ -22,9 +22,9 @@ APK_PACKAGES="ca-certificates curl jq iptables ip6tables conntrack-tools cgroup-
# at a pinned tag (its GitHub releases ship only container images, and its go.mod
# carries replace directives that block `go install pkg@version`). The agent half
# is the matching proxy-agent container image, pinned in k3s.initd.
BUILD_COMMANDS="CGO_ENABLED=0 GOFIPS140=v1.0.0 go build -o bin/eks-token-webhook ./cmd/eks-token-webhook && CGO_ENABLED=0 GOFIPS140=v1.0.0 go build -o bin/eks-gateway-publish ./cmd/eks-gateway-publish && CGO_ENABLED=0 GOFIPS140=v1.0.0 go build -o bin/eks-gateway-fetch ./cmd/eks-gateway-fetch && CGO_ENABLED=0 GOFIPS140=v1.0.0 go build -o bin/eks-webhook-cert ./cmd/eks-webhook-cert && CGO_ENABLED=0 GOFIPS140=v1.0.0 go build -o bin/eks-konnectivity-cert ./cmd/eks-konnectivity-cert && CGO_ENABLED=0 GOFIPS140=v1.0.0 go build -o bin/eks-etcd-member ./cmd/eks-etcd-member && CGO_ENABLED=0 GOFIPS140=v1.0.0 go build -o bin/ecr-credential-provider ./cmd/ecr-credential-provider && rm -rf /tmp/anp-build && git clone --depth 1 --branch v0.30.3 https://github.com/kubernetes-sigs/apiserver-network-proxy /tmp/anp-build && ( cd /tmp/anp-build && CGO_ENABLED=0 GOFIPS140=v1.0.0 go build -o /tmp/anp-build/konnectivity-server ./cmd/server ) && cp /tmp/anp-build/konnectivity-server bin/konnectivity-server && rm -rf /tmp/anp-build"
BUILD_COMMANDS="CGO_ENABLED=0 GOFIPS140=v1.0.0 go build -o bin/eks-token-webhook ./cmd/eks-token-webhook && CGO_ENABLED=0 GOFIPS140=v1.0.0 go build -o bin/eks-gateway-publish ./cmd/eks-gateway-publish && CGO_ENABLED=0 GOFIPS140=v1.0.0 go build -o bin/eks-gateway-fetch ./cmd/eks-gateway-fetch && CGO_ENABLED=0 GOFIPS140=v1.0.0 go build -o bin/eks-webhook-cert ./cmd/eks-webhook-cert && CGO_ENABLED=0 GOFIPS140=v1.0.0 go build -o bin/eks-konnectivity-cert ./cmd/eks-konnectivity-cert && CGO_ENABLED=0 GOFIPS140=v1.0.0 go build -o bin/eks-etcd-member ./cmd/eks-etcd-member && CGO_ENABLED=0 GOFIPS140=v1.0.0 go build -o bin/eks-pod-identity-webhook ./cmd/eks-pod-identity-webhook && CGO_ENABLED=0 GOFIPS140=v1.0.0 go build -o bin/ecr-credential-provider ./cmd/ecr-credential-provider && rm -rf /tmp/anp-build && git clone --depth 1 --branch v0.30.3 https://github.com/kubernetes-sigs/apiserver-network-proxy /tmp/anp-build && ( cd /tmp/anp-build && CGO_ENABLED=0 GOFIPS140=v1.0.0 go build -o /tmp/anp-build/konnectivity-server ./cmd/server ) && cp /tmp/anp-build/konnectivity-server bin/konnectivity-server && rm -rf /tmp/anp-build"
INSTALL_BINARIES="bin/eks-token-webhook:/usr/local/bin/eks-token-webhook bin/eks-gateway-publish:/usr/local/bin/eks-gateway-publish bin/eks-gateway-fetch:/usr/local/bin/eks-gateway-fetch bin/eks-webhook-cert:/usr/local/bin/eks-webhook-cert bin/eks-konnectivity-cert:/usr/local/bin/eks-konnectivity-cert bin/eks-etcd-member:/usr/local/bin/eks-etcd-member bin/konnectivity-server:/usr/local/bin/konnectivity-server bin/ecr-credential-provider:/usr/local/bin/ecr-credential-provider"
INSTALL_BINARIES="bin/eks-token-webhook:/usr/local/bin/eks-token-webhook bin/eks-gateway-publish:/usr/local/bin/eks-gateway-publish bin/eks-gateway-fetch:/usr/local/bin/eks-gateway-fetch bin/eks-webhook-cert:/usr/local/bin/eks-webhook-cert bin/eks-konnectivity-cert:/usr/local/bin/eks-konnectivity-cert bin/eks-etcd-member:/usr/local/bin/eks-etcd-member bin/eks-pod-identity-webhook:/usr/local/bin/eks-pod-identity-webhook bin/konnectivity-server:/usr/local/bin/konnectivity-server bin/ecr-credential-provider:/usr/local/bin/ecr-credential-provider"
# All role services ship; none but the selector are enabled at bake time.
# eks-node-role picks server vs agent at first boot and enables the rest.
@@ -36,6 +36,7 @@ scripts/images/eks-node/k3s-prestart.sh:/usr/local/sbin/k3s-prestart \
scripts/images/eks-node/k3s-agent.initd:/etc/init.d/k3s-agent \
scripts/images/eks-node/k3s-agent-prestart.sh:/usr/local/sbin/k3s-agent-prestart \
scripts/images/eks-node/eks-token-webhook.initd:/etc/init.d/eks-token-webhook \
scripts/images/eks-node/eks-pod-identity-webhook.initd:/etc/init.d/eks-pod-identity-webhook \
scripts/images/eks-node/k3s-first-boot.initd:/etc/init.d/k3s-first-boot \
scripts/images/eks-node/k3s-first-boot.sh:/usr/local/sbin/k3s-first-boot \
scripts/images/eks-node/mulga-eks-state-report.initd:/etc/init.d/mulga-eks-state-report \
+2 -1
View File
@@ -44,7 +44,8 @@ ln -sf /usr/local/bin/k3s /usr/local/bin/ctr
# role's services are baked; the selector enables the right ones at first boot.
chmod 0755 /etc/init.d/eks-node-role /etc/init.d/k3s /etc/init.d/k3s-agent \
/etc/init.d/eks-token-webhook /etc/init.d/k3s-first-boot /etc/init.d/mulga-eks-state-report \
/etc/init.d/mulga-eks-addon-sync /etc/init.d/mulga-eks-node-gc /etc/init.d/konnectivity-server
/etc/init.d/mulga-eks-addon-sync /etc/init.d/mulga-eks-node-gc /etc/init.d/konnectivity-server \
/etc/init.d/eks-pod-identity-webhook
chmod 0755 /usr/local/sbin/eks-node-role /usr/local/sbin/k3s-first-boot \
/usr/local/sbin/mulga-eks-state-report /usr/local/sbin/mulga-eks-addon-sync \
/usr/local/sbin/mulga-eks-node-gc
@@ -0,0 +1,25 @@
#!/bin/sh
# Every /etc/init.d script that INSTALL_FILES ships at 0644 must be chmod'd
# 0755 by setup.sh, or OpenRC refuses to enable it ("is not executable") and
# eks-node-role, running under set -eu, aborts before k3s starts.
#
# Run: sh scripts/images/eks-node/setup_initd_test.sh
set -eu
SCRIPT_DIR=$(cd "$(dirname "$0")" && pwd)
MANIFEST="${SCRIPT_DIR}/manifest.conf"
SETUP="${SETUP_SH:-${SCRIPT_DIR}/setup.sh}"
fail=0
for dst in $(grep -o ':/etc/init\.d/[A-Za-z0-9._-]*' "${MANIFEST}" | cut -d: -f2); do
if ! grep -E '^[[:space:]]*(chmod 0755 )?.*'"${dst}"'([[:space:]\\]|$)' "${SETUP}" \
| grep -q . ; then
echo "FAIL: ${dst} is installed 0644 and never chmod'd 0755 in setup.sh"
fail=1
fi
done
if [ "${fail}" -ne 0 ]; then
exit 1
fi
echo "PASS: every shipped init script is chmod'd 0755"
+2 -1
View File
@@ -100,7 +100,8 @@ profile_binaries() {
./cmd/eks-gateway-fetch:usr/local/bin/eks-gateway-fetch \
./cmd/eks-webhook-cert:usr/local/bin/eks-webhook-cert \
./cmd/eks-konnectivity-cert:usr/local/bin/eks-konnectivity-cert \
./cmd/eks-etcd-member:usr/local/bin/eks-etcd-member"
./cmd/eks-etcd-member:usr/local/bin/eks-etcd-member \
./cmd/eks-pod-identity-webhook:usr/local/bin/eks-pod-identity-webhook"
;;
ecs) echo "./cmd/ecs-agent:usr/local/bin/ecs-agent" ;;
*) echo "" ;;
+3 -3
View File
@@ -76,7 +76,7 @@ func ensureCert(dir, caCertPath, caKeyPath, cn string, dns []string, ips []net.I
return certPath, keyPath, nil
}
caCert, caKey, err := loadCA(caCertPath, caKeyPath)
caCert, caKey, err := LoadCA(caCertPath, caKeyPath)
if err != nil {
return "", "", err
}
@@ -135,8 +135,8 @@ func certCoversSANs(certPath, keyPath string, dns []string, ips []net.IP) bool {
return true
}
// loadCA parses the PEM CA cert + EC/PKCS8 private key used to sign the leaf.
func loadCA(certPath, keyPath string) (*x509.Certificate, *ecdsa.PrivateKey, error) {
// LoadCA parses the PEM CA cert + EC/PKCS8 private key used to sign the leaf.
func LoadCA(certPath, keyPath string) (*x509.Certificate, *ecdsa.PrivateKey, error) {
certPEM, err := os.ReadFile(certPath)
if err != nil {
return nil, nil, fmt.Errorf("read ca cert %s: %w", certPath, err)
+196
View File
@@ -0,0 +1,196 @@
package podidentity
import (
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"crypto/tls"
"crypto/x509"
"crypto/x509/pkix"
"encoding/pem"
"fmt"
"math/big"
"net"
"os"
"path/filepath"
"time"
"github.com/mulgadc/spinifex/spinifex/agents/eks/konnectivitycert"
)
const (
// ClientCN is the identity the webhook presents to the API server; the
// ClusterRoleBinding names this user.
ClientCN = "spinifex:eks-pod-identity-webhook"
servingCertFile = "serving.crt"
servingKeyFile = "serving.key"
clientCertFile = "client.crt"
clientKeyFile = "client.key"
certRenewWindow = 30 * 24 * time.Hour
certValidity = 365 * 24 * time.Hour
)
// CertConfig names the k3s CAs that sign the webhook's two certificates and
// the directory they are cached in.
type CertConfig struct {
Dir string
ServerCACert string
ServerCAKey string
ClientCACert string
ClientCAKey string
}
// Certs are the paths of the cached certificate and key pairs.
type Certs struct {
ServingCert, ServingKey string
ClientCert, ClientKey string
}
// EnsureCerts mints, or reuses while still valid and signed by the current CA,
// a serving certificate for 127.0.0.1 signed by the k3s server CA and a client
// certificate signed by the k3s client CA. Keys are written 0600.
func EnsureCerts(c CertConfig) (Certs, error) {
out := Certs{
ServingCert: filepath.Join(c.Dir, servingCertFile), ServingKey: filepath.Join(c.Dir, servingKeyFile),
ClientCert: filepath.Join(c.Dir, clientCertFile), ClientKey: filepath.Join(c.Dir, clientKeyFile),
}
if err := os.MkdirAll(c.Dir, 0o700); err != nil {
return Certs{}, fmt.Errorf("mkdir %s: %w", c.Dir, err)
}
serving := leafSpec{cn: "pod-identity-webhook", usage: x509.ExtKeyUsageServerAuth, ips: []net.IP{net.IPv4(127, 0, 0, 1)}}
if err := ensureLeaf(out.ServingCert, out.ServingKey, c.ServerCACert, c.ServerCAKey, serving); err != nil {
return Certs{}, fmt.Errorf("serving certificate: %w", err)
}
client := leafSpec{cn: ClientCN, usage: x509.ExtKeyUsageClientAuth}
if err := ensureLeaf(out.ClientCert, out.ClientKey, c.ClientCACert, c.ClientCAKey, client); err != nil {
return Certs{}, fmt.Errorf("client certificate: %w", err)
}
return out, nil
}
type leafSpec struct {
cn string
usage x509.ExtKeyUsage
ips []net.IP
}
func ensureLeaf(certPath, keyPath, caCertPath, caKeyPath string, spec leafSpec) error {
ca, caKey, err := konnectivitycert.LoadCA(caCertPath, caKeyPath)
if err != nil {
return err
}
if leafCurrent(certPath, keyPath, ca, spec) {
return nil
}
priv, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
return fmt.Errorf("generate key: %w", err)
}
serial, err := rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 128))
if err != nil {
return fmt.Errorf("serial: %w", err)
}
now := time.Now().UTC()
tmpl := x509.Certificate{
SerialNumber: serial,
Subject: pkix.Name{CommonName: spec.cn},
NotBefore: now.Add(-time.Hour),
NotAfter: now.Add(certValidity),
KeyUsage: x509.KeyUsageDigitalSignature,
ExtKeyUsage: []x509.ExtKeyUsage{spec.usage},
BasicConstraintsValid: true,
IPAddresses: spec.ips,
}
der, err := x509.CreateCertificate(rand.Reader, &tmpl, ca, &priv.PublicKey, caKey)
if err != nil {
return fmt.Errorf("create certificate: %w", err)
}
keyDER, err := x509.MarshalECPrivateKey(priv)
if err != nil {
return fmt.Errorf("marshal key: %w", err)
}
// Key first: a cert without its key is never published.
if err := writeAtomic(keyPath, pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: keyDER}), 0o600); err != nil {
return err
}
return writeAtomic(certPath, pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der}), 0o644)
}
// leafCurrent reports whether the cached pair loads, matches, was signed by ca,
// has the wanted usage and SANs, and is not near expiry.
func leafCurrent(certPath, keyPath string, ca *x509.Certificate, spec leafSpec) bool {
pair, err := tls.LoadX509KeyPair(certPath, keyPath)
if err != nil || len(pair.Certificate) == 0 {
return false
}
crt, err := x509.ParseCertificate(pair.Certificate[0])
if err != nil || crt.CheckSignatureFrom(ca) != nil {
return false
}
if inRenewWindow(crt, time.Now()) || crt.Subject.CommonName != spec.cn {
return false
}
if len(crt.ExtKeyUsage) != 1 || crt.ExtKeyUsage[0] != spec.usage {
return false
}
for _, ip := range spec.ips {
found := false
for _, have := range crt.IPAddresses {
found = found || have.Equal(ip)
}
if !found {
return false
}
}
return true
}
func writeAtomic(path string, data []byte, mode os.FileMode) error {
tmp, err := os.CreateTemp(filepath.Dir(path), ".tmp-*")
if err != nil {
return fmt.Errorf("create temp for %s: %w", path, err)
}
defer func() { _ = os.Remove(tmp.Name()) }()
if err := tmp.Chmod(mode); err != nil {
_ = tmp.Close()
return fmt.Errorf("chmod %s: %w", path, err)
}
if _, err := tmp.Write(data); err != nil {
_ = tmp.Close()
return fmt.Errorf("write %s: %w", path, err)
}
if err := tmp.Close(); err != nil {
return fmt.Errorf("close %s: %w", path, err)
}
if err := os.Rename(tmp.Name(), path); err != nil {
return fmt.Errorf("rename %s: %w", path, err)
}
return nil
}
// inRenewWindow reports whether crt expires within the renew window at now.
func inRenewWindow(crt *x509.Certificate, now time.Time) bool {
return crt.NotAfter.Sub(now) < certRenewWindow
}
// NeedsRenewal reports whether either cached certificate is inside its renew
// window at now. A running process exits when this is true so the supervisor
// restarts it and EnsureCerts reissues; an unreadable certificate also counts.
func NeedsRenewal(c Certs, now time.Time) bool {
for _, path := range []string{c.ServingCert, c.ClientCert} {
b, err := os.ReadFile(path)
if err != nil {
return true
}
blk, _ := pem.Decode(b)
if blk == nil {
return true
}
crt, err := x509.ParseCertificate(blk.Bytes)
if err != nil || inRenewWindow(crt, now) {
return true
}
}
return false
}
+263
View File
@@ -0,0 +1,263 @@
package podidentity
import (
"bytes"
"context"
"crypto/tls"
"crypto/x509"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"os"
"strings"
"time"
)
const (
kubeCallTimeout = 15 * time.Second
watchRetryDelay = 2 * time.Second
maxKubeBody = 4 << 20
)
// KubeClient reaches the Kubernetes API with plain REST. It implements
// ServiceAccountGetter, ConfigMapStore and NamespaceSource using only the
// calls the webhook's ClusterRole grants.
type KubeClient struct {
base string
http *http.Client
}
var (
_ ServiceAccountGetter = (*KubeClient)(nil)
_ ConfigMapStore = (*KubeClient)(nil)
_ NamespaceSource = (*KubeClient)(nil)
)
// NewKubeClient returns a client for apiServer that trusts caFile and presents
// the client certificate in certFile and keyFile. The pair is re-read on every
// handshake, so a re-issued certificate is used without a restart.
func NewKubeClient(apiServer, caFile, certFile, keyFile string) (*KubeClient, error) {
ca, err := os.ReadFile(caFile)
if err != nil {
return nil, fmt.Errorf("read api server ca: %w", err)
}
pool := x509.NewCertPool()
if !pool.AppendCertsFromPEM(ca) {
return nil, fmt.Errorf("api server ca %s holds no certificate", caFile)
}
if _, err := tls.LoadX509KeyPair(certFile, keyFile); err != nil {
return nil, fmt.Errorf("load client certificate: %w", err)
}
tr := &http.Transport{TLSClientConfig: &tls.Config{
RootCAs: pool,
MinVersion: tls.VersionTLS12,
GetClientCertificate: func(*tls.CertificateRequestInfo) (*tls.Certificate, error) {
c, err := tls.LoadX509KeyPair(certFile, keyFile)
return &c, err
},
}}
return &KubeClient{base: strings.TrimSuffix(apiServer, "/"), http: &http.Client{Transport: tr}}, nil
}
type kubeObject struct {
APIVersion string `json:"apiVersion,omitempty"`
Kind string `json:"kind,omitempty"`
Metadata struct {
Name string `json:"name,omitempty"`
Namespace string `json:"namespace,omitempty"`
ResourceVersion string `json:"resourceVersion,omitempty"`
Annotations map[string]string `json:"annotations,omitempty"`
} `json:"metadata"`
Data map[string]string `json:"data,omitempty"`
}
// do sends one request and returns the status. A non-2xx status is returned
// without an error so callers can map the codes they expect.
func (k *KubeClient) do(ctx context.Context, method, path string, body, out any) (int, error) {
ctx, cancel := context.WithTimeout(ctx, kubeCallTimeout)
defer cancel()
var rd io.Reader
if body != nil {
b, err := json.Marshal(body)
if err != nil {
return 0, err
}
rd = bytes.NewReader(b)
}
req, err := http.NewRequestWithContext(ctx, method, k.base+path, rd)
if err != nil {
return 0, err
}
req.Header.Set("Accept", "application/json")
if body != nil {
req.Header.Set("Content-Type", "application/json")
}
resp, err := k.http.Do(req)
if err != nil {
return 0, err
}
defer func() { _ = resp.Body.Close() }()
raw, err := io.ReadAll(io.LimitReader(resp.Body, maxKubeBody))
if err != nil {
return resp.StatusCode, err
}
if resp.StatusCode/100 == 2 && out != nil {
if err := json.Unmarshal(raw, out); err != nil {
return resp.StatusCode, fmt.Errorf("decode %s %s: %w", method, path, err)
}
}
return resp.StatusCode, nil
}
func statusErr(method, path string, code int) error {
return fmt.Errorf("%s %s: unexpected status %d", method, path, code)
}
// GetServiceAccount implements ServiceAccountGetter.
func (k *KubeClient) GetServiceAccount(ctx context.Context, namespace, name string) (*ServiceAccount, error) {
path := "/api/v1/namespaces/" + url.PathEscape(namespace) + "/serviceaccounts/" + url.PathEscape(name)
var o kubeObject
switch code, err := k.do(ctx, http.MethodGet, path, nil, &o); {
case err != nil:
return nil, err
case code == http.StatusNotFound:
return nil, ErrServiceAccountNotFound
case code != http.StatusOK:
return nil, statusErr(http.MethodGet, path, code)
}
return &ServiceAccount{Annotations: o.Metadata.Annotations}, nil
}
func cmPath(namespace, name string) string {
p := "/api/v1/namespaces/" + url.PathEscape(namespace) + "/configmaps"
if name != "" {
p += "/" + url.PathEscape(name)
}
return p
}
func cmBody(cm ConfigMap) kubeObject {
o := kubeObject{APIVersion: "v1", Kind: "ConfigMap", Data: cm.Data}
o.Metadata.Name, o.Metadata.Namespace, o.Metadata.ResourceVersion = cm.Name, cm.Namespace, cm.ResourceVersion
return o
}
// Get implements ConfigMapStore.
func (k *KubeClient) Get(ctx context.Context, namespace, name string) (*ConfigMap, error) {
path := cmPath(namespace, name)
var o kubeObject
switch code, err := k.do(ctx, http.MethodGet, path, nil, &o); {
case err != nil:
return nil, err
case code == http.StatusNotFound:
return nil, ErrNotFound
case code != http.StatusOK:
return nil, statusErr(http.MethodGet, path, code)
}
return &ConfigMap{Namespace: namespace, Name: name, Data: o.Data, ResourceVersion: o.Metadata.ResourceVersion}, nil
}
// Create implements ConfigMapStore.
func (k *KubeClient) Create(ctx context.Context, cm ConfigMap) error {
path := cmPath(cm.Namespace, "")
switch code, err := k.do(ctx, http.MethodPost, path, cmBody(cm), nil); {
case err != nil:
return err
case code == http.StatusConflict:
return ErrAlreadyExists
case code/100 != 2:
return statusErr(http.MethodPost, path, code)
}
return nil
}
// Update implements ConfigMapStore.
func (k *KubeClient) Update(ctx context.Context, cm ConfigMap) error {
path := cmPath(cm.Namespace, cm.Name)
switch code, err := k.do(ctx, http.MethodPut, path, cmBody(cm), nil); {
case err != nil:
return err
case code == http.StatusConflict:
return ErrConflict
case code == http.StatusNotFound:
return ErrNotFound
case code/100 != 2:
return statusErr(http.MethodPut, path, code)
}
return nil
}
// List implements NamespaceSource.
func (k *KubeClient) List(ctx context.Context) ([]string, error) {
const path = "/api/v1/namespaces"
var l struct {
Items []kubeObject `json:"items"`
}
if code, err := k.do(ctx, http.MethodGet, path, nil, &l); err != nil {
return nil, err
} else if code != http.StatusOK {
return nil, statusErr(http.MethodGet, path, code)
}
names := make([]string, 0, len(l.Items))
for _, it := range l.Items {
names = append(names, it.Metadata.Name)
}
return names, nil
}
// Watch implements NamespaceSource. The stream replays existing namespaces as
// ADDED first, which is harmless, and is re-opened when the server ends it.
// The channel closes only when ctx ends.
func (k *KubeClient) Watch(ctx context.Context) (<-chan string, error) {
out := make(chan string, 16)
go func() {
defer close(out)
for ctx.Err() == nil {
k.watchOnce(ctx, out)
select {
case <-ctx.Done():
case <-time.After(watchRetryDelay):
}
}
}()
return out, nil
}
func (k *KubeClient) watchOnce(ctx context.Context, out chan<- string) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, k.base+"/api/v1/namespaces?watch=true&timeoutSeconds=300", nil)
if err != nil {
return
}
resp, err := k.http.Do(req)
if err != nil {
return
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusOK {
return
}
dec := json.NewDecoder(resp.Body)
for {
var ev struct {
Type string `json:"type"`
Object kubeObject `json:"object"`
}
if err := dec.Decode(&ev); err != nil {
if !errors.Is(err, io.EOF) && ctx.Err() == nil {
return
}
return
}
if ev.Type != "ADDED" || ev.Object.Metadata.Name == "" {
continue
}
select {
case out <- ev.Object.Metadata.Name:
case <-ctx.Done():
return
}
}
}
@@ -0,0 +1,191 @@
package podidentity
import (
"context"
"crypto/tls"
"encoding/json"
"encoding/pem"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// fakeAPI is a TLS API server that requires the webhook's client certificate.
type fakeAPI struct {
srv *httptest.Server
handler http.HandlerFunc
seenCN string
}
func newFakeAPI(t *testing.T) (*fakeAPI, *KubeClient) {
t.Helper()
f := &fakeAPI{}
f.srv = httptest.NewUnstartedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
f.seenCN = r.TLS.PeerCertificates[0].Subject.CommonName
f.handler(w, r)
}))
f.srv.TLS = &tls.Config{ClientAuth: tls.RequireAnyClientCert, MinVersion: tls.VersionTLS12}
f.srv.StartTLS()
t.Cleanup(f.srv.Close)
cfg, _ := certTestConfig(t)
certs, err := EnsureCerts(cfg)
require.NoError(t, err)
ca := filepath.Join(t.TempDir(), "ca.crt")
require.NoError(t, os.WriteFile(ca, pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: f.srv.Certificate().Raw}), 0o644))
k, err := NewKubeClient(f.srv.URL+"/", ca, certs.ClientCert, certs.ClientKey)
require.NoError(t, err)
return f, k
}
func reply(w http.ResponseWriter, code int, body any) {
w.WriteHeader(code)
if body != nil {
_ = json.NewEncoder(w).Encode(body)
}
}
func TestKubeClient_PresentsTheClientCertificate(t *testing.T) {
f, k := newFakeAPI(t)
f.handler = func(w http.ResponseWriter, _ *http.Request) {
reply(w, 200, map[string]any{"metadata": map[string]any{}})
}
_, err := k.GetServiceAccount(context.Background(), "app", "sa")
require.NoError(t, err)
assert.Equal(t, ClientCN, f.seenCN)
}
func TestKubeClient_GetServiceAccount(t *testing.T) {
f, k := newFakeAPI(t)
f.handler = func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/api/v1/namespaces/app/serviceaccounts/sa":
reply(w, 200, map[string]any{"metadata": map[string]any{"annotations": map[string]string{"a": "b"}}})
case "/api/v1/namespaces/app/serviceaccounts/gone":
reply(w, 404, nil)
default:
reply(w, 500, nil)
}
}
sa, err := k.GetServiceAccount(context.Background(), "app", "sa")
require.NoError(t, err)
assert.Equal(t, map[string]string{"a": "b"}, sa.Annotations)
_, err = k.GetServiceAccount(context.Background(), "app", "gone")
assert.ErrorIs(t, err, ErrServiceAccountNotFound)
_, err = k.GetServiceAccount(context.Background(), "app", "boom")
assert.Error(t, err)
assert.NotErrorIs(t, err, ErrServiceAccountNotFound)
}
func TestKubeClient_ConfigMapCalls(t *testing.T) {
f, k := newFakeAPI(t)
var gotBody kubeObject
f.handler = func(w http.ResponseWriter, r *http.Request) {
switch {
case r.Method == http.MethodGet && r.URL.Path == "/api/v1/namespaces/app/configmaps/ca":
reply(w, 200, map[string]any{"metadata": map[string]any{"resourceVersion": "7"}, "data": map[string]string{"k": "v"}})
case r.Method == http.MethodGet:
reply(w, 404, nil)
case r.Method == http.MethodPost && r.URL.Path == "/api/v1/namespaces/dup/configmaps":
reply(w, 409, nil)
case r.Method == http.MethodPost:
_ = json.NewDecoder(r.Body).Decode(&gotBody)
reply(w, 201, nil)
case r.Method == http.MethodPut && r.URL.Path == "/api/v1/namespaces/stale/configmaps/ca":
reply(w, 409, nil)
case r.Method == http.MethodPut && r.URL.Path == "/api/v1/namespaces/gone/configmaps/ca":
reply(w, 404, nil)
case r.Method == http.MethodPut && r.URL.Path == "/api/v1/namespaces/bad/configmaps/ca":
reply(w, 500, nil)
default:
reply(w, 200, nil)
}
}
ctx := context.Background()
cm, err := k.Get(ctx, "app", "ca")
require.NoError(t, err)
assert.Equal(t, "7", cm.ResourceVersion)
assert.Equal(t, "v", cm.Data["k"])
_, err = k.Get(ctx, "app", "missing")
assert.ErrorIs(t, err, ErrNotFound)
require.NoError(t, k.Create(ctx, ConfigMap{Namespace: "app", Name: "ca", Data: map[string]string{"x": "y"}}))
assert.Equal(t, "ca", gotBody.Metadata.Name)
assert.Equal(t, "y", gotBody.Data["x"])
assert.ErrorIs(t, k.Create(ctx, ConfigMap{Namespace: "dup", Name: "ca"}), ErrAlreadyExists)
require.NoError(t, k.Update(ctx, ConfigMap{Namespace: "app", Name: "ca"}))
assert.ErrorIs(t, k.Update(ctx, ConfigMap{Namespace: "stale", Name: "ca"}), ErrConflict)
assert.ErrorIs(t, k.Update(ctx, ConfigMap{Namespace: "gone", Name: "ca"}), ErrNotFound)
err = k.Update(ctx, ConfigMap{Namespace: "bad", Name: "ca"})
assert.Error(t, err)
assert.NotErrorIs(t, err, ErrConflict)
}
func TestKubeClient_ListNamespaces(t *testing.T) {
f, k := newFakeAPI(t)
ok := true
f.handler = func(w http.ResponseWriter, _ *http.Request) {
if !ok {
reply(w, 500, nil)
return
}
reply(w, 200, map[string]any{"items": []map[string]any{{"metadata": map[string]string{"name": "a"}}, {"metadata": map[string]string{"name": "b"}}}})
}
names, err := k.List(context.Background())
require.NoError(t, err)
assert.Equal(t, []string{"a", "b"}, names)
ok = false
_, err = k.List(context.Background())
assert.Error(t, err)
}
func TestKubeClient_WatchEmitsAddedNamespacesOnly(t *testing.T) {
f, k := newFakeAPI(t)
f.handler = func(w http.ResponseWriter, _ *http.Request) {
enc := json.NewEncoder(w)
for _, ev := range []struct{ t, n string }{{"ADDED", "one"}, {"MODIFIED", "two"}, {"DELETED", "three"}, {"ADDED", "four"}} {
_ = enc.Encode(map[string]any{"type": ev.t, "object": map[string]any{"metadata": map[string]string{"name": ev.n}}})
}
}
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
ch, err := k.Watch(ctx)
require.NoError(t, err)
got := []string{<-ch, <-ch}
assert.Equal(t, []string{"one", "four"}, got)
cancel()
require.Eventually(t, func() bool {
select {
case _, open := <-ch:
return !open
default:
return false
}
}, 5*time.Second, 10*time.Millisecond, "the channel closes when the context ends")
}
func TestNewKubeClient_RejectsBadInputs(t *testing.T) {
cfg, _ := certTestConfig(t)
certs, err := EnsureCerts(cfg)
require.NoError(t, err)
dir := t.TempDir()
empty := filepath.Join(dir, "empty.crt")
require.NoError(t, os.WriteFile(empty, []byte("not pem"), 0o644))
_, err = NewKubeClient("https://x", filepath.Join(dir, "absent.crt"), certs.ClientCert, certs.ClientKey)
assert.Error(t, err)
_, err = NewKubeClient("https://x", empty, certs.ClientCert, certs.ClientKey)
assert.Error(t, err)
_, err = NewKubeClient("https://x", cfg.ServerCACert, filepath.Join(dir, "no.crt"), certs.ClientKey)
assert.Error(t, err)
}
@@ -0,0 +1,52 @@
package podidentity
import (
"bytes"
"encoding/json"
)
// ClusterRoleName names the ClusterRole and ClusterRoleBinding the webhook runs as.
const ClusterRoleName = "spinifex:eks-pod-identity-webhook"
// RenderManifests returns the cluster objects the webhook needs, as JSON
// documents separated by "---" (valid YAML for the k3s manifest deployer): its
// least-privilege ClusterRole, the binding to its client-certificate user, and
// the MutatingWebhookConfiguration. The output is deterministic so every server
// writes the same bytes.
func RenderManifests(webhook WebhookConfig) ([]byte, error) {
cfg, err := RenderWebhookConfiguration(webhook)
if err != nil {
return nil, err
}
role := map[string]any{
"apiVersion": "rbac.authorization.k8s.io/v1",
"kind": "ClusterRole",
"metadata": map[string]any{"name": ClusterRoleName},
"rules": []map[string]any{
{"apiGroups": []string{""}, "resources": []string{"serviceaccounts"}, "verbs": []string{"get"}},
{"apiGroups": []string{""}, "resources": []string{"namespaces"}, "verbs": []string{"list", "watch"}},
{"apiGroups": []string{""}, "resources": []string{"configmaps"}, "verbs": []string{"create"}},
{"apiGroups": []string{""}, "resources": []string{"configmaps"}, "resourceNames": []string{CAConfigMapName}, "verbs": []string{"get", "update"}},
},
}
binding := map[string]any{
"apiVersion": "rbac.authorization.k8s.io/v1",
"kind": "ClusterRoleBinding",
"metadata": map[string]any{"name": ClusterRoleName},
"roleRef": map[string]any{"apiGroup": "rbac.authorization.k8s.io", "kind": "ClusterRole", "name": ClusterRoleName},
"subjects": []map[string]any{{"apiGroup": "rbac.authorization.k8s.io", "kind": "User", "name": ClientCN}},
}
var out bytes.Buffer
for i, doc := range []any{role, binding, json.RawMessage(cfg)} {
b, err := json.MarshalIndent(doc, "", " ")
if err != nil {
return nil, err
}
if i > 0 {
out.WriteString("---\n")
}
out.Write(b)
out.WriteByte('\n')
}
return out.Bytes(), nil
}
+307
View File
@@ -0,0 +1,307 @@
package podidentity
import (
"cmp"
"context"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"io"
"log/slog"
"math/rand/v2"
"net/http"
"strconv"
"strings"
)
const (
envRoleARN = "AWS_ROLE_ARN"
envTokenFile = "AWS_WEB_IDENTITY_TOKEN_FILE" //nolint:gosec // variable name, not a credential
envRegion = "AWS_REGION"
envDefRegion = "AWS_DEFAULT_REGION"
envRegionalS = "AWS_STS_REGIONAL_ENDPOINTS"
envSTSURL = "AWS_ENDPOINT_URL_STS"
envCABundle = "AWS_CA_BUNDLE"
envSSLCert = "SSL_CERT_FILE"
maxReviewSize = 8 << 20
)
type podObject struct {
Metadata struct {
Name string `json:"name"`
GenerateName string `json:"generateName"`
Namespace string `json:"namespace"`
Annotations map[string]string `json:"annotations"`
} `json:"metadata"`
Spec struct {
ServiceAccountName string `json:"serviceAccountName"`
ServiceAccount string `json:"serviceAccount"`
Containers []containerObject `json:"containers"`
InitContainers []containerObject `json:"initContainers"`
Volumes []nameOnly `json:"volumes"`
} `json:"spec"`
}
type nameOnly struct {
Name string `json:"name"`
}
type containerObject struct {
Name string `json:"name"`
Env []nameOnly `json:"env"`
VolumeMounts []nameOnly `json:"volumeMounts"`
}
type envVar struct {
Name string `json:"name"`
Value string `json:"value"`
}
type volumeMount struct {
Name string `json:"name"`
MountPath string `json:"mountPath"`
ReadOnly bool `json:"readOnly"`
}
// injection is what one pod gets, computed from its ServiceAccount and annotations.
type injection struct {
roleARN string
regional bool
skip map[string]bool
}
// Mutate returns the patch for one pod creation request. It never denies a pod.
func (m *Mutator) Mutate(ctx context.Context, req AdmissionRequest) AdmissionResponse {
resp := AdmissionResponse{UID: req.UID, Allowed: true}
if req.Operation != "CREATE" {
return resp
}
var pod podObject
if err := json.Unmarshal(req.Object, &pod); err != nil {
m.note(ctx, slog.LevelWarn, req.Namespace, "", ReasonDecode, "error", err)
return resp
}
ns := cmp.Or(req.Namespace, pod.Metadata.Namespace)
name := cmp.Or(pod.Metadata.Name, pod.Metadata.GenerateName)
saName := cmp.Or(pod.Spec.ServiceAccountName, pod.Spec.ServiceAccount, "default")
sa, err := m.sas.GetServiceAccount(ctx, ns, saName)
if err != nil {
if errors.Is(err, ErrServiceAccountNotFound) {
m.note(ctx, slog.LevelInfo, ns, name, ReasonServiceAccountMissing, "serviceaccount", saName)
} else {
m.note(ctx, slog.LevelWarn, ns, name, ReasonServiceAccountLookup, "serviceaccount", saName, "error", err)
}
return resp
}
dom := cmp.Or(m.cfg.AnnotationDomain, DefaultAnnotationDomain)
roleARN := sa.Annotations[dom+"/role-arn"]
if roleARN == "" {
return resp
}
in := injection{
roleARN: roleARN,
regional: sa.Annotations[dom+"/sts-regional-endpoints"] == "true",
skip: map[string]bool{},
}
for c := range strings.SplitSeq(pod.Metadata.Annotations[dom+"/skip-containers"], ",") {
if c = strings.TrimSpace(c); c != "" {
in.skip[c] = true
}
}
var ops []PatchOp
patched := 0
for _, set := range []struct {
field string
list []containerObject
}{{"containers", pod.Spec.Containers}, {"initContainers", pod.Spec.InitContainers}} {
for i, c := range set.list {
if in.skip[c.Name] {
m.note(ctx, slog.LevelInfo, ns, name, ReasonSkipContainer, "container", c.Name)
continue
}
cops := m.containerOps(fmt.Sprintf("/spec/%s/%d", set.field, i), c, in)
if cops == nil {
m.note(ctx, slog.LevelInfo, ns, name, ReasonAlreadyConfigured, "container", c.Name)
continue
}
ops = append(ops, cops...)
patched++
}
}
if patched == 0 {
return resp
}
ops = append(ops, volumeOps(pod, tokenExpiry(sa, pod.Metadata.Annotations, dom), cmp.Or(sa.Annotations[dom+"/audience"], DefaultAudience))...)
resp.Patch = ops
m.log.InfoContext(ctx, "pod identity injected", "namespace", ns, "pod", name, "serviceaccount", saName, "containers", patched)
return resp
}
// note logs a skip or failure with every attribute inline, not on a derived logger.
func (m *Mutator) note(ctx context.Context, lvl slog.Level, ns, pod, reason string, kv ...any) {
attrs := append([]any{"namespace", ns, "pod", pod, "reason", reason}, kv...)
m.log.Log(ctx, lvl, "pod identity skipped", attrs...)
}
// containerOps patches one container, or returns nil when it already carries
// the role, region and regional STS settings.
func (m *Mutator) containerOps(path string, c containerObject, in injection) []PatchOp {
has := map[string]bool{}
for _, e := range c.Env {
has[e.Name] = true
}
hasRole := has[envRoleARN] || has[envTokenFile]
hasRegion := m.cfg.Region == "" || has[envRegion] || has[envDefRegion]
hasSTS := has[envRegionalS]
if hasRole && hasRegion && hasSTS {
return nil
}
var vars []envVar
add := func(name, value string) { vars = append(vars, envVar{name, value}) }
if !hasRole {
add(envRoleARN, in.roleARN)
add(envTokenFile, TokenMountPath+"/"+TokenPath)
}
if !hasSTS && in.regional {
add(envRegionalS, "regional")
}
if !hasRegion {
add(envRegion, m.cfg.Region)
add(envDefRegion, m.cfg.Region)
}
caPath := CAMountPath + "/" + CABundleKey
for _, x := range []struct{ name, value string }{
{envSTSURL, m.cfg.STSEndpoint}, {envCABundle, caPath}, {envSSLCert, caPath},
} {
if x.value != "" && !has[x.name] {
add(x.name, x.value)
}
}
mounted := map[string]bool{}
for _, v := range c.VolumeMounts {
mounted[v.Name] = true
}
var mounts []volumeMount
if !mounted[TokenVolumeName] {
mounts = append(mounts, volumeMount{TokenVolumeName, TokenMountPath, true})
}
if !mounted[CAVolumeName] {
mounts = append(mounts, volumeMount{CAVolumeName, CAMountPath, true})
}
ops := appendOps(nil, path+"/env", len(c.Env) > 0, vars)
ops = appendOps(ops, path+"/volumeMounts", len(c.VolumeMounts) > 0, mounts)
if ops == nil {
ops = []PatchOp{}
}
return ops
}
// appendOps adds items to an array, creating the array first when it is absent
// (an add to "/-" on a missing array is not a valid JSON patch).
func appendOps[T any](ops []PatchOp, path string, exists bool, items []T) []PatchOp {
switch {
case len(items) == 0:
return ops
case !exists:
return append(ops, PatchOp{Op: "add", Path: path, Value: items})
}
for _, it := range items {
ops = append(ops, PatchOp{Op: "add", Path: path + "/-", Value: it})
}
return ops
}
func volumeOps(pod podObject, expiry int64, audience string) []PatchOp {
have := map[string]bool{}
for _, v := range pod.Spec.Volumes {
have[v.Name] = true
}
var vols []map[string]any
if !have[TokenVolumeName] {
vols = append(vols, map[string]any{
"name": TokenVolumeName,
"projected": map[string]any{"sources": []map[string]any{{
"serviceAccountToken": map[string]any{"audience": audience, "expirationSeconds": expiry, "path": TokenPath},
}}},
})
}
if !have[CAVolumeName] {
vols = append(vols, map[string]any{"name": CAVolumeName, "configMap": map[string]any{"name": CAConfigMapName}})
}
return appendOps(nil, "/spec/volumes", len(pod.Spec.Volumes) > 0, vols)
}
// tokenExpiry takes the pod annotation over the ServiceAccount's, clamps it to
// the allowed range and jitters the default so tokens do not all expire together.
func tokenExpiry(sa *ServiceAccount, podAnn map[string]string, dom string) int64 {
key := dom + "/token-expiration"
for _, raw := range []string{podAnn[key], sa.Annotations[key]} {
if raw == "" {
continue
}
if n, err := strconv.ParseInt(raw, 10, 64); err == nil {
return min(max(n, MinTokenExpiration), MaxTokenExpiration)
}
}
return DefaultMinTokenExpiration + rand.Int64N(MaxTokenExpiration-DefaultMinTokenExpiration+1) //nolint:gosec // jitter, not security
}
type admissionReview struct {
APIVersion string `json:"apiVersion"`
Kind string `json:"kind"`
Request *AdmissionRequest `json:"request,omitempty"`
Response *admissionResponse `json:"response,omitempty"`
}
type admissionResponse struct {
UID string `json:"uid"`
Allowed bool `json:"allowed"`
PatchType string `json:"patchType,omitempty"`
Patch string `json:"patch,omitempty"`
}
// ServeHTTP serves an AdmissionReview v1 request.
func (m *Mutator) ServeHTTP(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "POST required", http.StatusMethodNotAllowed)
return
}
body, err := io.ReadAll(http.MaxBytesReader(w, r.Body, maxReviewSize))
if err != nil {
http.Error(w, "read body", http.StatusBadRequest)
return
}
var in admissionReview
if err := json.Unmarshal(body, &in); err != nil || in.Request == nil {
http.Error(w, "not an AdmissionReview", http.StatusBadRequest)
return
}
res := m.Mutate(r.Context(), *in.Request)
out := admissionReview{
APIVersion: "admission.k8s.io/v1",
Kind: "AdmissionReview",
Response: &admissionResponse{UID: res.UID, Allowed: res.Allowed},
}
if len(res.Patch) > 0 {
patch, err := json.Marshal(res.Patch)
if err != nil {
http.Error(w, "encode patch", http.StatusInternalServerError)
return
}
out.Response.PatchType = "JSONPatch"
out.Response.Patch = base64.StdEncoding.EncodeToString(patch)
}
w.Header().Set("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(out); err != nil {
m.log.Warn("pod identity response write failed", "error", err)
}
}
@@ -0,0 +1,630 @@
package podidentity
import (
"bytes"
"context"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"log/slog"
"maps"
"net/http"
"net/http/httptest"
"strconv"
"strings"
"sync"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
const (
testRoleARN = "arn:aws:iam::000000000001:role/app"
testRegion = "ap-southeast-2"
testSTS = "https://sts.cluster.example:9999"
annRole = "eks.amazonaws.com/role-arn"
annAudience = "eks.amazonaws.com/audience"
annExpiry = "eks.amazonaws.com/token-expiration"
annRegionalS = "eks.amazonaws.com/sts-regional-endpoints"
annSkip = "eks.amazonaws.com/skip-containers"
)
type fakeSAs struct {
sas map[string]*ServiceAccount
err error
}
func (f *fakeSAs) GetServiceAccount(_ context.Context, ns, name string) (*ServiceAccount, error) {
if f.err != nil {
return nil, f.err
}
if sa, ok := f.sas[ns+"/"+name]; ok {
return sa, nil
}
return nil, ErrServiceAccountNotFound
}
// logRecords captures slog records for assertions.
type logRecords struct {
mu sync.Mutex
recs []map[string]string
}
func (l *logRecords) Enabled(context.Context, slog.Level) bool { return true }
func (l *logRecords) WithAttrs([]slog.Attr) slog.Handler { return l }
func (l *logRecords) WithGroup(string) slog.Handler { return l }
func (l *logRecords) Handle(_ context.Context, r slog.Record) error {
m := map[string]string{"msg": r.Message, "level": r.Level.String()}
r.Attrs(func(a slog.Attr) bool {
m[a.Key] = fmt.Sprint(a.Value.Any())
return true
})
l.mu.Lock()
l.recs = append(l.recs, m)
l.mu.Unlock()
return nil
}
// withReason returns the records at warn level or above that carry reason.
func (l *logRecords) withReason(reason string) []map[string]string {
l.mu.Lock()
defer l.mu.Unlock()
var out []map[string]string
for _, r := range l.recs {
if r["reason"] == reason {
out = append(out, r)
}
}
return out
}
func newMutator(sas ...*ServiceAccount) (*Mutator, *logRecords, *fakeSAs) {
f := &fakeSAs{sas: map[string]*ServiceAccount{}}
for i, sa := range sas {
f.sas[fmt.Sprintf("app/sa%d", i)] = sa
}
rec := &logRecords{}
return NewMutator(Config{Region: testRegion, STSEndpoint: testSTS}, f, slog.New(rec)), rec, f
}
func roleSA(extra map[string]string) *ServiceAccount {
ann := map[string]string{annRole: testRoleARN}
maps.Copy(ann, extra)
return &ServiceAccount{Annotations: ann}
}
type env struct {
Name string `json:"name"`
Value string `json:"value"`
}
func ctr(name string, envs ...env) map[string]any {
c := map[string]any{"name": name, "image": "registry.example/app:1"}
if len(envs) > 0 {
var e []map[string]any
for _, v := range envs {
e = append(e, map[string]any{"name": v.Name, "value": v.Value})
}
c["env"] = e
}
return c
}
func podOf(sa string, ann map[string]string, containers ...map[string]any) map[string]any {
spec := map[string]any{"containers": containers}
if sa != "" {
spec["serviceAccountName"] = sa
}
meta := map[string]any{"name": "web-1", "namespace": "app"}
if ann != nil {
meta["annotations"] = ann
}
return map[string]any{"apiVersion": "v1", "kind": "Pod", "metadata": meta, "spec": spec}
}
// The pod after the patch, decoded for assertions.
type outPod struct {
Spec struct {
Containers []outCtr `json:"containers"`
InitContainers []outCtr `json:"initContainers"`
Volumes []struct {
Name string `json:"name"`
Projected *struct {
Sources []struct {
SAT *struct {
Audience string `json:"audience"`
ExpirationSeconds int64 `json:"expirationSeconds"`
Path string `json:"path"`
} `json:"serviceAccountToken"`
} `json:"sources"`
} `json:"projected"`
ConfigMap *struct {
Name string `json:"name"`
} `json:"configMap"`
} `json:"volumes"`
} `json:"spec"`
}
type outCtr struct {
Name string `json:"name"`
Env []env `json:"env"`
VM []struct {
Name string `json:"name"`
MountPath string `json:"mountPath"`
ReadOnly bool `json:"readOnly"`
} `json:"volumeMounts"`
}
func (c outCtr) envMap() map[string]string {
m := map[string]string{}
for _, e := range c.Env {
m[e.Name] = e.Value
}
return m
}
func (c outCtr) mounts(name string) int {
n := 0
for _, v := range c.VM {
if v.Name == name {
n++
}
}
return n
}
func (p outPod) volumes(name string) int {
n := 0
for _, v := range p.Spec.Volumes {
if v.Name == name {
n++
}
}
return n
}
func (p outPod) tokenExpiry(t *testing.T) (aud string, exp int64) {
t.Helper()
for _, v := range p.Spec.Volumes {
if v.Name == TokenVolumeName && v.Projected != nil && len(v.Projected.Sources) == 1 && v.Projected.Sources[0].SAT != nil {
s := v.Projected.Sources[0].SAT
assert.Equal(t, TokenPath, s.Path)
return s.Audience, s.ExpirationSeconds
}
}
require.Failf(t, "no projected token volume", "volumes: %+v", p.Spec.Volumes)
return "", 0
}
// run mutates pod in namespace app and returns the patched pod and the response.
func run(t *testing.T, m *Mutator, pod map[string]any) (outPod, AdmissionResponse) {
t.Helper()
raw, err := json.Marshal(pod)
require.NoError(t, err)
resp := m.Mutate(context.Background(), AdmissionRequest{UID: "u1", Operation: "CREATE", Namespace: "app", Object: raw})
require.True(t, resp.Allowed, "the mutator must never deny a pod")
assert.Equal(t, "u1", resp.UID)
patched := applyPatch(t, raw, resp.Patch)
var out outPod
require.NoError(t, json.Unmarshal(patched, &out))
return out, resp
}
func TestMutate_InjectsTheAWSSetAndTheSpinifexExtras(t *testing.T) {
m, _, _ := newMutator(roleSA(map[string]string{annRegionalS: "true"}))
out, _ := run(t, m, podOf("sa0", nil, ctr("app")))
aud, exp := out.tokenExpiry(t)
assert.Equal(t, "sts.amazonaws.com", aud)
assert.GreaterOrEqual(t, exp, DefaultMinTokenExpiration)
assert.LessOrEqual(t, exp, MaxTokenExpiration)
require.Len(t, out.Spec.Containers, 1)
c := out.Spec.Containers[0]
assert.Equal(t, map[string]string{
"AWS_ROLE_ARN": testRoleARN,
"AWS_WEB_IDENTITY_TOKEN_FILE": TokenMountPath + "/" + TokenPath,
"AWS_STS_REGIONAL_ENDPOINTS": "regional",
"AWS_REGION": testRegion,
"AWS_DEFAULT_REGION": testRegion,
"AWS_ENDPOINT_URL_STS": testSTS,
"AWS_CA_BUNDLE": CAMountPath + "/" + CABundleKey,
"SSL_CERT_FILE": CAMountPath + "/" + CABundleKey,
}, c.envMap())
assert.Equal(t, 1, c.mounts(TokenVolumeName))
assert.Equal(t, 1, c.mounts(CAVolumeName))
for _, v := range c.VM {
assert.True(t, v.ReadOnly, "mount %s must be read-only", v.Name)
switch v.Name {
case TokenVolumeName:
assert.Equal(t, TokenMountPath, v.MountPath)
case CAVolumeName:
assert.Equal(t, CAMountPath, v.MountPath)
}
}
assert.Equal(t, 1, out.volumes(CAVolumeName))
for _, v := range out.Spec.Volumes {
if v.Name == CAVolumeName {
require.NotNil(t, v.ConfigMap)
assert.Equal(t, CAConfigMapName, v.ConfigMap.Name)
}
}
}
func TestMutate_RegionalSTSOnlyWhenTheServiceAccountAsksForIt(t *testing.T) {
m, _, _ := newMutator(roleSA(nil), roleSA(map[string]string{annRegionalS: "false"}))
for _, sa := range []string{"sa0", "sa1"} {
out, _ := run(t, m, podOf(sa, nil, ctr("app")))
require.Len(t, out.Spec.Containers, 1, sa)
assert.NotContains(t, out.Spec.Containers[0].envMap(), "AWS_STS_REGIONAL_ENDPOINTS", sa)
assert.Equal(t, testRoleARN, out.Spec.Containers[0].envMap()["AWS_ROLE_ARN"], sa)
}
}
func TestMutate_KeepsACallersOwnTrustSettings(t *testing.T) {
m, _, _ := newMutator(roleSA(nil))
out, _ := run(t, m, podOf("sa0", nil,
ctr("a", env{"SSL_CERT_FILE", "/etc/ssl/own.pem"}),
ctr("b", env{"AWS_CA_BUNDLE", "/etc/ssl/own-aws.pem"}, env{"AWS_ENDPOINT_URL_STS", "https://own"})))
a := out.Spec.Containers[0].envMap()
assert.Equal(t, "/etc/ssl/own.pem", a["SSL_CERT_FILE"], "an existing SSL_CERT_FILE replaces the image trust store and must be left alone")
assert.Equal(t, CAMountPath+"/"+CABundleKey, a["AWS_CA_BUNDLE"])
b := out.Spec.Containers[1].envMap()
assert.Equal(t, "/etc/ssl/own-aws.pem", b["AWS_CA_BUNDLE"])
assert.Equal(t, "https://own", b["AWS_ENDPOINT_URL_STS"])
assert.Equal(t, testRoleARN, b["AWS_ROLE_ARN"])
}
func TestMutate_InjectsInitContainersToo(t *testing.T) {
m, _, _ := newMutator(roleSA(nil))
pod := podOf("sa0", nil, ctr("app"))
pod["spec"].(map[string]any)["initContainers"] = []map[string]any{ctr("setup")}
out, _ := run(t, m, pod)
require.Len(t, out.Spec.InitContainers, 1)
assert.Equal(t, testRoleARN, out.Spec.InitContainers[0].envMap()["AWS_ROLE_ARN"])
assert.Equal(t, 1, out.Spec.InitContainers[0].mounts(TokenVolumeName))
}
func TestMutate_LeavesAContainerWithAllThreeKeySetsUntouched(t *testing.T) {
m, rec, _ := newMutator(roleSA(nil))
complete := ctr("done",
env{"AWS_ROLE_ARN", "arn:aws:iam::000000000001:role/own"},
env{"AWS_WEB_IDENTITY_TOKEN_FILE", "/own/token"},
env{"AWS_REGION", "us-east-1"},
env{"AWS_STS_REGIONAL_ENDPOINTS", "legacy"})
out, _ := run(t, m, podOf("sa0", nil, complete, ctr("plain")))
got := out.Spec.Containers[0]
assert.Len(t, got.Env, 4, "a complete container must not gain variables")
assert.Zero(t, got.mounts(TokenVolumeName)+got.mounts(CAVolumeName))
assert.Equal(t, testRoleARN, out.Spec.Containers[1].envMap()["AWS_ROLE_ARN"], "its neighbour is still injected")
assert.NotEmpty(t, rec.withReason(ReasonAlreadyConfigured))
}
func TestMutate_AddsOnlyTheMissingKeysToAPartiallyConfiguredContainer(t *testing.T) {
m, _, _ := newMutator(roleSA(map[string]string{annRegionalS: "true"}))
partial := ctr("partial", env{"AWS_ROLE_ARN", "arn:aws:iam::000000000001:role/own"})
out, _ := run(t, m, podOf("sa0", nil, partial))
e := out.Spec.Containers[0].envMap()
assert.Equal(t, "arn:aws:iam::000000000001:role/own", e["AWS_ROLE_ARN"], "the caller's role is kept")
assert.NotContains(t, e, "AWS_WEB_IDENTITY_TOKEN_FILE", "role keys already defined are not completed")
assert.Equal(t, testRegion, e["AWS_REGION"])
assert.Equal(t, testRegion, e["AWS_DEFAULT_REGION"])
assert.Equal(t, "regional", e["AWS_STS_REGIONAL_ENDPOINTS"])
assert.Equal(t, 1, out.Spec.Containers[0].mounts(TokenVolumeName))
count := 0
for _, kv := range out.Spec.Containers[0].Env {
if kv.Name == "AWS_ROLE_ARN" {
count++
}
}
assert.Equal(t, 1, count)
}
func TestMutate_DoesNotDuplicateAnExistingTokenVolume(t *testing.T) {
m, _, _ := newMutator(roleSA(nil))
pod := podOf("sa0", nil, ctr("app"))
pod["spec"].(map[string]any)["volumes"] = []map[string]any{{
"name": TokenVolumeName,
"projected": map[string]any{"sources": []map[string]any{{
"serviceAccountToken": map[string]any{"audience": "own", "expirationSeconds": 7200, "path": "token"},
}}},
}}
out, _ := run(t, m, pod)
assert.Equal(t, 1, out.volumes(TokenVolumeName))
aud, exp := out.tokenExpiry(t)
assert.Equal(t, "own", aud, "the caller's volume is kept")
assert.Equal(t, int64(7200), exp)
assert.Equal(t, 1, out.Spec.Containers[0].mounts(TokenVolumeName))
}
func TestMutate_AudienceAndExpiry(t *testing.T) {
cases := []struct {
name string
saAnn map[string]string
podAnn map[string]string
wantAud string
wantExp int64
}{
{"service account audience", map[string]string{annAudience: "custom.example"}, nil, "custom.example", 0},
{"service account expiry", map[string]string{annExpiry: "7200"}, nil, "sts.amazonaws.com", 7200},
{"pod expiry wins over service account", map[string]string{annExpiry: "7200"}, map[string]string{annExpiry: "1200"}, "sts.amazonaws.com", 1200},
{"below the minimum is raised to 600", nil, map[string]string{annExpiry: "100"}, "sts.amazonaws.com", 600},
{"above the maximum is lowered to 86400", map[string]string{annExpiry: "999999"}, nil, "sts.amazonaws.com", 86400},
{"invalid expiry falls back to the default", map[string]string{annExpiry: "soon"}, nil, "sts.amazonaws.com", 0},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
m, _, _ := newMutator(roleSA(tc.saAnn))
out, _ := run(t, m, podOf("sa0", tc.podAnn, ctr("app")))
aud, exp := out.tokenExpiry(t)
assert.Equal(t, tc.wantAud, aud)
if tc.wantExp != 0 {
assert.Equal(t, tc.wantExp, exp)
return
}
assert.GreaterOrEqual(t, exp, MaxTokenExpiration-7200)
assert.LessOrEqual(t, exp, MaxTokenExpiration)
})
}
}
func TestMutate_DefaultExpiryIsJitteredWithinTwoHours(t *testing.T) {
m, _, _ := newMutator(roleSA(nil))
seen := map[int64]bool{}
for range 60 {
out, _ := run(t, m, podOf("sa0", nil, ctr("app")))
_, exp := out.tokenExpiry(t)
require.GreaterOrEqual(t, exp, DefaultMinTokenExpiration)
require.LessOrEqual(t, exp, MaxTokenExpiration)
seen[exp] = true
}
assert.Greater(t, len(seen), 1, "60 pods all got the same default expiry; it is not jittered")
}
func TestMutate_SkipContainersAnnotation(t *testing.T) {
m, rec, _ := newMutator(roleSA(nil))
out, _ := run(t, m, podOf("sa0", map[string]string{annSkip: "sidecar, mesh"},
ctr("app"), ctr("sidecar"), ctr("mesh")))
assert.Equal(t, testRoleARN, out.Spec.Containers[0].envMap()["AWS_ROLE_ARN"])
for _, i := range []int{1, 2} {
c := out.Spec.Containers[i]
assert.Empty(t, c.Env, c.Name)
assert.Zero(t, c.mounts(TokenVolumeName), c.Name)
}
assert.Len(t, rec.withReason(ReasonSkipContainer), 2)
}
func TestMutate_LeavesPodsOfUnannotatedServiceAccountsAlone(t *testing.T) {
m, _, f := newMutator(&ServiceAccount{Annotations: map[string]string{"other": "x"}}, &ServiceAccount{})
f.sas["app/default"] = &ServiceAccount{}
for _, sa := range []string{"sa0", "sa1", ""} {
_, resp := run(t, m, podOf(sa, nil, ctr("app")))
assert.Empty(t, resp.Patch, "service account %q", sa)
}
}
func TestMutate_UsesTheDefaultServiceAccountWhenThePodNamesNone(t *testing.T) {
m, _, f := newMutator()
f.sas["app/default"] = roleSA(nil)
out, _ := run(t, m, podOf("", nil, ctr("app")))
assert.Equal(t, testRoleARN, out.Spec.Containers[0].envMap()["AWS_ROLE_ARN"])
}
// A pod of a managed add-on as render_addon writes it: the web identity,
// region and regional sts settings, the token volume and the gateway CA.
func TestMutate_LeavesARenderedManagedAddonPodUnchanged(t *testing.T) {
m, _, _ := newMutator(roleSA(map[string]string{annRegionalS: "true"}))
c := ctr("controller",
env{"AWS_ROLE_ARN", testRoleARN},
env{"AWS_WEB_IDENTITY_TOKEN_FILE", TokenMountPath + "/token"},
env{"AWS_STS_REGIONAL_ENDPOINTS", "regional"},
env{"AWS_REGION", testRegion},
env{"AWS_DEFAULT_REGION", testRegion},
env{"AWS_ENDPOINT_URL_STS", testSTS},
env{"AWS_CA_BUNDLE", "/etc/spinifex/gateway-ca/ca.pem"},
env{"SSL_CERT_FILE", "/etc/spinifex/gateway-ca/ca.pem"})
c["volumeMounts"] = []map[string]any{
{"name": TokenVolumeName, "mountPath": TokenMountPath, "readOnly": true},
{"name": "spinifex-gateway-ca", "mountPath": "/etc/spinifex/gateway-ca", "readOnly": true},
}
pod := podOf("sa0", nil, c)
pod["spec"].(map[string]any)["volumes"] = []map[string]any{
{"name": TokenVolumeName, "projected": map[string]any{"sources": []map[string]any{{
"serviceAccountToken": map[string]any{"audience": "sts.amazonaws.com", "expirationSeconds": 86400, "path": "token"}}}}},
{"name": "spinifex-gateway-ca", "secret": map[string]any{"secretName": "spinifex-gateway-ca"}},
}
_, resp := run(t, m, pod)
assert.Empty(t, resp.Patch, "a managed add-on pod must come out byte for byte as it went in")
}
func TestMutate_OnlyActsOnPodCreation(t *testing.T) {
m, _, _ := newMutator(roleSA(nil))
raw, err := json.Marshal(podOf("sa0", nil, ctr("app")))
require.NoError(t, err)
resp := m.Mutate(context.Background(), AdmissionRequest{UID: "u", Operation: "UPDATE", Namespace: "app", Object: raw})
assert.True(t, resp.Allowed)
assert.Empty(t, resp.Patch)
}
func TestMutate_LogsEverySkipAndFailureWithNamespacePodAndReason(t *testing.T) {
t.Run("lookup failure fails open", func(t *testing.T) {
m, rec, f := newMutator(roleSA(nil))
f.err = errors.New("apiserver unavailable")
_, resp := run(t, m, podOf("sa0", nil, ctr("app")))
assert.Empty(t, resp.Patch)
got := rec.withReason(ReasonServiceAccountLookup)
require.Len(t, got, 1)
assert.Equal(t, "app", got[0]["namespace"])
assert.Equal(t, "web-1", got[0]["pod"])
assert.Contains(t, got[0]["error"], "apiserver unavailable")
})
t.Run("missing service account", func(t *testing.T) {
m, rec, _ := newMutator()
_, resp := run(t, m, podOf("ghost", nil, ctr("app")))
assert.Empty(t, resp.Patch)
require.Len(t, rec.withReason(ReasonServiceAccountMissing), 1)
})
t.Run("undecodable object", func(t *testing.T) {
m, rec, _ := newMutator()
resp := m.Mutate(context.Background(), AdmissionRequest{UID: "u", Operation: "CREATE", Namespace: "app", Object: json.RawMessage(`{"spec":`)})
assert.True(t, resp.Allowed)
assert.Empty(t, resp.Patch)
got := rec.withReason(ReasonDecode)
require.Len(t, got, 1)
assert.Equal(t, "app", got[0]["namespace"])
})
t.Run("a pod with only generateName is named by it", func(t *testing.T) {
m, rec, _ := newMutator(roleSA(nil))
pod := podOf("sa0", map[string]string{annSkip: "app"}, ctr("app"))
meta := pod["metadata"].(map[string]any)
delete(meta, "name")
meta["generateName"] = "web-"
run(t, m, pod)
got := rec.withReason(ReasonSkipContainer)
require.Len(t, got, 1)
assert.True(t, strings.HasPrefix(got[0]["pod"], "web-"), "pod attr %q", got[0]["pod"])
})
t.Run("an unannotated pod is not a skip worth logging", func(t *testing.T) {
m, rec, _ := newMutator(&ServiceAccount{})
run(t, m, podOf("sa0", nil, ctr("app")))
for _, r := range rec.recs {
assert.NotEqual(t, "WARN", r["level"], "%v", r)
assert.NotEqual(t, "ERROR", r["level"], "%v", r)
}
})
}
func TestServeHTTP_AnswersAnAdmissionReview(t *testing.T) {
m, _, _ := newMutator(roleSA(nil))
obj, err := json.Marshal(podOf("sa0", nil, ctr("app")))
require.NoError(t, err)
body, err := json.Marshal(map[string]any{
"apiVersion": "admission.k8s.io/v1", "kind": "AdmissionReview",
"request": map[string]any{"uid": "abc-123", "operation": "CREATE", "namespace": "app", "object": json.RawMessage(obj)},
})
require.NoError(t, err)
rr := httptest.NewRecorder()
m.ServeHTTP(rr, httptest.NewRequest(http.MethodPost, "/mutate", bytes.NewReader(body)))
require.Equal(t, http.StatusOK, rr.Code)
var review struct {
APIVersion string `json:"apiVersion"`
Kind string `json:"kind"`
Response struct {
UID string `json:"uid"`
Allowed bool `json:"allowed"`
PatchType string `json:"patchType"`
Patch string `json:"patch"`
} `json:"response"`
}
require.NoError(t, json.Unmarshal(rr.Body.Bytes(), &review))
assert.Equal(t, "admission.k8s.io/v1", review.APIVersion)
assert.Equal(t, "AdmissionReview", review.Kind)
assert.Equal(t, "abc-123", review.Response.UID)
assert.True(t, review.Response.Allowed)
assert.Equal(t, "JSONPatch", review.Response.PatchType)
patch, err := base64.StdEncoding.DecodeString(review.Response.Patch)
require.NoError(t, err)
assert.Contains(t, string(patch), "AWS_ROLE_ARN")
}
// applyPatch applies add, replace and remove operations to raw.
func applyPatch(t *testing.T, raw []byte, ops []PatchOp) []byte {
t.Helper()
var doc any
require.NoError(t, json.Unmarshal(raw, &doc))
for _, op := range ops {
parts := strings.Split(strings.TrimPrefix(op.Path, "/"), "/")
for i := range parts {
parts[i] = strings.ReplaceAll(strings.ReplaceAll(parts[i], "~1", "/"), "~0", "~")
}
var val any
if op.Value != nil {
b, err := json.Marshal(op.Value)
require.NoError(t, err)
require.NoError(t, json.Unmarshal(b, &val))
}
doc = patchAt(t, doc, parts, op.Op, val)
}
out, err := json.Marshal(doc)
require.NoError(t, err)
return out
}
func patchAt(t *testing.T, node any, parts []string, op string, val any) any {
t.Helper()
if len(parts) == 1 {
key := parts[0]
switch n := node.(type) {
case map[string]any:
switch op {
case "add", "replace":
n[key] = val
case "remove":
delete(n, key)
default:
require.Failf(t, "unsupported op", "%s", op)
}
return n
case []any:
if key == "-" {
return append(n, val)
}
i, err := strconv.Atoi(key)
require.NoError(t, err)
switch op {
case "add":
n = append(n, nil)
copy(n[i+1:], n[i:])
n[i] = val
case "replace":
n[i] = val
case "remove":
n = append(n[:i], n[i+1:]...)
}
return n
}
require.Fail(t, "patch parent is neither object nor array")
}
switch n := node.(type) {
case map[string]any:
child, ok := n[parts[0]]
require.True(t, ok, "patch path parent %q missing", parts[0])
n[parts[0]] = patchAt(t, child, parts[1:], op, val)
return n
case []any:
i, err := strconv.Atoi(parts[0])
require.NoError(t, err)
n[i] = patchAt(t, n[i], parts[1:], op, val)
return n
}
require.Fail(t, "patch path crosses a scalar")
return nil
}
// Upstream skips a container only when the regional STS key is present too: a
// container with role and region but no STS key, on a ServiceAccount that does
// not ask for regional endpoints, still gets the mounts but no new STS variable.
func TestMutate_RoleAndRegionWithoutSTSKeyStillGetsMountsNotSTSEnv(t *testing.T) {
m, _, _ := newMutator(roleSA(nil))
c := ctr("app",
env{"AWS_ROLE_ARN", "arn:aws:iam::000000000001:role/own"},
env{"AWS_WEB_IDENTITY_TOKEN_FILE", "/own/token"},
env{"AWS_REGION", "us-east-1"})
out, _ := run(t, m, podOf("sa0", nil, c))
got := out.Spec.Containers[0]
assert.Equal(t, 1, got.mounts(TokenVolumeName))
assert.NotContains(t, got.envMap(), "AWS_STS_REGIONAL_ENDPOINTS")
assert.Equal(t, "arn:aws:iam::000000000001:role/own", got.envMap()["AWS_ROLE_ARN"])
}
@@ -0,0 +1,166 @@
// Package podidentity injects IRSA credentials into pods of annotated
// ServiceAccounts like the AWS pod identity webhook, plus the Spinifex STS
// endpoint and gateway CA. It uses no Kubernetes client; the cluster is reached through interfaces.
package podidentity
import (
"context"
"encoding/json"
"errors"
"log/slog"
)
const (
// DefaultAnnotationDomain is the annotation domain AWS uses.
DefaultAnnotationDomain = "eks.amazonaws.com"
// TokenVolumeName and TokenMountPath are where AWS mounts the projected token.
TokenVolumeName = "aws-iam-token" //nolint:gosec // volume name, not a credential
TokenMountPath = "/var/run/secrets/eks.amazonaws.com/serviceaccount" //nolint:gosec // mount path, not a credential
TokenPath = "token"
// DefaultAudience is the token audience when the ServiceAccount sets none.
DefaultAudience = "sts.amazonaws.com"
// MinTokenExpiration and MaxTokenExpiration bound a requested expiry in seconds.
MinTokenExpiration int64 = 600
MaxTokenExpiration int64 = 86400
// DefaultMinTokenExpiration is the low end of the jittered default expiry.
DefaultMinTokenExpiration int64 = 79200
// CAVolumeName, CAMountPath and CABundleKey describe the CA bundle ConfigMap
// mount; CAConfigMapName is the ConfigMap published into every namespace.
CAConfigMapName = "spinifex-ca-bundle"
CAVolumeName = "spinifex-ca-bundle"
CAMountPath = "/etc/spinifex/ca-bundle"
CABundleKey = "ca.pem"
)
// Reasons logged, with the namespace and pod, when injection is skipped or fails.
const (
ReasonDecode = "admission object could not be decoded"
ReasonServiceAccountLookup = "serviceaccount lookup failed"
ReasonServiceAccountMissing = "serviceaccount not found"
ReasonAlreadyConfigured = "container already has web identity, region and regional sts settings"
ReasonSkipContainer = "container named in skip-containers annotation"
)
var (
// ErrServiceAccountNotFound is returned by a ServiceAccountGetter when the
// ServiceAccount does not exist.
ErrServiceAccountNotFound = errors.New("serviceaccount not found")
// ErrNotFound, ErrAlreadyExists and ErrConflict are the ConfigMapStore errors.
ErrNotFound = errors.New("not found")
ErrAlreadyExists = errors.New("already exists")
ErrConflict = errors.New("conflict")
)
// Config is the mutator's configuration.
type Config struct {
// Region is injected as AWS_REGION and AWS_DEFAULT_REGION when non-empty.
Region string
// STSEndpoint is injected as AWS_ENDPOINT_URL_STS when non-empty.
STSEndpoint string
// AnnotationDomain defaults to DefaultAnnotationDomain.
AnnotationDomain string
}
// ServiceAccount is the part of a ServiceAccount the mutator reads.
type ServiceAccount struct {
Annotations map[string]string
}
// ServiceAccountGetter looks up a ServiceAccount.
type ServiceAccountGetter interface {
GetServiceAccount(ctx context.Context, namespace, name string) (*ServiceAccount, error)
}
// AdmissionRequest is the part of an AdmissionReview request the mutator reads.
type AdmissionRequest struct {
UID string `json:"uid"`
Operation string `json:"operation"`
Namespace string `json:"namespace"`
Object json.RawMessage `json:"object"`
}
// PatchOp is one RFC 6902 operation.
type PatchOp struct {
Op string `json:"op"`
Path string `json:"path"`
Value any `json:"value,omitempty"`
}
// AdmissionResponse is the mutator's answer. Patch is empty when the pod is
// left unchanged. The mutator never denies a pod.
type AdmissionResponse struct {
UID string
Allowed bool
Patch []PatchOp
}
// Mutator injects IRSA credentials into pods.
type Mutator struct {
cfg Config
sas ServiceAccountGetter
log *slog.Logger
}
// NewMutator returns a Mutator. A nil log uses slog.Default.
func NewMutator(cfg Config, sas ServiceAccountGetter, log *slog.Logger) *Mutator {
if log == nil {
log = slog.Default()
}
return &Mutator{cfg: cfg, sas: sas, log: log}
}
// ConfigMap is the part of a ConfigMap the publisher manages.
type ConfigMap struct {
Namespace string
Name string
Data map[string]string
// ResourceVersion is opaque and set by the store.
ResourceVersion string
}
// ConfigMapStore reads and writes ConfigMaps. Get returns ErrNotFound, Create
// returns ErrAlreadyExists, and Update returns ErrConflict or ErrNotFound.
type ConfigMapStore interface {
Get(ctx context.Context, namespace, name string) (*ConfigMap, error)
Create(ctx context.Context, cm ConfigMap) error
Update(ctx context.Context, cm ConfigMap) error
}
// NamespaceSource lists namespaces and reports ones created later.
type NamespaceSource interface {
List(ctx context.Context) ([]string, error)
// Watch sends the name of every namespace created after the call until ctx ends.
Watch(ctx context.Context) (<-chan string, error)
}
// PublisherConfig configures a Publisher.
type PublisherConfig struct {
// Bundle is the CA bundle published to every namespace as CABundleKey.
Bundle []byte
}
// Publisher keeps the CA bundle ConfigMap present in every namespace.
type Publisher struct {
cfg PublisherConfig
ns NamespaceSource
cms ConfigMapStore
log *slog.Logger
}
// NewPublisher returns a Publisher. A nil log uses slog.Default.
func NewPublisher(cfg PublisherConfig, ns NamespaceSource, cms ConfigMapStore, log *slog.Logger) *Publisher {
if log == nil {
log = slog.Default()
}
return &Publisher{cfg: cfg, ns: ns, cms: cms, log: log}
}
// WebhookConfig describes the MutatingWebhookConfiguration to render.
type WebhookConfig struct {
Name string
// URL is the https URL the API server calls, on 127.0.0.1.
URL string
// CA is the PEM the API server trusts for URL.
CA []byte
}
@@ -0,0 +1,107 @@
package podidentity
import (
"context"
"errors"
"maps"
"time"
)
const (
defaultResync = 5 * time.Minute
reconcileAttempts = 3
)
// Reconcile makes the CA bundle ConfigMap in one namespace match the bundle.
// Losing a create or update race to another publisher is not an error.
func (p *Publisher) Reconcile(ctx context.Context, namespace string) error {
want := string(p.cfg.Bundle)
var err error
for range reconcileAttempts {
var cm *ConfigMap
cm, err = p.cms.Get(ctx, namespace, CAConfigMapName)
switch {
case errors.Is(err, ErrNotFound):
err = p.cms.Create(ctx, ConfigMap{Namespace: namespace, Name: CAConfigMapName, Data: map[string]string{CABundleKey: want}})
if err == nil {
return nil
}
if !errors.Is(err, ErrAlreadyExists) {
return err
}
continue
case err != nil:
return err
case cm.Data[CABundleKey] == want:
return nil
}
data := maps.Clone(cm.Data)
if data == nil {
data = map[string]string{}
}
data[CABundleKey] = want
err = p.cms.Update(ctx, ConfigMap{Namespace: namespace, Name: CAConfigMapName, Data: data, ResourceVersion: cm.ResourceVersion})
if err == nil {
return nil
}
if !errors.Is(err, ErrConflict) && !errors.Is(err, ErrNotFound) {
return err
}
}
return err
}
// Run publishes to every existing namespace, then to each new one, and
// re-checks every namespace each resync interval until ctx ends.
func (p *Publisher) Run(ctx context.Context, resync time.Duration) error {
if resync <= 0 {
resync = defaultResync
}
created, err := p.ns.Watch(ctx)
if err != nil {
return err
}
p.reconcileAll(ctx)
tick := time.NewTicker(resync)
defer tick.Stop()
for {
select {
case <-ctx.Done():
return nil
case ns, ok := <-created:
if !ok {
created = nil
continue
}
p.reconcileOne(ctx, ns)
case <-tick.C:
if created == nil {
if ch, err := p.ns.Watch(ctx); err != nil {
p.log.Warn("namespace watch restart failed", "error", err)
} else {
created = ch
}
}
p.reconcileAll(ctx)
}
}
}
func (p *Publisher) reconcileAll(ctx context.Context) {
names, err := p.ns.List(ctx)
if err != nil {
p.log.Warn("namespace list failed", "error", err)
return
}
for _, ns := range names {
p.reconcileOne(ctx, ns)
}
}
func (p *Publisher) reconcileOne(ctx context.Context, ns string) {
if err := p.Reconcile(ctx, ns); err != nil && ctx.Err() == nil {
p.log.Warn("ca bundle publish failed", "namespace", ns, "error", err)
}
}
@@ -0,0 +1,321 @@
package podidentity
import (
"context"
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"crypto/x509"
"crypto/x509/pkix"
"encoding/base64"
"encoding/json"
"encoding/pem"
"math/big"
"strconv"
"strings"
"sync"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// memStore is an in-memory ConfigMapStore with the API server's create and
// update semantics: Create fails if present, Update needs the current version.
type memStore struct {
mu sync.Mutex
cms map[string]ConfigMap
version int
creates int
updates int
}
func newMemStore() *memStore { return &memStore{cms: map[string]ConfigMap{}} }
func (s *memStore) Get(_ context.Context, ns, name string) (*ConfigMap, error) {
s.mu.Lock()
defer s.mu.Unlock()
cm, ok := s.cms[ns+"/"+name]
if !ok {
return nil, ErrNotFound
}
return &cm, nil
}
func (s *memStore) Create(_ context.Context, cm ConfigMap) error {
s.mu.Lock()
defer s.mu.Unlock()
if _, ok := s.cms[cm.Namespace+"/"+cm.Name]; ok {
return ErrAlreadyExists
}
s.version++
cm.ResourceVersion = strconv.Itoa(s.version)
s.cms[cm.Namespace+"/"+cm.Name] = cm
s.creates++
return nil
}
func (s *memStore) Update(_ context.Context, cm ConfigMap) error {
s.mu.Lock()
defer s.mu.Unlock()
cur, ok := s.cms[cm.Namespace+"/"+cm.Name]
if !ok {
return ErrNotFound
}
if cur.ResourceVersion != cm.ResourceVersion {
return ErrConflict
}
s.version++
cm.ResourceVersion = strconv.Itoa(s.version)
s.cms[cm.Namespace+"/"+cm.Name] = cm
s.updates++
return nil
}
func (s *memStore) remove(ns, name string) {
s.mu.Lock()
delete(s.cms, ns+"/"+name)
s.mu.Unlock()
}
func (s *memStore) get(ns string) (ConfigMap, bool) {
s.mu.Lock()
defer s.mu.Unlock()
cm, ok := s.cms[ns+"/"+CAConfigMapName]
return cm, ok
}
// fakeNamespaces lists a fixed set and replays created ones on Watch.
type fakeNamespaces struct {
mu sync.Mutex
names []string
watcher chan string
}
func (f *fakeNamespaces) List(context.Context) ([]string, error) {
f.mu.Lock()
defer f.mu.Unlock()
return append([]string(nil), f.names...), nil
}
func (f *fakeNamespaces) Watch(context.Context) (<-chan string, error) {
f.mu.Lock()
defer f.mu.Unlock()
f.watcher = make(chan string, 8)
return f.watcher, nil
}
func (f *fakeNamespaces) create(name string) {
f.mu.Lock()
f.names = append(f.names, name)
w := f.watcher
f.mu.Unlock()
if w != nil {
w <- name
}
}
var testBundle = []byte("-----BEGIN CERTIFICATE-----\nQUJD\n-----END CERTIFICATE-----\n")
func TestPublisher_ReconcileCreatesTheBundleConfigMap(t *testing.T) {
st := newMemStore()
p := NewPublisher(PublisherConfig{Bundle: testBundle}, &fakeNamespaces{}, st, nil)
require.NoError(t, p.Reconcile(context.Background(), "team-a"))
cm, ok := st.get("team-a")
require.True(t, ok, "no %s ConfigMap in team-a", CAConfigMapName)
assert.Equal(t, string(testBundle), cm.Data[CABundleKey])
}
func TestPublisher_ReconcileIsIdempotentAndRepairsDrift(t *testing.T) {
st := newMemStore()
p := NewPublisher(PublisherConfig{Bundle: testBundle}, &fakeNamespaces{}, st, nil)
ctx := context.Background()
require.NoError(t, p.Reconcile(ctx, "team-a"))
require.NoError(t, p.Reconcile(ctx, "team-a"))
assert.Equal(t, 1, st.creates)
assert.Zero(t, st.updates, "an up-to-date ConfigMap must not be rewritten")
require.NoError(t, st.Update(ctx, ConfigMap{Namespace: "team-a", Name: CAConfigMapName,
Data: map[string]string{CABundleKey: "tampered"}, ResourceVersion: "1"}))
require.NoError(t, p.Reconcile(ctx, "team-a"))
cm, _ := st.get("team-a")
assert.Equal(t, string(testBundle), cm.Data[CABundleKey])
}
func TestPublisher_RunPublishesToEveryNamespaceIncludingLaterOnes(t *testing.T) {
st := newMemStore()
nss := &fakeNamespaces{names: []string{"default", "kube-system", "team-a"}}
p := NewPublisher(PublisherConfig{Bundle: testBundle}, nss, st, nil)
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
done := make(chan error, 1)
go func() { done <- p.Run(ctx, time.Hour) }()
for _, ns := range []string{"default", "kube-system", "team-a"} {
require.Eventually(t, func() bool { _, ok := st.get(ns); return ok }, 3*time.Second, 10*time.Millisecond, "namespace %s", ns)
}
nss.create("created-later")
require.Eventually(t, func() bool { _, ok := st.get("created-later"); return ok }, 3*time.Second, 10*time.Millisecond)
cancel()
select {
case err := <-done:
assert.NoError(t, err)
case <-time.After(3 * time.Second):
t.Fatal("Run did not return after its context ended")
}
}
func TestPublisher_RunRecreatesADeletedConfigMap(t *testing.T) {
st := newMemStore()
nss := &fakeNamespaces{names: []string{"team-a"}}
p := NewPublisher(PublisherConfig{Bundle: testBundle}, nss, st, nil)
ctx := t.Context()
go func() { _ = p.Run(ctx, 20*time.Millisecond) }()
require.Eventually(t, func() bool { _, ok := st.get("team-a"); return ok }, 3*time.Second, 10*time.Millisecond)
st.remove("team-a", CAConfigMapName)
require.Eventually(t, func() bool { _, ok := st.get("team-a"); return ok }, 3*time.Second, 10*time.Millisecond,
"the ConfigMap stayed deleted; pods created now would hang on the missing volume")
}
// All three control-plane servers run a publisher, so racing is the normal case.
func TestPublisher_ConcurrentPublishersDoNotFight(t *testing.T) {
st := newMemStore()
var wg sync.WaitGroup
errs := make(chan error, 12)
for range 3 {
p := NewPublisher(PublisherConfig{Bundle: testBundle}, &fakeNamespaces{}, st, nil)
for range 4 {
wg.Go(func() {
errs <- p.Reconcile(context.Background(), "team-a")
})
}
}
wg.Wait()
close(errs)
for err := range errs {
assert.NoError(t, err, "losing a create race is not an error")
}
cm, ok := st.get("team-a")
require.True(t, ok)
assert.Equal(t, string(testBundle), cm.Data[CABundleKey])
assert.Equal(t, 1, st.creates)
assert.Zero(t, st.updates, "publishers with the same bundle must not rewrite each other's ConfigMap")
}
func certPEM(t *testing.T, cn string) []byte {
t.Helper()
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
require.NoError(t, err)
tmpl := &x509.Certificate{
SerialNumber: big.NewInt(time.Now().UnixNano()),
Subject: pkix.Name{CommonName: cn},
NotBefore: time.Now().Add(-time.Hour),
NotAfter: time.Now().Add(time.Hour),
IsCA: true,
BasicConstraintsValid: true,
KeyUsage: x509.KeyUsageCertSign,
}
der, err := x509.CreateCertificate(rand.Reader, tmpl, tmpl, &key.PublicKey, key)
require.NoError(t, err)
return pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der})
}
func subjects(t *testing.T, bundle []byte) []string {
t.Helper()
var out []string
for rest := bundle; ; {
var b *pem.Block
b, rest = pem.Decode(rest)
if b == nil {
return out
}
c, err := x509.ParseCertificate(b.Bytes)
require.NoError(t, err)
out = append(out, c.Subject.CommonName)
}
}
func TestBuildCABundle_HoldsTheSystemRootsAndTheGatewayCAOnce(t *testing.T) {
root1, root2, gw := certPEM(t, "root-1"), certPEM(t, "root-2"), certPEM(t, "gateway")
system := append(append([]byte{}, root1...), root2...)
got := subjects(t, BuildCABundle(system, gw))
assert.ElementsMatch(t, []string{"root-1", "root-2", "gateway"}, got)
again := subjects(t, BuildCABundle(append(append([]byte{}, system...), gw...), gw))
assert.ElementsMatch(t, []string{"root-1", "root-2", "gateway"}, again, "a certificate in both inputs is listed once")
}
func TestRenderWebhookConfiguration(t *testing.T) {
ca := certPEM(t, "k3s-server-ca")
raw, err := RenderWebhookConfiguration(WebhookConfig{
Name: "spinifex-pod-identity",
URL: "https://127.0.0.1:8445/mutate",
CA: ca,
})
require.NoError(t, err)
var cfg struct {
APIVersion string `json:"apiVersion"`
Kind string `json:"kind"`
Metadata struct {
Name string `json:"name"`
} `json:"metadata"`
Webhooks []struct {
Name string `json:"name"`
FailurePolicy string `json:"failurePolicy"`
SideEffects string `json:"sideEffects"`
AdmissionReviewVersions []string `json:"admissionReviewVersions"`
ClientConfig struct {
URL string `json:"url"`
Service any `json:"service"`
CABundle string `json:"caBundle"`
} `json:"clientConfig"`
Rules []struct {
Operations []string `json:"operations"`
Resources []string `json:"resources"`
} `json:"rules"`
} `json:"webhooks"`
}
require.NoError(t, json.Unmarshal(raw, &cfg))
assert.Equal(t, "admissionregistration.k8s.io/v1", cfg.APIVersion)
assert.Equal(t, "MutatingWebhookConfiguration", cfg.Kind)
assert.Equal(t, "spinifex-pod-identity", cfg.Metadata.Name)
require.Len(t, cfg.Webhooks, 1)
w := cfg.Webhooks[0]
assert.Equal(t, "Ignore", w.FailurePolicy, "an unreachable injector must never block pod creation")
assert.Equal(t, "None", w.SideEffects)
assert.Contains(t, w.AdmissionReviewVersions, "v1")
assert.Nil(t, w.ClientConfig.Service, "a Service reference cannot be routed from the control plane")
assert.True(t, strings.HasPrefix(w.ClientConfig.URL, "https://127.0.0.1:"), "url %q", w.ClientConfig.URL)
got, err := base64.StdEncoding.DecodeString(w.ClientConfig.CABundle)
require.NoError(t, err)
assert.Equal(t, string(ca), string(got), "caBundle must be the k3s server CA")
require.Len(t, w.Rules, 1)
assert.Equal(t, []string{"CREATE"}, w.Rules[0].Operations)
assert.Equal(t, []string{"pods"}, w.Rules[0].Resources)
}
func TestRenderWebhookConfiguration_RejectsAURLThatIsNotLoopbackHTTPS(t *testing.T) {
ca := certPEM(t, "k3s-server-ca")
for _, url := range []string{
"http://127.0.0.1:8445/mutate",
"https://10.0.0.5:8445/mutate",
"https://webhook.kube-system.svc/mutate",
"",
} {
_, err := RenderWebhookConfiguration(WebhookConfig{Name: "n", URL: url, CA: ca})
assert.Error(t, err, "url %q", url)
}
_, err := RenderWebhookConfiguration(WebhookConfig{Name: "n", URL: "https://127.0.0.1:8445/mutate"})
assert.Error(t, err, "a missing CA must not render a webhook the API server cannot verify")
}
+110
View File
@@ -0,0 +1,110 @@
package podidentity
import (
"context"
"crypto/tls"
"crypto/x509"
"encoding/pem"
"io"
"log/slog"
"net"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func freeAddr(t *testing.T) string {
t.Helper()
l, err := net.Listen("tcp", "127.0.0.1:0")
require.NoError(t, err)
defer func() { _ = l.Close() }()
return l.Addr().String()
}
// startAPI serves a fake kube-apiserver whose certificate is signed by the k3s
// server CA, as the real one is.
func startAPI(t *testing.T, cfg CertConfig) *httptest.Server {
t.Helper()
other := cfg
other.Dir = t.TempDir()
certs, err := EnsureCerts(other)
require.NoError(t, err)
pair, err := tls.LoadX509KeyPair(certs.ServingCert, certs.ServingKey)
require.NoError(t, err)
api := httptest.NewUnstartedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Query().Get("watch") != "" {
return
}
_, _ = io.WriteString(w, `{"items":[]}`)
}))
api.TLS = &tls.Config{Certificates: []tls.Certificate{pair}, ClientAuth: tls.RequireAnyClientCert, MinVersion: tls.VersionTLS12}
api.StartTLS()
t.Cleanup(api.Close)
return api
}
func TestRun_ServesTheWebhookStagesManifestsAndStopsOnCancel(t *testing.T) {
cfg, tlsDir := certTestConfig(t)
api := startAPI(t, cfg)
dir := t.TempDir()
roots := filepath.Join(dir, "roots.pem")
require.NoError(t, os.WriteFile(roots, pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: api.Certificate().Raw}), 0o644))
c := ServiceConfig{
Addr: freeAddr(t), Dir: cfg.Dir, TLSDir: tlsDir, APIServer: api.URL,
Region: "ap-southeast-2", STSEndpoint: "https://gw:9999",
SystemRoots: roots, ManifestDir: filepath.Join(dir, "manifests"), Resync: time.Hour,
}
require.NoError(t, os.MkdirAll(c.ManifestDir, 0o755))
ctx, cancel := context.WithCancel(context.Background())
done := make(chan error, 1)
go func() { done <- Run(ctx, c, slog.New(slog.DiscardHandler)) }()
// The API server calls the webhook over TLS, trusting only the server CA.
caPEM, err := os.ReadFile(filepath.Join(tlsDir, "server-ca.crt"))
require.NoError(t, err)
pool := x509.NewCertPool()
require.True(t, pool.AppendCertsFromPEM(caPEM))
client := &http.Client{Transport: &http.Transport{TLSClientConfig: &tls.Config{RootCAs: pool, MinVersion: tls.VersionTLS12}}}
require.Eventually(t, func() bool {
resp, err := client.Get("https://" + c.Addr + "/healthz")
if err != nil {
return false
}
_ = resp.Body.Close()
return resp.StatusCode == http.StatusOK
}, 10*time.Second, 50*time.Millisecond)
_, err = os.Stat(filepath.Join(c.ManifestDir, manifestFile))
assert.NoError(t, err, "the RBAC and webhook configuration are staged for k3s")
cancel()
select {
case err := <-done:
assert.NoError(t, err)
case <-time.After(10 * time.Second):
t.Fatal("Run did not stop after cancel")
}
}
func TestRun_FailsWhenTheServerCAIsMissing(t *testing.T) {
cfg, tlsDir := certTestConfig(t)
require.NoError(t, os.Remove(filepath.Join(tlsDir, "server-ca.key")))
c := ServiceConfig{
Addr: freeAddr(t), Dir: cfg.Dir, TLSDir: tlsDir, APIServer: "https://127.0.0.1:1",
Region: "r", STSEndpoint: "https://gw", SystemRoots: filepath.Join(t.TempDir(), "none"), ManifestDir: t.TempDir(),
}
err := Run(context.Background(), c, slog.New(slog.DiscardHandler))
assert.Error(t, err, "the process exits so the supervisor retries until k3s has written its CAs")
}
func TestRun_RejectsANonLoopbackAddress(t *testing.T) {
err := Run(context.Background(), ServiceConfig{Addr: "0.0.0.0:8445", Region: "r", STSEndpoint: "s"}, slog.New(slog.DiscardHandler))
assert.Error(t, err)
}
+212
View File
@@ -0,0 +1,212 @@
package podidentity
import (
"bytes"
"context"
"crypto/tls"
"errors"
"flag"
"fmt"
"io"
"log/slog"
"net"
"net/http"
"os"
"time"
)
const (
// DefaultAddr is the loopback listener; 8443 belongs to eks-token-webhook.
DefaultAddr = "127.0.0.1:8445"
// MutatePath is the URL path the API server calls.
MutatePath = "/mutate"
defaultStateDir = "/var/lib/spinifex-eks/pod-identity"
defaultTLSDir = "/var/lib/rancher/k3s/server/tls"
defaultAPIServer = "https://127.0.0.1:6443"
defaultSystemRoot = "/etc/ssl/certs/ca-certificates.crt"
defaultManifests = "/var/lib/rancher/k3s/server/manifests"
manifestFile = "eks-pod-identity-webhook.yaml"
certCheckEvery = 24 * time.Hour
)
// ErrCertRenewal is returned by Run when a certificate entered its renew window.
// The process exits non-zero so the supervisor restarts it and EnsureCerts reissues.
var ErrCertRenewal = errors.New("certificate inside renew window")
// ServiceConfig is everything the webhook process needs.
type ServiceConfig struct {
Addr string
Dir string
TLSDir string
APIServer string
Region string
STSEndpoint string
GatewayCAFile string
SystemRoots string
ManifestDir string
Resync time.Duration
}
// ParseServiceConfig reads flags, defaulting region, STS endpoint and gateway
// CA from the environment the node already has (the cloud-init first-boot env
// that mulga-eks-addon-sync renders the managed add-ons from).
func ParseServiceConfig(args []string, getenv func(string) string, errOut io.Writer) (ServiceConfig, error) {
var c ServiceConfig
fs := flag.NewFlagSet("eks-pod-identity-webhook", flag.ContinueOnError)
fs.SetOutput(errOut)
fs.StringVar(&c.Addr, "addr", DefaultAddr, "loopback listen address")
fs.StringVar(&c.Dir, "dir", defaultStateDir, "directory holding the webhook's certificates")
fs.StringVar(&c.TLSDir, "k3s-tls-dir", defaultTLSDir, "k3s server tls directory")
fs.StringVar(&c.APIServer, "apiserver", defaultAPIServer, "kube-apiserver URL")
fs.StringVar(&c.Region, "region", getenv("EKS_REGION"), "region injected as AWS_REGION")
fs.StringVar(&c.STSEndpoint, "sts-endpoint", getenv("EKS_ADDON_GATEWAY_URL"), "endpoint injected as AWS_ENDPOINT_URL_STS")
fs.StringVar(&c.GatewayCAFile, "gateway-ca", getenv("EKS_GATEWAY_CA"), "gateway CA PEM added to the published bundle")
fs.StringVar(&c.SystemRoots, "system-roots", defaultSystemRoot, "system root certificates PEM")
fs.StringVar(&c.ManifestDir, "manifest-dir", defaultManifests, "k3s auto-deploy manifests directory")
fs.DurationVar(&c.Resync, "resync", 5*time.Minute, "how often every namespace's CA ConfigMap is re-checked")
if err := fs.Parse(args); err != nil {
return ServiceConfig{}, err
}
return c, c.Validate()
}
// Validate rejects a configuration that would expose the webhook beyond the
// node or run it with nothing to inject.
func (c ServiceConfig) Validate() error {
host, _, err := net.SplitHostPort(c.Addr)
if err != nil {
return fmt.Errorf("addr %q: %w", c.Addr, err)
}
if ip := net.ParseIP(host); ip == nil || !ip.IsLoopback() {
return fmt.Errorf("addr %q must be a loopback IP: only the local kube-apiserver may call the webhook", c.Addr)
}
if c.Region == "" || c.STSEndpoint == "" {
return errors.New("region and sts endpoint are required (EKS_REGION, EKS_ADDON_GATEWAY_URL)")
}
return nil
}
// CertConfig returns the certificate settings that follow from the k3s tls directory.
func (c ServiceConfig) CertConfig() CertConfig {
return CertConfig{
Dir: c.Dir,
ServerCACert: c.TLSDir + "/server-ca.crt", ServerCAKey: c.TLSDir + "/server-ca.key",
ClientCACert: c.TLSDir + "/client-ca.crt", ClientCAKey: c.TLSDir + "/client-ca.key",
}
}
// Run serves the webhook on the loopback address and keeps the CA ConfigMaps
// published until ctx ends.
func Run(ctx context.Context, c ServiceConfig, log *slog.Logger) error {
if err := c.Validate(); err != nil {
return err
}
certs, err := EnsureCerts(c.CertConfig())
if err != nil {
return err
}
bundle, err := c.caBundle()
if err != nil {
return err
}
if err := c.stageManifests(); err != nil {
return err
}
kube, err := NewKubeClient(c.APIServer, c.TLSDir+"/server-ca.crt", certs.ClientCert, certs.ClientKey)
if err != nil {
return err
}
pair, err := tls.LoadX509KeyPair(certs.ServingCert, certs.ServingKey)
if err != nil {
return fmt.Errorf("load serving certificate: %w", err)
}
mux := http.NewServeMux()
mux.Handle(MutatePath, NewMutator(Config{Region: c.Region, STSEndpoint: c.STSEndpoint}, kube, log))
mux.HandleFunc("/healthz", func(w http.ResponseWriter, _ *http.Request) { _, _ = io.WriteString(w, "ok") })
srv := &http.Server{
Handler: mux,
TLSConfig: &tls.Config{Certificates: []tls.Certificate{pair}, MinVersion: tls.VersionTLS12},
ReadHeaderTimeout: 5 * time.Second,
ReadTimeout: 10 * time.Second,
WriteTimeout: 10 * time.Second,
}
ln, err := net.Listen("tcp", c.Addr)
if err != nil {
return err
}
errc := make(chan error, 1)
go func() { errc <- srv.ServeTLS(ln, "", "") }()
go func() {
if err := NewPublisher(PublisherConfig{Bundle: bundle}, kube, kube, log).Run(ctx, c.Resync); err != nil {
log.Error("ca bundle publisher stopped", "error", err)
}
}()
log.Info("pod identity webhook listening", "addr", c.Addr)
renew := time.NewTicker(certCheckEvery)
defer renew.Stop()
for {
select {
case <-renew.C:
if !NeedsRenewal(certs, time.Now()) {
continue
}
log.Info("certificate inside renew window; exiting so the supervisor restarts and reissues")
shutdown, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
_ = srv.Shutdown(shutdown)
return ErrCertRenewal
case <-ctx.Done():
shutdown, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
return srv.Shutdown(shutdown)
case err := <-errc:
return err
}
}
}
// caBundle is the system roots plus the gateway CA, so a client that trusts only
// this bundle still reaches public endpoints as well as the gateway.
func (c ServiceConfig) caBundle() ([]byte, error) {
roots, err := os.ReadFile(c.SystemRoots)
if err != nil {
return nil, fmt.Errorf("read system roots: %w", err)
}
var gw []byte
if c.GatewayCAFile != "" {
if gw, err = os.ReadFile(c.GatewayCAFile); err != nil {
return nil, fmt.Errorf("read gateway ca: %w", err)
}
}
b := BuildCABundle(roots, gw)
if len(b) == 0 {
return nil, errors.New("ca bundle is empty")
}
return b, nil
}
// stageManifests writes the RBAC and webhook configuration into the k3s
// auto-deploy directory. Every server renders the same bytes (the CA is shared),
// and the file is only replaced when its content changes, so k3s re-applies it
// only on a real change.
func (c ServiceConfig) stageManifests() error {
ca, err := os.ReadFile(c.TLSDir + "/server-ca.crt")
if err != nil {
return fmt.Errorf("read server ca: %w", err)
}
body, err := RenderManifests(WebhookConfig{Name: WebhookName, URL: "https://" + c.Addr + MutatePath, CA: ca})
if err != nil {
return err
}
path := c.ManifestDir + "/" + manifestFile
if cur, err := os.ReadFile(path); err == nil && bytes.Equal(cur, body) {
return nil
}
return writeAtomic(path, body, 0o600)
}
@@ -0,0 +1,305 @@
package podidentity
import (
"bytes"
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"crypto/x509"
"crypto/x509/pkix"
"encoding/base64"
"encoding/json"
"encoding/pem"
"math/big"
"net"
"os"
"path/filepath"
"strings"
"testing"
"time"
"github.com/mulgadc/spinifex/spinifex/agents/eks/konnectivitycert"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// writeCA writes a self-signed CA cert and key as <name>.crt and <name>.key.
func writeCA(t *testing.T, dir, name string) *x509.Certificate {
t.Helper()
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
require.NoError(t, err)
tmpl := x509.Certificate{
SerialNumber: big.NewInt(1), Subject: pkix.Name{CommonName: name},
NotBefore: time.Now().Add(-time.Hour), NotAfter: time.Now().AddDate(10, 0, 0),
IsCA: true, BasicConstraintsValid: true, KeyUsage: x509.KeyUsageCertSign,
}
der, err := x509.CreateCertificate(rand.Reader, &tmpl, &tmpl, &key.PublicKey, key)
require.NoError(t, err)
kd, err := x509.MarshalECPrivateKey(key)
require.NoError(t, err)
require.NoError(t, os.WriteFile(filepath.Join(dir, name+".crt"), pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der}), 0o644))
require.NoError(t, os.WriteFile(filepath.Join(dir, name+".key"), pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: kd}), 0o600))
ca, err := x509.ParseCertificate(der)
require.NoError(t, err)
return ca
}
func certTestConfig(t *testing.T) (CertConfig, string) {
t.Helper()
tls := t.TempDir()
writeCA(t, tls, "server-ca")
writeCA(t, tls, "client-ca")
return CertConfig{
Dir: filepath.Join(t.TempDir(), "state"),
ServerCACert: filepath.Join(tls, "server-ca.crt"), ServerCAKey: filepath.Join(tls, "server-ca.key"),
ClientCACert: filepath.Join(tls, "client-ca.crt"), ClientCAKey: filepath.Join(tls, "client-ca.key"),
}, tls
}
func parseCert(t *testing.T, path string) *x509.Certificate {
t.Helper()
b, err := os.ReadFile(path)
require.NoError(t, err)
blk, _ := pem.Decode(b)
require.NotNil(t, blk)
c, err := x509.ParseCertificate(blk.Bytes)
require.NoError(t, err)
return c
}
func TestEnsureCerts_ServingCertIsForLoopbackAndSignedByTheServerCA(t *testing.T) {
cfg, _ := certTestConfig(t)
out, err := EnsureCerts(cfg)
require.NoError(t, err)
serverCA := parseCert(t, cfg.ServerCACert)
serving := parseCert(t, out.ServingCert)
require.NoError(t, serving.CheckSignatureFrom(serverCA))
require.Len(t, serving.IPAddresses, 1)
assert.True(t, serving.IPAddresses[0].IsLoopback())
assert.Equal(t, []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}, serving.ExtKeyUsage)
roots := x509.NewCertPool()
roots.AddCert(serverCA)
_, err = serving.Verify(x509.VerifyOptions{Roots: roots, DNSName: "127.0.0.1"})
assert.NoError(t, err, "the caBundle (server CA) must validate the serving cert for 127.0.0.1")
}
func TestEnsureCerts_ClientCertIsSignedByTheClientCAWithTheWebhookIdentity(t *testing.T) {
cfg, _ := certTestConfig(t)
out, err := EnsureCerts(cfg)
require.NoError(t, err)
client := parseCert(t, out.ClientCert)
require.NoError(t, client.CheckSignatureFrom(parseCert(t, cfg.ClientCACert)))
assert.Equal(t, ClientCN, client.Subject.CommonName)
assert.Equal(t, []x509.ExtKeyUsage{x509.ExtKeyUsageClientAuth}, client.ExtKeyUsage)
}
func TestEnsureCerts_KeysAreOwnerOnly(t *testing.T) {
cfg, _ := certTestConfig(t)
out, err := EnsureCerts(cfg)
require.NoError(t, err)
for _, k := range []string{out.ServingKey, out.ClientKey} {
st, err := os.Stat(k)
require.NoError(t, err)
assert.Equal(t, os.FileMode(0o600), st.Mode().Perm(), k)
}
}
func TestEnsureCerts_ReusesValidCertsAndReissuesWhenTheCAChanges(t *testing.T) {
cfg, tls := certTestConfig(t)
first, err := EnsureCerts(cfg)
require.NoError(t, err)
before, err := os.ReadFile(first.ServingCert)
require.NoError(t, err)
_, err = EnsureCerts(cfg)
require.NoError(t, err)
same, err := os.ReadFile(first.ServingCert)
require.NoError(t, err)
assert.True(t, bytes.Equal(before, same), "a valid cert must be reused so restarts do not churn it")
writeCA(t, tls, "server-ca") // the cluster CA is replaced
_, err = EnsureCerts(cfg)
require.NoError(t, err)
after, err := os.ReadFile(first.ServingCert)
require.NoError(t, err)
assert.False(t, bytes.Equal(before, after), "a cert signed by a retired CA must be reissued")
require.NoError(t, parseCert(t, first.ServingCert).CheckSignatureFrom(parseCert(t, cfg.ServerCACert)))
}
func TestEnsureCerts_MissingCAFails(t *testing.T) {
cfg, _ := certTestConfig(t)
cfg.ServerCACert = filepath.Join(t.TempDir(), "absent.crt")
_, err := EnsureCerts(cfg)
assert.Error(t, err)
}
func TestRenderManifests_GrantsLeastPrivilegeAndEmbedsTheWebhook(t *testing.T) {
out, err := RenderManifests(WebhookConfig{Name: "n", URL: "https://127.0.0.1:8445/mutate", CA: []byte("ca")})
require.NoError(t, err)
docs := strings.Split(string(out), "---\n")
require.Len(t, docs, 3)
var role struct {
Rules []struct {
Resources []string `json:"resources"`
ResourceNames []string `json:"resourceNames"`
Verbs []string `json:"verbs"`
} `json:"rules"`
}
require.NoError(t, json.Unmarshal([]byte(docs[0]), &role))
got := map[string][]string{}
for _, r := range role.Rules {
for _, res := range r.Resources {
key := res
if len(r.ResourceNames) > 0 {
key += "/named"
}
got[key] = append(got[key], r.Verbs...)
}
assert.NotContains(t, r.Verbs, "*")
assert.NotContains(t, r.Verbs, "delete")
}
assert.Equal(t, map[string][]string{
"serviceaccounts": {"get"},
"namespaces": {"list", "watch"},
"configmaps": {"create"},
"configmaps/named": {"get", "update"},
}, got)
var binding struct {
Subjects []struct {
Name string `json:"name"`
} `json:"subjects"`
}
require.NoError(t, json.Unmarshal([]byte(docs[1]), &binding))
assert.Equal(t, ClientCN, binding.Subjects[0].Name)
assert.Contains(t, docs[2], `"failurePolicy": "Ignore"`)
}
func TestRenderManifests_IsDeterministic(t *testing.T) {
w := WebhookConfig{Name: "n", URL: "https://127.0.0.1:8445/mutate", CA: []byte("ca")}
a, err := RenderManifests(w)
require.NoError(t, err)
b, err := RenderManifests(w)
require.NoError(t, err)
assert.Equal(t, a, b)
}
func TestParseServiceConfig_DefaultsFromTheNodeEnvironment(t *testing.T) {
env := map[string]string{"EKS_REGION": "ap-southeast-2", "EKS_ADDON_GATEWAY_URL": "https://gw:9999", "EKS_GATEWAY_CA": "/etc/gw.pem"}
c, err := ParseServiceConfig(nil, func(k string) string { return env[k] }, &bytes.Buffer{})
require.NoError(t, err)
assert.Equal(t, "ap-southeast-2", c.Region)
assert.Equal(t, "https://gw:9999", c.STSEndpoint)
assert.Equal(t, "/etc/gw.pem", c.GatewayCAFile)
assert.Equal(t, DefaultAddr, c.Addr)
}
func TestParseServiceConfig_RejectsANonLoopbackListener(t *testing.T) {
vars := map[string]string{"EKS_REGION": "r", "EKS_ADDON_GATEWAY_URL": "https://gw"}
env := func(k string) string { return vars[k] }
for _, addr := range []string{"0.0.0.0:8445", ":8445", "10.0.0.5:8445", "somehost:8445"} {
_, err := ParseServiceConfig([]string{"--addr=" + addr}, env, &bytes.Buffer{})
assert.Error(t, err, addr)
}
_, err := ParseServiceConfig([]string{"--addr=127.0.0.1:9000"}, env, &bytes.Buffer{})
assert.NoError(t, err)
}
func TestParseServiceConfig_RequiresRegionAndSTSEndpoint(t *testing.T) {
_, err := ParseServiceConfig(nil, func(string) string { return "" }, &bytes.Buffer{})
assert.Error(t, err)
}
func TestStageManifests_WritesTheServerCAAndIsIdempotent(t *testing.T) {
cfg, tls := certTestConfig(t)
c := ServiceConfig{Addr: DefaultAddr, TLSDir: tls, ManifestDir: t.TempDir()}
require.NoError(t, c.stageManifests())
path := filepath.Join(c.ManifestDir, manifestFile)
first, err := os.ReadFile(path)
require.NoError(t, err)
ca, err := os.ReadFile(cfg.ServerCACert)
require.NoError(t, err)
assert.Contains(t, string(first), base64Of(ca), "caBundle must be the k3s server CA")
assert.Contains(t, string(first), "https://127.0.0.1:8445/mutate")
st1, err := os.Stat(path)
require.NoError(t, err)
require.NoError(t, c.stageManifests())
st2, err := os.Stat(path)
require.NoError(t, err)
assert.True(t, st1.ModTime().Equal(st2.ModTime()), "an unchanged manifest must not be rewritten")
}
func TestCABundle_IsSystemRootsPlusGatewayCA(t *testing.T) {
dir := t.TempDir()
writeCA(t, dir, "sys")
writeCA(t, dir, "gw")
c := ServiceConfig{SystemRoots: filepath.Join(dir, "sys.crt"), GatewayCAFile: filepath.Join(dir, "gw.crt")}
b, err := c.caBundle()
require.NoError(t, err)
pool := x509.NewCertPool()
require.True(t, pool.AppendCertsFromPEM(b))
assert.Len(t, pool.Subjects(), 2) //nolint:staticcheck // counting entries only
}
func base64Of(b []byte) string { return base64.StdEncoding.EncodeToString(b) }
func TestEnsureCerts_AreValidForOneYear(t *testing.T) {
cfg, _ := certTestConfig(t)
out, err := EnsureCerts(cfg)
require.NoError(t, err)
for _, p := range []string{out.ServingCert, out.ClientCert} {
c := parseCert(t, p)
assert.InDelta(t, 365*24, c.NotAfter.Sub(c.NotBefore).Hours()-1, 24, p)
assert.Less(t, time.Until(c.NotAfter), 366*24*time.Hour, p)
}
}
func TestNeedsRenewal(t *testing.T) {
cfg, _ := certTestConfig(t)
out, err := EnsureCerts(cfg)
require.NoError(t, err)
now := time.Now()
assert.False(t, NeedsRenewal(out, now), "fresh certs are outside the window")
assert.False(t, NeedsRenewal(out, now.Add(300*24*time.Hour)))
assert.True(t, NeedsRenewal(out, now.Add(340*24*time.Hour)), "inside 30 days of expiry")
assert.True(t, NeedsRenewal(out, now.Add(400*24*time.Hour)), "already expired")
require.NoError(t, os.Remove(out.ClientCert))
assert.True(t, NeedsRenewal(out, now), "an unreadable certificate needs renewal")
}
func TestEnsureCerts_ReissuesACertInsideTheRenewWindow(t *testing.T) {
cfg, _ := certTestConfig(t)
out, err := EnsureCerts(cfg)
require.NoError(t, err)
// Replace the serving cert with one that expires in 10 days.
ca, caKey, err := konnectivitycert.LoadCA(cfg.ServerCACert, cfg.ServerCAKey)
require.NoError(t, err)
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
require.NoError(t, err)
tmpl := x509.Certificate{
SerialNumber: big.NewInt(7), Subject: pkix.Name{CommonName: "pod-identity-webhook"},
NotBefore: time.Now().Add(-time.Hour), NotAfter: time.Now().Add(10 * 24 * time.Hour),
KeyUsage: x509.KeyUsageDigitalSignature, ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
IPAddresses: []net.IP{net.IPv4(127, 0, 0, 1)},
}
der, err := x509.CreateCertificate(rand.Reader, &tmpl, ca, &key.PublicKey, caKey)
require.NoError(t, err)
kd, err := x509.MarshalECPrivateKey(key)
require.NoError(t, err)
require.NoError(t, os.WriteFile(out.ServingKey, pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: kd}), 0o600))
require.NoError(t, os.WriteFile(out.ServingCert, pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der}), 0o644))
require.True(t, NeedsRenewal(out, time.Now()))
_, err = EnsureCerts(cfg)
require.NoError(t, err)
assert.False(t, NeedsRenewal(out, time.Now()), "the restart path must leave certs outside the window")
}
@@ -0,0 +1,80 @@
package podidentity
import (
"crypto/sha256"
"encoding/base64"
"encoding/json"
"encoding/pem"
"errors"
"fmt"
"net/url"
)
// WebhookName is the fully qualified name of the webhook entry.
const WebhookName = "pod-identity.spinifex.mulgadc.com"
// RenderWebhookConfiguration returns the MutatingWebhookConfiguration as JSON.
// The URL must be https on 127.0.0.1: each API server calls its own webhook, and
// failurePolicy Ignore keeps an unreachable injector from blocking pod creation.
func RenderWebhookConfiguration(c WebhookConfig) ([]byte, error) {
if c.Name == "" {
return nil, errors.New("webhook configuration needs a name")
}
u, err := url.Parse(c.URL)
if err != nil || u.Scheme != "https" || u.Hostname() != "127.0.0.1" {
return nil, fmt.Errorf("webhook url %q must be https on 127.0.0.1", c.URL)
}
if len(c.CA) == 0 {
return nil, errors.New("webhook configuration needs a CA bundle")
}
cfg := map[string]any{
"apiVersion": "admissionregistration.k8s.io/v1",
"kind": "MutatingWebhookConfiguration",
"metadata": map[string]any{"name": c.Name},
"webhooks": []map[string]any{{
"name": WebhookName,
"clientConfig": map[string]any{
"url": c.URL,
"caBundle": base64.StdEncoding.EncodeToString(c.CA),
},
"rules": []map[string]any{{
"operations": []string{"CREATE"},
"apiGroups": []string{""},
"apiVersions": []string{"v1"},
"resources": []string{"pods"},
"scope": "Namespaced",
}},
"failurePolicy": "Ignore",
"sideEffects": "None",
"timeoutSeconds": 5,
"admissionReviewVersions": []string{"v1"},
}},
}
return json.Marshal(cfg)
}
// BuildCABundle returns the system roots plus the gateway CA as one PEM bundle,
// without duplicate certificates.
func BuildCABundle(system, gateway []byte) []byte {
seen := map[[sha256.Size]byte]bool{}
var out []byte
for _, src := range [][]byte{system, gateway} {
for rest := src; ; {
var b *pem.Block
b, rest = pem.Decode(rest)
if b == nil {
break
}
if b.Type != "CERTIFICATE" {
continue
}
sum := sha256.Sum256(b.Bytes)
if seen[sum] {
continue
}
seen[sum] = true
out = append(out, pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: b.Bytes})...)
}
}
return out
}
@@ -125,40 +125,28 @@ func TestAccessEntryRecord_DecodesHandWrittenJSON(t *testing.T) {
assert.Equal(t, time.Date(2024, 1, 2, 0, 0, 0, 0, time.UTC), rec.ModifiedAt)
}
// Pins present behavior: acctKVForCluster only checks the cluster exists, not
// its lifecycle Status, so AccessEntry actions succeed on a DELETING or FAILED
// cluster exactly as on an ACTIVE one.
func TestAccessEntryActionsIgnoreClusterLifecycleStatus(t *testing.T) {
// AccessEntry actions do not gate on a FAILED cluster's status: an entry can
// still be created and read there. A DELETING cluster refuses new entries;
// TestS2_EKSDeletingClusterAdmitsNoNewDependants pins that.
func TestAccessEntryActionsIgnoreFailedClusterStatus(t *testing.T) {
t.Parallel()
cases := []struct {
cluster string
status ClusterStatus
}{
{"deleting-cluster", ClusterStatusDeleting},
{"failed-cluster", ClusterStatusFailed},
}
for _, tc := range cases {
t.Run(string(tc.status), func(t *testing.T) {
t.Parallel()
svc := setupTestService(t)
js := testutil.NewJetStream(t, svc.deps.NATSConn)
kv, err := GetOrCreateAccountBucket(t.Context(), js, testAccountID)
require.NoError(t, err)
require.NoError(t, PutClusterMeta(t.Context(), kv, &ClusterMeta{Name: tc.cluster, Status: tc.status}))
svc := setupTestService(t)
js := testutil.NewJetStream(t, svc.deps.NATSConn)
kv, err := GetOrCreateAccountBucket(t.Context(), js, testAccountID)
require.NoError(t, err)
require.NoError(t, PutClusterMeta(t.Context(), kv, &ClusterMeta{Name: "failed-cluster", Status: ClusterStatusFailed}))
out, err := svc.CreateAccessEntry(context.Background(), &eks.CreateAccessEntryInput{
ClusterName: aws.String(tc.cluster), PrincipalArn: aws.String(testPrincipalARN),
}, testAccountID)
require.NoError(t, err)
require.NotNil(t, out.AccessEntry)
out, err := svc.CreateAccessEntry(context.Background(), &eks.CreateAccessEntryInput{
ClusterName: aws.String("failed-cluster"), PrincipalArn: aws.String(testPrincipalARN),
}, testAccountID)
require.NoError(t, err)
require.NotNil(t, out.AccessEntry)
desc, err := svc.DescribeAccessEntry(context.Background(), &eks.DescribeAccessEntryInput{
ClusterName: aws.String(tc.cluster), PrincipalArn: aws.String(testPrincipalARN),
}, testAccountID)
require.NoError(t, err)
require.NotNil(t, desc.AccessEntry)
})
}
desc, err := svc.DescribeAccessEntry(context.Background(), &eks.DescribeAccessEntryInput{
ClusterName: aws.String("failed-cluster"), PrincipalArn: aws.String(testPrincipalARN),
}, testAccountID)
require.NoError(t, err)
require.NotNil(t, desc.AccessEntry)
}
// AssociateAccessPolicy on a principal with no AccessEntry today returns the
@@ -252,10 +240,10 @@ func TestAccessEntry_UpdateWithNoFieldsKeepsUsernameAndGroups(t *testing.T) {
assert.True(t, after.ModifiedAt.After(before.ModifiedAt))
}
// DeleteClusterPrefix_SweepsEveryKey (cluster_state_test.go) covers the
// DeleteClusterChildren_SweepsEveryKey (cluster_state_test.go) covers the
// nodegroup/OIDC/event keys; this covers the access-entries key specifically,
// scoped correctly so a sibling cluster's entry survives.
func TestDeleteClusterPrefix_SweepsAccessEntriesScopedToCluster(t *testing.T) {
func TestDeleteClusterChildren_SweepsAccessEntriesScopedToCluster(t *testing.T) {
t.Parallel()
kv := newClusterStateTestKV(t)
@@ -269,7 +257,7 @@ func TestDeleteClusterPrefix_SweepsAccessEntriesScopedToCluster(t *testing.T) {
})
require.NoError(t, err)
require.NoError(t, DeleteClusterPrefix(t.Context(), kv, "cluster-a"))
require.NoError(t, DeleteClusterChildren(t.Context(), kv, "cluster-a"))
_, err = access.Get(t.Context(), kv, "cluster-a", testPrincipalARN)
require.ErrorIs(t, err, access.ErrNotFound)
@@ -288,22 +288,30 @@ func TestDeleteAddon_ReturnsDeletingAfterErasingRecordAndManifest(t *testing.T)
assert.Equal(t, awserrors.ErrorEKSResourceNotFound, awserrors.ValidErrorCodeFromError(err))
}
// Add-on actions check only that the cluster meta exists, not its status.
func TestAddonActionsIgnoreClusterLifecycleStatus(t *testing.T) {
// CreateAddon gates on a recorded delete only: a FAILED cluster still takes a
// new add-on, while a DELETING one refuses it with ResourceInUseException.
func TestCreateAddonRefusesOnlyADeletingCluster(t *testing.T) {
t.Parallel()
svc, kv := setupStagingAddonService(t)
require.NoError(t, PutClusterMeta(t.Context(), kv, &ClusterMeta{Name: "c1", Status: ClusterStatusDeleting}))
require.NoError(t, PutClusterMeta(t.Context(), kv, &ClusterMeta{Name: "c1", Status: ClusterStatusFailed}))
require.NoError(t, PutClusterMeta(t.Context(), kv, &ClusterMeta{Name: "c2", Status: ClusterStatusDeleting}))
out, err := svc.CreateAddon(context.Background(), &eks.CreateAddonInput{
ClusterName: aws.String("c1"), AddonName: aws.String("argocd"),
}, testAccountID)
require.NoError(t, err)
assert.Equal(t, eks.AddonStatusCreating, aws.StringValue(out.Addon.Status))
_, err = svc.CreateAddon(context.Background(), &eks.CreateAddonInput{
ClusterName: aws.String("c2"), AddonName: aws.String("argocd"),
}, testAccountID)
require.Error(t, err)
assert.Equal(t, awserrors.ErrorEKSResourceInUse, awserrors.ValidErrorCodeFromError(err))
}
// Cluster delete erases add-ons only through the prefix purge, record and
// manifest alike, with no per-add-on delete; a sibling cluster's add-on stays.
func TestDeleteClusterPrefix_SweepsAddonRecordsAndManifestsScopedToCluster(t *testing.T) {
func TestDeleteClusterChildren_SweepsAddonRecordsAndManifestsScopedToCluster(t *testing.T) {
t.Parallel()
svc, kv := setupStagingAddonService(t)
seedTestCluster(t, svc, "c2")
@@ -314,7 +322,7 @@ func TestDeleteClusterPrefix_SweepsAddonRecordsAndManifestsScopedToCluster(t *te
require.NoError(t, err)
}
require.NoError(t, DeleteClusterPrefix(t.Context(), kv, "c1"))
require.NoError(t, DeleteClusterChildren(t.Context(), kv, "c1"))
for _, key := range []string{albRecordKey, albManifestKey} {
_, err := kv.Get(t.Context(), key)
+1 -1
View File
@@ -95,7 +95,7 @@ func (s *EKSServiceImpl) CreateAddon(ctx context.Context, input *eks.CreateAddon
} else if !spec.SupportsVersion(version) {
return nil, errors.New(awserrors.ErrorInvalidParameterValue)
}
acctKV, err := s.acctKVForCluster(ctx, accountID, cluster)
acctKV, err := s.acctKVForNewDependant(ctx, accountID, cluster)
if err != nil {
return nil, err
}
@@ -617,6 +617,9 @@ func (r *ClusterReconciler) reconcileOnce(ctx context.Context) (time.Duration, e
return r.createRevisit(meta), r.failIfCreateTimedOut(ctx, meta, "healthz not ready: "+issue)
}
if err := SetClusterStatus(ctx, r.acctKV, r.clusterName, ClusterStatusActive); err != nil {
if errors.Is(err, ErrClusterDeleting) {
return 0, ErrReconcilerClusterDeleting
}
return 0, err
}
if err := SetClusterHealthState(ctx, r.acctKV, r.clusterName, "", nodeCount, nodegroupReady); err != nil {
+335 -70
View File
@@ -139,30 +139,33 @@ type ClusterMeta struct {
NLBTargetGroupArn string `json:"nlbTargetGroupArn,omitempty"`
KonnTargetGroupArn string `json:"konnTargetGroupArn,omitempty"`
CreatedAt time.Time `json:"createdAt"`
// DeletingSince stamps when the cluster entered DELETING. The teardown
// backstop reaper waits out a healthy synchronous DeleteCluster (min-age)
// before re-driving purgeClusterInfra, so it only ever re-drives a wedged
// teardown, never one still in progress. No omitempty: encoding/json never
// treats a time.Time as empty.
// DeletingSince stamps when the cluster entered DELETING. No omitempty:
// encoding/json never treats a time.Time as empty.
DeletingSince time.Time `json:"deletingSince"`
// DeleteReapAttempts counts backstop-reaper re-drive attempts against this
// DELETING cluster since DeletingSince. Drives the reaper's exponential
// backoff and the terminal give-up after maxDeleteReapAttempts.
// DeleteReapAttempts counts teardown passes against this DELETING cluster
// since DeletingSince. Drives the reaper's capped exponential backoff.
DeleteReapAttempts int `json:"deleteReapAttempts,omitempty"`
// LastDeleteReapAttempt stamps the most recent backstop-reaper re-drive, so
// backoff grows from the last attempt rather than from DeletingSince.
// LastDeleteReapAttempt stamps the most recent teardown pass, so backoff
// grows from the last attempt rather than from DeletingSince.
// No omitempty: encoding/json never treats a time.Time as empty.
LastDeleteReapAttempt time.Time `json:"lastDeleteReapAttempt"`
// DeleteReapExhausted marks a DELETING cluster whose backstop reaper gave up
// after maxDeleteReapAttempts. Status stays DELETING — its infra remains
// tracked and billable pending operator intervention — only the automatic
// re-drive stops.
// DeleteReapExhausted marks a DELETING cluster whose teardown has failed
// maxDeleteReapAttempts times, for an operator. Retries continue regardless.
DeleteReapExhausted bool `json:"deleteReapExhausted,omitempty"`
// LastDeleteReapError is the error string from the most recent failed
// backstop-reaper re-drive, so an operator sees why teardown is stuck
// instead of just that it is. Set on every failed attempt, not only on
// exhaustion, and cleared once a re-drive succeeds.
// LastDeleteReapError is the error from the most recent failed teardown
// pass, so an operator sees why it is stuck instead of just that it is.
LastDeleteReapError string `json:"lastDeleteReapError,omitempty"`
// DeleteFailedStep names, in tenant-facing words, the teardown steps the
// last failed pass could not complete. DescribeCluster surfaces it; the raw
// error above carries system-account IDs and is never shown to the tenant.
DeleteFailedStep string `json:"deleteFailedStep,omitempty"`
// LaunchOwner names the node whose create launch is still building this
// record, and LaunchHeartbeat is when that launch last confirmed it was
// alive. The teardown leaves the record alone while the heartbeat is fresh,
// so an overtaken launch hands its refs over before the record goes.
LaunchOwner string `json:"launchOwner,omitempty"`
// No omitempty: encoding/json never treats a time.Time as empty.
LaunchHeartbeat time.Time `json:"launchHeartbeat"`
// HealthIssue is the last health failure reason ("" = healthy).
// DescribeCluster surfaces it as a ClusterHealth issue.
HealthIssue string `json:"healthIssue,omitempty"`
@@ -287,21 +290,259 @@ func casUpdateMeta(ctx context.Context, kv jetstream.KeyValue, name string, muta
return err
}
// SetClusterStatus does a CAS update of meta.Status. Returns ErrClusterNotFound if deleted concurrently.
// ErrClusterDeleting is returned by a status writer that would move a cluster
// off DELETING. A recorded delete is the tenant's decision and nothing but the
// teardown removing the record may end it.
var ErrClusterDeleting = errors.New("eks: cluster is deleting")
// SetClusterStatus does a CAS update of meta.Status. Returns ErrClusterNotFound
// if deleted concurrently, and ErrClusterDeleting rather than leave DELETING.
func SetClusterStatus(ctx context.Context, kv jetstream.KeyValue, name string, status ClusterStatus) error {
if name == "" {
return errors.New("eks: SetClusterStatus empty name")
}
return casUpdateMeta(ctx, kv, name, func(m *ClusterMeta) bool {
refused := false
err := casUpdateMeta(ctx, kv, name, func(m *ClusterMeta) bool {
refused = false
if m.Status == status {
return false
}
if m.Status == ClusterStatusDeleting {
refused = true
return false
}
m.Status = status
if status == ClusterStatusDeleting && m.DeletingSince.IsZero() {
m.DeletingSince = time.Now().UTC()
}
return true
})
if err == nil && refused {
return ErrClusterDeleting
}
return err
}
// errLaunchSuperseded is returned by persistLaunchMeta when the launch no
// longer owns the record it is building: a delete was recorded against it,
// the record is gone, or the name now belongs to a newer cluster.
var errLaunchSuperseded = errors.New("eks: cluster launch superseded")
// errLaunchNewGeneration is errLaunchSuperseded for a record that belongs to a
// different cluster of the same name, created after this launch's was removed.
var errLaunchNewGeneration = fmt.Errorf("%w: the name belongs to a newer cluster", errLaunchSuperseded)
// errLaunchMarkedFailed is returned when the launch's record was marked FAILED
// under it. The refs are merged in and the launch releases what it built.
var errLaunchMarkedFailed = errors.New("eks: cluster marked FAILED during launch")
// sameGeneration reports whether two records describe the same cluster, not
// merely the same name: CreatedAt and the ARN are stamped once, at the claim.
func sameGeneration(a, b *ClusterMeta) bool {
return a.CreatedAt.Equal(b.CreatedAt) && a.Arn == b.Arn
}
// persistLaunchMeta is the create path's meta write, fenced on the record's
// generation. While its own record is CREATING it writes meta whole. Once a
// delete is recorded it only adds the launch's resource refs, so the teardown
// reaches them, and returns errLaunchSuperseded; a FAILED record also takes
// the refs, with errLaunchMarkedFailed. It never writes a newer cluster's
// record and never recreates a removed one.
func persistLaunchMeta(ctx context.Context, kv jetstream.KeyValue, meta *ClusterMeta) error {
if meta == nil || meta.Name == "" {
return errors.New("eks: persistLaunchMeta missing meta")
}
var outcome error
err := casUpdateMeta(ctx, kv, meta.Name, func(m *ClusterMeta) bool {
outcome = nil
if !sameGeneration(m, meta) {
outcome = errLaunchNewGeneration
return false
}
switch m.Status {
case ClusterStatusCreating:
*m = *meta
m.Status = ClusterStatusCreating
m.LaunchHeartbeat = time.Now().UTC()
return true
case ClusterStatusDeleting:
// Handing the refs over acknowledges the delete: the launch stops.
outcome = errLaunchSuperseded
merged := mergeOwnershipRefs(m, meta)
return clearLaunchOwner(m) || merged
case ClusterStatusFailed:
outcome = errLaunchMarkedFailed
merged := mergeOwnershipRefs(m, meta)
return clearLaunchOwner(m) || merged
default:
return mergeOwnershipRefs(m, meta)
}
})
if errors.Is(err, ErrClusterNotFound) {
return fmt.Errorf("%w: %w", errLaunchSuperseded, ErrClusterNotFound)
}
if err != nil {
return err
}
return outcome
}
func clearLaunchOwner(m *ClusterMeta) bool {
if m.LaunchOwner == "" {
return false
}
m.LaunchOwner = ""
return true
}
// launchInFlight reports whether a create launch is still building the record:
// it has an owner whose heartbeat is younger than staleAfter. A launch that
// crashed stops heartbeating and stops counting once staleAfter passes.
func launchInFlight(m *ClusterMeta, staleAfter time.Duration) bool {
return m.LaunchOwner != "" && time.Since(m.LaunchHeartbeat) < staleAfter
}
// putIfGeneration writes key only while the cluster record under meta's name
// is meta's generation. The key's revision is read before the record is
// checked, so a newer cluster's write in between fails the update and the
// retry sees the newer record.
func putIfGeneration(ctx context.Context, kv jetstream.KeyValue, meta *ClusterMeta, key string, value []byte) error {
for range maxClusterStateCASRetries {
var rev uint64
entry, err := kv.Get(ctx, key)
switch {
case err == nil:
rev = entry.Revision()
case errors.Is(err, jetstream.ErrKeyNotFound):
default:
return fmt.Errorf("kv get %s: %w", key, err)
}
cur, err := GetClusterMeta(ctx, kv, meta.Name)
if err != nil {
if errors.Is(err, ErrClusterNotFound) {
return fmt.Errorf("%w: %w", errLaunchSuperseded, ErrClusterNotFound)
}
return err
}
if !sameGeneration(cur, meta) {
return errLaunchNewGeneration
}
if rev == 0 {
_, err = kv.Create(ctx, key, value)
} else {
_, err = kv.Update(ctx, key, value, rev)
}
if err == nil {
return nil
}
if !isRevisionConflict(err) {
return fmt.Errorf("kv write %s: %w", key, err)
}
}
return fmt.Errorf("eks: write %s kept conflicting", key)
}
// isRevisionConflict reports a revision-conditional write losing its race.
func isRevisionConflict(err error) bool {
if errors.Is(err, jetstream.ErrKeyExists) || errors.Is(err, jetstream.ErrKeyRevisionMismatch) {
return true
}
if apiErr, ok := errors.AsType[*jetstream.APIError](err); ok {
return apiErr.ErrorCode == jetstream.JSErrCodeStreamWrongLastSequence ||
apiErr.ErrorCode == jetstream.JSErrCodeStreamWrongLastSequenceConstant
}
return false
}
// clusterOwnershipRefs lists every resource ID the teardown reads from meta to
// find what it must remove. A ref present in a later read and absent from the
// one a teardown pass worked from means that pass did not remove it.
func clusterOwnershipRefs(m *ClusterMeta) map[string]struct{} {
refs := map[string]struct{}{}
add := func(kind, id string) {
if id != "" {
refs[kind+"="+id] = struct{}{}
}
}
if m == nil {
return refs
}
add("nlb", m.NLBArn)
add("tg", m.NLBTargetGroupArn)
add("konn-tg", m.KonnTargetGroupArn)
add("pe-eni", m.PrivateEndpointENIID)
add("instance", m.ControlPlaneInstanceID)
add("eni", m.ControlPlaneENIID)
for _, n := range m.ControlPlaneNodes {
add("instance", n.InstanceID)
add("eni", n.ENIID)
}
add("egress-eip", m.EgressEIPAllocationID)
add("spread", m.ControlPlaneSpreadGroup)
if m.ManagedCPVPC != nil {
add("cp-vpc", m.ManagedCPVPC.VpcId)
add("cp-igw", m.ManagedCPVPC.IGWId)
add("cp-natgw", m.ManagedCPVPC.NatGatewayId)
add("cp-nat-eip", m.ManagedCPVPC.NatEIPAllocationID)
}
if m.ResourcesVpcConfig != nil {
add("vpc", m.ResourcesVpcConfig.VpcId)
}
return refs
}
// refsAddedSince reports whether after records a ref that before did not.
func refsAddedSince(before, after *ClusterMeta) bool {
seen := clusterOwnershipRefs(before)
for ref := range clusterOwnershipRefs(after) {
if _, ok := seen[ref]; !ok {
return true
}
}
return false
}
// mergeOwnershipRefs copies src's resource refs into dst where dst lacks them,
// leaving dst's status and delete bookkeeping alone. Reports whether dst changed.
func mergeOwnershipRefs(dst, src *ClusterMeta) bool {
if !refsAddedSince(dst, src) {
return false
}
setIfEmpty := func(d *string, v string) {
if *d == "" {
*d = v
}
}
setIfEmpty(&dst.NLBArn, src.NLBArn)
setIfEmpty(&dst.NLBTargetGroupArn, src.NLBTargetGroupArn)
setIfEmpty(&dst.KonnTargetGroupArn, src.KonnTargetGroupArn)
setIfEmpty(&dst.PrivateEndpointENIID, src.PrivateEndpointENIID)
setIfEmpty(&dst.PrivateEndpointIP, src.PrivateEndpointIP)
setIfEmpty(&dst.ControlPlaneInstanceID, src.ControlPlaneInstanceID)
setIfEmpty(&dst.ControlPlaneENIID, src.ControlPlaneENIID)
setIfEmpty(&dst.ControlPlaneENIIP, src.ControlPlaneENIIP)
setIfEmpty(&dst.ControlPlaneMgmtIP, src.ControlPlaneMgmtIP)
setIfEmpty(&dst.EgressEIPAllocationID, src.EgressEIPAllocationID)
setIfEmpty(&dst.EgressEIPPublicIP, src.EgressEIPPublicIP)
setIfEmpty(&dst.ControlPlaneSpreadGroup, src.ControlPlaneSpreadGroup)
known := map[string]struct{}{}
for _, n := range dst.ControlPlaneNodes {
known[n.InstanceID] = struct{}{}
}
for _, n := range src.ControlPlaneNodes {
if _, ok := known[n.InstanceID]; !ok {
dst.ControlPlaneNodes = append(dst.ControlPlaneNodes, n)
}
}
if dst.ManagedCPVPC == nil && src.ManagedCPVPC != nil {
cp := *src.ManagedCPVPC
dst.ManagedCPVPC = &cp
}
if dst.ResourcesVpcConfig == nil && src.ResourcesVpcConfig != nil {
vc := *src.ResourcesVpcConfig
dst.ResourcesVpcConfig = &vc
}
return true
}
// MarkClusterFailed transitions the cluster to FAILED with a reason, but only
@@ -320,21 +561,55 @@ func MarkClusterFailed(ctx context.Context, kv jetstream.KeyValue, name, reason
})
}
// maxDeleteReapAttempts caps automatic backstop re-drives of a wedged DELETING
// cluster. A teardown that still fails after this many attempts is failing for
// a reason retries will not fix (e.g. an unretriable DependencyViolation), so
// further re-drives would only keep hammering AWS/OVN forever.
// maxDeleteReapAttempts is how many failed teardown passes a DELETING cluster
// takes before it is flagged DeleteReapExhausted. The flag is a marker for an
// operator; the reaper keeps retrying at its capped interval regardless.
const maxDeleteReapAttempts = 6
// RecordDeleteReapAttempt increments the backstop reaper's attempt counter and
// stamps LastDeleteReapAttempt, returning the updated count. Called immediately
// before each re-drive so a crash mid-purge still counts the attempt on restart.
// MarkClusterDeleting records the tenant's delete: status DELETING, stamped,
// with the teardown bookkeeping reset. It returns the record as written and
// whether this call made the transition; a record already DELETING is
// returned unchanged so concurrent deletes record one obligation.
func MarkClusterDeleting(ctx context.Context, kv jetstream.KeyValue, name string) (*ClusterMeta, bool, error) {
if name == "" {
return nil, false, errors.New("eks: MarkClusterDeleting empty name")
}
var out ClusterMeta
transitioned := false
err := casUpdateMeta(ctx, kv, name, func(m *ClusterMeta) bool {
transitioned = false
if m.Status != ClusterStatusDeleting {
m.Status = ClusterStatusDeleting
m.DeletingSince = time.Now().UTC()
m.DeleteReapAttempts = 0
m.LastDeleteReapAttempt = time.Time{}
m.DeleteReapExhausted = false
m.LastDeleteReapError = ""
m.DeleteFailedStep = ""
transitioned = true
}
out = *m
return transitioned
})
if err != nil {
return nil, false, err
}
return &out, transitioned, nil
}
// RecordDeleteReapAttempt increments the teardown attempt counter and stamps
// LastDeleteReapAttempt on a DELETING record, returning the updated count.
// Called before each pass so a crash mid-teardown still counts the attempt.
func RecordDeleteReapAttempt(ctx context.Context, kv jetstream.KeyValue, name string) (int, error) {
if name == "" {
return 0, errors.New("eks: RecordDeleteReapAttempt empty name")
}
var attempts int
err := casUpdateMeta(ctx, kv, name, func(m *ClusterMeta) bool {
attempts = m.DeleteReapAttempts
if m.Status != ClusterStatusDeleting {
return false
}
m.DeleteReapAttempts++
m.LastDeleteReapAttempt = time.Now().UTC()
attempts = m.DeleteReapAttempts
@@ -343,35 +618,51 @@ func RecordDeleteReapAttempt(ctx context.Context, kv jetstream.KeyValue, name st
return attempts, err
}
// RecordDeleteReapFailure persists the error from a failed backstop re-drive
// onto ClusterMeta, so an operator inspecting a wedged DELETING cluster sees
// why teardown keeps failing instead of just that it is. Called on every
// failed attempt, not only on eventual exhaustion.
func RecordDeleteReapFailure(ctx context.Context, kv jetstream.KeyValue, name string, purgeErr error) error {
// RecordDeleteReapFailure persists a failed teardown pass: the error for an
// operator, the tenant-facing names of the failed steps for DescribeCluster,
// and DeleteReapExhausted once the attempt count reaches maxDeleteReapAttempts.
func RecordDeleteReapFailure(ctx context.Context, kv jetstream.KeyValue, name string, steps []string, purgeErr error) error {
if name == "" {
return errors.New("eks: RecordDeleteReapFailure empty name")
}
return casUpdateMeta(ctx, kv, name, func(m *ClusterMeta) bool {
if m.Status != ClusterStatusDeleting {
return false
}
m.LastDeleteReapError = purgeErr.Error()
m.DeleteFailedStep = strings.Join(steps, "; ")
if m.DeleteReapAttempts >= maxDeleteReapAttempts {
m.DeleteReapExhausted = true
}
return true
})
}
// MarkDeleteReapExhausted flags a DELETING cluster whose backstop reaper has
// given up after maxDeleteReapAttempts. Status stays DELETING — a cluster with
// un-torn-down infra must stay DELETING so its resources remain tracked and
// billable — only the automatic re-drive stops; an operator must intervene.
func MarkDeleteReapExhausted(ctx context.Context, kv jetstream.KeyValue, name string) error {
// DeleteClusterChildren removes every KV key under clusters/{name}/ except
// the meta. It stops at the first failure, so a record whose children could not
// all be removed is still there to own them.
func DeleteClusterChildren(ctx context.Context, kv jetstream.KeyValue, name string) error {
if name == "" {
return errors.New("eks: MarkDeleteReapExhausted empty name")
return errors.New("eks: DeleteClusterChildren empty name")
}
return casUpdateMeta(ctx, kv, name, func(m *ClusterMeta) bool {
if m.Status != ClusterStatusDeleting || m.DeleteReapExhausted {
return false
prefix := fmt.Sprintf("clusters/%s/", name)
keys, err := kv.Keys(ctx)
if err != nil {
if errors.Is(err, jetstream.ErrNoKeysFound) {
return nil
}
m.DeleteReapExhausted = true
return true
})
return fmt.Errorf("kv keys: %w", err)
}
meta := ClusterMetaKey(name)
for _, k := range keys {
if !strings.HasPrefix(k, prefix) || k == meta {
continue
}
if err := kv.Delete(ctx, k); err != nil {
return fmt.Errorf("kv delete %s: %w", k, err)
}
}
return nil
}
// SetClusterCertificateAuthority does a CAS update of meta.CertificateAuthorityB64.
@@ -487,29 +778,3 @@ func SwapControlPlaneMember(ctx context.Context, kv jetstream.KeyValue, name, de
return true
})
}
// DeleteClusterPrefix removes every KV key under clusters/{name}/.
// Returns the first error but continues sweeping.
func DeleteClusterPrefix(ctx context.Context, kv jetstream.KeyValue, name string) error {
if name == "" {
return errors.New("eks: DeleteClusterPrefix empty name")
}
prefix := fmt.Sprintf("clusters/%s/", name)
keys, err := kv.Keys(ctx)
if err != nil {
if errors.Is(err, jetstream.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(ctx, k); err != nil && firstErr == nil {
firstErr = fmt.Errorf("kv delete %s: %w", k, err)
}
}
return firstErr
}
@@ -225,7 +225,7 @@ func TestSetClusterCertificateAuthority_EmptyInputsRejected(t *testing.T) {
require.Error(t, SetClusterCertificateAuthority(t.Context(), kv, "alpha", ""))
}
func TestDeleteClusterPrefix_SweepsEveryKey(t *testing.T) {
func TestDeleteClusterChildren_SweepsEveryKey(t *testing.T) {
t.Parallel()
kv := newClusterStateTestKV(t)
@@ -241,10 +241,11 @@ func TestDeleteClusterPrefix_SweepsEveryKey(t *testing.T) {
// Sibling cluster must survive the sweep.
require.NoError(t, PutClusterMeta(t.Context(), kv, sampleClusterMeta("beta")))
require.NoError(t, DeleteClusterPrefix(t.Context(), kv, "alpha"))
require.NoError(t, DeleteClusterChildren(t.Context(), kv, "alpha"))
_, err = GetClusterMeta(t.Context(), kv, "alpha")
require.NoError(t, err, "the meta is not a child: the teardown removes it last, on its own")
for _, k := range []string{
ClusterMetaKey("alpha"),
NodegroupKey("alpha", "ng-1"),
OIDCSigningKeyKey("alpha"),
OIDCJWKSKey("alpha"),
@@ -259,16 +260,16 @@ func TestDeleteClusterPrefix_SweepsEveryKey(t *testing.T) {
assert.Equal(t, "beta", got.Name)
}
func TestDeleteClusterPrefix_EmptyBucketIsNoop(t *testing.T) {
func TestDeleteClusterChildren_EmptyBucketIsNoop(t *testing.T) {
t.Parallel()
kv := newClusterStateTestKV(t)
require.NoError(t, DeleteClusterPrefix(t.Context(), kv, "ghost"))
require.NoError(t, DeleteClusterChildren(t.Context(), kv, "ghost"))
}
func TestDeleteClusterPrefix_EmptyNameRejected(t *testing.T) {
func TestDeleteClusterChildren_EmptyNameRejected(t *testing.T) {
t.Parallel()
kv := newClusterStateTestKV(t)
require.Error(t, DeleteClusterPrefix(t.Context(), kv, ""))
require.Error(t, DeleteClusterChildren(t.Context(), kv, ""))
}
@@ -0,0 +1,342 @@
package cluster
import (
"context"
"errors"
"fmt"
"log/slog"
"time"
"github.com/mulgadc/spinifex/spinifex/foundation/state/kvlease"
"github.com/mulgadc/spinifex/spinifex/foundation/state/kvutil"
telemetry "github.com/mulgadc/spinifex/spinifex/foundation/telemetry"
"github.com/nats-io/nats.go/jetstream"
)
// teardownConcurrency bounds how many cluster teardowns one node runs at once.
// Request kicks and reaper hand-offs share it; one refused is left to the
// reaper's next sweep rather than queued. Var for tests.
var teardownConcurrency = 4
// maxTeardownPasses bounds how often one converger run re-passes because a
// writer added refs to the record while it was tearing down.
const maxTeardownPasses = 5
// Teardown lease timing. TTL must match the leader bucket's TTL; vars for tests.
var (
teardownLeaseTTL = KVBucketEKSLeaderTTL
teardownLeaseRenew = defaultReconcileLeaseRefresh
)
var (
errTeardownNotRecorded = errors.New("eks: cluster is not DELETING, teardown refused")
errTeardownLeaseLost = errors.New("eks: teardown lease lost")
errNodegroupsAttached = errors.New("eks: cluster still has node groups")
errLaunchInFlight = errors.New("eks: cluster create launch still running")
)
// teardownLeaderKey is the per-cluster cross-node gate for a DELETING
// teardown. It is distinct from reconcilerLeaderKey so a delete never contends
// with the still-live reconciler, which holds that lease until it reads DELETING.
func teardownLeaderKey(accountID, clusterName string) string {
return reconcilerLeaderKey(accountID, clusterName) + "/teardown"
}
// teardownLease is a held teardown lease: lost closes if renewal loses the key.
type teardownLease struct {
release func()
lost <-chan struct{}
}
// acquireTeardownLease takes the per-cluster teardown lease, renewing it on
// ctx. The lease re-adopts a key its own holder already has, so it serialises
// nodes, not goroutines; startTeardown's in-process gate covers the latter. A
// nil leaderKV (single-node/test) skips gating.
func (s *EKSServiceImpl) acquireTeardownLease(ctx context.Context, accountID, clusterName string) (teardownLease, bool) {
if s.leaderKV == nil {
return teardownLease{release: func() {}}, true
}
key := teardownLeaderKey(accountID, clusterName)
lease, err := kvlease.New(kvlease.Config{
Name: "eks/teardown",
Bucket: kvlease.StaticBucket(s.leaderKV),
Key: key,
Holder: s.deps.HolderID,
Attrs: []any{"account", accountID, "cluster", clusterName},
TTL: teardownLeaseTTL,
Renew: teardownLeaseRenew,
})
if err != nil {
slog.ErrorContext(ctx, "eks: teardown lease config invalid", "key", key, "err", err)
return teardownLease{}, false
}
if !lease.TryAcquire(ctx) {
return teardownLease{}, false
}
return teardownLease{release: func() { lease.Release(ctx) }, lost: lease.Lost()}, true
}
// teardownPassTimeout bounds one converger run, so a step that hangs cannot
// hold the gate and the lease for ever; the reaper retries it. Var for tests.
var teardownPassTimeout = 15 * time.Minute
// holdTeardown claims the right to tear a cluster down on this node: the
// in-process gate, which the lease cannot provide because it re-adopts a key
// its own node holds, then the cross-node lease. It refuses, without waiting,
// when either is held, or, if capped, when the node is at teardownConcurrency.
func (s *EKSServiceImpl) holdTeardown(accountID, name string, capped bool) (release func(), lost <-chan struct{}, ok bool) {
key := teardownLeaderKey(accountID, name)
s.teardownMu.Lock()
if s.teardownRunning == nil {
s.teardownRunning = map[string]struct{}{}
}
_, busy := s.teardownRunning[key]
if busy || (capped && len(s.teardownRunning) >= teardownConcurrency) {
s.teardownMu.Unlock()
return nil, nil, false
}
s.teardownRunning[key] = struct{}{}
s.teardownMu.Unlock()
leave := func() {
s.teardownMu.Lock()
delete(s.teardownRunning, key)
s.teardownMu.Unlock()
}
// The lease renews on the service's context so it outlives any request and
// stops with the daemon.
lease, held := s.acquireTeardownLease(s.bgCtx, accountID, name)
if !held {
leave()
return nil, nil, false
}
return func() { lease.release(); leave() }, lease.lost, true
}
// startTeardown hands one DELETING cluster to a background converger and
// reports whether it did. When holdTeardown refuses, the record stays DELETING
// for the reaper.
func (s *EKSServiceImpl) startTeardown(accountID, name string) bool {
release, lost, ok := s.holdTeardown(accountID, name, true)
if !ok {
return false
}
s.teardownWG.Go(func() {
defer release()
ctx, cancel := context.WithTimeout(s.bgCtx, teardownPassTimeout)
defer cancel()
started := time.Now()
if err := s.convergeClusterDeletion(ctx, accountID, name, lost); err != nil {
slog.Warn("eks: cluster teardown incomplete, the record stays DELETING for retry",
"cluster", name, "account", accountID, "err", err)
return
}
slog.Info("eks: cluster teardown complete", "cluster", name, "account", accountID,
"duration_ms", telemetry.Millis(time.Since(started)))
})
return true
}
// WaitTeardowns blocks until every teardown this process started has returned.
// Intended for tests.
func (s *EKSServiceImpl) WaitTeardowns() { s.teardownWG.Wait() }
// convergeClusterDeletion finishes a recorded delete: it removes every
// dependant the record names and then the record itself, last. It acts only
// on a record that is DELETING when re-read under the lease, keeps the record
// with its refs on any failure, and stops at a step boundary if lost closes.
func (s *EKSServiceImpl) convergeClusterDeletion(ctx context.Context, accountID, name string, lost <-chan struct{}) error {
acctKV, err := s.teardownBucket(ctx, accountID)
if err != nil {
return err
}
checkpoint := func() error {
select {
case <-lost:
return errTeardownLeaseLost
case <-ctx.Done():
return ctx.Err()
default:
return nil
}
}
for range maxTeardownPasses {
if err := checkpoint(); err != nil {
return err
}
// Waiting on node groups or on a running launch is not a failed pass, so
// both are checked before the attempt is counted.
pre, err := GetClusterMeta(ctx, acctKV, name)
if err != nil {
if errors.Is(err, ErrClusterNotFound) {
return nil
}
return err
}
if pre.Status != ClusterStatusDeleting {
return errTeardownNotRecorded
}
if err := s.refuseWhileNodegroups(ctx, acctKV, name); err != nil {
return err
}
if err := s.waitForLaunch(ctx, acctKV, pre); err != nil {
return err
}
if _, err := RecordDeleteReapAttempt(ctx, acctKV, name); err != nil {
if errors.Is(err, ErrClusterNotFound) {
return nil
}
return fmt.Errorf("record teardown attempt: %w", err)
}
meta, err := GetClusterMeta(ctx, acctKV, name)
if err != nil {
if errors.Is(err, ErrClusterNotFound) {
return nil
}
return err
}
if meta.Status != ClusterStatusDeleting {
return errTeardownNotRecorded
}
steps, terr := s.teardownClusterInfra(ctx, accountID, name, meta, acctKV, checkpoint)
if terr != nil {
if errors.Is(terr, errTeardownLeaseLost) || errors.Is(ctx.Err(), context.Canceled) {
return terr
}
// A pass that ran out of time is a failed pass: record it so the
// reaper backs off, on a context the deadline has not ended.
s.recordTeardownFailure(context.WithoutCancel(ctx), acctKV, name, steps, terr)
return terr
}
done, err := s.removeClusterRecord(ctx, acctKV, name, meta)
if err != nil {
if !errors.Is(err, errNodegroupsAttached) && !errors.Is(err, errTeardownNotRecorded) {
s.recordTeardownFailure(context.WithoutCancel(ctx), acctKV, name, []string{stepRecord}, err)
}
return err
}
if done {
return nil
}
slog.Info("eks: cluster record gained refs during teardown, running another pass", "cluster", name)
}
return fmt.Errorf("eks: cluster %s record kept changing across %d teardown passes", name, maxTeardownPasses)
}
// waitForLaunch keeps the record while a create launch for it is still running:
// removing it would let the launch find it gone and recreate it, so a caller
// would read the cluster as missing and then DELETING again. The launch hands
// its refs over at its next write and starts the teardown when it returns.
func (s *EKSServiceImpl) waitForLaunch(ctx context.Context, acctKV jetstream.KeyValue, meta *ClusterMeta) error {
if !launchInFlight(meta, s.launchStaleAfter) {
return nil
}
if meta.DeleteFailedStep != stepLaunch {
s.recordTeardownFailure(ctx, acctKV, meta.Name, []string{stepLaunch}, errLaunchInFlight)
}
return errLaunchInFlight
}
// refuseWhileNodegroups keeps the record when a node group is still recorded
// under it. Removing the record would orphan the group's running workers, and
// DeleteNodegroup still works on a DELETING cluster to clear them.
func (s *EKSServiceImpl) refuseWhileNodegroups(ctx context.Context, acctKV jetstream.KeyValue, name string) error {
groups, err := ListNodegroupRecords(ctx, acctKV, name)
if err != nil {
return fmt.Errorf("list node groups: %w", err)
}
if len(groups) == 0 {
return nil
}
if meta, err := GetClusterMeta(ctx, acctKV, name); err == nil && meta.DeleteFailedStep != stepNodegroups {
s.recordTeardownFailure(ctx, acctKV, name, []string{stepNodegroups}, errNodegroupsAttached)
}
return errNodegroupsAttached
}
// teardownBucket opens the account bucket the converger works in;
// s.teardownKV replaces it in tests that inject KV faults.
func (s *EKSServiceImpl) teardownBucket(ctx context.Context, accountID string) (jetstream.KeyValue, error) {
if s.teardownKV != nil {
return s.teardownKV(ctx, accountID)
}
js, err := jetstream.New(s.deps.NATSConn)
if err != nil {
return nil, fmt.Errorf("jetstream: %w", err)
}
kv, err := GetOrCreateAccountBucket(ctx, js, accountID)
if err != nil {
return nil, fmt.Errorf("get account bucket: %w", err)
}
return kv, nil
}
// removeClusterRecord deletes the cluster's KV state once a pass has torn down
// everything tornDown recorded. It reports false, with nothing deleted from the
// meta, when the record now names a ref tornDown did not, so the caller runs
// another pass; the meta is deleted last and only at the revision it checked.
func (s *EKSServiceImpl) removeClusterRecord(ctx context.Context, acctKV jetstream.KeyValue, name string, tornDown *ClusterMeta) (bool, error) {
cur, err := GetClusterMeta(ctx, acctKV, name)
if err != nil {
if errors.Is(err, ErrClusterNotFound) {
return true, nil
}
return false, err
}
if cur.Status != ClusterStatusDeleting {
return false, errTeardownNotRecorded
}
if refsAddedSince(tornDown, cur) {
return false, nil
}
if err := s.refuseWhileNodegroups(ctx, acctKV, name); err != nil {
return false, err
}
if err := DeleteClusterChildren(ctx, acctKV, name); err != nil {
return false, err
}
errRefsAdded := errors.New("refs added")
_, err = kvutil.DeleteIf(ctx, acctKV, ClusterMetaKey(name), kvutil.CASConfig{
Attempts: maxClusterStateCASRetries,
NotFound: ErrClusterNotFound,
}, func(m *ClusterMeta) error {
if m.Status != ClusterStatusDeleting {
return errTeardownNotRecorded
}
if refsAddedSince(tornDown, m) || launchInFlight(m, s.launchStaleAfter) {
return errRefsAdded
}
return nil
})
switch {
case err == nil, errors.Is(err, ErrClusterNotFound):
s.sweepLateChildren(ctx, acctKV, name)
return true, nil
case errors.Is(err, errRefsAdded):
return false, nil
default:
return false, err
}
}
// sweepLateChildren removes child keys written between the children sweep and
// the meta delete, by a launch or bootstrap still running for the cluster. A
// writer that lands after this finds the record gone and removes its own.
func (s *EKSServiceImpl) sweepLateChildren(ctx context.Context, acctKV jetstream.KeyValue, name string) {
if _, err := GetClusterMeta(ctx, acctKV, name); !errors.Is(err, ErrClusterNotFound) {
return
}
if err := DeleteClusterChildren(ctx, acctKV, name); err != nil {
slog.WarnContext(ctx, "eks: remove late child records", "cluster", name, "err", err)
}
}
func (s *EKSServiceImpl) recordTeardownFailure(ctx context.Context, acctKV jetstream.KeyValue, name string, steps []string, terr error) {
if err := RecordDeleteReapFailure(ctx, acctKV, name, steps, terr); err != nil && !errors.Is(err, ErrClusterNotFound) {
slog.WarnContext(ctx, "eks: record teardown failure", "cluster", name, "err", err)
}
}
@@ -65,8 +65,7 @@ func TestCreateCluster_FailedCreateThenDeleteReclaimsNLB(t *testing.T) {
// The NLB the partial create provisioned exists in the fake.
require.NotEmpty(t, f.nlb.createLBCalls, "create provisioned an NLB")
_, err = f.svc.DeleteCluster(context.Background(), deleteInput("alpha"), testAccountID)
require.NoError(t, err)
deleteAndSettle(t, f.svc, "alpha")
assert.NotEmpty(t, f.nlb.deleteLBCalls, "DeleteCluster must tear down the NLB recorded by the failed create")
_, getErr := GetClusterMeta(t.Context(), f.kv, "alpha")
@@ -109,8 +108,7 @@ func TestCreateCluster_PostPlacementFailedThenDeleteTerminatesControlPlane(t *te
f.svc.WaitLaunches()
require.NotEmpty(t, f.inst.launchCalls, "create launched a CP VM")
_, err = f.svc.DeleteCluster(context.Background(), deleteInput("alpha"), testAccountID)
require.NoError(t, err)
deleteAndSettle(t, f.svc, "alpha")
assert.NotEmpty(t, f.inst.terminateCalls, "DeleteCluster must terminate the CP VM recorded by the failed create")
_, getErr := GetClusterMeta(t.Context(), f.kv, "alpha")
@@ -0,0 +1,524 @@
package cluster
import (
"context"
"errors"
"strings"
"sync"
"testing"
"time"
"github.com/aws/aws-sdk-go/aws"
"github.com/aws/aws-sdk-go/service/eks"
"github.com/mulgadc/spinifex/internal/testkit"
awserrors "github.com/mulgadc/spinifex/spinifex/foundation/aws/errors"
"github.com/nats-io/nats.go/jetstream"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// blockTerminate holds the first control-plane terminate until release is
// called, and reports on entered once the teardown has reached it.
func blockTerminate(t *testing.T, inst *fakeK3sInst) (entered <-chan string, release func()) {
t.Helper()
in := make(chan string, 1)
gate := make(chan struct{})
var once, releaseOnce sync.Once
inst.terminateHook = func(id string) {
first := false
once.Do(func() { first = true })
if !first {
return
}
in <- id
<-gate
}
release = func() { releaseOnce.Do(func() { close(gate) }) }
t.Cleanup(release)
return in, release
}
// seedHAControlPlane records two control-plane members on the fixture cluster.
func seedHAControlPlane(t *testing.T, f *deleteClusterFixture, name string) {
t.Helper()
meta, err := GetClusterMeta(t.Context(), f.kv, name)
require.NoError(t, err)
meta.ControlPlaneNodes = []ControlPlaneNode{
{InstanceID: "i-aaa111", ENIID: "eni-aaa111", ENIIP: "10.0.1.42"},
{InstanceID: "i-bbb222", ENIID: "eni-bbb222", ENIIP: "10.0.1.43"},
}
require.NoError(t, PutClusterMeta(t.Context(), f.kv, meta))
}
func waitEntered(t *testing.T, entered <-chan string) string {
t.Helper()
select {
case id := <-entered:
return id
case <-time.After(10 * time.Second):
t.Fatal("the teardown never reached the control-plane terminate")
return ""
}
}
func countOf(ids []string, id string) int {
n := 0
for _, v := range ids {
if v == id {
n++
}
}
return n
}
// TestS8_EKSDeleteClusterReturnsBeforeTheTeardownCompletes pins ADR-0003:S8 for
// EKS DeleteCluster: with the control-plane terminate held, the request still
// returns DELETING, and the record carries the obligation, refs intact, until
// the teardown finishes and removes it.
func TestS8_EKSDeleteClusterReturnsBeforeTheTeardownCompletes(t *testing.T) {
t.Parallel()
f := newDeleteClusterFixture(t, "alpha")
entered, release := blockTerminate(t, f.inst)
type result struct {
out *eks.DeleteClusterOutput
err error
}
done := make(chan result, 1)
go func() {
out, err := f.svc.DeleteCluster(context.Background(), deleteInput("alpha"), testAccountID)
done <- result{out, err}
}()
var res result
select {
case res = <-done:
case <-time.After(5 * time.Second):
release()
t.Fatal("ADR-0003:S8: DeleteCluster must return without waiting for the control-plane teardown")
}
require.NoError(t, res.err)
assert.Equal(t, string(ClusterStatusDeleting), aws.StringValue(res.out.Cluster.Status))
waitEntered(t, entered)
meta, err := GetClusterMeta(t.Context(), f.kv, "alpha")
require.NoError(t, err, "ADR-0003:S8: the record carries the obligation while the teardown runs")
assert.Equal(t, ClusterStatusDeleting, meta.Status)
assert.False(t, meta.DeletingSince.IsZero())
assert.Equal(t, "i-aaa111", meta.ControlPlaneInstanceID, "the refs the teardown still needs stay recorded")
release()
f.svc.WaitTeardowns()
_, err = GetClusterMeta(t.Context(), f.kv, "alpha")
assert.ErrorIs(t, err, ErrClusterNotFound, "the teardown removes the record once it completes")
}
// TestS8_EKSSecondDeleteAnswersFromTheRecordWithoutASecondTeardown pins the S8
// rule that a second request for a recorded obligation answers from the record
// and starts no second convergence, even after the first teardown failed.
func TestS8_EKSSecondDeleteAnswersFromTheRecordWithoutASecondTeardown(t *testing.T) {
t.Parallel()
f := newDeleteClusterFixture(t, "alpha")
f.inst.terminateErr = errors.New("hypervisor unreachable")
_, _ = f.svc.DeleteCluster(context.Background(), deleteInput("alpha"), testAccountID)
f.svc.WaitTeardowns()
require.Len(t, f.inst.terminated(), 1)
out, err := f.svc.DeleteCluster(context.Background(), deleteInput("alpha"), testAccountID)
require.NoError(t, err, "a repeat delete of a DELETING cluster answers from the record")
assert.Equal(t, string(ClusterStatusDeleting), aws.StringValue(out.Cluster.Status))
f.svc.WaitTeardowns()
assert.Len(t, f.inst.terminated(), 1, "the repeat request must start no teardown of its own")
}
// TestS8_EKSTeardownLeaseOutlivesTheRequest: the teardown lease renews on the
// service's context, so cancelling the request context mid-teardown does not
// let the lease expire and a second node start a duplicate teardown.
func TestS8_EKSTeardownLeaseOutlivesTheRequest(t *testing.T) {
f := newDeleteClusterFixture(t, "alpha")
_, nc, _ := testutil.StartTestJetStream(t)
js := testutil.NewJetStream(t, nc)
short, err := js.CreateKeyValue(t.Context(), jetstream.KeyValueConfig{Bucket: "eks-teardown-short-ttl", TTL: time.Second})
require.NoError(t, err)
f.svc.leaderKV = short
origTTL, origRenew := teardownLeaseTTL, teardownLeaseRenew
teardownLeaseTTL, teardownLeaseRenew = time.Second, 200*time.Millisecond
t.Cleanup(func() { teardownLeaseTTL, teardownLeaseRenew = origTTL, origRenew })
entered, release := blockTerminate(t, f.inst)
ctx, cancel := context.WithCancel(context.Background())
go func() { _, _ = f.svc.DeleteCluster(ctx, deleteInput("alpha"), testAccountID) }()
waitEntered(t, entered)
cancel()
time.Sleep(2500 * time.Millisecond)
_, err = short.Create(t.Context(), teardownLeaderKey(testAccountID, "alpha"), []byte("node-2"))
require.ErrorIs(t, err, jetstream.ErrKeyExists,
"the running teardown must still hold its lease after the request context is gone")
release()
f.svc.WaitTeardowns()
}
// TestS8_EKSKickAndSameNodeSweepRunOneTeardown: the teardown lease re-adopts a
// key its own node holds, so only the in-process gate stops a same-node reaper
// sweep from starting a second teardown beside the request's. Each member must
// be terminated exactly once.
func TestS8_EKSKickAndSameNodeSweepRunOneTeardown(t *testing.T) {
t.Parallel()
f := newDeleteClusterFixture(t, "alpha")
seedHAControlPlane(t, f, "alpha")
entered, release := blockTerminate(t, f.inst)
deleteDone := make(chan struct{})
go func() {
defer close(deleteDone)
_, _ = f.svc.DeleteCluster(context.Background(), deleteInput("alpha"), testAccountID)
}()
waitEntered(t, entered)
sweepDone := make(chan struct{})
go func() {
defer close(sweepDone)
_, _ = (&EKSDeletingReaper{svc: f.svc}).Sweep(context.Background())
}()
select {
case <-sweepDone:
case <-time.After(3 * time.Second):
}
release()
<-sweepDone
<-deleteDone
f.svc.WaitTeardowns()
got := f.inst.terminated()
assert.Equal(t, 1, countOf(got, "i-aaa111"), "one teardown per cluster on a node: %v", got)
assert.Equal(t, 1, countOf(got, "i-bbb222"), "one teardown per cluster on a node: %v", got)
}
// TestS2_EKSTeardownDoesNotSweepARecordChangedDuringTeardown: a member added to
// the record while the teardown runs (a reconciler replacement, a late launch
// write) is torn down by another pass before the record goes. The fixture uses
// the managed CP VPC topology, whose teardown writes the record itself.
func TestS2_EKSTeardownDoesNotSweepARecordChangedDuringTeardown(t *testing.T) {
t.Parallel()
f := newDeleteClusterFixture(t, "alpha")
meta, err := GetClusterMeta(t.Context(), f.kv, "alpha")
require.NoError(t, err)
meta.ManagedCPVPC = &ManagedCPVPC{VpcId: "vpc-cp"}
require.NoError(t, PutClusterMeta(t.Context(), f.kv, meta))
entered, release := blockTerminate(t, f.inst)
deleteDone := make(chan struct{})
go func() {
defer close(deleteDone)
_, _ = f.svc.DeleteCluster(context.Background(), deleteInput("alpha"), testAccountID)
}()
waitEntered(t, entered)
require.NoError(t, casUpdateMeta(t.Context(), f.kv, "alpha", func(m *ClusterMeta) bool {
m.ControlPlaneNodes = []ControlPlaneNode{
{InstanceID: "i-aaa111", ENIID: "eni-aaa111"},
{InstanceID: "i-late", ENIID: "eni-late"},
}
return true
}))
release()
<-deleteDone
f.svc.WaitTeardowns()
assert.Contains(t, f.inst.terminated(), "i-late", "a member recorded mid-teardown must be torn down, not orphaned")
_, err = GetClusterMeta(t.Context(), f.kv, "alpha")
assert.ErrorIs(t, err, ErrClusterNotFound, "the record goes once every recorded member is gone")
}
// TestS2_EKSTeardownRemovesTheMetaLast: the meta owns the cluster's other
// records, so the teardown deletes it only after every child key is gone. A
// child that cannot be removed keeps the record DELETING for the next pass.
func TestS2_EKSTeardownRemovesTheMetaLast(t *testing.T) {
t.Parallel()
f := newDeleteClusterFixture(t, "alpha")
_, err := f.kv.Put(t.Context(), "clusters/alpha/addons/vpc-cni", []byte(`{}`))
require.NoError(t, err)
f.svc.teardownKV = func(context.Context, string) (jetstream.KeyValue, error) {
return &failDeleteKV{KeyValue: f.kv, fail: "clusters/alpha/addons/vpc-cni"}, nil
}
deleteAndSettle(t, f.svc, "alpha")
meta := requireTeardownRetained(t, f.kv, "alpha")
assert.Equal(t, stepRecord, meta.DeleteFailedStep)
}
type failDeleteKV struct {
jetstream.KeyValue
fail string
}
func (f *failDeleteKV) Delete(ctx context.Context, key string, opts ...jetstream.KVDeleteOpt) error {
if key == f.fail {
return errors.New("injected delete failure")
}
return f.KeyValue.Delete(ctx, key, opts...)
}
// TestS2_EKSTeardownResumesAfterAnInterruptedConverge pins crash recovery: a
// teardown stopped between members (its lease lost) keeps the record, and the
// reaper's next pass finishes it with no step failing on what is already gone.
func TestS2_EKSTeardownResumesAfterAnInterruptedConverge(t *testing.T) {
t.Parallel()
f := newDeleteClusterFixture(t, "alpha")
seedHAControlPlane(t, f, "alpha")
_, _, err := MarkClusterDeleting(t.Context(), f.kv, "alpha")
require.NoError(t, err)
lost := make(chan struct{})
var once sync.Once
f.inst.terminateHook = func(string) { once.Do(func() { close(lost) }) }
err = f.svc.convergeClusterDeletion(t.Context(), testAccountID, "alpha", lost)
require.ErrorIs(t, err, errTeardownLeaseLost)
assert.Equal(t, []string{"i-aaa111"}, f.inst.terminated(), "the teardown stops at the next step boundary")
meta, err := GetClusterMeta(t.Context(), f.kv, "alpha")
require.NoError(t, err)
assert.Equal(t, ClusterStatusDeleting, meta.Status)
f.inst.terminateHook = nil
redrive(t, f.svc)
_, err = GetClusterMeta(t.Context(), f.kv, "alpha")
require.ErrorIs(t, err, ErrClusterNotFound)
assert.Contains(t, f.inst.terminated(), "i-bbb222")
}
// TestS7_EKSTeardownActsOnlyOnARecordedDelete: the teardown terminates
// control-plane instances and their volumes, so it may act only on the
// tenant's recorded decision. Driven directly and through the reaper, it
// touches nothing on an ACTIVE, CREATING or FAILED record.
func TestS7_EKSTeardownActsOnlyOnARecordedDelete(t *testing.T) {
t.Parallel()
for _, status := range []ClusterStatus{ClusterStatusActive, ClusterStatusCreating, ClusterStatusFailed} {
t.Run(string(status), func(t *testing.T) {
t.Parallel()
f := newDeleteClusterFixture(t, "alpha")
meta, err := GetClusterMeta(t.Context(), f.kv, "alpha")
require.NoError(t, err)
meta.Status = status
require.NoError(t, PutClusterMeta(t.Context(), f.kv, meta))
err = f.svc.convergeClusterDeletion(t.Context(), testAccountID, "alpha", nil)
require.ErrorIs(t, err, errTeardownNotRecorded)
redrive(t, f.svc)
assert.Empty(t, f.inst.terminated(), "ADR-0003:S7: no instance is terminated without a recorded delete")
assert.Empty(t, f.eip.releaseCalls)
assert.Empty(t, f.nlb.deleteLBCalls)
after, err := GetClusterMeta(t.Context(), f.kv, "alpha")
require.NoError(t, err)
assert.Equal(t, status, after.Status)
})
}
}
// TestS7_EKSTeardownNeverRemovesAClusterWithNodegroupWorkers: the cluster
// teardown never terminates node group workers and never erases their record,
// which would orphan running customer instances. A DELETING cluster that still
// has a node group is left whole, with the wait surfaced to the tenant.
func TestS7_EKSTeardownNeverRemovesAClusterWithNodegroupWorkers(t *testing.T) {
t.Parallel()
f := newDeleteClusterFixture(t, "alpha")
markDeleting(t, f, "alpha", 10*time.Minute)
require.NoError(t, PutNodegroupRecord(t.Context(), f.kv, &NodegroupRecord{
ClusterName: "alpha", Name: "ng1", Status: eks.NodegroupStatusActive,
InstanceIDs: []string{"i-worker1"},
}))
redrive(t, f.svc)
assert.Empty(t, f.inst.terminated(), "the control plane stays while workers depend on it")
_, err := GetNodegroupRecord(t.Context(), f.kv, "alpha", "ng1")
require.NoError(t, err, "ADR-0003:S7: the node group record that owns running workers must survive")
meta, err := GetClusterMeta(t.Context(), f.kv, "alpha")
require.NoError(t, err)
assert.Equal(t, ClusterStatusDeleting, meta.Status)
assert.Equal(t, stepNodegroups, meta.DeleteFailedStep)
}
// TestS2_EKSLaunchWriteAfterDeleteNeitherRevertsNorRecreates: a create launch
// still running when the delete is recorded must not move the record off
// DELETING; its refs join the record so the teardown reaches them. Once the
// record is gone, the launch's write must not bring it back.
func TestS2_EKSLaunchWriteAfterDeleteNeitherRevertsNorRecreates(t *testing.T) {
t.Parallel()
t.Run("record still DELETING", func(t *testing.T) {
t.Parallel()
f := newEKSServiceFixture(t)
held := make(chan struct{})
gate := make(chan struct{})
var once sync.Once
f.inst.launchHook = func() { once.Do(func() { close(held); <-gate }) }
f.nlb.deleteLBErr = errors.New("elbv2 unavailable") // keep the record DELETING
_, err := f.svc.CreateCluster(context.Background(), createInput("alpha"), testAccountID, "")
require.NoError(t, err)
<-held
_, _ = f.svc.DeleteCluster(context.Background(), deleteInput("alpha"), testAccountID)
f.svc.WaitTeardowns()
close(gate)
f.svc.WaitLaunches()
meta, err := GetClusterMeta(t.Context(), f.kv, "alpha")
require.NoError(t, err)
assert.Equal(t, ClusterStatusDeleting, meta.Status, "a launch write must never move a cluster off DELETING")
assert.NotEmpty(t, meta.ControlPlaneInstanceID, "the launch's control-plane VM joins the record so the teardown reaches it")
})
t.Run("record already removed", func(t *testing.T) {
// The launch records what it built as DELETING, so the teardown owns it.
t.Parallel()
f := newEKSServiceFixture(t)
silenceLaunches(f.svc)
held := make(chan struct{})
gate := make(chan struct{})
var once sync.Once
f.inst.launchHook = func() { once.Do(func() { close(held); <-gate }) }
_, err := f.svc.CreateCluster(context.Background(), createInput("alpha"), testAccountID, "")
require.NoError(t, err)
<-held
_, _ = f.svc.DeleteCluster(context.Background(), deleteInput("alpha"), testAccountID)
f.svc.WaitTeardowns()
_, err = GetClusterMeta(t.Context(), f.kv, "alpha")
require.ErrorIs(t, err, ErrClusterNotFound)
close(gate)
f.svc.WaitLaunches()
f.svc.WaitTeardowns()
_, err = GetClusterMeta(t.Context(), f.kv, "alpha")
assert.ErrorIs(t, err, ErrClusterNotFound, "a launch never brings a swept cluster back")
assert.Contains(t, f.inst.terminated(), "i-aaa111", "the VM started after the record went is handed to the teardown and released")
})
}
// TestS2_EKSDeletingIsStickyForStatusWriters: no status writer moves a record
// off DELETING.
func TestS2_EKSDeletingIsStickyForStatusWriters(t *testing.T) {
t.Parallel()
f := newDeleteClusterFixture(t, "alpha")
_, _, err := MarkClusterDeleting(t.Context(), f.kv, "alpha")
require.NoError(t, err)
for _, status := range []ClusterStatus{ClusterStatusActive, ClusterStatusFailed, ClusterStatusCreating} {
err := SetClusterStatus(t.Context(), f.kv, "alpha", status)
require.ErrorIs(t, err, ErrClusterDeleting, "moving DELETING to %s must be refused", status)
}
meta, err := GetClusterMeta(t.Context(), f.kv, "alpha")
require.NoError(t, err)
assert.Equal(t, ClusterStatusDeleting, meta.Status)
}
// TestDescribeCluster_StuckDeleteShowsTheStepNotTheError: a failed teardown is
// visible to the tenant as one INTERNAL_FAILURE issue naming the step, never
// the internal error with its system-account IDs.
func TestDescribeCluster_StuckDeleteShowsTheStepNotTheError(t *testing.T) {
t.Parallel()
meta := sampleClusterMeta("alpha")
meta.Status = ClusterStatusDeleting
meta.DeleteFailedStep = stepControlPlane
meta.LastDeleteReapError = "terminate K3s VM i-0123: route terminate: nats: timeout"
out := clusterMetaToAWS(meta)
require.NotNil(t, out.Health)
require.Len(t, out.Health.Issues, 1)
issue := out.Health.Issues[0]
assert.Equal(t, eks.ClusterIssueCodeInternalFailure, aws.StringValue(issue.Code))
assert.Contains(t, aws.StringValue(issue.Message), stepControlPlane)
assert.NotContains(t, aws.StringValue(issue.Message), "i-0123")
assert.NotContains(t, aws.StringValue(issue.Message), "nats")
meta.Status = ClusterStatusActive
assert.Nil(t, clusterMetaToAWS(meta).Health, "only a DELETING cluster reports a stuck delete")
}
// TestS8_EKSDeleteClusterRefusesWhileNodegroupsExist: the request decides the
// error AWS returns for a cluster that still has managed node groups, and
// leaves the cluster as it was.
func TestS8_EKSDeleteClusterRefusesWhileNodegroupsExist(t *testing.T) {
t.Parallel()
f := newDeleteClusterFixture(t, "alpha")
require.NoError(t, PutNodegroupRecord(t.Context(), f.kv, &NodegroupRecord{
ClusterName: "alpha", Name: "ng1", Status: eks.NodegroupStatusActive,
InstanceIDs: []string{"i-worker1"},
}))
_, err := f.svc.DeleteCluster(context.Background(), deleteInput("alpha"), testAccountID)
require.Error(t, err)
code, message, _ := awserrors.ResolveErrorDetail(err)
assert.Equal(t, awserrors.ErrorEKSResourceInUse, code)
assert.Contains(t, message, "nodegroups attached")
f.svc.WaitTeardowns()
meta, getErr := GetClusterMeta(t.Context(), f.kv, "alpha")
require.NoError(t, getErr)
assert.Equal(t, ClusterStatusCreating, meta.Status, "a refused delete records nothing")
assert.Empty(t, f.inst.terminated())
_, ngErr := GetNodegroupRecord(t.Context(), f.kv, "alpha", "ng1")
require.NoError(t, ngErr, "the node group and its workers are untouched")
}
// TestS2_EKSDeletingClusterAdmitsNoNewDependants: once a delete is recorded,
// access entries are refused, since the teardown would sweep them with the
// record; TestCreateAddonRefusesOnlyADeletingCluster covers add-ons.
func TestS2_EKSDeletingClusterAdmitsNoNewDependants(t *testing.T) {
t.Parallel()
f := newDeleteClusterFixture(t, "alpha")
_, _, err := MarkClusterDeleting(t.Context(), f.kv, "alpha")
require.NoError(t, err)
_, err = f.svc.CreateAccessEntry(context.Background(), &eks.CreateAccessEntryInput{
ClusterName: aws.String("alpha"), PrincipalArn: aws.String(testPrincipalARN),
}, testAccountID)
code, _, _ := awserrors.ResolveErrorDetail(err)
assert.Equal(t, awserrors.ErrorEKSResourceInUse, code, "CreateAccessEntry on a DELETING cluster")
}
// TestS2_EKSLaunchOvertakenByDeleteLeavesNoChildRecords: a create launch whose
// cluster is deleted and swept just before it seeds the creator's access entry
// must not leave that entry, or any other key under the cluster, behind.
func TestS2_EKSLaunchOvertakenByDeleteLeavesNoChildRecords(t *testing.T) {
t.Parallel()
f := newEKSServiceFixture(t)
silenceLaunches(f.svc)
held := make(chan struct{})
gate := make(chan struct{})
var once sync.Once
f.svc.launchStepHook = func(step string) {
if step == "seed-creator-admin" {
once.Do(func() { close(held); <-gate })
}
}
_, err := f.svc.CreateCluster(context.Background(), createInput("alpha"), testAccountID, testPrincipalARN)
require.NoError(t, err)
<-held
_, err = f.svc.DeleteCluster(context.Background(), deleteInput("alpha"), testAccountID)
require.NoError(t, err)
f.svc.WaitTeardowns()
_, err = GetClusterMeta(t.Context(), f.kv, "alpha")
require.ErrorIs(t, err, ErrClusterNotFound, "the teardown swept the record before the launch resumed")
close(gate)
f.svc.WaitLaunches()
f.svc.WaitTeardowns()
keys, err := f.kv.Keys(t.Context())
if !errors.Is(err, jetstream.ErrNoKeysFound) {
require.NoError(t, err)
}
var left []string
for _, k := range keys {
if strings.HasPrefix(k, "clusters/alpha/") {
left = append(left, k)
}
}
assert.Empty(t, left, "ADR-0003:S2: no child record may outlive the swept cluster record")
}
@@ -0,0 +1,208 @@
package cluster
import (
"context"
"sync/atomic"
"testing"
"time"
"github.com/aws/aws-sdk-go/aws"
"github.com/aws/aws-sdk-go/service/eks"
"github.com/nats-io/nats.go/jetstream"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// TestS2_EKSDeletedClusterNeverReadsMissingWhileItsLaunchRuns: while a create
// launch for the cluster is still running, the teardown keeps the record, so
// DescribeCluster reads DELETING throughout and never NotFound followed by
// DELETING again. The launch hands its refs over and the delete then finishes.
func TestS2_EKSDeletedClusterNeverReadsMissingWhileItsLaunchRuns(t *testing.T) {
t.Parallel()
f := newEKSServiceFixture(t)
f.svc.launchHeartbeatEvery = 20 * time.Millisecond
f.svc.launchStaleAfter = 5 * time.Second
held, release := holdLaunch(t, f.inst)
_, err := f.svc.CreateCluster(context.Background(), createInput("alpha"), testAccountID, "")
require.NoError(t, err)
<-held
_, err = f.svc.DeleteCluster(context.Background(), deleteInput("alpha"), testAccountID)
require.NoError(t, err)
f.svc.WaitTeardowns()
for range 5 {
out, err := f.svc.DescribeCluster(context.Background(), &eks.DescribeClusterInput{Name: aws.String("alpha")}, testAccountID)
require.NoError(t, err, "the record must stay while its launch is still running")
assert.Equal(t, eks.ClusterStatusDeleting, aws.StringValue(out.Cluster.Status))
time.Sleep(30 * time.Millisecond)
}
meta, err := GetClusterMeta(t.Context(), f.kv, "alpha")
require.NoError(t, err)
assert.Equal(t, stepLaunch, meta.DeleteFailedStep)
assert.Zero(t, meta.DeleteReapAttempts, "waiting for the launch is not a failed pass")
release()
f.svc.WaitLaunches()
f.svc.WaitTeardowns()
_, err = GetClusterMeta(t.Context(), f.kv, "alpha")
require.ErrorIs(t, err, ErrClusterNotFound, "once the launch hands over, the delete finishes")
assert.Contains(t, f.inst.terminated(), "i-aaa111", "the VM the launch started is torn down with the cluster")
}
// TestS2_EKSCrashedLaunchDoesNotPinTheDelete: a launch owner whose heartbeat
// stopped is taken as crashed once it is stale, and the delete proceeds.
func TestS2_EKSCrashedLaunchDoesNotPinTheDelete(t *testing.T) {
t.Parallel()
f := newDeleteClusterFixture(t, "alpha")
markDeleting(t, f, "alpha", 10*time.Minute)
require.NoError(t, casUpdateMeta(t.Context(), f.kv, "alpha", func(m *ClusterMeta) bool {
m.LaunchOwner = "node-gone"
m.LaunchHeartbeat = time.Now().UTC().Add(-10 * time.Minute)
return true
}))
redrive(t, f.svc)
_, err := GetClusterMeta(t.Context(), f.kv, "alpha")
assert.ErrorIs(t, err, ErrClusterNotFound, "a stale launch owner must not hold the delete for ever")
}
// TestS2_EKSFailedClusterReclaimIsNotRefusedForCapacity: the per-node bound
// on teardowns is about background work; re-creating a FAILED cluster's name
// must not be answered ResourceInUse because the node is busy elsewhere.
func TestS2_EKSFailedClusterReclaimIsNotRefusedForCapacity(t *testing.T) {
f := newDeleteClusterFixture(t, "alpha")
meta, err := GetClusterMeta(t.Context(), f.kv, "alpha")
require.NoError(t, err)
meta.Status = ClusterStatusFailed
require.NoError(t, PutClusterMeta(t.Context(), f.kv, meta))
orig := teardownConcurrency
teardownConcurrency = 0
t.Cleanup(func() { teardownConcurrency = orig })
_, err = f.svc.CreateCluster(context.Background(), createInput("alpha"), testAccountID, "")
require.NoError(t, err, "a full teardown queue is not the name being in use")
f.svc.WaitLaunches()
}
// TestS2_EKSOvertakenLaunchCannotReplaceANewerClustersSigningKey: an old
// launch that resumes after its cluster was deleted and the name re-created
// must not overwrite the newer cluster's OIDC signing key, which would break
// every IRSA token the newer cluster issues.
func TestS2_EKSOvertakenLaunchCannotReplaceANewerClustersSigningKey(t *testing.T) {
t.Parallel()
f := newEKSServiceFixture(t)
silenceLaunches(f.svc)
held := make(chan struct{})
gate := make(chan struct{})
var first atomic.Bool
f.svc.launchStepHook = func(step string) {
if step == "oidc-keypair" && first.CompareAndSwap(false, true) {
close(held)
<-gate
}
}
t.Cleanup(func() {
select {
case <-gate:
default:
close(gate)
}
})
_, err := f.svc.CreateCluster(context.Background(), createInput("alpha"), testAccountID, "")
require.NoError(t, err)
<-held
_, err = f.svc.DeleteCluster(context.Background(), deleteInput("alpha"), testAccountID)
require.NoError(t, err)
f.svc.WaitTeardowns()
_, err = f.svc.CreateCluster(context.Background(), createInput("alpha"), testAccountID, "")
require.NoError(t, err)
eventually(t, func() bool {
m, err := GetClusterMeta(t.Context(), f.kv, "alpha")
return err == nil && m.ControlPlaneInstanceID != ""
}, "the newer cluster's launch gets past its keypair")
newer, err := f.kv.Get(t.Context(), OIDCSigningKeyKey("alpha"))
require.NoError(t, err)
close(gate)
f.svc.WaitLaunches()
f.svc.WaitTeardowns()
after, err := f.kv.Get(t.Context(), OIDCSigningKeyKey("alpha"))
require.NoError(t, err)
assert.Equal(t, string(newer.Value()), string(after.Value()), "the newer cluster's signing key must survive the older launch")
}
// blockingDeleteKV holds a delete of one key until its context ends.
type blockingDeleteKV struct {
jetstream.KeyValue
block string
}
func (b *blockingDeleteKV) Delete(ctx context.Context, key string, opts ...jetstream.KVDeleteOpt) error {
if key == b.block {
<-ctx.Done()
return ctx.Err()
}
return b.KeyValue.Delete(ctx, key, opts...)
}
// TestS8_EKSDeadlineDuringRecordRemovalIsRecorded: a pass that runs out of time
// while removing the record is recorded like any other failed pass.
func TestS8_EKSDeadlineDuringRecordRemovalIsRecorded(t *testing.T) {
f := newDeleteClusterFixture(t, "alpha")
orig := teardownPassTimeout
teardownPassTimeout = 300 * time.Millisecond
t.Cleanup(func() { teardownPassTimeout = orig })
_, err := f.kv.Put(t.Context(), "clusters/alpha/addons/vpc-cni", []byte(`{}`))
require.NoError(t, err)
f.svc.teardownKV = func(context.Context, string) (jetstream.KeyValue, error) {
return &blockingDeleteKV{KeyValue: f.kv, block: "clusters/alpha/addons/vpc-cni"}, nil
}
deleteAndSettle(t, f.svc, "alpha")
meta := requireTeardownRetained(t, f.kv, "alpha")
assert.Equal(t, stepRecord, meta.DeleteFailedStep)
}
// TestEKSBootstrapRunRemovesItsOwnEntryOnExit: a bootstrap subscriber that
// returns on its own leaves no entry behind for stopBootstrap to find.
func TestEKSBootstrapRunRemovesItsOwnEntryOnExit(t *testing.T) {
t.Parallel()
f := newDeleteClusterFixture(t, "alpha")
f.svc.spawnBootstrap(testAccountID, "alpha", f.kv, []string{"no-such-kind"})
eventually(t, func() bool {
f.svc.bootstrapMu.Lock()
defer f.svc.bootstrapMu.Unlock()
_, ok := f.svc.bootstraps[reconcilerLeaderKey(testAccountID, "alpha")]
return !ok
}, "an exited bootstrap must remove its own entry")
}
// TestS7_EKSTeardownRefusesANonDeletingRecordEvenWithNodegroups: the S7
// boundary is checked before anything else, so an ACTIVE record with node
// groups is refused as not recorded, never reported as waiting on them.
func TestS7_EKSTeardownRefusesANonDeletingRecordEvenWithNodegroups(t *testing.T) {
t.Parallel()
f := newDeleteClusterFixture(t, "alpha")
meta, err := GetClusterMeta(t.Context(), f.kv, "alpha")
require.NoError(t, err)
meta.Status = ClusterStatusActive
require.NoError(t, PutClusterMeta(t.Context(), f.kv, meta))
require.NoError(t, PutNodegroupRecord(t.Context(), f.kv, &NodegroupRecord{
ClusterName: "alpha", Name: "ng1", Status: eks.NodegroupStatusActive,
}))
err = f.svc.convergeClusterDeletion(t.Context(), testAccountID, "alpha", nil)
require.ErrorIs(t, err, errTeardownNotRecorded)
require.NotErrorIs(t, err, errNodegroupsAttached)
after, err := GetClusterMeta(t.Context(), f.kv, "alpha")
require.NoError(t, err)
assert.Empty(t, after.DeleteFailedStep, "nothing is recorded on a cluster nobody asked to delete")
}
@@ -0,0 +1,305 @@
package cluster
import (
"context"
"errors"
"sync"
"testing"
"time"
"github.com/aws/aws-sdk-go/aws"
"github.com/aws/aws-sdk-go/service/ec2"
"github.com/aws/aws-sdk-go/service/eks"
awserrors "github.com/mulgadc/spinifex/spinifex/foundation/aws/errors"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// holdLaunch blocks the first control-plane launch until the returned release
// is called, and closes held once the launch has reached it.
func holdLaunch(t *testing.T, inst *fakeK3sInst) (held <-chan struct{}, release func()) {
t.Helper()
h := make(chan struct{})
gate := make(chan struct{})
var once, releaseOnce sync.Once
inst.launchHook = func() {
first := false
once.Do(func() { first = true })
if first {
close(h)
<-gate
}
}
release = func() { releaseOnce.Do(func() { close(gate) }) }
t.Cleanup(release)
return h, release
}
// silenceLaunches models a launch whose node stopped heartbeating, so the
// teardown no longer waits for it and can sweep the record under it.
func silenceLaunches(svc *EKSServiceImpl) {
svc.launchHeartbeatEvery = 0
svc.launchStaleAfter = time.Nanosecond
}
func eventually(t *testing.T, cond func() bool, msg string) {
t.Helper()
require.Eventually(t, cond, 10*time.Second, 10*time.Millisecond, msg)
}
// TestS2_EKSOvertakenLaunchNeverWritesANewerClusterOfTheSameName: a launch
// whose cluster was deleted and swept, and whose name a tenant then created
// again, must not overwrite the newer record or delete anything by name. It
// releases only the instance it launched itself.
func TestS2_EKSOvertakenLaunchNeverWritesANewerClusterOfTheSameName(t *testing.T) {
t.Parallel()
f := newEKSServiceFixture(t)
silenceLaunches(f.svc)
f.inst.uniqueIDs = true
held, release := holdLaunch(t, f.inst)
_, err := f.svc.CreateCluster(context.Background(), createInput("alpha"), testAccountID, "")
require.NoError(t, err)
<-held
_, err = f.svc.DeleteCluster(context.Background(), deleteInput("alpha"), testAccountID)
require.NoError(t, err)
f.svc.WaitTeardowns()
_, err = GetClusterMeta(t.Context(), f.kv, "alpha")
require.ErrorIs(t, err, ErrClusterNotFound)
_, err = f.svc.CreateCluster(context.Background(), createInput("alpha"), testAccountID, "")
require.NoError(t, err, "the name is free once the first cluster's record is gone")
var newer *ClusterMeta
eventually(t, func() bool {
newer, err = GetClusterMeta(t.Context(), f.kv, "alpha")
return err == nil && newer.ControlPlaneInstanceID != ""
}, "the newer cluster's launch records its control plane")
deletesBefore := len(f.nlb.deleteLBCalls)
release()
f.svc.WaitLaunches()
f.svc.WaitTeardowns()
after, err := GetClusterMeta(t.Context(), f.kv, "alpha")
require.NoError(t, err)
assert.True(t, after.CreatedAt.Equal(newer.CreatedAt), "the newer cluster's record must not be overwritten by the older launch")
assert.Equal(t, newer.ControlPlaneInstanceID, after.ControlPlaneInstanceID)
got := f.inst.terminated()
assert.NotContains(t, got, newer.ControlPlaneInstanceID, "the newer cluster's control plane is not the older launch's to release")
require.Len(t, got, 1, "the older launch releases the one instance it started: %v", got)
assert.Len(t, f.nlb.deleteLBCalls, deletesBefore, "nothing is deleted by name on the newer cluster's behalf")
}
// TestS2_EKSOvertakenLaunchHandsItsInfraToTheTeardown: a launch that finds its
// record already swept records what it built as a DELETING cluster, so a
// failed release is retried by the teardown and its reaper, not dropped.
func TestS2_EKSOvertakenLaunchHandsItsInfraToTheTeardown(t *testing.T) {
t.Parallel()
f := newEKSServiceFixture(t)
silenceLaunches(f.svc)
held, release := holdLaunch(t, f.inst)
_, err := f.svc.CreateCluster(context.Background(), createInput("alpha"), testAccountID, "")
require.NoError(t, err)
<-held
_, err = f.svc.DeleteCluster(context.Background(), deleteInput("alpha"), testAccountID)
require.NoError(t, err)
f.svc.WaitTeardowns()
f.inst.mu.Lock()
f.inst.terminateErr = errors.New("hypervisor unreachable")
f.inst.mu.Unlock()
release()
f.svc.WaitLaunches()
f.svc.WaitTeardowns()
meta, err := GetClusterMeta(t.Context(), f.kv, "alpha")
require.NoError(t, err, "the launch's control plane must stay owned by a record when its release fails")
assert.Equal(t, ClusterStatusDeleting, meta.Status)
assert.Equal(t, "i-aaa111", meta.ControlPlaneInstanceID)
f.inst.mu.Lock()
f.inst.terminateErr = nil
f.inst.mu.Unlock()
redrive(t, f.svc)
_, err = GetClusterMeta(t.Context(), f.kv, "alpha")
require.ErrorIs(t, err, ErrClusterNotFound, "the reaper finishes the release")
}
// TestS2_EKSLaunchMarkedFailedReleasesWhatItBuilt: a record marked FAILED
// under a running launch (the create timed out) is not a teardown owning the
// launch's refs, so the launch merges them in and releases what it built.
func TestS2_EKSLaunchMarkedFailedReleasesWhatItBuilt(t *testing.T) {
t.Parallel()
f := newEKSServiceFixture(t)
held, release := holdLaunch(t, f.inst)
_, err := f.svc.CreateCluster(context.Background(), createInput("alpha"), testAccountID, "")
require.NoError(t, err)
<-held
require.NoError(t, MarkClusterFailed(t.Context(), f.kv, "alpha", "create timed out"))
release()
f.svc.WaitLaunches()
assert.Contains(t, f.inst.terminated(), "i-aaa111", "the control plane launched after FAILED must be released")
meta, err := GetClusterMeta(t.Context(), f.kv, "alpha")
require.NoError(t, err)
assert.Equal(t, ClusterStatusFailed, meta.Status)
}
// TestS2_EKSFreshClaimInheritsNoStaleChildRecords: keys an earlier cluster of
// the same name left behind are not inherited by a new one.
func TestS2_EKSFreshClaimInheritsNoStaleChildRecords(t *testing.T) {
t.Parallel()
f := newEKSServiceFixture(t)
stale := []string{AdminKubeconfigKey("alpha"), NodeTokenKey("alpha"), "clusters/alpha/access-entries/old"}
for _, k := range stale {
_, err := f.kv.Put(t.Context(), k, []byte("stale"))
require.NoError(t, err)
}
f.inst.launchErr = errors.New("no capacity") // stop the launch early; only the claim matters
_, err := f.svc.CreateCluster(context.Background(), createInput("alpha"), testAccountID, "")
require.NoError(t, err)
f.svc.WaitLaunches()
for _, k := range stale {
entry, err := f.kv.Get(t.Context(), k)
if err == nil {
assert.NotEqual(t, "stale", string(entry.Value()), "%s must not be inherited from an earlier cluster", k)
}
}
}
// TestS2_EKSBootstrapWritesNothingIntoADeletingCluster: a bootstrap subscriber
// still running for a cluster being deleted must not write its artifacts.
func TestS2_EKSBootstrapWritesNothingIntoADeletingCluster(t *testing.T) {
t.Parallel()
f := newDeleteClusterFixture(t, "alpha")
_, _, err := MarkClusterDeleting(t.Context(), f.kv, "alpha")
require.NoError(t, err)
boot, err := NewNATSBootstrap(f.svc.deps.NATSConn, f.kv, bootstrapTestMasterKey, testAccountID, "alpha")
require.NoError(t, err)
err = boot.persistToken(t.Context(), []byte(`{"token":"K10abc"}`))
require.ErrorIs(t, err, errBootstrapPermanent)
_, getErr := f.kv.Get(t.Context(), NodeTokenKey("alpha"))
require.Error(t, getErr, "no node token is written into a DELETING cluster")
}
// TestS2_EKSLastNodegroupDeletedResumesTheClusterDelete: a cluster delete
// waiting on node groups resumes when the last one is deleted, and the wait
// itself never counted as a failed pass.
func TestS2_EKSLastNodegroupDeletedResumesTheClusterDelete(t *testing.T) {
t.Parallel()
f := newDeleteClusterFixture(t, "alpha")
markDeleting(t, f, "alpha", 10*time.Minute)
require.NoError(t, PutNodegroupRecord(t.Context(), f.kv, &NodegroupRecord{
ClusterName: "alpha", Name: "ng1", Status: eks.NodegroupStatusActive,
}))
redrive(t, f.svc)
meta, err := GetClusterMeta(t.Context(), f.kv, "alpha")
require.NoError(t, err)
assert.Zero(t, meta.DeleteReapAttempts, "waiting on node groups must not grow the backoff")
_, err = f.svc.DeleteNodegroup(context.Background(), &eks.DeleteNodegroupInput{
ClusterName: aws.String("alpha"), NodegroupName: aws.String("ng1"),
}, testAccountID)
require.NoError(t, err)
f.svc.WaitTeardowns()
_, err = GetClusterMeta(t.Context(), f.kv, "alpha")
assert.ErrorIs(t, err, ErrClusterNotFound, "deleting the last node group lets the cluster delete finish")
}
// TestS8_EKSHungTeardownStepReleasesTheGate: a pass is bounded, so a step that
// never returns cannot hold the gate and the lease for ever; the pass is
// recorded as failed and the cluster can be handed out again.
func TestS8_EKSHungTeardownStepReleasesTheGate(t *testing.T) {
f := newDeleteClusterFixture(t, "alpha")
orig := teardownPassTimeout
teardownPassTimeout = 300 * time.Millisecond
t.Cleanup(func() { teardownPassTimeout = orig })
var once sync.Once
f.nlb.describeLBHook = func(ctx context.Context) { once.Do(func() { <-ctx.Done() }) }
_, err := f.svc.DeleteCluster(context.Background(), deleteInput("alpha"), testAccountID)
require.NoError(t, err)
done := make(chan struct{})
go func() { f.svc.WaitTeardowns(); close(done) }()
select {
case <-done:
case <-time.After(5 * time.Second):
t.Fatal("a hung teardown step must not hold the teardown past its pass deadline")
}
meta, err := GetClusterMeta(t.Context(), f.kv, "alpha")
require.NoError(t, err)
assert.Equal(t, ClusterStatusDeleting, meta.Status)
assert.NotEmpty(t, meta.LastDeleteReapError, "the timed-out pass is recorded so the reaper backs off")
assert.True(t, f.svc.startTeardown(testAccountID, "alpha"), "the gate and lease are free again")
f.svc.WaitTeardowns()
}
// TestS2_EKSFailedClusterReclaimRunsUnderTheTeardownLease: re-creating a FAILED
// cluster's name tears the old attempt down, so it is refused while another
// node holds that cluster's teardown.
func TestS2_EKSFailedClusterReclaimRunsUnderTheTeardownLease(t *testing.T) {
t.Parallel()
f := newDeleteClusterFixture(t, "alpha")
meta, err := GetClusterMeta(t.Context(), f.kv, "alpha")
require.NoError(t, err)
meta.Status = ClusterStatusFailed
require.NoError(t, PutClusterMeta(t.Context(), f.kv, meta))
_, err = f.svc.leaderKV.Create(t.Context(), teardownLeaderKey(testAccountID, "alpha"), []byte("node-2"))
require.NoError(t, err)
_, err = f.svc.CreateCluster(context.Background(), createInput("alpha"), testAccountID, "")
code, _, _ := awserrors.ResolveErrorDetail(err)
assert.Equal(t, awserrors.ErrorEKSResourceInUse, code)
f.svc.WaitLaunches()
assert.Empty(t, f.inst.terminated(), "no teardown of the old attempt while another node holds it")
}
// csiVolumeRecorder lists one CSI volume owned by the cluster and records any
// delete, which the cluster teardown must never issue.
type csiVolumeRecorder struct {
mu sync.Mutex
deleted []string
}
func (c *csiVolumeRecorder) DescribeVolumes(context.Context, *ec2.DescribeVolumesInput, string) (*ec2.DescribeVolumesOutput, error) {
return &ec2.DescribeVolumesOutput{Volumes: []*ec2.Volume{
tagVol("vol-pvc", map[string]string{"kubernetes.io/cluster/alpha": "owned"}),
}}, nil
}
func (c *csiVolumeRecorder) DeleteVolume(_ context.Context, in *ec2.DeleteVolumeInput, _ string) (*ec2.DeleteVolumeOutput, error) {
c.mu.Lock()
defer c.mu.Unlock()
c.deleted = append(c.deleted, aws.StringValue(in.VolumeId))
return &ec2.DeleteVolumeOutput{}, nil
}
// TestS7_EKSTeardownNeverDeletesCSIVolumesOrWorkers: the cluster teardown
// removes the control plane the tenant asked to delete and nothing holding the
// tenant's data: no CSI-provisioned volume and no node group worker.
func TestS7_EKSTeardownNeverDeletesCSIVolumesOrWorkers(t *testing.T) {
t.Parallel()
f := newEKSServiceFixture(t)
vols := &csiVolumeRecorder{}
f.svc.deps.Volume = vols
meta := sampleClusterMeta("alpha")
meta.ControlPlaneInstanceID = "i-aaa111"
require.NoError(t, PutClusterMeta(t.Context(), f.kv, meta))
deleteAndSettle(t, f.svc, "alpha")
_, err := GetClusterMeta(t.Context(), f.kv, "alpha")
require.ErrorIs(t, err, ErrClusterNotFound)
vols.mu.Lock()
assert.Empty(t, vols.deleted, "ADR-0003:S7: a CSI volume holds tenant data and is only ever reported")
vols.mu.Unlock()
f.worker.mu.Lock()
assert.Empty(t, f.worker.terminateCalls, "ADR-0003:S7: workers are terminated only by DeleteNodegroup")
f.worker.mu.Unlock()
}
@@ -155,6 +155,39 @@ func newDeleteClusterFixture(t *testing.T, clusterName string) *deleteClusterFix
return &deleteClusterFixture{svc: f.svc, kv: kv, nlb: f.nlb, inst: f.inst, vpc: f.vpc, eip: f.eip, sg: f.sg}
}
// deleteAndSettle issues DeleteCluster, which must accept the delete with
// DELETING, then waits for the teardown that the request started to return.
func deleteAndSettle(t *testing.T, svc *EKSServiceImpl, name string) *eks.DeleteClusterOutput {
t.Helper()
out, err := svc.DeleteCluster(context.Background(), deleteInput(name), testAccountID)
require.NoError(t, err, "DeleteCluster records the delete and returns; the teardown's outcome is not its answer")
require.NotNil(t, out.Cluster)
require.Equal(t, string(ClusterStatusDeleting), aws.StringValue(out.Cluster.Status))
svc.WaitTeardowns()
return out
}
// redrive runs the deleting reaper once with no backoff and waits for the
// teardown it hands off.
func redrive(t *testing.T, svc *EKSServiceImpl) {
t.Helper()
_, err := (&EKSDeletingReaper{svc: svc}).Sweep(t.Context())
require.NoError(t, err)
svc.WaitTeardowns()
}
// requireTeardownRetained asserts a failed teardown kept the record DELETING
// with its failure recorded for the next pass.
func requireTeardownRetained(t *testing.T, kv jetstream.KeyValue, name string) *ClusterMeta {
t.Helper()
meta, err := GetClusterMeta(t.Context(), kv, name)
require.NoError(t, err, "a failed teardown must keep the record that owns the stranded resources")
require.Equal(t, ClusterStatusDeleting, meta.Status)
require.NotEmpty(t, meta.LastDeleteReapError, "the failure must be recorded, not swallowed")
require.NotEmpty(t, meta.DeleteFailedStep)
return meta
}
// TestDeleteCluster_LeakedSGKeepsClusterDeleting guards the customer-VPC SG
// no-orphan contract: the cluster SGs live in the customer VPC,
// so a DeleteClusterSGs failure must surface and leave the cluster DELETING — its
@@ -172,12 +205,10 @@ func TestDeleteCluster_LeakedSGKeepsClusterDeleting(t *testing.T) {
// A worker ENI never detaches — the SG delete keeps returning DependencyViolation.
f.sg.deleteErr = errors.New("DependencyViolation: resource has a dependent object")
_, err := f.svc.DeleteCluster(context.Background(), deleteInput("alpha"), testAccountID)
require.Error(t, err, "a leaked customer-VPC SG must surface, not be swallowed")
deleteAndSettle(t, f.svc, "alpha")
meta, getErr := GetClusterMeta(t.Context(), f.kv, "alpha")
require.NoError(t, getErr, "the KV sweep must NOT run while an SG is still leaked — meta must survive for retry")
assert.Equal(t, ClusterStatusDeleting, meta.Status, "a cluster with a leaked SG must stay DELETING")
meta := requireTeardownRetained(t, f.kv, "alpha")
assert.Contains(t, meta.DeleteFailedStep, stepSecurityGroups)
}
// TestRLC5_DeleteClusterCPVPCReleasesNATGWEIPAfterRoutes locks the
@@ -246,11 +277,9 @@ func TestDeleteCluster_AllTeardownSucceedsSweepsKV(t *testing.T) {
t.Parallel()
f := newDeleteClusterFixture(t, "alpha")
out, err := f.svc.DeleteCluster(context.Background(), deleteInput("alpha"), testAccountID)
require.NoError(t, err)
require.NotNil(t, out)
deleteAndSettle(t, f.svc, "alpha")
_, err = GetClusterMeta(t.Context(), f.kv, "alpha")
_, err := GetClusterMeta(t.Context(), 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)
@@ -273,9 +302,7 @@ func TestDeleteCluster_DetachesPrivateEndpointENIBeforeDelete(t *testing.T) {
require.NoError(t, PutClusterMeta(t.Context(), f.kv, meta))
f.vpc.inUseUntilDetached = map[string]bool{"eni-pe-001": true}
out, err := f.svc.DeleteCluster(context.Background(), deleteInput("alpha"), testAccountID)
require.NoError(t, err, "teardown must complete: the private-endpoint ENI is detached before delete")
require.NotNil(t, out)
deleteAndSettle(t, f.svc, "alpha")
assert.Contains(t, f.vpc.detachCalls, "eni-pe-001", "private-endpoint ENI must be detached before delete")
_, getErr := GetClusterMeta(t.Context(), f.kv, "alpha")
@@ -294,12 +321,9 @@ func TestDeleteCluster_NLBFailureLeavesMetaAndDELETING(t *testing.T) {
}
f.nlb.deleteLBErr = errors.New("elbv2 unavailable")
_, err := f.svc.DeleteCluster(context.Background(), deleteInput("alpha"), testAccountID)
require.Error(t, err, "teardown failure must surface, not be swallowed")
deleteAndSettle(t, f.svc, "alpha")
meta, getErr := GetClusterMeta(t.Context(), f.kv, "alpha")
require.NoError(t, getErr, "meta must survive so a retry can find the resource ARNs")
assert.Equal(t, ClusterStatusDeleting, meta.Status)
meta := requireTeardownRetained(t, f.kv, "alpha")
assert.NotEmpty(t, meta.NLBArn, "NLB ARN must remain recorded for retry")
}
@@ -308,12 +332,10 @@ func TestDeleteCluster_VMTerminateFailureLeavesMeta(t *testing.T) {
f := newDeleteClusterFixture(t, "alpha")
f.inst.terminateErr = errors.New("hypervisor unreachable")
_, err := f.svc.DeleteCluster(context.Background(), deleteInput("alpha"), testAccountID)
require.Error(t, err)
deleteAndSettle(t, f.svc, "alpha")
meta, getErr := GetClusterMeta(t.Context(), f.kv, "alpha")
require.NoError(t, getErr)
assert.Equal(t, ClusterStatusDeleting, meta.Status)
meta := requireTeardownRetained(t, f.kv, "alpha")
assert.Equal(t, stepControlPlane, meta.DeleteFailedStep)
}
// A prior retry (or the egress-delete cascade) already released the allocation,
@@ -324,9 +346,7 @@ func TestDeleteCluster_EgressEIPAlreadyReleasedSweepsKV(t *testing.T) {
f := newDeleteClusterFixture(t, "alpha")
f.eip.releaseErr = errors.New(awserrors.ErrorInvalidAllocationIDNotFound)
out, err := f.svc.DeleteCluster(context.Background(), deleteInput("alpha"), testAccountID)
require.NoError(t, err)
require.NotNil(t, out)
deleteAndSettle(t, f.svc, "alpha")
require.Len(t, f.eip.releaseCalls, 1, "release must still be attempted")
_, getErr := GetClusterMeta(t.Context(), f.kv, "alpha")
@@ -340,54 +360,42 @@ func TestDeleteCluster_EgressEIPReleaseRealErrorLeavesMeta(t *testing.T) {
f := newDeleteClusterFixture(t, "alpha")
f.eip.releaseErr = errors.New("AddressLimitExceeded")
_, err := f.svc.DeleteCluster(context.Background(), deleteInput("alpha"), testAccountID)
require.Error(t, err)
deleteAndSettle(t, f.svc, "alpha")
meta, getErr := GetClusterMeta(t.Context(), f.kv, "alpha")
require.NoError(t, getErr)
assert.Equal(t, ClusterStatusDeleting, meta.Status)
meta := requireTeardownRetained(t, f.kv, "alpha")
assert.NotEmpty(t, meta.EgressEIPAllocationID, "alloc ID must remain for retry")
}
// TestDeleteClusterSingleFlightLoserSkipsTeardown locks the contract: when a
// concurrent handler already holds the per-cluster teardown lease (an SDK retry
// fanned to another worker), DeleteCluster must return the cluster as DELETING
// without running purgeClusterInfra — no duplicate ENI/NLB/EIP teardown — and
// must leave the meta for the lease holder to sweep.
func TestDeleteClusterSingleFlightLoserSkipsTeardown(t *testing.T) {
// TestDeleteClusterLeaseHeldElsewhereLeavesTeardownToTheHolder: when another
// node holds the teardown lease, DeleteCluster still records DELETING and
// returns it, but runs no teardown of its own and leaves the record for the
// holder (or the reaper) to finish.
func TestDeleteClusterLeaseHeldElsewhereLeavesTeardownToTheHolder(t *testing.T) {
t.Parallel()
f := newDeleteClusterFixture(t, "alpha")
// Stand in for the winning handler: hold the teardown lease.
_, err := f.svc.leaderKV.Create(t.Context(), teardownLeaderKey(testAccountID, "alpha"), []byte("node-2"))
require.NoError(t, err)
out, err := f.svc.DeleteCluster(context.Background(), deleteInput("alpha"), testAccountID)
require.NoError(t, err)
require.NotNil(t, out.Cluster)
assert.Equal(t, string(ClusterStatusDeleting), aws.StringValue(out.Cluster.Status),
"the loser must report the cluster as DELETING (AWS-async delete semantics)")
assert.Empty(t, f.inst.terminateCalls, "the loser must not terminate the CP VM — the lease holder owns the teardown")
assert.Empty(t, f.eip.releaseCalls, "the loser must not release the billable EIP")
deleteAndSettle(t, f.svc, "alpha")
assert.Empty(t, f.inst.terminated(), "the lease holder owns the teardown")
assert.Empty(t, f.eip.releaseCalls, "no second release of the billable EIP")
meta, getErr := GetClusterMeta(t.Context(), f.kv, "alpha")
require.NoError(t, getErr, "the loser must leave the meta for the lease holder to sweep")
assert.Equal(t, ClusterStatusCreating, meta.Status, "the loser must not mutate KV state; the winner owns the DELETING transition")
require.NoError(t, getErr, "the record stays for the lease holder to sweep")
assert.Equal(t, ClusterStatusDeleting, meta.Status, "the delete is recorded even though another node tears it down")
}
// TestDeleteClusterWinnerReleasesTeardownLease: a successful synchronous delete
// must release the teardown lease so a later delete or the backstop reaper can
// re-acquire it (a leaked lease would wedge every future teardown until TTL).
func TestDeleteClusterWinnerReleasesTeardownLease(t *testing.T) {
// TestDeleteClusterTeardownReleasesItsLease: a completed teardown must release
// the lease so a later delete or the reaper can re-acquire it.
func TestDeleteClusterTeardownReleasesItsLease(t *testing.T) {
t.Parallel()
f := newDeleteClusterFixture(t, "alpha")
_, err := f.svc.DeleteCluster(context.Background(), deleteInput("alpha"), testAccountID)
require.NoError(t, err)
deleteAndSettle(t, f.svc, "alpha")
_, getErr := f.svc.leaderKV.Get(t.Context(), teardownLeaderKey(testAccountID, "alpha"))
assert.ErrorIs(t, getErr, jetstream.ErrKeyNotFound, "the teardown lease must be released after a successful delete")
assert.ErrorIs(t, getErr, jetstream.ErrKeyNotFound, "the teardown lease must be released after the teardown")
}
func TestTeardownLeaseReleaseSurvivesCallerCancellation(t *testing.T) {
@@ -395,10 +403,10 @@ func TestTeardownLeaseReleaseSurvivesCallerCancellation(t *testing.T) {
f := newDeleteClusterFixture(t, "alpha")
ctx, cancel := context.WithCancel(t.Context())
release, acquired := f.svc.acquireTeardownLease(ctx, testAccountID, "alpha")
lease, acquired := f.svc.acquireTeardownLease(ctx, testAccountID, "alpha")
require.True(t, acquired)
cancel()
release()
lease.release()
_, err := f.svc.leaderKV.Get(t.Context(), teardownLeaderKey(testAccountID, "alpha"))
require.ErrorIs(t, err, jetstream.ErrKeyNotFound)
@@ -423,8 +431,7 @@ func TestDeleteCluster_ManagedCPVPCReclaimsCustomerVPCSGs(t *testing.T) {
f.sg.existing["eks-cluster-alpha-control-plane-sg|vpc-aaa"] = "sg-cust-cp"
f.sg.existing["eks-cluster-alpha-nodegroup-sg|vpc-aaa"] = "sg-cust-ng"
_, err = f.svc.DeleteCluster(context.Background(), deleteInput("alpha"), testAccountID)
require.NoError(t, err)
deleteAndSettle(t, f.svc, "alpha")
var deleted []string
for _, c := range f.sg.deleteCalls {
@@ -463,12 +470,9 @@ func TestDeleteCluster_ManagedCPVPCAlreadyGoneClearsStateRecord(t *testing.T) {
}
f.nlb.deleteLBErr = errors.New("elbv2 unavailable")
_, err = f.svc.DeleteCluster(context.Background(), deleteInput("alpha"), testAccountID)
require.Error(t, err, "the unrelated NLB failure must still surface")
deleteAndSettle(t, f.svc, "alpha")
stuck, getErr := GetClusterMeta(t.Context(), f.kv, "alpha")
require.NoError(t, getErr, "meta must survive for retry")
assert.Equal(t, ClusterStatusDeleting, stuck.Status)
stuck := requireTeardownRetained(t, f.kv, "alpha")
assert.Nil(t, stuck.ManagedCPVPC,
"the internal cp-vpc state record must be cleared once its VPC converges to deleted, independent of the NLB failure")
@@ -476,10 +480,9 @@ func TestDeleteCluster_ManagedCPVPCAlreadyGoneClearsStateRecord(t *testing.T) {
// and must NOT re-attempt any CP-VPC/SG calls against the stale VpcId,
// since the state record is already cleared.
f.nlb.deleteLBErr = nil
_, err = f.svc.DeleteCluster(context.Background(), deleteInput("alpha"), testAccountID)
require.NoError(t, err, "the re-drive must converge to fully deleted")
redrive(t, f.svc)
_, getErr = GetClusterMeta(t.Context(), f.kv, "alpha")
_, getErr := GetClusterMeta(t.Context(), f.kv, "alpha")
assert.ErrorIs(t, getErr, ErrClusterNotFound, "meta must be swept once teardown converges")
}
@@ -11,20 +11,20 @@ import (
"github.com/nats-io/nats.go/jetstream"
)
// deletingReapMinAge is how long a cluster must sit in DELETING before the
// backstop reaper re-drives its teardown. It must exceed a healthy synchronous
// DeleteCluster so the reaper never races an in-flight delete; only a wedged
// teardown (failed first attempt with no client retry) is re-driven. Var for tests.
// deletingReapMinAge is the base of the reaper's retry backoff after a failed
// teardown pass. It is not a race guard: the teardown lease and the in-process
// gate already keep a second converger off a cluster. Var for tests.
var deletingReapMinAge = 90 * time.Second
// EKSDeletingReaper is the ADR-0003:S2 (behaviour; mechanism from historic Lifecycle README rule #4) tracked-async-teardown backstop. A
// DeleteCluster whose synchronous teardown fails leaves the cluster in DELETING
// with its billable infra (NAT-GW EIP, NLB, CP VPC) still allocated and nothing
// to re-drive it: the per-cluster reconciler exits on DELETING and the billable
// reaper only acts once the cluster meta is GONE. This reaper finds clusters
// stuck in DELETING past deletingReapMinAge and re-runs purgeClusterInfra to
// completion, idempotently, under the per-cluster reconciler leader lease so only
// one node drives each teardown.
// deleteReapBackoffCap bounds the retry interval, so a cluster whose teardown
// keeps failing is still retried every half hour for as long as it takes.
var deleteReapBackoffCap = 30 * time.Minute
// EKSDeletingReaper is the backstop for recorded cluster deletes (ADR-0003:S2,
// S8). DeleteCluster records DELETING and starts the teardown itself; this
// reaper finds every DELETING record whose teardown is not running, because the
// kick died with its daemon or a pass failed, and hands it to the same
// converger. Node-local: the lease decides which node runs each cluster.
type EKSDeletingReaper struct {
svc *EKSServiceImpl
minAge time.Duration
@@ -32,7 +32,7 @@ type EKSDeletingReaper struct {
var _ vm.Reaper = (*EKSDeletingReaper)(nil)
// NewDeletingReaper builds the EKS wedged-DELETING teardown backstop.
// NewDeletingReaper builds the EKS DELETING teardown backstop.
func (s *EKSServiceImpl) NewDeletingReaper() *EKSDeletingReaper {
return &EKSDeletingReaper{svc: s, minAge: deletingReapMinAge}
}
@@ -40,9 +40,9 @@ func (s *EKSServiceImpl) NewDeletingReaper() *EKSDeletingReaper {
func (r *EKSDeletingReaper) Class() string { return "eks-deleting" }
func (r *EKSDeletingReaper) Scope() vm.ReaperScope { return vm.ScopeNodeLocal }
// Sweep re-drives every cluster stuck in DELETING past minAge. Idempotent:
// purgeClusterInfra tolerates already-gone resources, so a clean prior delete is
// a no-op and the meta is swept on success.
// Sweep hands every due DELETING cluster to a background converger and
// returns how many it started. It never runs a teardown inline, since the GC
// runs every reaper in sequence and one teardown can take minutes.
func (r *EKSDeletingReaper) Sweep(ctx context.Context) (int, error) {
if !r.svc.depsReadyForOrchestration() {
return 0, nil
@@ -51,18 +51,18 @@ func (r *EKSDeletingReaper) Sweep(ctx context.Context) (int, error) {
if err != nil {
return 0, fmt.Errorf("jetstream: %w", err)
}
// A truncated bucket listing would silently leave a wedged teardown in place
// A truncated bucket listing would silently leave a recorded delete in place
// for another sweep interval, so an incomplete enumeration fails the sweep.
names, err := accountBucketNames(ctx, r.svc.deps.NATSConn)
if err != nil {
return 0, fmt.Errorf("enumerate account buckets: %w", err)
}
reaped := 0
started := 0
for _, name := range names {
select {
case <-ctx.Done():
return reaped, ctx.Err()
return started, ctx.Err()
default:
}
accountID := strings.TrimPrefix(name, KVBucketEKSAccountPrefix)
@@ -75,85 +75,47 @@ func (r *EKSDeletingReaper) Sweep(ctx context.Context) (int, error) {
continue
}
for _, cluster := range clusters {
n, err := r.reapCluster(ctx, accountID, acctKV, cluster)
if err != nil {
slog.Warn("eks-deleting: re-drive teardown failed", "cluster", cluster, "err", err)
meta, err := GetClusterMeta(ctx, acctKV, cluster)
if err != nil || !r.due(meta) {
continue
}
if r.svc.startTeardown(accountID, cluster) {
slog.Info("eks-deleting: resuming recorded cluster delete",
"cluster", cluster, "account", accountID, "attempts", meta.DeleteReapAttempts,
"exhausted", meta.DeleteReapExhausted)
started++
}
reaped += n
}
}
return reaped, nil
return started, nil
}
// deleteReapBackoff returns how long the reaper must wait since the last
// re-drive attempt before trying again: minAge on the first attempt, doubling
// after each subsequent failure. priorAttempts is the re-drive count already
// made (meta.DeleteReapAttempts), so the 2nd attempt waits 2×minAge, the 3rd
// waits 4×minAge, and so on — a permanently-failing purge backs off instead of
// re-driving on every GC tick.
func deleteReapBackoff(minAge time.Duration, priorAttempts int) time.Duration {
return minAge * time.Duration(1<<uint(priorAttempts))
}
// reapCluster re-drives one cluster's teardown if it is wedged in DELETING past
// minAge (and any subsequent backoff) and its leader lease can be acquired.
// Returns 1 when it completed a teardown, 0 otherwise. After maxDeleteReapAttempts
// consecutive failures it gives up permanently (DeleteReapExhausted) rather than
// re-driving a purge that keeps failing the same way forever.
func (r *EKSDeletingReaper) reapCluster(ctx context.Context, accountID string, acctKV jetstream.KeyValue, cluster string) (int, error) {
meta, err := GetClusterMeta(ctx, acctKV, cluster)
if err != nil {
return 0, nil // gone or unreadable: nothing to re-drive
}
// due reports whether a record is DELETING and past its retry backoff. A
// record no pass has touched is due at once; the gate and lease turn the
// sweep away if its kick is still running.
func (r *EKSDeletingReaper) due(meta *ClusterMeta) bool {
if meta.Status != ClusterStatusDeleting {
return 0, nil
return false
}
if meta.DeleteReapExhausted {
return 0, nil // backstop already gave up; needs operator intervention
if meta.DeleteReapAttempts == 0 || meta.LastDeleteReapAttempt.IsZero() {
return true
}
if !meta.DeletingSince.IsZero() && time.Since(meta.DeletingSince) < r.minAge {
return 0, nil // still within the in-flight synchronous-delete window
}
// backoffRef is the last re-drive attempt once one has happened, so the
// wait grows attempt-over-attempt instead of always measuring from
// DeletingSince (which would let backoff collapse back to minAge forever).
backoffRef := meta.DeletingSince
if !meta.LastDeleteReapAttempt.IsZero() {
backoffRef = meta.LastDeleteReapAttempt
}
if time.Since(backoffRef) < deleteReapBackoff(r.minAge, meta.DeleteReapAttempts) {
return 0, nil // backing off after a recent failed re-drive
}
release, ok := r.svc.acquireTeardownLease(ctx, accountID, cluster)
if !ok {
return 0, nil // a synchronous delete or another node's reaper owns this teardown
}
defer release()
attempts, err := RecordDeleteReapAttempt(ctx, acctKV, cluster)
if err != nil {
return 0, fmt.Errorf("record delete reap attempt: %w", err)
}
slog.Warn("eks-deleting: re-driving wedged DELETING teardown",
"cluster", cluster, "account", accountID, "deletingSince", meta.DeletingSince, "attempt", attempts)
if purgeErr := r.svc.purgeClusterInfra(context.Background(), accountID, cluster, meta, acctKV, true); purgeErr != nil {
// Persisted on every failed attempt, not only once the reaper gives
// up, so an operator inspecting a still-retrying cluster already sees
// why it is stuck.
if recErr := RecordDeleteReapFailure(context.Background(), acctKV, cluster, purgeErr); recErr != nil {
return 0, fmt.Errorf("record delete reap failure: %w", recErr)
}
if attempts >= maxDeleteReapAttempts {
slog.Error("eks-deleting: giving up on wedged DELETING teardown after repeated failures",
"cluster", cluster, "account", accountID, "attempts", attempts, "err", purgeErr.Error())
if markErr := MarkDeleteReapExhausted(context.Background(), acctKV, cluster); markErr != nil {
return 0, fmt.Errorf("mark delete reap exhausted: %w", markErr)
}
}
return 0, purgeErr
}
slog.Info("eks-deleting: teardown completed, meta swept", "cluster", cluster, "account", accountID)
return 1, nil
return time.Since(meta.LastDeleteReapAttempt) >= deleteReapBackoff(r.minAge, meta.DeleteReapAttempts)
}
// deleteReapBackoff is the wait after the attempts-th teardown pass before the
// next: minAge after the first, doubling after each, capped at
// deleteReapBackoffCap.
func deleteReapBackoff(minAge time.Duration, attempts int) time.Duration {
if attempts <= 0 {
return 0
}
d := minAge
for i := 1; i < attempts; i++ {
d *= 2
if d >= deleteReapBackoffCap {
return deleteReapBackoffCap
}
}
return min(d, deleteReapBackoffCap)
}
@@ -10,8 +10,8 @@ import (
"github.com/stretchr/testify/require"
)
// markDeleting transitions a seeded cluster to DELETING and backdates
// DeletingSince so the reaper treats it as wedged (past min-age).
// markDeleting records a seeded cluster as DELETING without starting its
// teardown, standing in for a delete whose kick died, and backdates it by age.
func markDeleting(t *testing.T, f *deleteClusterFixture, name string, age time.Duration) {
t.Helper()
require.NoError(t, SetClusterStatus(t.Context(), f.kv, name, ClusterStatusDeleting))
@@ -21,49 +21,62 @@ func markDeleting(t *testing.T, f *deleteClusterFixture, name string, age time.D
require.NoError(t, PutClusterMeta(t.Context(), f.kv, meta))
}
// TestRLC4_DeletingReaperReDrivesWedgedTeardown locks the contract: a cluster
// stuck in DELETING past min-age (its synchronous DeleteCluster failed and no
// client re-issued) must be re-driven to completion by the backstop reaper —
// infra torn down and meta swept — so its billable EIP is never stranded.
// backdateLastAttempt moves the last teardown pass past its backoff window.
func backdateLastAttempt(t *testing.T, f *deleteClusterFixture, name string) *ClusterMeta {
t.Helper()
meta, err := GetClusterMeta(t.Context(), f.kv, name)
require.NoError(t, err)
meta.LastDeleteReapAttempt = time.Now().UTC().Add(-2 * deleteReapBackoff(deletingReapMinAge, meta.DeleteReapAttempts))
require.NoError(t, PutClusterMeta(t.Context(), f.kv, meta))
return meta
}
// sweepAndSettle runs one reaper sweep and waits for what it handed off.
func sweepAndSettle(t *testing.T, f *deleteClusterFixture) int {
t.Helper()
n, err := f.svc.NewDeletingReaper().Sweep(context.Background())
require.NoError(t, err)
f.svc.WaitTeardowns()
return n
}
// TestRLC4_DeletingReaperReDrivesWedgedTeardown: a DELETING record whose
// teardown is not running (its kick died with the daemon) is finished by the
// reaper, infra torn down and record swept, so its billable EIP is not stranded.
func TestRLC4_DeletingReaperReDrivesWedgedTeardown(t *testing.T) {
t.Parallel()
f := newDeleteClusterFixture(t, "alpha")
markDeleting(t, f, "alpha", 10*time.Minute)
reaper := f.svc.NewDeletingReaper()
n, err := reaper.Sweep(context.Background())
require.NoError(t, err)
assert.Equal(t, 1, n, "the wedged DELETING cluster must be re-driven")
assert.Equal(t, 1, sweepAndSettle(t, f), "the recorded delete must be handed to a converger")
_, getErr := GetClusterMeta(t.Context(), f.kv, "alpha")
assert.ErrorIs(t, getErr, ErrClusterNotFound, "meta must be swept after the backstop completes teardown")
assert.GreaterOrEqual(t, len(f.inst.terminateCalls), 1, "CP VM must be terminated")
assert.ErrorIs(t, getErr, ErrClusterNotFound, "meta must be swept once the teardown completes")
assert.GreaterOrEqual(t, len(f.inst.terminated()), 1, "CP VM must be terminated")
assert.Len(t, f.eip.releaseCalls, 1, "the billable egress EIP must be released, not stranded")
}
// TestDeletingReaperSkipsFreshDelete: a cluster that just entered DELETING is
// within the in-flight synchronous-delete window; the reaper must not race it.
func TestDeletingReaperSkipsFreshDelete(t *testing.T) {
// TestDeletingReaperLeavesAHeldTeardownAlone: a teardown another node is
// running holds the lease, so the sweep starts nothing.
func TestDeletingReaperLeavesAHeldTeardownAlone(t *testing.T) {
t.Parallel()
f := newDeleteClusterFixture(t, "beta")
markDeleting(t, f, "beta", 1*time.Second) // younger than min-age
reaper := f.svc.NewDeletingReaper()
n, err := reaper.Sweep(context.Background())
markDeleting(t, f, "beta", time.Second)
_, err := f.svc.leaderKV.Create(t.Context(), teardownLeaderKey(testAccountID, "beta"), []byte("node-2"))
require.NoError(t, err)
assert.Equal(t, 0, n, "a freshly-DELETING cluster must be left to its in-flight delete")
assert.Equal(t, 0, sweepAndSettle(t, f))
meta, getErr := GetClusterMeta(t.Context(), f.kv, "beta")
require.NoError(t, getErr, "meta must survive — the reaper did not re-drive")
require.NoError(t, getErr)
assert.Equal(t, ClusterStatusDeleting, meta.Status)
assert.Empty(t, f.inst.terminateCalls, "no teardown must run within the min-age window")
assert.Empty(t, f.inst.terminated(), "the lease holder owns the teardown")
}
// TestDeletingReaper_EnumerationFailureIsReported: the KV bucket lister closes
// its channel the same way whether the listing completed or failed, so a sweep
// that ignored the terminal error would report "nothing wedged" for an
// unreachable JetStream — leaving a stuck teardown holding its billable EIP with
// nothing to say the sweep never looked.
// unreachable JetStream.
func TestDeletingReaper_EnumerationFailureIsReported(t *testing.T) {
t.Parallel()
f := newDeleteClusterFixture(t, "delta")
@@ -72,141 +85,96 @@ func TestDeletingReaper_EnumerationFailureIsReported(t *testing.T) {
// Closing the connection fails the stream-names request behind the listing.
f.svc.deps.NATSConn.Close()
reaper := f.svc.NewDeletingReaper()
n, err := reaper.Sweep(context.Background())
n, err := f.svc.NewDeletingReaper().Sweep(context.Background())
require.Error(t, err, "a failed bucket enumeration must not report a completed sweep")
assert.Zero(t, n)
}
// TestDeletingReaper_BacksOffAfterFailedAttempt guards against the re-drive
// re-running on every 2-minute GC tick with no backoff: a permanently-failing
// purge must not be retried again until the exponential backoff window (2×
// minAge after 1 prior attempt) has elapsed, and the attempt is persisted so
// the window survives a restart. No sleeping — the clock is advanced by
// backdating LastDeleteReapAttempt directly.
// TestDeletingReaper_BacksOffAfterFailedAttempt: a failing teardown is not
// re-run on every GC tick; the next pass waits out the backoff measured from
// the persisted attempt time, so the window survives a restart.
func TestDeletingReaper_BacksOffAfterFailedAttempt(t *testing.T) {
t.Parallel()
f := newDeleteClusterFixture(t, "alpha")
f.inst.terminateErr = errors.New("hypervisor unreachable")
markDeleting(t, f, "alpha", 10*time.Minute)
reaper := f.svc.NewDeletingReaper()
n, err := reaper.Sweep(context.Background())
require.NoError(t, err, "Sweep logs and continues past a single cluster's re-drive failure")
assert.Equal(t, 0, n)
assert.Len(t, f.inst.terminateCalls, 1, "the first re-drive attempt must run")
assert.Equal(t, 1, sweepAndSettle(t, f))
assert.Len(t, f.inst.terminated(), 1, "the first pass must run")
meta, getErr := GetClusterMeta(t.Context(), f.kv, "alpha")
require.NoError(t, getErr)
assert.Equal(t, 1, meta.DeleteReapAttempts, "the attempt must be counted")
assert.False(t, meta.LastDeleteReapAttempt.IsZero(), "the attempt time must be stamped")
// Immediately re-sweeping must be a no-op: still inside the backoff window.
n, err = reaper.Sweep(context.Background())
require.NoError(t, err)
assert.Equal(t, 0, n)
assert.Len(t, f.inst.terminateCalls, 1, "must not re-drive again inside the backoff window")
// Advance past the backoff window without sleeping, by backdating the
// last-attempt timestamp; the reaper must then re-drive again.
meta.LastDeleteReapAttempt = time.Now().UTC().Add(-2 * deleteReapBackoff(deletingReapMinAge, meta.DeleteReapAttempts))
require.NoError(t, PutClusterMeta(t.Context(), f.kv, meta))
n, err = reaper.Sweep(context.Background())
require.NoError(t, err)
assert.Equal(t, 0, n)
assert.Len(t, f.inst.terminateCalls, 2, "must re-drive again once the backoff window elapses")
assert.Equal(t, 0, sweepAndSettle(t, f), "inside the backoff window nothing is started")
assert.Len(t, f.inst.terminated(), 1)
backdateLastAttempt(t, f, "alpha")
assert.Equal(t, 1, sweepAndSettle(t, f))
assert.Len(t, f.inst.terminated(), 2, "the next pass runs once the window elapses")
meta, getErr = GetClusterMeta(t.Context(), f.kv, "alpha")
require.NoError(t, getErr)
assert.Equal(t, 2, meta.DeleteReapAttempts)
}
// TestDeletingReaper_ExhaustsAfterMaxAttempts guards the terminal give-up: a
// purge that keeps failing the same way forever (e.g. an unretriable
// DependencyViolation) must stop being re-driven after maxDeleteReapAttempts,
// not loop forever on every GC tick. It also checks the incomplete-cleanup
// invariant (historic PROP-LIFECYCLE-006 §6; behaviour per ADR-0003:S2): an exhausted cluster must stay DELETING — its infra stays tracked
// and billable — not silently vanish or move to some other status.
func TestDeletingReaper_ExhaustsAfterMaxAttempts(t *testing.T) {
// TestDeletingReaper_KeepsRetryingPastTheOldAttemptLimit: once the request no
// longer reports a teardown failure, abandoning the record would hold its
// capacity behind a 200 forever. Past maxDeleteReapAttempts the record is
// flagged for an operator, stays DELETING, and is still retried.
func TestDeletingReaper_KeepsRetryingPastTheOldAttemptLimit(t *testing.T) {
t.Parallel()
f := newDeleteClusterFixture(t, "alpha")
f.inst.terminateErr = errors.New("permanent hypervisor failure")
markDeleting(t, f, "alpha", 10*time.Minute)
reaper := f.svc.NewDeletingReaper()
for i := 1; i <= maxDeleteReapAttempts; i++ {
n, err := reaper.Sweep(context.Background())
require.NoError(t, err, "Sweep logs and continues past a single cluster's re-drive failure")
assert.Equal(t, 0, n)
meta, getErr := GetClusterMeta(t.Context(), f.kv, "alpha")
require.NoError(t, getErr)
assert.Equal(t, i, meta.DeleteReapAttempts, "attempt count after sweep %d", i)
if i < maxDeleteReapAttempts {
assert.False(t, meta.DeleteReapExhausted, "must not exhaust before maxDeleteReapAttempts")
// Fast-forward past the next backoff window without sleeping.
meta.LastDeleteReapAttempt = time.Now().UTC().Add(-2 * deleteReapBackoff(deletingReapMinAge, meta.DeleteReapAttempts))
require.NoError(t, PutClusterMeta(t.Context(), f.kv, meta))
}
for i := 1; i <= maxDeleteReapAttempts+2; i++ {
assert.Equal(t, 1, sweepAndSettle(t, f), "pass %d must be started", i)
backdateLastAttempt(t, f, "alpha")
}
meta, getErr := GetClusterMeta(t.Context(), f.kv, "alpha")
require.NoError(t, getErr)
assert.True(t, meta.DeleteReapExhausted, "the backstop must give up after maxDeleteReapAttempts")
assert.Equal(t, maxDeleteReapAttempts+2, meta.DeleteReapAttempts)
assert.True(t, meta.DeleteReapExhausted, "a long-failing teardown is flagged for an operator")
assert.Equal(t, ClusterStatusDeleting, meta.Status,
"ADR-0003:S2 behaviour; historic PROP-LIFECYCLE-006 §6 mechanism, incomplete-cleanup invariant: an exhausted cluster must stay DELETING")
terminateCallsAtExhaustion := len(f.inst.terminateCalls)
// Even long after exhaustion, further sweeps must never re-drive it again.
meta.LastDeleteReapAttempt = time.Now().UTC().Add(-24 * time.Hour)
require.NoError(t, PutClusterMeta(t.Context(), f.kv, meta))
n, err := reaper.Sweep(context.Background())
require.NoError(t, err, "a skipped exhausted cluster must not surface a sweep error")
assert.Equal(t, 0, n)
assert.Len(t, f.inst.terminateCalls, terminateCallsAtExhaustion, "an exhausted cluster must never be re-driven again")
"ADR-0003:S2: incomplete cleanup keeps its record DELETING")
assert.Len(t, f.inst.terminated(), maxDeleteReapAttempts+2, "every pass retried the teardown")
}
// TestDeletingReaper_PersistsLastErrorOnFailure guards the second cleanup condition:
// a terminal give-up must carry the last error, not just a bare boolean flag.
// The error must be visible on the very first failed attempt (so an operator
// checking a still-retrying cluster already sees why), and the same error must
// still be there once the reaper exhausts its attempts and gives up.
// TestDeletingReaper_ResumesAnAlreadyExhaustedRecord: a record an older build
// flagged exhausted and stopped retrying is picked up and finished.
func TestDeletingReaper_ResumesAnAlreadyExhaustedRecord(t *testing.T) {
t.Parallel()
f := newDeleteClusterFixture(t, "alpha")
markDeleting(t, f, "alpha", 48*time.Hour)
meta, err := GetClusterMeta(t.Context(), f.kv, "alpha")
require.NoError(t, err)
meta.DeleteReapAttempts = maxDeleteReapAttempts
meta.DeleteReapExhausted = true
meta.LastDeleteReapAttempt = time.Now().UTC().Add(-24 * time.Hour)
require.NoError(t, PutClusterMeta(t.Context(), f.kv, meta))
sweepAndSettle(t, f)
_, getErr := GetClusterMeta(t.Context(), f.kv, "alpha")
assert.ErrorIs(t, getErr, ErrClusterNotFound, "an exhausted record must be retried and, once it succeeds, swept")
assert.Len(t, f.inst.terminated(), 1)
}
// TestDeletingReaper_PersistsLastErrorOnFailure: the failure reason is on the
// record from the first failed pass, so an operator sees why it is stuck.
func TestDeletingReaper_PersistsLastErrorOnFailure(t *testing.T) {
t.Parallel()
f := newDeleteClusterFixture(t, "alpha")
f.inst.terminateErr = errors.New("dependency violation: eni still attached")
markDeleting(t, f, "alpha", 10*time.Minute)
reaper := f.svc.NewDeletingReaper()
n, err := reaper.Sweep(context.Background())
require.NoError(t, err)
assert.Equal(t, 0, n)
sweepAndSettle(t, f)
meta, getErr := GetClusterMeta(t.Context(), f.kv, "alpha")
require.NoError(t, getErr)
assert.Contains(t, meta.LastDeleteReapError, "dependency violation",
"the failure reason must be persisted after the very first failed attempt")
for i := 2; i <= maxDeleteReapAttempts; i++ {
meta.LastDeleteReapAttempt = time.Now().UTC().Add(-2 * deleteReapBackoff(deletingReapMinAge, meta.DeleteReapAttempts))
require.NoError(t, PutClusterMeta(t.Context(), f.kv, meta))
_, err := reaper.Sweep(context.Background())
require.NoError(t, err)
meta, getErr = GetClusterMeta(t.Context(), f.kv, "alpha")
require.NoError(t, getErr)
}
assert.True(t, meta.DeleteReapExhausted, "the backstop must have given up by now")
assert.Contains(t, meta.LastDeleteReapError, "dependency violation",
"the last error must still be carried once the cluster reaches its terminal give-up")
assert.Contains(t, meta.LastDeleteReapError, "dependency violation")
assert.Equal(t, stepControlPlane, meta.DeleteFailedStep)
}
// TestDeletingReaperSkipsNonDeleting: a CREATING/ACTIVE cluster is never touched.
@@ -214,13 +182,18 @@ func TestDeletingReaperSkipsNonDeleting(t *testing.T) {
t.Parallel()
f := newDeleteClusterFixture(t, "gamma") // stays CREATING
reaper := f.svc.NewDeletingReaper()
n, err := reaper.Sweep(context.Background())
require.NoError(t, err)
assert.Equal(t, 0, n)
assert.Equal(t, 0, sweepAndSettle(t, f))
meta, getErr := GetClusterMeta(t.Context(), f.kv, "gamma")
require.NoError(t, getErr)
assert.Equal(t, ClusterStatusCreating, meta.Status)
assert.Empty(t, f.inst.terminateCalls)
assert.Empty(t, f.inst.terminated())
}
func TestDeleteReapBackoffIsCapped(t *testing.T) {
t.Parallel()
assert.Zero(t, deleteReapBackoff(time.Minute, 0))
assert.Equal(t, time.Minute, deleteReapBackoff(time.Minute, 1))
assert.Equal(t, 4*time.Minute, deleteReapBackoff(time.Minute, 3))
assert.Equal(t, deleteReapBackoffCap, deleteReapBackoff(time.Minute, 200), "no overflow, and never past the cap")
}
@@ -97,6 +97,7 @@ func (f *fakeK3sVPC) DescribeNetworkInterfaces(_ context.Context, input *ec2.Des
}
type fakeK3sInst struct {
mu sync.Mutex
launchCalls []*systeminstance.SystemInstanceInput
launchNodes []string // TargetNodeID per launch (parallel to launchCalls)
terminateCalls []string
@@ -104,6 +105,20 @@ type fakeK3sInst struct {
launchOut *systeminstance.SystemInstanceOutput
launchErr error
terminateErr error
// launchHook / terminateHook run before the call records, unlocked, so a
// test can hold a launch or a teardown at that step.
launchHook func()
terminateHook func(instanceID string)
// uniqueIDs makes each launch return a fresh instance ID.
uniqueIDs bool
}
// terminated returns a copy of the terminate calls so far.
func (f *fakeK3sInst) terminated() []string {
f.mu.Lock()
defer f.mu.Unlock()
return append([]string(nil), f.terminateCalls...)
}
var _ k3sInstanceLauncher = (*fakeK3sInst)(nil)
@@ -113,6 +128,11 @@ func (f *fakeK3sInst) LaunchSystemInstance(input *systeminstance.SystemInstanceI
}
func (f *fakeK3sInst) LaunchSystemInstanceOnNode(nodeID string, input *systeminstance.SystemInstanceInput) (*systeminstance.SystemInstanceOutput, error) {
if f.launchHook != nil {
f.launchHook()
}
f.mu.Lock()
defer f.mu.Unlock()
f.launchCalls = append(f.launchCalls, input)
f.launchNodes = append(f.launchNodes, nodeID)
if f.launchErr != nil {
@@ -121,10 +141,18 @@ func (f *fakeK3sInst) LaunchSystemInstanceOnNode(nodeID string, input *systemins
if f.launchOut != nil {
return f.launchOut, nil
}
if f.uniqueIDs {
return &systeminstance.SystemInstanceOutput{InstanceID: fmt.Sprintf("i-launch-%d", len(f.launchCalls))}, nil
}
return &systeminstance.SystemInstanceOutput{InstanceID: "i-aaa111"}, nil
}
func (f *fakeK3sInst) TerminateSystemInstance(instanceID string) error {
if f.terminateHook != nil {
f.terminateHook(instanceID)
}
f.mu.Lock()
defer f.mu.Unlock()
f.terminateCalls = append(f.terminateCalls, instanceID)
return f.terminateErr
}
@@ -6,6 +6,7 @@ import (
"testing"
"github.com/aws/aws-sdk-go/aws"
"github.com/aws/aws-sdk-go/service/eks"
"github.com/aws/aws-sdk-go/service/elbv2"
awserrors "github.com/mulgadc/spinifex/spinifex/foundation/aws/errors"
"github.com/stretchr/testify/assert"
@@ -25,24 +26,46 @@ func TestRLC1_EKSDeleteClusterNotFoundOnAbsent(t *testing.T) {
assert.Empty(t, f.inst.terminateCalls, "an absent cluster must trigger no billable teardown")
}
// TestRLC2_EKSBillableTeardownBeforeKVSweep exercises the historic PROP-LIFECYCLE-006 §6 (behaviour per ADR-0003:S2) billable-before-
// sweep ordering: the KV sweep (DeleteClusterPrefix) must NOT run while any
// billable teardown (OIDC / NLB / VM / EIP) returned an error. A failed VM
// terminate must leave the cluster meta in DELETING — its resource ARNs/IDs
// intact for a retry — never erased out from under a still-running billable VM.
// Regression guard for the errors.Join-before-sweep ordering in purgeClusterInfra.
// TestRLC2_EKSBillableTeardownBeforeKVSweep pins billable-before-sweep
// (ADR-0003:S2): no key under the cluster is removed while a billable teardown
// step (OIDC / NLB / VM / EIP) is failing. The request accepts the delete; the
// failed terminate leaves the record DELETING with every ref intact, surfaces
// the failure in DescribeCluster, and a later pass with the fault gone sweeps it.
func TestRLC2_EKSBillableTeardownBeforeKVSweep(t *testing.T) {
t.Parallel()
f := newDeleteClusterFixture(t, "alpha")
f.inst.terminateErr = errors.New("hypervisor unreachable")
_, err := f.kv.Put(t.Context(), "clusters/alpha/addons/vpc-cni", []byte(`{}`))
require.NoError(t, err)
_, err := f.svc.DeleteCluster(context.Background(), deleteInput("alpha"), testAccountID)
require.Error(t, err, "ADR-0003:S2 behaviour; historic PROP-LIFECYCLE-006 §6 mechanism: a failed billable teardown must surface, not be swallowed")
out, err := f.svc.DeleteCluster(context.Background(), deleteInput("alpha"), testAccountID)
require.NoError(t, err, "ADR-0003:S8: the request records the delete and returns; the teardown's outcome is not its answer")
assert.Equal(t, string(ClusterStatusDeleting), aws.StringValue(out.Cluster.Status))
f.svc.WaitTeardowns()
require.Len(t, f.inst.terminateCalls, 1, "the VM teardown must have been attempted")
require.Len(t, f.inst.terminated(), 1, "the VM teardown must have been attempted")
meta, getErr := GetClusterMeta(t.Context(), f.kv, "alpha")
require.NoErrorf(t, getErr, "ADR-0003:S2 behaviour; historic PROP-LIFECYCLE-006 §6 mechanism, billable-before-sweep: the KV sweep must NOT run while a billable teardown is failing — the meta must survive for retry")
require.NoErrorf(t, getErr, "ADR-0003:S2 billable-before-sweep: the record must survive a failing billable teardown")
assert.Equal(t, ClusterStatusDeleting, meta.Status, "a cluster with failed teardown must stay DELETING")
assert.Equal(t, "i-aaa111", meta.ControlPlaneInstanceID, "the stranded VM's ID must stay recorded for the retry")
assert.Equal(t, "eipalloc-fake01", meta.EgressEIPAllocationID)
assert.NotEmpty(t, meta.NLBArn)
assert.Contains(t, meta.LastDeleteReapError, "hypervisor unreachable")
_, keyErr := f.kv.Get(t.Context(), "clusters/alpha/addons/vpc-cni")
require.NoError(t, keyErr, "the KV sweep must not have run: the cluster's child records stay with it")
desc, err := f.svc.DescribeCluster(context.Background(), &eks.DescribeClusterInput{Name: aws.String("alpha")}, testAccountID)
require.NoError(t, err)
require.NotNil(t, desc.Cluster.Health, "the failure must surface to the caller, not be swallowed")
require.Len(t, desc.Cluster.Health.Issues, 1)
assert.Equal(t, eks.ClusterIssueCodeInternalFailure, aws.StringValue(desc.Cluster.Health.Issues[0].Code))
f.inst.mu.Lock()
f.inst.terminateErr = nil
f.inst.mu.Unlock()
redrive(t, f.svc)
_, getErr = GetClusterMeta(t.Context(), f.kv, "alpha")
require.ErrorIs(t, getErr, ErrClusterNotFound, "once the teardown succeeds the record is swept")
}
// TestRLC3_EKSNLBNoOrphanTargetGroupAfterDelete exercises the historic PROP-LIFECYCLE-006 §6 (behaviour per ADR-0003:S2) NLB
@@ -65,8 +88,7 @@ func TestRLC3_EKSNLBNoOrphanTargetGroupAfterDelete(t *testing.T) {
TargetGroupName: aws.String(tgName),
}
_, err := f.svc.DeleteCluster(context.Background(), deleteInput("alpha"), testAccountID)
require.NoError(t, err)
deleteAndSettle(t, f.svc, "alpha")
_, getErr := GetClusterMeta(t.Context(), f.kv, "alpha")
require.ErrorIs(t, getErr, ErrClusterNotFound, "a fully torn-down cluster must be swept")
@@ -236,6 +236,23 @@ func (b *NATSBootstrap) RunForKinds(ctx context.Context, kinds []string) error {
}
}
// clusterAcceptsArtifacts refuses a write into a cluster whose record is gone
// or being deleted: the teardown has swept, or is about to sweep, its prefix,
// and a key written now would outlive it into a later cluster of the same name.
func (b *NATSBootstrap) clusterAcceptsArtifacts(ctx context.Context) error {
meta, err := GetClusterMeta(ctx, b.kv, b.clusterName)
if err != nil {
if errors.Is(err, ErrClusterNotFound) {
return fmt.Errorf("%w: cluster record is gone", errBootstrapPermanent)
}
return err
}
if meta.Status == ClusterStatusDeleting {
return fmt.Errorf("%w: cluster is being deleted", errBootstrapPermanent)
}
return nil
}
func (b *NATSBootstrap) persistToken(ctx context.Context, data []byte) error {
env, err := unmarshalBootstrapEnvelope(data)
if err != nil {
@@ -248,6 +265,9 @@ func (b *NATSBootstrap) persistToken(ctx context.Context, data []byte) error {
if err != nil {
return fmt.Errorf("encrypt token: %w", err)
}
if err := b.clusterAcceptsArtifacts(ctx); err != nil {
return err
}
if _, err := b.kv.Put(ctx, NodeTokenKey(b.clusterName), []byte(ct)); err != nil {
return fmt.Errorf("kv put %s: %w", NodeTokenKey(b.clusterName), err)
}
@@ -266,6 +286,9 @@ func (b *NATSBootstrap) persistKubeconfig(ctx context.Context, data []byte) erro
if err != nil {
return fmt.Errorf("encrypt kubeconfig: %w", err)
}
if err := b.clusterAcceptsArtifacts(ctx); err != nil {
return err
}
if _, err := b.kv.Put(ctx, AdminKubeconfigKey(b.clusterName), []byte(ct)); err != nil {
return fmt.Errorf("kv put %s: %w", AdminKubeconfigKey(b.clusterName), err)
}
@@ -288,6 +311,9 @@ func (b *NATSBootstrap) persistJWKS(ctx context.Context, data []byte) error {
return err
}
// Reconciler gates ACTIVE on this verified-marker, not OIDCJWKSKey (pre-seeded by controller).
if err := b.clusterAcceptsArtifacts(ctx); err != nil {
return err
}
if _, err := b.kv.Put(ctx, OIDCJWKSVerifiedKey(b.clusterName), []byte("verified")); err != nil {
return fmt.Errorf("kv put %s: %w", OIDCJWKSVerifiedKey(b.clusterName), err)
}
+8 -2
View File
@@ -42,7 +42,10 @@ type fakeNLBProvisioner struct {
tagsByArn map[string][]*elbv2.Tag // lb arn → tags (LBC ownership reap)
describeTagsCalls []*elbv2.DescribeTagsInput
describeTagsErr error
// describeLBHook, when set, runs at the start of DescribeLoadBalancers.
describeLBHook func(ctx context.Context)
describeTagsErr error
createLBOut *elbv2.CreateLoadBalancerOutput
createTGOut *elbv2.CreateTargetGroupOutput
@@ -128,7 +131,10 @@ func (f *fakeNLBProvisioner) CreateLoadBalancer(_ context.Context, input *elbv2.
return out, nil
}
func (f *fakeNLBProvisioner) DescribeLoadBalancers(_ context.Context, input *elbv2.DescribeLoadBalancersInput, _ string) (*elbv2.DescribeLoadBalancersOutput, error) {
func (f *fakeNLBProvisioner) DescribeLoadBalancers(ctx context.Context, input *elbv2.DescribeLoadBalancersInput, _ string) (*elbv2.DescribeLoadBalancersOutput, error) {
if f.describeLBHook != nil {
f.describeLBHook(ctx)
}
f.describeLBCalls = append(f.describeLBCalls, input)
out := &elbv2.DescribeLoadBalancersOutput{}
// No name/arn filter: list every LB (account-scoped in the real impl), as the
+7 -1
View File
@@ -431,7 +431,7 @@ func gpuFieldsForInstanceTypes(instanceTypes []string) (gpuEnabled bool, gpuVend
// nodegroup's cluster. Any failure only logs: a GPU nodegroup must still come
// up even if add-on staging fails.
func (s *EKSServiceImpl) stageGPUDeviceAddon(ctx context.Context, accountID, cluster string) {
acctKV, err := s.acctKVForCluster(ctx, accountID, cluster)
acctKV, err := s.acctKVForNewDependant(ctx, accountID, cluster)
if err == nil {
err = s.addons().EnsureGPUDevicePlugin(ctx, acctKV, accountID, cluster)
}
@@ -1239,6 +1239,12 @@ func (s *EKSServiceImpl) deleteNodegroup(ctx context.Context, acctKV jetstream.K
if err := DeleteNodegroupRecord(ctx, acctKV, cluster, ng); err != nil {
return nil, err
}
// A cluster delete waits on its node groups; the last one going lets it run.
if meta, err := GetClusterMeta(ctx, acctKV, cluster); err == nil && meta.Status == ClusterStatusDeleting {
if rest, err := ListNodegroupRecords(ctx, acctKV, cluster); err == nil && len(rest) == 0 {
s.startTeardown(accountID, cluster)
}
}
rec.Status = eks.NodegroupStatusDeleting
return &eks.DeleteNodegroupOutput{Nodegroup: nodegroupRecordToAWS(rec)}, nil
@@ -220,7 +220,7 @@ func TestCurrentBehaviour_ClusterPrefixDeletedIsNotFound(t *testing.T) {
require.NoError(t, err)
require.True(t, found, "sanity: JWKS published before the prefix is swept")
require.NoError(t, DeleteClusterPrefix(t.Context(), kv, jwksReaderCluster))
require.NoError(t, DeleteClusterChildren(t.Context(), kv, jwksReaderCluster))
raw, found, err := reader.LookupClusterJWKS(t.Context(), jwksReaderAccountID, jwksReaderCluster)
require.NoError(t, err)
assert.False(t, found)
+19 -2
View File
@@ -61,6 +61,23 @@ const p256CoordLen = 32
// GenerateClusterOIDCKeypair creates an ECDSA-P256 keypair, encrypts the private key PEM
// into KV, and writes the JWKS document. Returns plaintext PEM + JWKS for immediate use.
func GenerateClusterOIDCKeypair(ctx context.Context, kv jetstream.KeyValue, clusterName string, masterKey []byte) (privPEM string, jwksBytes []byte, err error) {
put := func(key string, value []byte) error {
_, err := kv.Put(ctx, key, value)
return err
}
return generateClusterOIDCKeypair(clusterName, masterKey, put)
}
// GenerateClusterOIDCKeypairFor is GenerateClusterOIDCKeypair for a create
// launch: each write lands only while the record is meta's generation, so a
// launch overtaken by a newer cluster of the same name cannot replace that
// cluster's signing key.
func GenerateClusterOIDCKeypairFor(ctx context.Context, kv jetstream.KeyValue, meta *ClusterMeta, masterKey []byte) (privPEM string, jwksBytes []byte, err error) {
put := func(key string, value []byte) error { return putIfGeneration(ctx, kv, meta, key, value) }
return generateClusterOIDCKeypair(meta.Name, masterKey, put)
}
func generateClusterOIDCKeypair(clusterName string, masterKey []byte, put func(key string, value []byte) error) (privPEM string, jwksBytes []byte, err error) {
if clusterName == "" {
return "", nil, errors.New("eks: GenerateClusterOIDCKeypair empty cluster name")
}
@@ -83,7 +100,7 @@ func GenerateClusterOIDCKeypair(ctx context.Context, kv jetstream.KeyValue, clus
if err != nil {
return "", nil, fmt.Errorf("encrypt OIDC private key: %w", err)
}
if _, err := kv.Put(ctx, OIDCSigningKeyKey(clusterName), []byte(ciphertext)); err != nil {
if err := put(OIDCSigningKeyKey(clusterName), []byte(ciphertext)); err != nil {
return "", nil, fmt.Errorf("kv put %s: %w", OIDCSigningKeyKey(clusterName), err)
}
@@ -91,7 +108,7 @@ func GenerateClusterOIDCKeypair(ctx context.Context, kv jetstream.KeyValue, clus
if err != nil {
return "", nil, err
}
if _, err := kv.Put(ctx, OIDCJWKSKey(clusterName), jwksBytes); err != nil {
if err := put(OIDCJWKSKey(clusterName), jwksBytes); err != nil {
return "", nil, fmt.Errorf("kv put %s: %w", OIDCJWKSKey(clusterName), err)
}
return string(pemBlock), jwksBytes, nil
+415 -114
View File
@@ -33,7 +33,6 @@ import (
resourcearn "github.com/mulgadc/spinifex/spinifex/foundation/aws/arn"
"github.com/mulgadc/spinifex/spinifex/foundation/aws/errors"
"github.com/mulgadc/spinifex/spinifex/foundation/lifecycle/idempotency"
"github.com/mulgadc/spinifex/spinifex/foundation/state/kvlease"
"github.com/mulgadc/spinifex/spinifex/providers/objectstore"
"github.com/nats-io/nats.go"
"github.com/nats-io/nats.go/jetstream"
@@ -264,6 +263,25 @@ type EKSServiceImpl struct {
// launchWG tracks in-flight async create launches for test determinism (WaitLaunches).
launchWG sync.WaitGroup
// launchStepHook, when set by a test, runs at named points in the launch.
launchStepHook func(step string)
// launchHeartbeatEvery is how often a running launch refreshes its record's
// LaunchHeartbeat; launchStaleAfter is how old a heartbeat may be before the
// teardown stops waiting for the launch. Tests inject small values.
launchHeartbeatEvery time.Duration
launchStaleAfter time.Duration
// teardownRunning holds the clusters this process is tearing down, the
// in-process gate the teardown lease cannot provide; teardownWG tracks them.
teardownMu sync.Mutex
teardownRunning map[string]struct{}
teardownWG sync.WaitGroup
// teardownKV, when set by a test, replaces the converger's account bucket.
teardownKV func(ctx context.Context, accountID string) (jetstream.KeyValue, error)
// bootstraps cancels the bootstrap subscribers this process runs, by cluster.
bootstrapMu sync.Mutex
bootstraps map[string]*bootstrapRun
// baseDomain is the northstar default_domain; "" disables endpoint DNS naming
// (the cluster then publishes its bare IP:port endpoint as before).
@@ -300,6 +318,13 @@ func (s *EKSServiceImpl) WaitLaunches() { s.launchWG.Wait() }
const defaultK8sVersion = "1.32"
// A running launch heartbeats every 20 s; one silent for 90 s is taken as
// crashed, so the teardown waits for a live launch and not for a dead one.
const (
defaultLaunchHeartbeatEvery = 20 * time.Second
defaultLaunchStaleAfter = 90 * time.Second
)
// NewEKSServiceImpl initialises EKSServiceImpl, wiring the leader KV and reconciler registry.
func NewEKSServiceImpl(deps EKSServiceDeps) (*EKSServiceImpl, error) {
if err := addon.ValidateCatalog(); err != nil {
@@ -333,6 +358,8 @@ func NewEKSServiceImpl(deps EKSServiceDeps) (*EKSServiceImpl, error) {
workerLaunchRetryTimeout: defaultWorkerLaunchRetryTimeout,
workerLaunchRetryBackoff: defaultWorkerLaunchRetryBackoff,
spawnScanRetryBackoff: defaultSpawnScanRetryBackoff,
launchHeartbeatEvery: defaultLaunchHeartbeatEvery,
launchStaleAfter: defaultLaunchStaleAfter,
}, nil
}
@@ -613,6 +640,8 @@ func (s *EKSServiceImpl) CreateCluster(ctx context.Context, input *eks.CreateClu
Logging: clusterLoggingFromInput(input.Logging),
Tags: aws.StringValueMap(input.Tags),
CreatedAt: time.Now().UTC(),
LaunchOwner: s.launchOwnerID(),
LaunchHeartbeat: time.Now().UTC(),
}
// Claim the cluster name before any launching; duplicate/retry handlers lose the claim.
if err := s.claimClusterName(ctx, accountID, acctKV, meta); err != nil {
@@ -709,6 +738,9 @@ type clusterLaunchCtx struct {
// launch runs after the CREATING response was already sent, so a failure can
// only surface as status, never as the CreateCluster return value.
func (s *EKSServiceImpl) failClusterLaunch(ctx context.Context, kv jetstream.KeyValue, name, accountID string, meta *ClusterMeta, stage string, err error) {
if s.launchSuperseded(ctx, kv, name, accountID, meta, err) {
return
}
_ = logCreateErr(name, accountID, stage, err)
// Release any billable infra already provisioned this launch (LB VM + its
// associated EIP, egress EIP, NLB, CP VMs, OIDC) so a failed create never
@@ -717,9 +749,9 @@ func (s *EKSServiceImpl) failClusterLaunch(ctx context.Context, kv jetstream.Key
// internet-facing LB VM's EIP leaks permanently. Operate on the in-memory
// meta: a failure in PutClusterMeta itself leaves the freshest refs only
// here, not in the KV record. Best-effort; the FAILED status is recorded
// regardless (deleteMeta=false keeps the meta for DescribeCluster/reclaim).
// regardless; the meta is kept for DescribeCluster and the reclaim.
if meta != nil {
if perr := s.purgeClusterInfra(ctx, accountID, name, meta, kv, false); perr != nil {
if perr := s.purgeClusterInfra(ctx, accountID, name, meta, kv); perr != nil {
slog.WarnContext(ctx, "failClusterLaunch: infra purge incomplete", "cluster", name, "stage", stage, "err", perr)
}
}
@@ -728,6 +760,162 @@ func (s *EKSServiceImpl) failClusterLaunch(ctx context.Context, kv jetstream.Key
}
}
// launchSuperseded stops a launch that no longer owns its record, and makes
// sure what it built is still owned by something. A DELETING record has the
// launch's refs and the teardown owns them. A removed record is recreated as
// DELETING with those refs, so the teardown and its reaper own them too. A
// record that now belongs to a newer cluster gets nothing: the launch releases
// only the instances it launched itself, by ID, never anything found by name.
func (s *EKSServiceImpl) launchSuperseded(ctx context.Context, kv jetstream.KeyValue, name, accountID string, meta *ClusterMeta, err error) bool {
if meta == nil {
return false
}
if !errors.Is(err, errLaunchSuperseded) {
err = persistLaunchMeta(ctx, kv, meta)
if !errors.Is(err, errLaunchSuperseded) {
return false
}
}
slog.InfoContext(ctx, "launchClusterInfra: stopping, the cluster no longer owns its record",
"cluster", name, "accountID", accountID, "reason", err)
switch {
case errors.Is(err, ErrClusterNotFound):
if s.adoptLaunchForTeardown(ctx, kv, accountID, meta) {
return true
}
s.releaseLaunchInstances(ctx, kv, accountID, meta)
case errors.Is(err, errLaunchNewGeneration):
s.releaseLaunchInstances(ctx, kv, accountID, meta)
}
return true
}
// launchOwnerID names this process as a launch owner in the record.
func (s *EKSServiceImpl) launchOwnerID() string {
if s.deps.HolderID != "" {
return s.deps.HolderID
}
return "local"
}
// heartbeatLaunch refreshes the record's LaunchHeartbeat while the launch runs,
// so the teardown of a cluster deleted mid-launch waits for the launch to hand
// its refs over. A record that is DELETING, gone or a newer cluster's cancels
// the launch, which then stops at its next step. The returned func stops it.
func (s *EKSServiceImpl) heartbeatLaunch(ctx context.Context, cancel context.CancelFunc, kv jetstream.KeyValue, meta *ClusterMeta) func() {
name, owner, createdAt, arn := meta.Name, meta.LaunchOwner, meta.CreatedAt, meta.Arn
every := s.launchHeartbeatEvery
done := make(chan struct{})
stopped := make(chan struct{})
go func() {
defer close(stopped)
if every <= 0 {
<-done
return
}
ticker := time.NewTicker(every)
defer ticker.Stop()
for {
select {
case <-done:
return
case <-ctx.Done():
return
case <-ticker.C:
}
stop := false
err := casUpdateMeta(ctx, kv, name, func(m *ClusterMeta) bool {
stop = false
if !m.CreatedAt.Equal(createdAt) || m.Arn != arn || m.LaunchOwner != owner || m.Status != ClusterStatusCreating {
stop = true
return false
}
m.LaunchHeartbeat = time.Now().UTC()
return true
})
if stop || errors.Is(err, ErrClusterNotFound) {
cancel()
return
}
}
}()
return func() {
close(done)
<-stopped
}
}
// endLaunch clears the record's launch owner when the launch returns, by
// whatever path. A record that is DELETING by then was waiting for this
// launch, so its teardown is started now rather than at the next sweep.
func (s *EKSServiceImpl) endLaunch(kv jetstream.KeyValue, accountID string, meta *ClusterMeta) {
ctx := context.WithoutCancel(s.bgCtx)
deleting := false
err := casUpdateMeta(ctx, kv, meta.Name, func(m *ClusterMeta) bool {
deleting = m.Status == ClusterStatusDeleting
if !sameGeneration(m, meta) {
deleting = false
return false
}
return clearLaunchOwner(m)
})
if err != nil && !errors.Is(err, ErrClusterNotFound) {
slog.WarnContext(ctx, "launchClusterInfra: clear launch owner", "cluster", meta.Name, "err", err)
}
if deleting {
s.startTeardown(accountID, meta.Name)
}
}
// adoptLaunchForTeardown recreates a removed record as DELETING with the
// launch's refs and hands it to the teardown. It reports false if the name was
// claimed again first.
func (s *EKSServiceImpl) adoptLaunchForTeardown(ctx context.Context, kv jetstream.KeyValue, accountID string, meta *ClusterMeta) bool {
rec := *meta
rec.Status = ClusterStatusDeleting
rec.StatusReason = ""
rec.DeletingSince = time.Now().UTC()
rec.DeleteReapAttempts = 0
rec.LastDeleteReapAttempt = time.Time{}
rec.DeleteReapExhausted = false
rec.LastDeleteReapError = ""
rec.DeleteFailedStep = ""
rec.LaunchOwner = ""
data, err := json.Marshal(&rec)
if err != nil {
return false
}
if _, err := kv.Create(ctx, ClusterMetaKey(meta.Name), data); err != nil {
if !errors.Is(err, jetstream.ErrKeyExists) {
slog.WarnContext(ctx, "launchClusterInfra: record the overtaken launch for teardown", "cluster", meta.Name, "err", err)
}
return false
}
s.startTeardown(accountID, meta.Name)
return true
}
// releaseLaunchInstances terminates the control-plane instances this launch
// started that the record now under its name does not own. Instances are the
// only refs unique to one launch; name-keyed resources (load balancer, CP VPC,
// security groups) belong to whoever holds the name and are left to it.
func (s *EKSServiceImpl) releaseLaunchInstances(ctx context.Context, kv jetstream.KeyValue, accountID string, meta *ClusterMeta) {
current, _ := GetClusterMeta(ctx, kv, meta.Name)
infraAcct := accountID
if meta.ManagedCPVPC != nil {
infraAcct = awsidentifiers.GlobalAccountID
}
for _, cp := range controlPlaneTeardownNodes(meta) {
if current != nil && current.IsControlPlaneMember(cp.InstanceID) {
continue
}
if err := TerminateK3sServerVM(ctx, s.deps.VPCK3s, s.deps.Instance, infraAcct, cp.InstanceID, cp.ENIID); err != nil {
slog.WarnContext(ctx, "launchClusterInfra: release overtaken control-plane instance", "cluster", meta.Name,
"instanceId", cp.InstanceID, "err", err)
}
}
}
// launchClusterInfra is CreateCluster's slow phase (SGs, NLB, OIDC, CP VMs, egress, bootstrap).
// Runs on a background goroutine; every failure marks the cluster FAILED.
func (s *EKSServiceImpl) launchClusterInfra(ctx context.Context, lc clusterLaunchCtx) {
@@ -742,6 +930,11 @@ func (s *EKSServiceImpl) launchClusterInfra(ctx context.Context, lc clusterLaunc
meta := lc.meta
acctKV := lc.acctKV
ctx, cancel := context.WithCancel(ctx)
stopHeartbeat := s.heartbeatLaunch(ctx, cancel, acctKV, meta)
defer s.endLaunch(acctKV, accountID, meta)
defer stopHeartbeat()
// Build the managed control-plane VPC ("Set B") under the system account: the
// AWS-managed-account analogue the customer never provisions. The
// internet-facing NLB lives in its public subnet and the control-plane VM(s)
@@ -758,7 +951,7 @@ func (s *EKSServiceImpl) launchClusterInfra(ctx context.Context, lc clusterLaunc
meta.ManagedCPVPC = managedCPVPCFromRefs(cpRefs)
// Persist the CP VPC refs before any further fallible step so teardown can
// reclaim the VPC/subnets/IGW/NAT GW even if the launch fails after here.
if err := PutClusterMeta(ctx, acctKV, meta); err != nil {
if err := persistLaunchMeta(ctx, acctKV, meta); err != nil {
s.failClusterLaunch(ctx, acctKV, name, accountID, meta, "persist managed CP VPC", err)
return
}
@@ -794,7 +987,7 @@ func (s *EKSServiceImpl) launchClusterInfra(ctx context.Context, lc clusterLaunc
return
}
meta.ResourcesVpcConfig.ClusterSecurityGroupId = custClusterSG
if err := PutClusterMeta(ctx, acctKV, meta); err != nil {
if err := persistLaunchMeta(ctx, acctKV, meta); err != nil {
s.failClusterLaunch(ctx, acctKV, name, accountID, meta, "persist cluster security group id", err)
return
}
@@ -816,7 +1009,7 @@ func (s *EKSServiceImpl) launchClusterInfra(ctx context.Context, lc clusterLaunc
}
meta.PrivateEndpointENIID = pe.ENIID
meta.PrivateEndpointIP = pe.ENIIP
if err := PutClusterMeta(ctx, acctKV, meta); err != nil {
if err := persistLaunchMeta(ctx, acctKV, meta); err != nil {
s.failClusterLaunch(ctx, acctKV, name, accountID, meta, "persist private endpoint refs", err)
return
}
@@ -868,7 +1061,7 @@ func (s *EKSServiceImpl) launchClusterInfra(ctx context.Context, lc clusterLaunc
// Persist the endpoint before publishing it so reconcile never observes a DNS
// record whose creating cluster lacks its desired endpoint metadata.
if err := PutClusterMeta(ctx, acctKV, meta); err != nil {
if err := persistLaunchMeta(ctx, acctKV, meta); err != nil {
s.failClusterLaunch(ctx, acctKV, name, accountID, meta, "persist endpoint metadata", err)
return
}
@@ -880,7 +1073,10 @@ func (s *EKSServiceImpl) launchClusterInfra(ctx context.Context, lc clusterLaunc
}
meta.OIDCIssuer = oidcIssuer
privPEM, _, err := GenerateClusterOIDCKeypair(ctx, acctKV, name, s.deps.MasterKey)
if s.launchStepHook != nil {
s.launchStepHook("oidc-keypair")
}
privPEM, _, err := GenerateClusterOIDCKeypairFor(ctx, acctKV, meta, s.deps.MasterKey)
if err != nil {
s.failClusterLaunch(ctx, acctKV, name, accountID, meta, "generate OIDC keypair", err)
return
@@ -978,7 +1174,7 @@ func (s *EKSServiceImpl) launchClusterInfra(ctx context.Context, lc clusterLaunc
// failure between here and the next PutClusterMeta leaves them launched but
// unrecorded, so neither DeleteCluster nor the FAILED-cluster reclaim could
// reach them and the sys.medium VMs leak.
if err := PutClusterMeta(ctx, acctKV, meta); err != nil {
if err := persistLaunchMeta(ctx, acctKV, meta); err != nil {
s.failClusterLaunch(ctx, acctKV, name, accountID, meta, "persist control-plane ids", err)
return
}
@@ -1002,17 +1198,30 @@ func (s *EKSServiceImpl) launchClusterInfra(ctx context.Context, lc clusterLaunc
return
}
if err := PutClusterMeta(ctx, acctKV, meta); err != nil {
if err := persistLaunchMeta(ctx, acctKV, meta); err != nil {
s.failClusterLaunch(ctx, acctKV, name, accountID, meta, "persist final meta", err)
return
}
// Seed creator system:masters AccessEntry so the token webhook can authenticate the creator immediately.
if s.launchStepHook != nil {
s.launchStepHook("seed-creator-admin")
}
if bootstrapCreatorAdmin(input) && callerPrincipalARN != "" {
if err := persistLaunchMeta(ctx, acctKV, meta); err != nil {
s.failClusterLaunch(ctx, acctKV, name, accountID, meta, "confirm cluster before seeding access entry", err)
return
}
if err := s.owner.SeedCreatorAdmin(ctx, acctKV, accountID, name, callerPrincipalARN); err != nil {
s.failClusterLaunch(ctx, acctKV, name, accountID, meta, "seed cluster-creator admin access entry", err)
return
}
// The entry is a child of the record: if a delete removed the record
// while it was being written, it must not outlive it.
if err := persistLaunchMeta(ctx, acctKV, meta); err != nil {
s.failClusterLaunch(ctx, acctKV, name, accountID, meta, "confirm cluster after seeding access entry", err)
return
}
} else if bootstrapCreatorAdmin(input) {
slog.WarnContext(ctx, "CreateCluster: bootstrapClusterCreatorAdminPermissions set but caller principal ARN unknown; skipping creator-admin AccessEntry",
"cluster", name, "accountID", accountID)
@@ -1129,70 +1338,33 @@ func (s *EKSServiceImpl) DeleteCluster(ctx context.Context, input *eks.DeleteClu
return nil, err
}
// Single-flight the teardown: SDK retries of the same DeleteCluster are fanned
// across nodes by the worker queue group, so without this gate multiple
// purgeClusterInfra runs race the same cluster (duplicate ENI/NLB teardown,
// risking a double-release on the billable NAT-GW EIP). The loser returns the
// cluster as DELETING — AWS-async delete semantics — and the winner (or the
// DELETING backstop reaper) drives the teardown to completion.
release, ok := s.acquireTeardownLease(ctx, accountID, name)
if !ok {
meta.Status = ClusterStatusDeleting
// A recorded delete is answered from the record: the teardown it started is
// still converging, so a retry starts nothing.
if meta.Status == ClusterStatusDeleting {
return &eks.DeleteClusterOutput{Cluster: clusterMetaToAWS(meta)}, nil
}
defer release()
if err := SetClusterStatus(ctx, acctKV, name, ClusterStatusDeleting); err != nil {
return nil, fmt.Errorf("set DELETING: %w", err)
}
meta.Status = ClusterStatusDeleting
if err := s.purgeClusterInfra(ctx, accountID, name, meta, acctKV, true); err != nil {
slog.ErrorContext(ctx, "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
}
// teardownLeaderKey is the per-cluster single-flight gate for a DELETING
// teardown. It is distinct from reconcilerLeaderKey so a delete never contends
// with the still-live reconciler, which holds the reconciler lease until it
// observes DELETING and exits; only concurrent purgeClusterInfra runs meet here.
func teardownLeaderKey(accountID, clusterName string) string {
return reconcilerLeaderKey(accountID, clusterName) + "/teardown"
}
// acquireTeardownLease takes the per-cluster teardown lease so only one
// purgeClusterInfra runs at a time for a cluster — the gate against the
// concurrent-handler herd from SDK-retried DeleteClusters fanned across nodes by
// the worker queue group. Shared by the synchronous DeleteCluster path and the
// DELETING backstop reaper. CAS Create fails when another holder owns it. A nil
// leaderKV (single-node/test) skips gating; the bucket TTL reaps a lease whose
// release never runs.
func (s *EKSServiceImpl) acquireTeardownLease(ctx context.Context, accountID, clusterName string) (func(), bool) {
if s.leaderKV == nil {
return func() {}, true
}
key := teardownLeaderKey(accountID, clusterName)
lease, err := kvlease.New(kvlease.Config{
Name: "eks/teardown",
Bucket: kvlease.StaticBucket(s.leaderKV),
Key: key,
Holder: s.deps.HolderID,
Attrs: []any{"account", accountID, "cluster", clusterName},
TTL: KVBucketEKSLeaderTTL,
Renew: defaultReconcileLeaseRefresh,
})
// AWS refuses while managed node groups remain: deleting the control plane
// under them would orphan their running workers.
groups, err := ListNodegroupRecords(ctx, acctKV, name)
if err != nil {
slog.ErrorContext(ctx, "eks: teardown lease config invalid", "key", key, "err", err)
return nil, false
return nil, fmt.Errorf("list node groups: %w", err)
}
if !lease.TryAcquire(ctx) {
return nil, false
if len(groups) > 0 {
return nil, awserrors.Errorf(awserrors.ErrorEKSResourceInUse, "Cluster has nodegroups attached")
}
return func() { lease.Release(ctx) }, true
recorded, transitioned, err := MarkClusterDeleting(ctx, acctKV, name)
if err != nil {
if errors.Is(err, ErrClusterNotFound) {
return nil, errors.New(awserrors.ErrorEKSResourceNotFound)
}
return nil, fmt.Errorf("record DELETING: %w", err)
}
if transitioned && !s.startTeardown(accountID, name) {
slog.InfoContext(ctx, "DeleteCluster: teardown left to the deleting reaper", "cluster", name)
}
return &eks.DeleteClusterOutput{Cluster: clusterMetaToAWS(recorded)}, nil
}
// claimClusterName atomically claims the cluster meta key before any launch work.
@@ -1205,12 +1377,26 @@ func (s *EKSServiceImpl) claimClusterName(ctx context.Context, accountID string,
}
if _, cerr := acctKV.Create(ctx, ClusterMetaKey(name), data); cerr == nil {
return nil // won a fresh claim
// Keys an earlier cluster of this name left under its prefix (a late
// bootstrap or launch write) must not be inherited by this one.
if derr := DeleteClusterChildren(ctx, acctKV, name); derr != nil {
slog.WarnContext(ctx, "CreateCluster: clear stale child records", "cluster", name, "err", derr)
}
return nil
} else if !errors.Is(cerr, jetstream.ErrKeyExists) {
return logCreateErr(name, accountID, "claim cluster name", cerr)
}
// Key already present: reclaim a FAILED attempt via CAS; old refs survive for teardown.
// Key already present: reclaim a FAILED attempt via CAS; old refs survive for
// teardown. The purge below is a teardown, so it runs under the same gate
// and lease as one, and a cluster whose teardown is running is in use.
if existing, gerr := GetClusterMeta(ctx, acctKV, name); gerr == nil && existing.Status == ClusterStatusFailed {
release, _, held := s.holdTeardown(accountID, name, false)
if !held {
return errors.New(awserrors.ErrorEKSResourceInUse)
}
defer release()
}
var oldRefs *ClusterMeta
reclaimed := false
if err := casUpdateMeta(ctx, acctKV, name, func(m *ClusterMeta) bool {
@@ -1225,6 +1411,10 @@ func (s *EKSServiceImpl) claimClusterName(ctx context.Context, accountID string,
snapshot := *m
oldRefs = &snapshot
m.Status = ClusterStatusCreating
m.CreatedAt = meta.CreatedAt
m.Arn = meta.Arn
m.LaunchOwner = meta.LaunchOwner
m.LaunchHeartbeat = time.Now().UTC()
reclaimed = true
return true
}); err != nil {
@@ -1243,30 +1433,59 @@ func (s *EKSServiceImpl) claimClusterName(ctx context.Context, accountID string,
slog.InfoContext(ctx, "CreateCluster: reclaiming FAILED cluster before recreate",
"name", name, "accountID", accountID)
// Purge the failed attempt's infra but KEEP the meta claim (deleteMeta=false)
// — the CREATING record is our lock for the rest of this create.
if perr := s.purgeClusterInfra(ctx, accountID, name, oldRefs, acctKV, false); perr != nil {
// Purge the failed attempt's infra but KEEP the meta claim: the CREATING
// record is our lock for the rest of this create.
if perr := s.purgeClusterInfra(ctx, accountID, name, oldRefs, acctKV); perr != nil {
s.markFailed(ctx, acctKV, name)
return logCreateErr(name, accountID, "reclaim failed cluster", perr)
}
// Infra gone: overwrite with the fresh CREATING meta (clears the old refs).
if err := PutClusterMeta(ctx, acctKV, meta); err != nil {
if err := persistLaunchMeta(ctx, acctKV, meta); err != nil {
if errors.Is(err, errLaunchSuperseded) {
return errors.New(awserrors.ErrorEKSResourceInUse)
}
s.markFailed(ctx, acctKV, name)
return logCreateErr(name, accountID, "persist reclaimed meta", err)
}
return 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), NLB, VM, SG, IGW, and CP-VPC teardown are all blocking: their
// failures are joined and returned BEFORE the KV sweep, so neither a billable
// resource nor a VPC-pinning SG is orphaned without the meta record that owns
// its IDs. The cluster SGs sit in the customer VPC, so a leaked SG pins the VPC
// on DependencyViolation — leaving the cluster DELETING for a retry is correct.
// Shared by DeleteCluster and the FAILED-cluster reclaim path in CreateCluster.
func (s *EKSServiceImpl) purgeClusterInfra(ctx context.Context, accountID, name string, meta *ClusterMeta, acctKV jetstream.KeyValue, deleteMeta bool) error {
// purgeClusterInfra tears down a cluster's live AWS resources without touching
// its record, for the create paths that release a failed launch's infra.
func (s *EKSServiceImpl) purgeClusterInfra(ctx context.Context, accountID, name string, meta *ClusterMeta, acctKV jetstream.KeyValue) error {
_, err := s.teardownClusterInfra(ctx, accountID, name, meta, acctKV, nil)
return err
}
// Tenant-facing names for teardown steps. DescribeCluster reports which step a
// stuck delete is retrying by these, never by the error, which carries
// system-account resource IDs.
const (
stepOIDCKey = "erasing the OIDC signing key"
stepEndpointLB = "deleting the endpoint load balancer"
stepPrivateENI = "deleting the private endpoint network interface"
stepControlPlane = "terminating control-plane instances"
stepEgressAddress = "releasing the egress address"
stepClusterLBs = "deleting load balancers created in the cluster"
stepSecurityGroups = "deleting cluster security groups"
stepCPNetwork = "deleting the control-plane network"
stepInternetGateway = "deleting the cluster internet gateway"
stepRecord = "removing the cluster record"
stepNodegroups = "waiting for the cluster's node groups to be deleted"
stepLaunch = "waiting for the cluster's creation to stop"
)
// teardownClusterInfra removes every dependant the meta records and returns
// the joined error with the tenant-facing names of the steps that failed.
// Zeroize (no recoverable key material), NLB, VM, EIP, SG, IGW and CP-VPC
// failures are all blocking, so the caller keeps the record that owns their
// IDs. checkpoint, when set, runs between steps and stops the teardown early.
func (s *EKSServiceImpl) teardownClusterInfra(ctx context.Context, accountID, name string, meta *ClusterMeta, acctKV jetstream.KeyValue, checkpoint func() error) ([]string, error) {
s.registry.Stop(accountID, name)
s.stopBootstrap(accountID, name)
if checkpoint == nil {
checkpoint = func() error { return nil }
}
// infraAcct owns the NLB, CP VMs, and CP VPC. Clusters built with the managed
// CP VPC ("Set B") hold them under the system account; legacy clusters
@@ -1277,11 +1496,21 @@ func (s *EKSServiceImpl) purgeClusterInfra(ctx context.Context, accountID, name
}
var teardownErrs []error
if err := ZeroizeClusterOIDCKey(ctx, acctKV, name); err != nil {
teardownErrs = append(teardownErrs, fmt.Errorf("zeroize OIDC key: %w", err))
var failedSteps []string
fail := func(step string, err error) {
teardownErrs = append(teardownErrs, err)
if !slices.Contains(failedSteps, step) {
failedSteps = append(failedSteps, step)
}
}
if err := ZeroizeClusterOIDCKey(ctx, acctKV, name); err != nil {
fail(stepOIDCKey, fmt.Errorf("zeroize OIDC key: %w", err))
}
if err := checkpoint(); err != nil {
return failedSteps, 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.
@@ -1291,7 +1520,7 @@ func (s *EKSServiceImpl) purgeClusterInfra(ctx context.Context, accountID, name
}
}
if err := DeleteClusterNLB(ctx, s.deps.NLB, infraAcct, name); err != nil {
teardownErrs = append(teardownErrs, fmt.Errorf("delete NLB: %w", err))
fail(stepEndpointLB, fmt.Errorf("delete NLB: %w", err))
}
}
@@ -1303,17 +1532,26 @@ func (s *EKSServiceImpl) purgeClusterInfra(ctx context.Context, accountID, name
// missing/already-detached ENI is fine; the delete is the authoritative gate.
if meta.PrivateEndpointENIID != "" {
if err := detachAndDeleteServerENI(ctx, s.deps.VPCK3s, accountID, meta.PrivateEndpointENIID); err != nil {
teardownErrs = append(teardownErrs, fmt.Errorf("delete private-endpoint ENI: %w", err))
fail(stepPrivateENI, fmt.Errorf("delete private-endpoint ENI: %w", err))
}
}
if err := checkpoint(); err != nil {
return failedSteps, err
}
for _, cp := range controlPlaneTeardownNodes(meta) {
if err := checkpoint(); err != nil {
return failedSteps, err
}
if err := TerminateK3sServerVM(ctx, s.deps.VPCK3s, s.deps.Instance,
infraAcct, cp.InstanceID, cp.ENIID); err != nil {
teardownErrs = append(teardownErrs, fmt.Errorf("terminate K3s VM %s: %w", cp.InstanceID, err))
fail(stepControlPlane, fmt.Errorf("terminate K3s VM %s: %w", cp.InstanceID, err))
}
}
if err := checkpoint(); err != nil {
return failedSteps, 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
@@ -1345,7 +1583,7 @@ func (s *EKSServiceImpl) purgeClusterInfra(ctx context.Context, accountID, name
slog.DebugContext(ctx, "purgeClusterInfra: egress EIP already released",
"cluster", name, "allocationId", meta.EgressEIPAllocationID)
default:
teardownErrs = append(teardownErrs, fmt.Errorf("release egress EIP: %w", err))
fail(stepEgressAddress, fmt.Errorf("release egress EIP: %w", err))
}
}
}
@@ -1359,6 +1597,9 @@ func (s *EKSServiceImpl) purgeClusterInfra(ctx context.Context, accountID, name
}
}
if err := checkpoint(); err != nil {
return failedSteps, err
}
// Reap LBC-orphaned ALBs (k8s-toc-*) in the customer VPC before the SG sweep:
// the in-cluster AWS LoadBalancer Controller runs under the customer account and
// spinifex never tracks its ALBs, so nothing else deletes them and they pin the
@@ -1366,10 +1607,13 @@ func (s *EKSServiceImpl) purgeClusterInfra(ctx context.Context, accountID, name
// so the ALB no longer references the SG it reaps.
if meta.ResourcesVpcConfig != nil && meta.ResourcesVpcConfig.VpcId != "" {
if err := ReapLBCLoadBalancers(ctx, s.deps.NLB, accountID, name, meta.ResourcesVpcConfig.VpcId); err != nil {
teardownErrs = append(teardownErrs, fmt.Errorf("reap LBC ALBs: %w", err))
fail(stepClusterLBs, fmt.Errorf("reap LBC ALBs: %w", err))
}
}
if err := checkpoint(); err != nil {
return failedSteps, err
}
// SG / IGW / CP-VPC cleanup runs even when an earlier step failed: the cluster
// SGs sit in the customer VPC, so skipping them would pin the VPC. Failures are
// joined into teardownErrs (not swallowed) so the meta survives for a retry.
@@ -1379,19 +1623,19 @@ func (s *EKSServiceImpl) purgeClusterInfra(ctx context.Context, accountID, name
// the IGW + VPC after the SGs, so SG cleanup must precede it.
cpSGErr := DeleteClusterSGs(ctx, s.deps.VPCSG, infraAcct, name, meta.ManagedCPVPC.VpcId)
if cpSGErr != nil {
teardownErrs = append(teardownErrs, fmt.Errorf("delete cluster SGs: %w", cpSGErr))
fail(stepSecurityGroups, fmt.Errorf("delete cluster SGs: %w", cpSGErr))
}
// launchNodegroupInfra also creates the cluster SGs in the customer VPC for
// worker<->control-plane networking; reclaim them here too, or they orphan
// (cross-referencing each other) and pin the customer VPC on destroy.
if meta.ResourcesVpcConfig != nil && meta.ResourcesVpcConfig.VpcId != "" {
if err := DeleteClusterSGs(ctx, s.deps.VPCSG, accountID, name, meta.ResourcesVpcConfig.VpcId); err != nil {
teardownErrs = append(teardownErrs, fmt.Errorf("delete customer-VPC cluster SGs: %w", err))
fail(stepSecurityGroups, fmt.Errorf("delete customer-VPC cluster SGs: %w", err))
}
}
cpVPCErr := DeleteClusterCPVPC(ctx, s.cpVPCDeps(), infraAcct, name, meta.ManagedCPVPC)
if cpVPCErr != nil {
teardownErrs = append(teardownErrs, fmt.Errorf("delete managed CP VPC: %w", cpVPCErr))
fail(stepCPNetwork, fmt.Errorf("delete managed CP VPC: %w", cpVPCErr))
}
if cpSGErr == nil && cpVPCErr == nil {
// The CP VPC (including when already gone — tolerated as success)
@@ -1402,7 +1646,7 @@ func (s *EKSServiceImpl) purgeClusterInfra(ctx context.Context, accountID, name
// DependencyViolation into a permanent DELETING loop.
meta.ManagedCPVPC = nil
if perr := ClearClusterManagedCPVPC(ctx, acctKV, name); perr != nil && !errors.Is(perr, ErrClusterNotFound) {
teardownErrs = append(teardownErrs, fmt.Errorf("clear managed CP VPC state: %w", perr))
fail(stepCPNetwork, fmt.Errorf("clear managed CP VPC state: %w", perr))
}
}
} else if meta.ResourcesVpcConfig != nil && meta.ResourcesVpcConfig.VpcId != "" {
@@ -1410,11 +1654,11 @@ func (s *EKSServiceImpl) purgeClusterInfra(ctx context.Context, accountID, name
// cluster-owned IGW (ownership-scoped — a reused customer IGW is left
// intact). IGW delete must precede any VPC delete to avoid DependencyViolation.
if err := DeleteClusterSGs(ctx, s.deps.VPCSG, accountID, name, meta.ResourcesVpcConfig.VpcId); err != nil {
teardownErrs = append(teardownErrs, fmt.Errorf("delete cluster SGs: %w", err))
fail(stepSecurityGroups, fmt.Errorf("delete cluster SGs: %w", err))
}
if s.deps.IGW != nil {
if err := DeleteClusterIGW(ctx, s.deps.IGW, accountID, meta.ResourcesVpcConfig.VpcId, name); err != nil {
teardownErrs = append(teardownErrs, fmt.Errorf("delete cluster IGW: %w", err))
fail(stepInternetGateway, fmt.Errorf("delete cluster IGW: %w", err))
}
}
}
@@ -1434,22 +1678,11 @@ func (s *EKSServiceImpl) purgeClusterInfra(ctx context.Context, accountID, name
}
// Any blocking-teardown failure (billable infra or a VPC-pinning SG) keeps the
// meta — and the IDs that own the stranded resources — alive for a delete retry
// or the DELETING backstop, rather than completing deletion with infra leaked.
// meta, and the IDs that own the stranded resources, for the next pass.
if len(teardownErrs) > 0 {
return errors.Join(teardownErrs...)
return failedSteps, errors.Join(teardownErrs...)
}
// Only now, with NLB + VM confirmed gone, erase the meta + per-cluster KV.
// The reclaim path passes deleteMeta=false: it has already CAS-claimed the
// meta key (status CREATING) as its lock for the recreate, so the key must
// survive — only the failed attempt's infra is purged here.
if deleteMeta {
if err := DeleteClusterPrefix(ctx, acctKV, name); err != nil {
return fmt.Errorf("delete cluster prefix: %w", err)
}
}
return nil
return nil, nil
}
// --- Nodegroup ---
@@ -1533,6 +1766,27 @@ func (s *EKSServiceImpl) acctKVForCluster(ctx context.Context, accountID, cluste
return acctKV, nil
}
// acctKVForNewDependant is acctKVForCluster for a call that adds a dependant
// to the cluster. A recorded delete admits no new dependant (ADR-0003:S2): the
// teardown would sweep it with the record, so it is refused with ResourceInUse.
func (s *EKSServiceImpl) acctKVForNewDependant(ctx context.Context, accountID, cluster string) (jetstream.KeyValue, error) {
acctKV, err := s.acctKVForCluster(ctx, accountID, cluster)
if err != nil {
return nil, err
}
meta, err := GetClusterMeta(ctx, acctKV, cluster)
if err != nil {
if errors.Is(err, ErrClusterNotFound) {
return nil, errors.New(awserrors.ErrorEKSResourceNotFound)
}
return nil, err
}
if meta.Status == ClusterStatusDeleting {
return nil, awserrors.Errorf(awserrors.ErrorEKSResourceInUse, "Cluster %s is being deleted", cluster)
}
return acctKV, nil
}
func (s *EKSServiceImpl) CreateAccessEntry(ctx context.Context, input *eks.CreateAccessEntryInput, accountID string) (*eks.CreateAccessEntryOutput, error) {
if input == nil {
return nil, errors.New(awserrors.ErrorInvalidParameterValue)
@@ -1550,7 +1804,7 @@ func (s *EKSServiceImpl) CreateAccessEntry(ctx context.Context, input *eks.Creat
// Non-standard types (EC2_LINUX etc.) are not yet implemented.
return nil, errors.New(awserrors.ErrorInvalidParameterValue)
}
acctKV, err := s.acctKVForCluster(ctx, accountID, cluster)
acctKV, err := s.acctKVForNewDependant(ctx, accountID, cluster)
if err != nil {
return nil, err
}
@@ -2108,7 +2362,7 @@ func (s *EKSServiceImpl) DesiredDNSChanges() (changes []dns.Change, ok bool) {
}
func (s *EKSServiceImpl) markFailed(ctx context.Context, kv jetstream.KeyValue, name string) {
if err := SetClusterStatus(ctx, kv, name, ClusterStatusFailed); err != nil {
if err := SetClusterStatus(ctx, kv, name, ClusterStatusFailed); err != nil && !errors.Is(err, ErrClusterDeleting) {
slog.Warn("CreateCluster: SetClusterStatus(FAILED) failed", "cluster", name, "err", err)
}
}
@@ -2122,8 +2376,28 @@ func (s *EKSServiceImpl) spawnBootstrap(accountID, clusterName string, kv jetstr
slog.Error("spawnBootstrap: NewNATSBootstrap failed", "cluster", clusterName, "err", err)
return
}
ctx, cancel := context.WithCancel(s.bgCtx)
key := reconcilerLeaderKey(accountID, clusterName)
entry := &bootstrapRun{cancel: cancel}
s.bootstrapMu.Lock()
if s.bootstraps == nil {
s.bootstraps = map[string]*bootstrapRun{}
}
if prev := s.bootstraps[key]; prev != nil {
prev.cancel()
}
s.bootstraps[key] = entry
s.bootstrapMu.Unlock()
go func() {
err := boot.RunForKinds(s.bgCtx, kinds)
defer func() {
cancel()
s.bootstrapMu.Lock()
if s.bootstraps[key] == entry {
delete(s.bootstraps, key)
}
s.bootstrapMu.Unlock()
}()
err := boot.RunForKinds(ctx, kinds)
if err == nil || errors.Is(err, context.Canceled) {
return
}
@@ -2139,6 +2413,25 @@ func (s *EKSServiceImpl) spawnBootstrap(accountID, clusterName string, kv jetstr
}()
}
// bootstrapRun is one running bootstrap subscriber; its pointer identifies the
// map entry so an exiting run removes only its own.
type bootstrapRun struct {
cancel context.CancelFunc
}
// stopBootstrap cancels this process's bootstrap subscriber for a cluster, so
// a cluster being torn down stops collecting artifacts into its prefix.
func (s *EKSServiceImpl) stopBootstrap(accountID, clusterName string) {
key := reconcilerLeaderKey(accountID, clusterName)
s.bootstrapMu.Lock()
run := s.bootstraps[key]
delete(s.bootstraps, key)
s.bootstrapMu.Unlock()
if run != nil {
run.cancel()
}
}
func (s *EKSServiceImpl) spawnReconciler(accountID, clusterName string, _ *ClusterMeta) {
js, err := jetstream.New(s.deps.NATSConn)
if err != nil {
@@ -2366,6 +2659,14 @@ func clusterMetaToAWS(meta *ClusterMeta) *eks.Cluster {
Message: aws.String(meta.HealthIssue),
})
}
// A stuck delete names its step in tenant words only; LastDeleteReapError
// carries system-account IDs and stays operator-side.
if meta.Status == ClusterStatusDeleting && meta.DeleteFailedStep != "" {
issues = append(issues, &eks.ClusterIssue{
Code: aws.String(eks.ClusterIssueCodeInternalFailure),
Message: aws.String("Cluster deletion has not completed and is being retried. Waiting on: " + meta.DeleteFailedStep + "."),
})
}
// StatusReason is the launch-failure cause MarkClusterFailed recorded; surface
// it here too, since the real EKS DescribeCluster response has no dedicated
// "why did CREATE_FAILED happen" field and Health.Issues is the analogue.
@@ -202,15 +202,12 @@ func TestDeleteCluster_ZeroizesOIDCKeyBeforeTeardown(t *testing.T) {
f := newDeleteClusterFixture(t, "alpha")
f.inst.terminateErr = errors.New("hypervisor unreachable")
_, err := f.svc.DeleteCluster(context.Background(), deleteInput("alpha"), testAccountID)
require.Error(t, err, "teardown failure must surface")
deleteAndSettle(t, f.svc, "alpha")
_, getErr := f.kv.Get(t.Context(), OIDCSigningKeyKey("alpha"))
assert.ErrorIs(t, getErr, jetstream.ErrKeyNotFound, "OIDC key must be zeroized before teardown, even when teardown fails")
meta, metaErr := GetClusterMeta(t.Context(), f.kv, "alpha")
require.NoError(t, metaErr, "meta must survive a failed teardown for retry")
assert.Equal(t, ClusterStatusDeleting, meta.Status)
requireTeardownRetained(t, f.kv, "alpha")
}
// In shim mode (orchestration deps absent) the mutating nodegroup methods
+102 -32
View File
@@ -11,6 +11,76 @@ import (
const defaultTimeout = 30 * time.Second
// subjectWork classifies an eks request subject by the work its daemon handler
// does before replying (ADR-0003:S8). The budget does not change with the
// class: no budget bounds a cost that grows with the resource.
type subjectWork int
const (
// requestScaled handlers answer from records or write a bounded number of
// them, and hand anything longer to a background owner.
requestScaled subjectWork = iota
// resourceScaledGap handlers still hold the request for work that grows with
// the resource. They are not conformant; each is listed so that it is not
// mistaken for one that is, and none may be added to silently.
resourceScaledGap
)
// eksSubjects is every eks request subject, classified by its handler's work.
var eksSubjects = map[string]subjectWork{
"eks.AssociateAccessPolicy": requestScaled,
"eks.CreateAccessEntry": requestScaled,
"eks.CreateAddon": requestScaled,
"eks.CreateNodegroup": requestScaled, // workers launch in the background
"eks.DeleteAccessEntry": requestScaled,
"eks.DeleteAddon": requestScaled,
"eks.DeleteCluster": requestScaled, // teardown converges in the background
"eks.DescribeAccessEntry": requestScaled,
"eks.DescribeAddon": requestScaled,
"eks.DescribeAddonVersions": requestScaled,
"eks.DescribeCluster": requestScaled,
"eks.DescribeNodegroup": requestScaled,
"eks.DisassociateAccessPolicy": requestScaled,
"eks.GetRecoveryDirective": requestScaled,
"eks.ListAccessEntries": requestScaled,
"eks.ListAccessPolicies": requestScaled,
"eks.ListAddons": requestScaled,
"eks.ListAssociatedAccessPolicies": requestScaled,
"eks.ListClusters": requestScaled,
"eks.ListManagedWorkers": requestScaled,
"eks.ListNodegroups": requestScaled,
"eks.ListStagedAddonManifests": requestScaled,
"eks.ListTagsForResource": requestScaled,
"eks.SetRecoveryDirective": requestScaled,
"eks.TagResource": requestScaled,
"eks.UntagResource": requestScaled,
"eks.UpdateAccessEntry": requestScaled,
"eks.UpdateAddon": requestScaled,
// Reclaiming a FAILED cluster of the same name tears its infra down inline.
"eks.CreateCluster": resourceScaledGap,
// One routed terminate per worker, awaited.
"eks.DeleteNodegroup": resourceScaledGap,
// One awaited launch or terminate per worker added or removed.
"eks.UpdateNodegroupConfig": resourceScaledGap,
// Launches a control-plane VM and fences the old one inline.
"eks.RestoreSnapshot": resourceScaledGap,
}
// classBudgets is the budget per class. A gap keeps the ordinary budget on
// purpose: raising it would hide the defect rather than remove it.
var classBudgets = map[subjectWork]time.Duration{
requestScaled: defaultTimeout,
resourceScaledGap: defaultTimeout,
}
// requestTimeout is the gateway's budget for an eks request subject.
// TestS8_EKSSubjectBudgets fails for any subject missing from eksSubjects, so
// a new one is classified deliberately rather than by inheriting a default.
func requestTimeout(subject string) time.Duration {
return classBudgets[eksSubjects[subject]]
}
// NATSEKSService is the gateway-side adapter that forwards every EKSService
// method as a NATS request to the daemon's matching subscriber.
type NATSEKSService struct {
@@ -27,138 +97,138 @@ func NewNATSEKSService(conn *nats.Conn) EKSService {
// --- Cluster ---
func (s *NATSEKSService) CreateCluster(ctx context.Context, input *eks.CreateClusterInput, accountID, callerPrincipalARN string) (*eks.CreateClusterOutput, error) {
return natsmsg.NATSRequest[eks.CreateClusterOutput](ctx, s.natsConn, "eks.CreateCluster", input, defaultTimeout, accountID,
return natsmsg.NATSRequest[eks.CreateClusterOutput](ctx, s.natsConn, "eks.CreateCluster", input, requestTimeout("eks.CreateCluster"), accountID,
natsmsg.NATSHeader{Key: natsmsg.PrincipalARNHeader, Value: callerPrincipalARN})
}
func (s *NATSEKSService) DescribeCluster(ctx context.Context, input *eks.DescribeClusterInput, accountID string) (*eks.DescribeClusterOutput, error) {
return natsmsg.NATSRequest[eks.DescribeClusterOutput](ctx, s.natsConn, "eks.DescribeCluster", input, defaultTimeout, accountID)
return natsmsg.NATSRequest[eks.DescribeClusterOutput](ctx, s.natsConn, "eks.DescribeCluster", input, requestTimeout("eks.DescribeCluster"), accountID)
}
func (s *NATSEKSService) ListClusters(ctx context.Context, input *eks.ListClustersInput, accountID string) (*eks.ListClustersOutput, error) {
return natsmsg.NATSRequest[eks.ListClustersOutput](ctx, s.natsConn, "eks.ListClusters", input, defaultTimeout, accountID)
return natsmsg.NATSRequest[eks.ListClustersOutput](ctx, s.natsConn, "eks.ListClusters", input, requestTimeout("eks.ListClusters"), accountID)
}
func (s *NATSEKSService) DeleteCluster(ctx context.Context, input *eks.DeleteClusterInput, accountID string) (*eks.DeleteClusterOutput, error) {
return natsmsg.NATSRequest[eks.DeleteClusterOutput](ctx, s.natsConn, "eks.DeleteCluster", input, defaultTimeout, accountID)
return natsmsg.NATSRequest[eks.DeleteClusterOutput](ctx, s.natsConn, "eks.DeleteCluster", input, requestTimeout("eks.DeleteCluster"), accountID)
}
// --- Nodegroup ---
func (s *NATSEKSService) CreateNodegroup(ctx context.Context, input *eks.CreateNodegroupInput, accountID string) (*eks.CreateNodegroupOutput, error) {
return natsmsg.NATSRequest[eks.CreateNodegroupOutput](ctx, s.natsConn, "eks.CreateNodegroup", input, defaultTimeout, accountID)
return natsmsg.NATSRequest[eks.CreateNodegroupOutput](ctx, s.natsConn, "eks.CreateNodegroup", input, requestTimeout("eks.CreateNodegroup"), accountID)
}
func (s *NATSEKSService) DescribeNodegroup(ctx context.Context, input *eks.DescribeNodegroupInput, accountID string) (*eks.DescribeNodegroupOutput, error) {
return natsmsg.NATSRequest[eks.DescribeNodegroupOutput](ctx, s.natsConn, "eks.DescribeNodegroup", input, defaultTimeout, accountID)
return natsmsg.NATSRequest[eks.DescribeNodegroupOutput](ctx, s.natsConn, "eks.DescribeNodegroup", input, requestTimeout("eks.DescribeNodegroup"), accountID)
}
func (s *NATSEKSService) ListNodegroups(ctx context.Context, input *eks.ListNodegroupsInput, accountID string) (*eks.ListNodegroupsOutput, error) {
return natsmsg.NATSRequest[eks.ListNodegroupsOutput](ctx, s.natsConn, "eks.ListNodegroups", input, defaultTimeout, accountID)
return natsmsg.NATSRequest[eks.ListNodegroupsOutput](ctx, s.natsConn, "eks.ListNodegroups", input, requestTimeout("eks.ListNodegroups"), accountID)
}
func (s *NATSEKSService) UpdateNodegroupConfig(ctx context.Context, input *eks.UpdateNodegroupConfigInput, accountID string) (*eks.UpdateNodegroupConfigOutput, error) {
return natsmsg.NATSRequest[eks.UpdateNodegroupConfigOutput](ctx, s.natsConn, "eks.UpdateNodegroupConfig", input, defaultTimeout, accountID)
return natsmsg.NATSRequest[eks.UpdateNodegroupConfigOutput](ctx, s.natsConn, "eks.UpdateNodegroupConfig", input, requestTimeout("eks.UpdateNodegroupConfig"), accountID)
}
func (s *NATSEKSService) DeleteNodegroup(ctx context.Context, input *eks.DeleteNodegroupInput, accountID string) (*eks.DeleteNodegroupOutput, error) {
return natsmsg.NATSRequest[eks.DeleteNodegroupOutput](ctx, s.natsConn, "eks.DeleteNodegroup", input, defaultTimeout, accountID)
return natsmsg.NATSRequest[eks.DeleteNodegroupOutput](ctx, s.natsConn, "eks.DeleteNodegroup", input, requestTimeout("eks.DeleteNodegroup"), accountID)
}
// --- AccessEntry + AccessPolicy ---
func (s *NATSEKSService) CreateAccessEntry(ctx context.Context, input *eks.CreateAccessEntryInput, accountID string) (*eks.CreateAccessEntryOutput, error) {
return natsmsg.NATSRequest[eks.CreateAccessEntryOutput](ctx, s.natsConn, "eks.CreateAccessEntry", input, defaultTimeout, accountID)
return natsmsg.NATSRequest[eks.CreateAccessEntryOutput](ctx, s.natsConn, "eks.CreateAccessEntry", input, requestTimeout("eks.CreateAccessEntry"), accountID)
}
func (s *NATSEKSService) DescribeAccessEntry(ctx context.Context, input *eks.DescribeAccessEntryInput, accountID string) (*eks.DescribeAccessEntryOutput, error) {
return natsmsg.NATSRequest[eks.DescribeAccessEntryOutput](ctx, s.natsConn, "eks.DescribeAccessEntry", input, defaultTimeout, accountID)
return natsmsg.NATSRequest[eks.DescribeAccessEntryOutput](ctx, s.natsConn, "eks.DescribeAccessEntry", input, requestTimeout("eks.DescribeAccessEntry"), accountID)
}
func (s *NATSEKSService) ListAccessEntries(ctx context.Context, input *eks.ListAccessEntriesInput, accountID string) (*eks.ListAccessEntriesOutput, error) {
return natsmsg.NATSRequest[eks.ListAccessEntriesOutput](ctx, s.natsConn, "eks.ListAccessEntries", input, defaultTimeout, accountID)
return natsmsg.NATSRequest[eks.ListAccessEntriesOutput](ctx, s.natsConn, "eks.ListAccessEntries", input, requestTimeout("eks.ListAccessEntries"), accountID)
}
func (s *NATSEKSService) UpdateAccessEntry(ctx context.Context, input *eks.UpdateAccessEntryInput, accountID string) (*eks.UpdateAccessEntryOutput, error) {
return natsmsg.NATSRequest[eks.UpdateAccessEntryOutput](ctx, s.natsConn, "eks.UpdateAccessEntry", input, defaultTimeout, accountID)
return natsmsg.NATSRequest[eks.UpdateAccessEntryOutput](ctx, s.natsConn, "eks.UpdateAccessEntry", input, requestTimeout("eks.UpdateAccessEntry"), accountID)
}
func (s *NATSEKSService) DeleteAccessEntry(ctx context.Context, input *eks.DeleteAccessEntryInput, accountID string) (*eks.DeleteAccessEntryOutput, error) {
return natsmsg.NATSRequest[eks.DeleteAccessEntryOutput](ctx, s.natsConn, "eks.DeleteAccessEntry", input, defaultTimeout, accountID)
return natsmsg.NATSRequest[eks.DeleteAccessEntryOutput](ctx, s.natsConn, "eks.DeleteAccessEntry", input, requestTimeout("eks.DeleteAccessEntry"), accountID)
}
func (s *NATSEKSService) AssociateAccessPolicy(ctx context.Context, input *eks.AssociateAccessPolicyInput, accountID string) (*eks.AssociateAccessPolicyOutput, error) {
return natsmsg.NATSRequest[eks.AssociateAccessPolicyOutput](ctx, s.natsConn, "eks.AssociateAccessPolicy", input, defaultTimeout, accountID)
return natsmsg.NATSRequest[eks.AssociateAccessPolicyOutput](ctx, s.natsConn, "eks.AssociateAccessPolicy", input, requestTimeout("eks.AssociateAccessPolicy"), accountID)
}
func (s *NATSEKSService) DisassociateAccessPolicy(ctx context.Context, input *eks.DisassociateAccessPolicyInput, accountID string) (*eks.DisassociateAccessPolicyOutput, error) {
return natsmsg.NATSRequest[eks.DisassociateAccessPolicyOutput](ctx, s.natsConn, "eks.DisassociateAccessPolicy", input, defaultTimeout, accountID)
return natsmsg.NATSRequest[eks.DisassociateAccessPolicyOutput](ctx, s.natsConn, "eks.DisassociateAccessPolicy", input, requestTimeout("eks.DisassociateAccessPolicy"), accountID)
}
func (s *NATSEKSService) ListAssociatedAccessPolicies(ctx context.Context, input *eks.ListAssociatedAccessPoliciesInput, accountID string) (*eks.ListAssociatedAccessPoliciesOutput, error) {
return natsmsg.NATSRequest[eks.ListAssociatedAccessPoliciesOutput](ctx, s.natsConn, "eks.ListAssociatedAccessPolicies", input, defaultTimeout, accountID)
return natsmsg.NATSRequest[eks.ListAssociatedAccessPoliciesOutput](ctx, s.natsConn, "eks.ListAssociatedAccessPolicies", input, requestTimeout("eks.ListAssociatedAccessPolicies"), accountID)
}
func (s *NATSEKSService) ListAccessPolicies(ctx context.Context, input *eks.ListAccessPoliciesInput, accountID string) (*eks.ListAccessPoliciesOutput, error) {
return natsmsg.NATSRequest[eks.ListAccessPoliciesOutput](ctx, s.natsConn, "eks.ListAccessPolicies", input, defaultTimeout, accountID)
return natsmsg.NATSRequest[eks.ListAccessPoliciesOutput](ctx, s.natsConn, "eks.ListAccessPolicies", input, requestTimeout("eks.ListAccessPolicies"), accountID)
}
// --- Addons ---
func (s *NATSEKSService) ListAddons(ctx context.Context, input *eks.ListAddonsInput, accountID string) (*eks.ListAddonsOutput, error) {
return natsmsg.NATSRequest[eks.ListAddonsOutput](ctx, s.natsConn, "eks.ListAddons", input, defaultTimeout, accountID)
return natsmsg.NATSRequest[eks.ListAddonsOutput](ctx, s.natsConn, "eks.ListAddons", input, requestTimeout("eks.ListAddons"), accountID)
}
func (s *NATSEKSService) DescribeAddonVersions(ctx context.Context, input *eks.DescribeAddonVersionsInput, accountID string) (*eks.DescribeAddonVersionsOutput, error) {
return natsmsg.NATSRequest[eks.DescribeAddonVersionsOutput](ctx, s.natsConn, "eks.DescribeAddonVersions", input, defaultTimeout, accountID)
return natsmsg.NATSRequest[eks.DescribeAddonVersionsOutput](ctx, s.natsConn, "eks.DescribeAddonVersions", input, requestTimeout("eks.DescribeAddonVersions"), accountID)
}
func (s *NATSEKSService) CreateAddon(ctx context.Context, input *eks.CreateAddonInput, accountID string) (*eks.CreateAddonOutput, error) {
return natsmsg.NATSRequest[eks.CreateAddonOutput](ctx, s.natsConn, "eks.CreateAddon", input, defaultTimeout, accountID)
return natsmsg.NATSRequest[eks.CreateAddonOutput](ctx, s.natsConn, "eks.CreateAddon", input, requestTimeout("eks.CreateAddon"), accountID)
}
func (s *NATSEKSService) DeleteAddon(ctx context.Context, input *eks.DeleteAddonInput, accountID string) (*eks.DeleteAddonOutput, error) {
return natsmsg.NATSRequest[eks.DeleteAddonOutput](ctx, s.natsConn, "eks.DeleteAddon", input, defaultTimeout, accountID)
return natsmsg.NATSRequest[eks.DeleteAddonOutput](ctx, s.natsConn, "eks.DeleteAddon", input, requestTimeout("eks.DeleteAddon"), accountID)
}
func (s *NATSEKSService) DescribeAddon(ctx context.Context, input *eks.DescribeAddonInput, accountID string) (*eks.DescribeAddonOutput, error) {
return natsmsg.NATSRequest[eks.DescribeAddonOutput](ctx, s.natsConn, "eks.DescribeAddon", input, defaultTimeout, accountID)
return natsmsg.NATSRequest[eks.DescribeAddonOutput](ctx, s.natsConn, "eks.DescribeAddon", input, requestTimeout("eks.DescribeAddon"), accountID)
}
func (s *NATSEKSService) ListStagedAddonManifests(ctx context.Context, input *ListStagedAddonManifestsInput, accountID string) (*ListStagedAddonManifestsOutput, error) {
return natsmsg.NATSRequest[ListStagedAddonManifestsOutput](ctx, s.natsConn, "eks.ListStagedAddonManifests", input, defaultTimeout, accountID)
return natsmsg.NATSRequest[ListStagedAddonManifestsOutput](ctx, s.natsConn, "eks.ListStagedAddonManifests", input, requestTimeout("eks.ListStagedAddonManifests"), accountID)
}
func (s *NATSEKSService) ListManagedWorkers(ctx context.Context, input *ListManagedWorkersInput, accountID string) (*ListManagedWorkersOutput, error) {
return natsmsg.NATSRequest[ListManagedWorkersOutput](ctx, s.natsConn, "eks.ListManagedWorkers", input, defaultTimeout, accountID)
return natsmsg.NATSRequest[ListManagedWorkersOutput](ctx, s.natsConn, "eks.ListManagedWorkers", input, requestTimeout("eks.ListManagedWorkers"), accountID)
}
func (s *NATSEKSService) UpdateAddon(ctx context.Context, input *eks.UpdateAddonInput, accountID string) (*eks.UpdateAddonOutput, error) {
return natsmsg.NATSRequest[eks.UpdateAddonOutput](ctx, s.natsConn, "eks.UpdateAddon", input, defaultTimeout, accountID)
return natsmsg.NATSRequest[eks.UpdateAddonOutput](ctx, s.natsConn, "eks.UpdateAddon", input, requestTimeout("eks.UpdateAddon"), accountID)
}
func (s *NATSEKSService) GetRecoveryDirective(ctx context.Context, input *GetRecoveryDirectiveInput, accountID string) (*GetRecoveryDirectiveOutput, error) {
return natsmsg.NATSRequest[GetRecoveryDirectiveOutput](ctx, s.natsConn, "eks.GetRecoveryDirective", input, defaultTimeout, accountID)
return natsmsg.NATSRequest[GetRecoveryDirectiveOutput](ctx, s.natsConn, "eks.GetRecoveryDirective", input, requestTimeout("eks.GetRecoveryDirective"), accountID)
}
func (s *NATSEKSService) SetRecoveryDirective(ctx context.Context, input *SetRecoveryDirectiveInput, accountID string) (*SetRecoveryDirectiveOutput, error) {
return natsmsg.NATSRequest[SetRecoveryDirectiveOutput](ctx, s.natsConn, "eks.SetRecoveryDirective", input, defaultTimeout, accountID)
return natsmsg.NATSRequest[SetRecoveryDirectiveOutput](ctx, s.natsConn, "eks.SetRecoveryDirective", input, requestTimeout("eks.SetRecoveryDirective"), accountID)
}
func (s *NATSEKSService) RestoreSnapshot(ctx context.Context, input *RestoreSnapshotInput, accountID string) (*RestoreSnapshotOutput, error) {
return natsmsg.NATSRequest[RestoreSnapshotOutput](ctx, s.natsConn, "eks.RestoreSnapshot", input, defaultTimeout, accountID)
return natsmsg.NATSRequest[RestoreSnapshotOutput](ctx, s.natsConn, "eks.RestoreSnapshot", input, requestTimeout("eks.RestoreSnapshot"), accountID)
}
// --- Tags ---
func (s *NATSEKSService) TagResource(ctx context.Context, input *eks.TagResourceInput, accountID string) (*eks.TagResourceOutput, error) {
return natsmsg.NATSRequest[eks.TagResourceOutput](ctx, s.natsConn, "eks.TagResource", input, defaultTimeout, accountID)
return natsmsg.NATSRequest[eks.TagResourceOutput](ctx, s.natsConn, "eks.TagResource", input, requestTimeout("eks.TagResource"), accountID)
}
func (s *NATSEKSService) UntagResource(ctx context.Context, input *eks.UntagResourceInput, accountID string) (*eks.UntagResourceOutput, error) {
return natsmsg.NATSRequest[eks.UntagResourceOutput](ctx, s.natsConn, "eks.UntagResource", input, defaultTimeout, accountID)
return natsmsg.NATSRequest[eks.UntagResourceOutput](ctx, s.natsConn, "eks.UntagResource", input, requestTimeout("eks.UntagResource"), accountID)
}
func (s *NATSEKSService) ListTagsForResource(ctx context.Context, input *eks.ListTagsForResourceInput, accountID string) (*eks.ListTagsForResourceOutput, error) {
return natsmsg.NATSRequest[eks.ListTagsForResourceOutput](ctx, s.natsConn, "eks.ListTagsForResource", input, defaultTimeout, accountID)
return natsmsg.NATSRequest[eks.ListTagsForResourceOutput](ctx, s.natsConn, "eks.ListTagsForResource", input, requestTimeout("eks.ListTagsForResource"), accountID)
}
@@ -0,0 +1,71 @@
package cluster
import (
"os"
"regexp"
"slices"
"sort"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
var eksSubjectLiteral = regexp.MustCompile(`"(eks\.[A-Z]\w+)"`)
// subjectsIn returns the eks request subjects named as string literals in a
// source file, the gateway adapter or the daemon's subscription table.
func subjectsIn(t *testing.T, path string) []string {
t.Helper()
src, err := os.ReadFile(path)
require.NoError(t, err)
var out []string
for _, m := range eksSubjectLiteral.FindAllStringSubmatch(string(src), -1) {
if m[1] == TokenVerifySubject {
continue
}
out = append(out, m[1])
}
sort.Strings(out)
return slices.Compact(out)
}
// TestS8_EKSSubjectBudgets is the ADR-0003:S8 subject table for EKS: every eks
// request subject the gateway sends and the daemon serves is classified by the
// work its handler does, a request-scaled handler keeps the ordinary budget,
// and the handlers still holding a request for resource-scaled work are named
// here so none is added, or mistaken for conformant, silently.
func TestS8_EKSSubjectBudgets(t *testing.T) {
t.Parallel()
classified := make([]string, 0, len(eksSubjects))
for s := range eksSubjects {
classified = append(classified, s)
}
sort.Strings(classified)
assert.Equal(t, classified, subjectsIn(t, "service_nats.go"),
"every subject the gateway adapter sends must be classified, and nothing else")
assert.Equal(t, classified, subjectsIn(t, "../../../runtime/daemon/daemon.go"),
"every eks subject the daemon serves must be classified, and nothing else")
for _, s := range classified {
assert.Equal(t, defaultTimeout, requestTimeout(s),
"%s: no budget is raised to cover a handler's work", s)
}
var gaps []string
for s, w := range eksSubjects {
if w == resourceScaledGap {
gaps = append(gaps, s)
}
}
sort.Strings(gaps)
assert.Equal(t, []string{
"eks.CreateCluster",
"eks.DeleteNodegroup",
"eks.RestoreSnapshot",
"eks.UpdateNodegroupConfig",
}, gaps, "the known non-conformant handlers; moving one out needs its handler fixed, adding one needs a decision")
assert.Equal(t, requestScaled, eksSubjects["eks.DeleteCluster"],
"ADR-0003:S8: DeleteCluster records the delete and returns")
}
@@ -0,0 +1,20 @@
package cluster
import (
"net"
"strconv"
"testing"
"github.com/mulgadc/spinifex/spinifex/agents/eks/podidentity"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// Both webhooks run on every control-plane VM. A shared port makes the second
// listener fail with "address already in use" and respawn forever.
func TestPodIdentityWebhookPortDoesNotCollideWithTokenWebhookReadiness(t *testing.T) {
_, port, err := net.SplitHostPort(podidentity.DefaultAddr)
require.NoError(t, err)
assert.NotEqual(t, strconv.FormatInt(webhookReadyPort, 10), port,
"pod identity webhook must not listen on the token webhook readiness port")
}
@@ -0,0 +1,105 @@
package iam
import (
"context"
"encoding/json"
"sync/atomic"
"testing"
"github.com/nats-io/nats.go/jetstream"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestSetAccountStatusCountsOnlyChanges(t *testing.T) {
t.Parallel()
svc := setupTestIAMService(t)
account, err := svc.CreateAccount("generation-counts")
require.NoError(t, err)
assert.Zero(t, account.Generation)
steps := []struct {
status string
want uint64
}{
{AccountStatusActive, 0},
{AccountStatusSuspended, 1},
{AccountStatusSuspended, 1},
{AccountStatusActive, 2},
{AccountStatusTerminating, 3},
{AccountStatusTerminating, 3},
}
for _, step := range steps {
got, err := svc.SetAccountStatus(account.AccountID, step.status)
require.NoError(t, err)
assert.Equal(t, step.want, got.Generation, "status %s", step.status)
stored, err := svc.GetAccount(account.AccountID)
require.NoError(t, err)
assert.Equal(t, step.want, stored.Generation, "stored record after %s", step.status)
}
}
func TestAccountRecordWithoutGenerationReadsAsZero(t *testing.T) {
t.Parallel()
svc := setupTestIAMService(t)
account, err := svc.CreateAccount("legacy-record")
require.NoError(t, err)
legacy, err := json.Marshal(map[string]string{
"account_id": account.AccountID, "account_name": "legacy-record",
"status": AccountStatusActive, "created_at": account.CreatedAt,
})
require.NoError(t, err)
_, err = svc.accountsBucket.Put(context.Background(), account.AccountID, legacy)
require.NoError(t, err)
got, err := svc.SetAccountStatus(account.AccountID, AccountStatusSuspended)
require.NoError(t, err)
assert.Equal(t, uint64(1), got.Generation)
}
// racingBucket lets a second writer land between the status writer's read and its update.
type racingBucket struct {
jetstream.KeyValue
raced atomic.Bool
compete func()
}
func (b *racingBucket) Update(ctx context.Context, key string, value []byte, last uint64) (uint64, error) {
if b.raced.CompareAndSwap(false, true) {
b.compete()
}
return b.KeyValue.Update(ctx, key, value, last)
}
func TestSetAccountStatusRetriesOnAConcurrentWrite(t *testing.T) {
t.Parallel()
svc := setupTestIAMService(t)
account, err := svc.CreateAccount("racing-writer")
require.NoError(t, err)
underlying := svc.accountsBucket
svc.accountsBucket = &racingBucket{KeyValue: underlying, compete: func() {
entry, err := underlying.Get(context.Background(), account.AccountID)
require.NoError(t, err)
var other Account
require.NoError(t, json.Unmarshal(entry.Value(), &other))
other.AccountName = "renamed-by-the-other-writer"
data, err := json.Marshal(other)
require.NoError(t, err)
_, err = underlying.Put(context.Background(), account.AccountID, data)
require.NoError(t, err)
}}
got, err := svc.SetAccountStatus(account.AccountID, AccountStatusSuspended)
require.NoError(t, err)
stored, err := svc.GetAccount(account.AccountID)
require.NoError(t, err)
assert.Equal(t, AccountStatusSuspended, stored.Status, "the status change must land")
assert.Equal(t, "renamed-by-the-other-writer", stored.AccountName, "the competing write must not be lost")
assert.Equal(t, uint64(1), stored.Generation)
assert.Equal(t, stored.Generation, got.Generation)
}
+17 -17
View File
@@ -1226,27 +1226,27 @@ func (s *IAMServiceImpl) SetAccountStatus(accountID, status string) (*Account, e
status, AccountStatusActive, AccountStatusSuspended, AccountStatusTerminating)
}
account, err := s.GetAccount(accountID)
account, err := kvutil.Update(context.Background(), s.accountsBucket, accountID,
kvutil.CASConfig{NotFound: errors.New(awserrors.ErrorIAMNoSuchEntity)},
func(a *Account) (bool, error) {
if a.Status == AccountStatusTerminating && status != AccountStatusTerminating {
return false, fmt.Errorf("account %s is terminating: %w", accountID, ErrAccountUndeletable)
}
if a.Status == status {
return false, nil
}
a.Status = status
a.Generation++
return true, nil
})
if err != nil {
if errors.Is(err, kvutil.ErrWrite) || errors.Is(err, kvutil.ErrRead) || errors.Is(err, kvutil.ErrDecode) {
return nil, fmt.Errorf("store account status: %w", err)
}
return nil, err
}
if account.Status == AccountStatusTerminating && status != AccountStatusTerminating {
return nil, fmt.Errorf("account %s is terminating: %w", accountID, ErrAccountUndeletable)
}
if account.Status == status {
return account, nil
}
account.Status = status
data, err := json.Marshal(account)
if err != nil {
return nil, fmt.Errorf("marshal account: %w", err)
}
if _, err := s.accountsBucket.Put(context.Background(), accountID, data); err != nil {
return nil, fmt.Errorf("store account status: %w", err)
}
slog.Info("Account status changed", "accountID", accountID, "status", status)
slog.Info("Account status changed", "accountID", accountID, "status", status, "generation", account.Generation)
return account, nil
}
+3
View File
@@ -184,4 +184,7 @@ type Account struct {
AccountName string `json:"account_name"` // Friendly name
Status string `json:"status"` // ACTIVE, SUSPENDED or TERMINATING
CreatedAt string `json:"created_at"` // RFC3339
// Generation counts status changes. Records written before it existed read as 0.
Generation uint64 `json:"generation,omitempty"`
}
@@ -4,39 +4,131 @@
package accountadmission
import (
"context"
"encoding/json"
"errors"
"fmt"
"log/slog"
"math"
"sync"
"sync/atomic"
"time"
iamdomain "github.com/mulgadc/spinifex/spinifex/domains/iam"
awserrors "github.com/mulgadc/spinifex/spinifex/foundation/aws/errors"
"github.com/mulgadc/spinifex/spinifex/ingress/aws/credential"
"github.com/nats-io/nats.go/jetstream"
)
// ActiveTTL bounds how long an account is trusted to still be ACTIVE without
// re-reading it. Only the ACTIVE answer is cached, so suspending or terminating an
// account takes effect within this window rather than being held open by a warm
// entry.
// ActiveTTL is the backstop on how long an account is trusted to still be ACTIVE
// without re-reading it. Only the ACTIVE answer is cached, and a running watcher
// evicts an entry the moment the account record changes, so the TTL only matters if
// an update is somehow missed.
const ActiveTTL = 5 * time.Second
// ErrNotActive means the account exists but is suspended or being torn down.
var ErrNotActive = credential.ErrAccountNotActive
// activeEntry is a cached ACTIVE answer and the account generation it was read at.
type activeEntry struct {
until time.Time
generation uint64
}
// Admitter reads account status through the IAM service. Every authenticated request
// needs the answer and the account record changes perhaps twice in its lifetime, so
// an uncached read would double the auth path's KV traffic to learn nothing new.
//
// The cache is only trusted while Watch is running: without a watcher a status change
// made by another process could be held open for the whole TTL, so every request reads.
type Admitter struct {
iam iamdomain.IAMService
watching atomic.Bool
mu sync.RWMutex
active map[string]time.Time
active map[string]activeEntry
// latest is the newest generation the watcher has seen per account, so a read that
// raced an update cannot be cached after the update evicted its predecessor.
latest map[string]uint64
}
// New returns an Admitter. A nil iam fails every admission closed.
func New(iam iamdomain.IAMService) *Admitter {
return &Admitter{iam: iam, active: make(map[string]time.Time)}
return &Admitter{iam: iam, active: make(map[string]activeEntry), latest: make(map[string]uint64)}
}
// Watch follows the accounts bucket until ctx is cancelled, evicting an account's cached
// entry when its record changes or is deleted. It returns once the initial state has
// been replayed, so the cache is already trusted; the returned channel closes when the
// watcher has stopped and the cache is bypassed again.
func (a *Admitter) Watch(ctx context.Context, accounts jetstream.KeyValue) (<-chan struct{}, error) {
w, err := accounts.WatchAll(ctx)
if err != nil {
return nil, fmt.Errorf("watch accounts: %w", err)
}
a.mu.Lock()
clear(a.active)
clear(a.latest)
a.mu.Unlock()
replayed := make(chan struct{})
done := make(chan struct{})
go func() {
defer close(done)
defer a.watching.Store(false)
defer func() { _ = w.Stop() }()
var once sync.Once
for entry := range w.Updates() {
if entry == nil {
once.Do(func() {
a.watching.Store(true)
close(replayed)
})
continue
}
a.observe(entry)
}
once.Do(func() { close(replayed) })
}()
select {
case <-replayed:
case <-ctx.Done():
<-done
return nil, ctx.Err()
}
if !a.watching.Load() {
return done, errors.New("account watcher stopped before replay finished")
}
return done, nil
}
// observe applies one watched record to the cache.
func (a *Admitter) observe(entry jetstream.KeyValueEntry) {
accountID := entry.Key()
a.mu.Lock()
defer a.mu.Unlock()
if entry.Operation() != jetstream.KeyValuePut {
// The account is gone and its ID is never reissued, so nothing read before
// the delete may be cached after it.
a.latest[accountID] = math.MaxUint64
delete(a.active, accountID)
return
}
var account iamdomain.Account
if err := json.Unmarshal(entry.Value(), &account); err != nil {
slog.Warn("Auth: unreadable account record from watcher, evicting", "accountID", accountID, "err", err)
delete(a.active, accountID)
return
}
a.latest[accountID] = account.Generation
if cached, ok := a.active[accountID]; ok && (account.Generation > cached.generation || account.Status != iamdomain.AccountStatusActive) {
delete(a.active, accountID)
}
}
// Admit returns nil when the account may proceed, ErrNotActive when it may not, and
@@ -49,7 +141,7 @@ func (a *Admitter) Admit(accountID, logIP string) error {
return nil
}
now := time.Now()
if a.activeUntil(accountID, now) {
if a != nil && a.watching.Load() && a.activeUntil(accountID, now) {
return nil
}
if a == nil || a.iam == nil {
@@ -71,19 +163,10 @@ func (a *Admitter) Admit(accountID, logIP string) error {
return ErrNotActive
}
a.markActive(accountID, now)
return nil
}
// Forget drops an entry so a status change takes effect immediately on the node that
// made it, rather than after the TTL.
func (a *Admitter) Forget(accountID string) {
if a == nil {
return
if a.watching.Load() {
a.markActive(accountID, account.Generation, now)
}
a.mu.Lock()
defer a.mu.Unlock()
delete(a.active, accountID)
return nil
}
func (a *Admitter) activeUntil(accountID string, now time.Time) bool {
@@ -92,14 +175,18 @@ func (a *Admitter) activeUntil(accountID string, now time.Time) bool {
}
a.mu.RLock()
defer a.mu.RUnlock()
expiry, ok := a.active[accountID]
return ok && now.Before(expiry)
entry, ok := a.active[accountID]
return ok && now.Before(entry.until)
}
func (a *Admitter) markActive(accountID string, now time.Time) {
// markActive caches an ACTIVE read unless the watcher has already seen a newer record.
func (a *Admitter) markActive(accountID string, generation uint64, now time.Time) {
a.mu.Lock()
defer a.mu.Unlock()
a.active[accountID] = now.Add(ActiveTTL)
if latest, ok := a.latest[accountID]; ok && latest > generation {
return
}
a.active[accountID] = activeEntry{until: now.Add(ActiveTTL), generation: generation}
}
// Active reports whether the account may proceed. A suspended, terminating or deleted
@@ -54,32 +54,55 @@ func TestAdmit(t *testing.T) {
func TestAdmit_CachesOnlyActive(t *testing.T) {
svc := &fakeIAM{status: iamdomain.AccountStatusActive}
a := New(svc)
a.watching.Store(true)
require.NoError(t, a.Admit("1", ""))
require.NoError(t, a.Admit("1", ""))
assert.Equal(t, 1, svc.reads, "a warm ACTIVE entry is not re-read")
a.Forget("1")
svc.status = iamdomain.AccountStatusSuspended
assert.ErrorIs(t, a.Admit("1", ""), ErrNotActive, "forget makes a status change take effect at once")
a.evictForTest("1")
assert.ErrorIs(t, a.Admit("1", ""), ErrNotActive)
assert.ErrorIs(t, a.Admit("1", ""), ErrNotActive)
assert.Equal(t, 3, svc.reads, "a non-active answer is never cached")
}
func TestAdmit_WithoutAWatcherEveryRequestReads(t *testing.T) {
svc := &fakeIAM{status: iamdomain.AccountStatusActive}
a := New(svc)
require.NoError(t, a.Admit("1", ""))
require.NoError(t, a.Admit("1", ""))
assert.Equal(t, 2, svc.reads, "an unwatched cache could hold a stale ACTIVE for the whole TTL")
}
func TestActiveEntryExpires(t *testing.T) {
a := New(&fakeIAM{status: iamdomain.AccountStatusActive})
now := time.Now()
assert.False(t, a.activeUntil("1", now), "an unseen account must not read as active")
a.markActive("1", now)
a.markActive("1", 0, now)
assert.True(t, a.activeUntil("1", now))
assert.False(t, a.activeUntil("1", now.Add(ActiveTTL+time.Second)), "the entry must expire with the TTL")
}
func TestNilAdmitterForgetsAndReadsNothing(t *testing.T) {
func TestMarkActiveSkipsARecordTheWatcherHasAlreadySupersededAfterTheRead(t *testing.T) {
a := New(&fakeIAM{status: iamdomain.AccountStatusActive})
a.latest["1"] = 2
a.markActive("1", 1, time.Now())
assert.False(t, a.activeUntil("1", time.Now()), "a read older than the newest watched record must not be cached")
a.markActive("1", 2, time.Now())
assert.True(t, a.activeUntil("1", time.Now()))
}
func TestNilAdmitterReadsNothing(t *testing.T) {
var a *Admitter
a.Forget("1")
assert.False(t, a.activeUntil("1", time.Now()))
}
func (a *Admitter) evictForTest(accountID string) {
a.mu.Lock()
defer a.mu.Unlock()
delete(a.active, accountID)
}
func TestActive(t *testing.T) {
cases := []struct {
name string
@@ -0,0 +1,105 @@
package accountadmission
import (
"context"
"testing"
"time"
"github.com/mulgadc/spinifex/internal/testkit"
iamdomain "github.com/mulgadc/spinifex/spinifex/domains/iam"
"github.com/nats-io/nats.go/jetstream"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// watchedPair is two IAM services over one JetStream, standing in for the gateway and
// a separate CLI process that writes account status.
type watchedPair struct {
gateway *Admitter
cli iamdomain.IAMService
account string
bucket jetstream.KeyValue
}
func newWatchedPair(t *testing.T) (*watchedPair, context.CancelFunc, <-chan struct{}) {
t.Helper()
_, nc, js := testutil.StartTestJetStream(t)
masterKey, err := iamdomain.GenerateMasterKey()
require.NoError(t, err)
gatewayIAM, err := iamdomain.NewIAMServiceImpl(t.Context(), nc, masterKey)
require.NoError(t, err)
cli, err := iamdomain.NewIAMServiceImpl(t.Context(), nc, masterKey)
require.NoError(t, err)
account, err := cli.CreateAccount("watched")
require.NoError(t, err)
bucket, err := js.KeyValue(t.Context(), iamdomain.KVBucketAccounts)
require.NoError(t, err)
admitter := New(gatewayIAM)
ctx, cancel := context.WithCancel(t.Context())
t.Cleanup(cancel)
done, err := admitter.Watch(ctx, bucket)
require.NoError(t, err)
return &watchedPair{gateway: admitter, cli: cli, account: account.AccountID, bucket: bucket}, cancel, done
}
func TestWatchEvictsOnAStatusChangeFromAnotherClient(t *testing.T) {
p, _, _ := newWatchedPair(t)
require.NoError(t, p.gateway.Admit(p.account, ""))
require.True(t, p.gateway.activeUntil(p.account, time.Now()), "the ACTIVE answer must be cached before the change")
_, err := p.cli.SetAccountStatus(p.account, iamdomain.AccountStatusTerminating)
require.NoError(t, err)
require.Eventually(t, func() bool {
return p.gateway.Admit(p.account, "") != nil
}, ActiveTTL-time.Second, 10*time.Millisecond, "eviction must beat the TTL")
}
func TestWatchEvictsOnADelete(t *testing.T) {
p, _, _ := newWatchedPair(t)
_, err := p.cli.SetAccountStatus(p.account, iamdomain.AccountStatusTerminating)
require.NoError(t, err)
require.Eventually(t, func() bool { return p.gateway.Admit(p.account, "") != nil }, 2*time.Second, 10*time.Millisecond)
other, err := p.cli.CreateAccount("to-delete")
require.NoError(t, err)
require.NoError(t, p.gateway.Admit(other.AccountID, ""))
require.True(t, p.gateway.activeUntil(other.AccountID, time.Now()))
require.NoError(t, p.bucket.Delete(t.Context(), other.AccountID))
require.Eventually(t, func() bool {
return !p.gateway.activeUntil(other.AccountID, time.Now())
}, ActiveTTL-time.Second, 10*time.Millisecond, "a deleted account must leave the cache before the TTL")
}
func TestWatchKeepsOtherAccountsCached(t *testing.T) {
p, _, _ := newWatchedPair(t)
other, err := p.cli.CreateAccount("bystander")
require.NoError(t, err)
require.NoError(t, p.gateway.Admit(other.AccountID, ""))
_, err = p.cli.SetAccountStatus(p.account, iamdomain.AccountStatusTerminating)
require.NoError(t, err)
require.Eventually(t, func() bool { return p.gateway.Admit(p.account, "") != nil }, 2*time.Second, 10*time.Millisecond)
assert.True(t, p.gateway.activeUntil(other.AccountID, time.Now()), "another account's entry must survive")
}
func TestStoppedWatcherBypassesTheCache(t *testing.T) {
p, cancel, done := newWatchedPair(t)
require.NoError(t, p.gateway.Admit(p.account, ""))
cancel()
select {
case <-done:
case <-time.After(5 * time.Second):
t.Fatal("the watcher goroutine did not stop with its context")
}
_, err := p.cli.SetAccountStatus(p.account, iamdomain.AccountStatusTerminating)
require.NoError(t, err)
assert.ErrorIs(t, p.gateway.Admit(p.account, ""), ErrNotActive, "with no watcher the change must be seen at once")
}
@@ -8,7 +8,6 @@ import (
"crypto/rand"
"encoding/base64"
"encoding/json"
"github.com/mulgadc/spinifex/spinifex/runtime/roles/awsgw/accountadmission"
"net/http"
"net/http/httptest"
"net/url"
@@ -22,11 +21,15 @@ import (
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/mulgadc/spinifex/internal/testkit/gwfix"
ekscluster "github.com/mulgadc/spinifex/spinifex/domains/eks/cluster"
iamdomain "github.com/mulgadc/spinifex/spinifex/domains/iam"
stsdomain "github.com/mulgadc/spinifex/spinifex/domains/sts"
awserrors "github.com/mulgadc/spinifex/spinifex/foundation/aws/errors"
"github.com/mulgadc/spinifex/spinifex/foundation/state/kvutil"
"github.com/mulgadc/spinifex/spinifex/ingress/aws/pipeline"
"github.com/mulgadc/spinifex/spinifex/runtime/roles/awsgw/accountadmission"
"github.com/mulgadc/spinifex/spinifex/runtime/roles/awsgw/credverify"
)
// Pins CURRENT behaviour: wire outcomes for invalid long-lived credentials, and
@@ -411,29 +414,43 @@ func TestCurrentBehaviour_AntiEnumeration_RejectionShapes(t *testing.T) {
}
// Pins CURRENT behaviour: the door caches only an account's ACTIVE status, per
// gateway, for accountadmission.ActiveTTL; keys and sessions are re-read on every request.
// gateway, and only while its accounts watcher runs; keys and sessions are re-read on
// every request. A watched cache drops an entry when the record changes, so an
// out-of-band status write is seen well before accountadmission.ActiveTTL.
func TestCurrentBehaviour_Cache_AccountStatusIsTheOnlyAuthCache(t *testing.T) {
require.Equal(t, 5*time.Second, accountadmission.ActiveTTL)
denied := cbOutcome{status: http.StatusForbidden, code: awserrors.ErrorAccessDenied,
message: awserrors.ErrorLookup[awserrors.ErrorAccessDenied].Message}
for _, status := range []string{iamdomain.AccountStatusSuspended, iamdomain.AccountStatusTerminating} {
t.Run(status, func(t *testing.T) {
e := newCBEnv(t, cbEnvOpts{})
require.Equal(t, http.StatusOK, e.callerIdentity(t, e.alice).Code)
start := time.Now()
_, err := e.iam.SetAccountStatus(e.accountID, status)
accounts, err := e.js.KeyValue(t.Context(), iamdomain.KVBucketAccounts)
require.NoError(t, err)
admission := accountadmission.New(e.counter)
_, err = admission.Watch(t.Context(), accounts)
require.NoError(t, err)
// CURRENT: an out-of-band status write is not seen by a gateway
// holding a warm ACTIVE entry until that entry expires.
warm := e.callerIdentity(t, e.alice)
if time.Since(start) >= accountadmission.ActiveTTL {
t.Skip("cache window elapsed before the warm request; outcome not attributable")
}
assert.Equal(t, http.StatusOK, warm.Code)
gw := gwfix.WithDeps(&pipeline.Config{
DisableLogging: true,
Region: gwfix.Region,
Credentials: credverify.New(e.counter, e.sts),
Admission: admission,
}, gwfix.Deps{NATS: e.nc, Nodes: 1, IAM: e.counter, STS: e.sts})
withCapabilities(gw)
withIAMAndSTS(gw)
h := gw.SetupRoutes()
denied := cbOutcome{status: http.StatusForbidden, code: awserrors.ErrorAccessDenied,
message: awserrors.ErrorLookup[awserrors.ErrorAccessDenied].Message}
assert.Equal(t, denied, cbOutcomeOf(e.request(t, e.gateway(), e.alice, "sts", cbForm("GetCallerIdentity"))))
require.Equal(t, http.StatusOK, e.request(t, h, e.alice, "sts", cbForm("GetCallerIdentity")).Code)
start := time.Now()
_, err = e.iam.SetAccountStatus(e.accountID, status)
require.NoError(t, err)
require.Eventually(t, func() bool {
return cbOutcomeOf(e.request(t, h, e.alice, "sts", cbForm("GetCallerIdentity"))) == denied
}, accountadmission.ActiveTTL-time.Second, 10*time.Millisecond)
assert.Less(t, time.Since(start), accountadmission.ActiveTTL, "the watcher, not the TTL, must have ended the warm entry")
})
}
}
+22
View File
@@ -70,6 +70,27 @@ import (
toml "github.com/pelletier/go-toml/v2"
)
// startAdmissionWatch lets the admitter cache ACTIVE answers. If the watch cannot start,
// the admitter keeps reading every request, which is slower but never stale.
func startAdmissionWatch(ctx context.Context, js jetstream.JetStream, admission *accountadmission.Admitter) {
accounts, err := js.KeyValue(ctx, iamdomain.KVBucketAccounts)
if err != nil {
slog.Warn("Account admission cache disabled: cannot open accounts bucket", "err", err)
return
}
done, err := admission.Watch(ctx, accounts)
if err != nil {
slog.Warn("Account admission cache disabled: cannot watch accounts bucket", "err", err)
return
}
go func() {
<-done
if ctx.Err() == nil {
slog.Warn("Account admission watcher stopped; every request now reads account status")
}
}()
}
// startInstanceStatus starts the read-only informer over the live instance
// record space and pairs it with node liveness. The describe path still serves
// from the fan-out and KV; only status synthesis reads the cache. Without it a
@@ -517,6 +538,7 @@ func launchService(config *config.ClusterConfig) error {
ecrVerifier := ecrauth.NewVerifier(verifyKeys, ecrAudience)
credentials := credverify.New(iamService, stsService)
admission := accountadmission.New(iamService)
startAdmissionWatch(janitorCtx, jwksJS, admission)
ecrBridge, err := ecrregistry.NewBridge(registryadapt.Config(ecrregistry.BridgeConfig{
Registry: ecrRegistry,
Verifier: ecrVerifier,
@@ -128,7 +128,8 @@ func TestCurrentBehaviour_OIDCDiscoveryAfterClusterPrefixDeleted(t *testing.T) {
e := newDiscoveryEnv(t)
e.putMeta(t, ekscluster.ClusterStatusDeleting, testDiscIssuer)
e.putJWKS(t, `{"keys":[{"kty":"EC","kid":"abc"}]}`)
require.NoError(t, ekscluster.DeleteClusterPrefix(t.Context(), e.kv, testDiscCluster))
require.NoError(t, ekscluster.DeleteClusterChildren(t.Context(), e.kv, testDiscCluster))
require.NoError(t, e.kv.Delete(t.Context(), ekscluster.ClusterMetaKey(testDiscCluster)))
for _, p := range []string{cbDiscPath, cbKeysPath} {
rec := getOIDC(t, e.h, p)
@@ -1,2 +1,2 @@
import{r as e}from"./rolldown-runtime-XKyaHtl9.js";import{Ht as t,Rt as n,Ut as r,X as i}from"./schemas-BGv4hnp9.js";import{t as a}from"./useSuspenseQuery-C45dM0fa.js";import{t as o}from"./link-D5tcioHY.js";import{t as s}from"./data-table-CqrUk0mI.js";import{m as c}from"./detail-panel-BBYLO5I7.js";import{t as l}from"./plus-U2P-lQY5.js";import{a as u,t as d}from"./cli-command-panel-DBkpfo1W.js";import{o as f}from"./useValueAsRef-BoFfo9R-.js";import{c as p,f as m,t as h}from"./field-B2ItYtQp.js";import{f as g}from"./ec2-CkEARsnn.js";import{D as _,p as v}from"./ec2-B8367M9s.js";import{t as y}from"./create-panel-DMDXu4FP.js";import{t as b}from"./page-heading-75JYUGDy.js";import{t as x}from"./resource-view-BXETZc8v.js";import{t as S}from"./selection-bar-MuN8z7Qc.js";import{t as C}from"./state-badge-DuJRo9eW.js";import{t as w}from"./use-row-selection-CZtmDppg.js";import{n as T,t as E}from"./form-lock-C6qPAte1.js";import{o as D,s as O}from"./index.esm-ClOaByEg.js";import{t as k}from"./zod-DmrlGH9Y.js";import{a as A}from"./ec2-59Y0OXl4.js";import{t as j}from"./describe-internet-gateways-DxvQneDq.js";import{t as M}from"./bulk-delete-B7E-9nKZ.js";import{n as N,t as P}from"./internet-gateway-details-Bw_-0Eks.js";var F=e(r(),1),I=n(),L=t();function R(e){let t=(0,I.c)(33),{onClose:n}=e,r=n,i=v(),a;t[0]===Symbol.for(`react.memo_cache_sentinel`)?(a={resolver:k(A),defaultValues:{name:``}},t[0]=a):a=t[0];let{control:o,handleSubmit:s,register:c,formState:l}=D(a),{isSubmitting:u}=l,f;t[1]===o?f=t[2]:(f={control:o},t[1]=o,t[2]=f);let g=O(f),_;t[3]!==i||t[4]!==r?(_=async e=>{let t;try{t=await i.mutateAsync({name:e.name?.trim()??void 0})}catch{return}r(t.InternetGateway?.InternetGatewayId)},t[3]=i,t[4]=r,t[5]=_):_=t[5];let y=_,b;t[6]!==s||t[7]!==y?(b=s(y),t[6]=s,t[7]=y,t[8]=b):b=t[8];let x=i.isPending||u,S;t[9]===Symbol.for(`react.memo_cache_sentinel`)?(S=(0,L.jsx)(p,{children:(0,L.jsx)(`label`,{htmlFor:`name`,children:`Name (optional)`})}),t[9]=S):S=t[9];let C;t[10]===c?C=t[11]:(C=c(`name`),t[10]=c,t[11]=C);let w;t[12]===C?w=t[13]:(w=(0,L.jsxs)(h,{children:[S,(0,L.jsx)(m,{id:`name`,placeholder:`my-internet-gateway`,...C})]}),t[12]=C,t[13]=w);let j=g.name??``,M;t[14]===j?M=t[15]:(M=z(j),t[14]=j,t[15]=M);let N;t[16]===M?N=t[17]:(N=(0,L.jsx)(d,{commands:M}),t[16]=M,t[17]=N);let P;t[18]===r?P=t[19]:(P=()=>{r()},t[18]=r,t[19]=P);let F;t[20]!==i.error||t[21]!==i.isPending||t[22]!==u||t[23]!==P?(F=(0,L.jsx)(T,{error:i.error,errorMsg:`Failed to create Internet Gateway`,isPending:i.isPending,isSubmitting:u,onCancel:P,pendingLabel:`Creating…`,submitLabel:`Create`}),t[20]=i.error,t[21]=i.isPending,t[22]=u,t[23]=P,t[24]=F):F=t[24];let R;t[25]!==N||t[26]!==F||t[27]!==x||t[28]!==w?(R=(0,L.jsxs)(E,{className:`space-y-6`,locked:x,children:[w,N,F]}),t[25]=N,t[26]=F,t[27]=x,t[28]=w,t[29]=R):R=t[29];let B;return t[30]!==R||t[31]!==b?(B=(0,L.jsx)(`form`,{onSubmit:b,children:R}),t[30]=R,t[31]=b,t[32]=B):B=t[32],B}function z(e){let t=e.trim();return[{label:`Create Internet Gateway`,parts:[{type:`bin`,value:`AWS_PROFILE=spinifex aws ec2 create-internet-gateway`},...u(`\\
--tag-specifications`,t&&`'ResourceType=internet-gateway,Tags=[{Key=Name,Value=${t}}]'`)]}]}var B=[{id:`gateway`,header:`Internet gateway`,sortValue:e=>i(e.Tags)??e.InternetGatewayId,cell:e=>{let t=i(e.Tags),n=e.InternetGatewayId??``;return(0,L.jsxs)(o,{className:`group block max-w-72 min-w-0 rounded-sm focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none`,from:`/ec2/describe-internet-gateways/`,search:e=>({...e,...c,selected:n}),to:`.`,children:[(0,L.jsx)(`span`,{className:`block truncate font-medium text-primary group-hover:underline`,children:t??n}),t&&(0,L.jsx)(`span`,{className:`block truncate font-mono text-xs text-muted-foreground`,children:n})]})}},{id:`state`,header:`State`,sortValue:N,cell:e=>(0,L.jsx)(C,{state:N(e)})},{id:`vpc`,header:`VPC`,sortValue:e=>e.Attachments?.[0]?.VpcId,hideBelow:`sm`,cell:e=>{let t=e.Attachments?.[0]?.VpcId;return t?(0,L.jsx)(o,{className:`font-mono text-xs text-primary hover:underline`,params:{id:t},to:`/ec2/describe-vpcs/$id`,children:t}):(0,L.jsx)(`span`,{className:`text-muted-foreground`,children:`—`})}},{id:`owner`,header:`Owner`,sortValue:e=>e.OwnerId,hideBelow:`lg`,cell:e=>(0,L.jsx)(`span`,{className:`font-mono text-xs tabular-nums`,children:e.OwnerId})}];function V(e){let t=(0,I.c)(45),{internetGateways:n,search:r,onSearchChange:i}=e,[a,u]=(0,F.useState)(),d;if(t[0]!==n||t[1]!==r.selected){let e;t[3]===r.selected?e=t[4]:(e=e=>r.selected!==``&&e.InternetGatewayId===r.selected,t[3]=r.selected,t[4]=e),d=n.find(e),t[0]=n,t[1]=r.selected,t[2]=d}else d=t[2];let p=d,m;t[5]===i?m=t[6]:(m=function(){i(c)},t[5]=i,t[6]=m);let h=m,g;t[7]===n?g=t[8]:(g=n.map(G),t[7]=n,t[8]=g);let v=w(g,p!==void 0,h),C=_(),T;t[9]===n?T=t[10]:(T=new Map(n.map(W)),t[9]=n,t[10]=T);let E=T,D=null;if(r.create){let e;t[11]===i?e=t[12]:(e=(0,L.jsx)(R,{onClose:e=>{i({...c,selected:e??``})}}),t[11]=i,t[12]=e);let n;t[13]!==h||t[14]!==e?(n=(0,L.jsx)(y,{onClose:h,title:`Create internet gateway`,children:e}),t[13]=h,t[14]=e,t[15]=n):n=t[15],D=n}else if(p?.InternetGatewayId){let e;t[16]===i?e=t[17]:(e=e=>{i({tab:e})},t[16]=i,t[17]=e);let a;t[18]!==h||t[19]!==n||t[20]!==p||t[21]!==r.tab||t[22]!==e?(a=(0,L.jsx)(P,{gateways:n,igw:p,onClose:h,onTabChange:e,tab:r.tab},p.InternetGatewayId),t[18]=h,t[19]=n,t[20]=p,t[21]=r.tab,t[22]=e,t[23]=a):a=t[23],D=a}let O=D,k;t[24]===Symbol.for(`react.memo_cache_sentinel`)?(k=f(),t[24]=k):k=t[24];let A;t[25]===Symbol.for(`react.memo_cache_sentinel`)?(A=(0,L.jsx)(b,{actions:(0,L.jsxs)(o,{className:k,from:`/ec2/describe-internet-gateways/`,search:U,to:`.`,children:[(0,L.jsx)(l,{"data-icon":`inline-start`}),`Create internet gateway`]}),subtitle:`Connect a VPC to the outside world.`,title:`Internet gateways`}),t[25]=A):A=t[25];let j;t[26]!==C||t[27]!==E||t[28]!==v.clear||t[29]!==v.drop||t[30]!==v.ids?(j=v.ids.length>0&&(0,L.jsx)(S,{count:v.ids.length,onClear:v.clear,children:(0,L.jsx)(M,{deleteOne:C.mutateAsync,description:`Each must be detached from its VPC first. This cannot be undone.`,ids:v.ids,label:e=>{let t=E.get(e);return t?`${t} (${e})`:e},noun:[`internet gateway`,`internet gateways`],onDeleted:v.drop})}),t[26]=C,t[27]=E,t[28]=v.clear,t[29]=v.drop,t[30]=v.ids,t[31]=j):j=t[31];let N=p?.InternetGatewayId,z;t[32]===i?z=t[33]:(z=e=>{i({...c,selected:e})},t[32]=i,t[33]=z);let V;t[34]!==n||t[35]!==v.onSelectedChange||t[36]!==v.selected||t[37]!==a||t[38]!==z||t[39]!==N?(V=(0,L.jsx)(s,{activeId:N,caption:`Internet gateways`,columns:B,empty:`No internet gateways yet.`,getRowId:H,onRowClick:z,onSelectedChange:v.onSelectedChange,onSortChange:u,rows:n,selected:v.selected,sort:a}),t[34]=n,t[35]=v.onSelectedChange,t[36]=v.selected,t[37]=a,t[38]=z,t[39]=N,t[40]=V):V=t[40];let K;return t[41]!==D||t[42]!==V||t[43]!==j?(K=(0,L.jsxs)(x,{panel:O,children:[A,j,V]}),t[41]=D,t[42]=V,t[43]=j,t[44]=K):K=t[44],K}function H(e){return e.InternetGatewayId??``}function U(e){return{...e,...c,create:!0}}function W(e){return[e.InternetGatewayId,i(e.Tags)]}function G(e){return e.InternetGatewayId??``}function K(){let e=(0,I.c)(8),{data:t}=a(g),n=j.useSearch(),r=j.useNavigate(),i;e[0]===r?i=e[1]:(i=function(e){r({search:t=>({...t,...e}),replace:!(`selected`in e||`create`in e)})},e[0]=r,e[1]=i);let o=i,s;e[2]===t.InternetGateways?s=e[3]:(s=t.InternetGateways??[],e[2]=t.InternetGateways,e[3]=s);let c;return e[4]!==o||e[5]!==n||e[6]!==s?(c=(0,L.jsx)(V,{internetGateways:s,onSearchChange:o,search:n}),e[4]=o,e[5]=n,e[6]=s,e[7]=c):c=e[7],c}export{K as component};
import{r as e}from"./rolldown-runtime-XKyaHtl9.js";import{Ht as t,Rt as n,Ut as r,X as i}from"./schemas-BGv4hnp9.js";import{t as a}from"./useSuspenseQuery-C45dM0fa.js";import{t as o}from"./link-D5tcioHY.js";import{t as s}from"./data-table-CqrUk0mI.js";import{m as c}from"./detail-panel-BBYLO5I7.js";import{t as l}from"./plus-U2P-lQY5.js";import{a as u,t as d}from"./cli-command-panel-DBkpfo1W.js";import{o as f}from"./useValueAsRef-BoFfo9R-.js";import{c as p,f as m,t as h}from"./field-B2ItYtQp.js";import{f as g}from"./ec2-CkEARsnn.js";import{D as _,p as v}from"./ec2-B8367M9s.js";import{t as y}from"./create-panel-DMDXu4FP.js";import{t as b}from"./page-heading-75JYUGDy.js";import{t as x}from"./resource-view-BXETZc8v.js";import{t as S}from"./selection-bar-MuN8z7Qc.js";import{t as C}from"./state-badge-DuJRo9eW.js";import{t as w}from"./use-row-selection-CZtmDppg.js";import{n as T,t as E}from"./form-lock-C6qPAte1.js";import{o as D,s as O}from"./index.esm-ClOaByEg.js";import{t as k}from"./zod-DmrlGH9Y.js";import{a as A}from"./ec2-59Y0OXl4.js";import{t as j}from"./describe-internet-gateways-nMZomPT3.js";import{t as M}from"./bulk-delete-B7E-9nKZ.js";import{n as N,t as P}from"./internet-gateway-details-Bw_-0Eks.js";var F=e(r(),1),I=n(),L=t();function R(e){let t=(0,I.c)(33),{onClose:n}=e,r=n,i=v(),a;t[0]===Symbol.for(`react.memo_cache_sentinel`)?(a={resolver:k(A),defaultValues:{name:``}},t[0]=a):a=t[0];let{control:o,handleSubmit:s,register:c,formState:l}=D(a),{isSubmitting:u}=l,f;t[1]===o?f=t[2]:(f={control:o},t[1]=o,t[2]=f);let g=O(f),_;t[3]!==i||t[4]!==r?(_=async e=>{let t;try{t=await i.mutateAsync({name:e.name?.trim()??void 0})}catch{return}r(t.InternetGateway?.InternetGatewayId)},t[3]=i,t[4]=r,t[5]=_):_=t[5];let y=_,b;t[6]!==s||t[7]!==y?(b=s(y),t[6]=s,t[7]=y,t[8]=b):b=t[8];let x=i.isPending||u,S;t[9]===Symbol.for(`react.memo_cache_sentinel`)?(S=(0,L.jsx)(p,{children:(0,L.jsx)(`label`,{htmlFor:`name`,children:`Name (optional)`})}),t[9]=S):S=t[9];let C;t[10]===c?C=t[11]:(C=c(`name`),t[10]=c,t[11]=C);let w;t[12]===C?w=t[13]:(w=(0,L.jsxs)(h,{children:[S,(0,L.jsx)(m,{id:`name`,placeholder:`my-internet-gateway`,...C})]}),t[12]=C,t[13]=w);let j=g.name??``,M;t[14]===j?M=t[15]:(M=z(j),t[14]=j,t[15]=M);let N;t[16]===M?N=t[17]:(N=(0,L.jsx)(d,{commands:M}),t[16]=M,t[17]=N);let P;t[18]===r?P=t[19]:(P=()=>{r()},t[18]=r,t[19]=P);let F;t[20]!==i.error||t[21]!==i.isPending||t[22]!==u||t[23]!==P?(F=(0,L.jsx)(T,{error:i.error,errorMsg:`Failed to create Internet Gateway`,isPending:i.isPending,isSubmitting:u,onCancel:P,pendingLabel:`Creating…`,submitLabel:`Create`}),t[20]=i.error,t[21]=i.isPending,t[22]=u,t[23]=P,t[24]=F):F=t[24];let R;t[25]!==N||t[26]!==F||t[27]!==x||t[28]!==w?(R=(0,L.jsxs)(E,{className:`space-y-6`,locked:x,children:[w,N,F]}),t[25]=N,t[26]=F,t[27]=x,t[28]=w,t[29]=R):R=t[29];let B;return t[30]!==R||t[31]!==b?(B=(0,L.jsx)(`form`,{onSubmit:b,children:R}),t[30]=R,t[31]=b,t[32]=B):B=t[32],B}function z(e){let t=e.trim();return[{label:`Create Internet Gateway`,parts:[{type:`bin`,value:`AWS_PROFILE=spinifex aws ec2 create-internet-gateway`},...u(`\\
--tag-specifications`,t&&`'ResourceType=internet-gateway,Tags=[{Key=Name,Value=${t}}]'`)]}]}var B=[{id:`gateway`,header:`Internet gateway`,sortValue:e=>i(e.Tags)??e.InternetGatewayId,cell:e=>{let t=i(e.Tags),n=e.InternetGatewayId??``;return(0,L.jsxs)(o,{className:`group block max-w-72 min-w-0 rounded-sm focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none`,from:`/ec2/describe-internet-gateways/`,search:e=>({...e,...c,selected:n}),to:`.`,children:[(0,L.jsx)(`span`,{className:`block truncate font-medium text-primary group-hover:underline`,children:t??n}),t&&(0,L.jsx)(`span`,{className:`block truncate font-mono text-xs text-muted-foreground`,children:n})]})}},{id:`state`,header:`State`,sortValue:N,cell:e=>(0,L.jsx)(C,{state:N(e)})},{id:`vpc`,header:`VPC`,sortValue:e=>e.Attachments?.[0]?.VpcId,hideBelow:`sm`,cell:e=>{let t=e.Attachments?.[0]?.VpcId;return t?(0,L.jsx)(o,{className:`font-mono text-xs text-primary hover:underline`,params:{id:t},to:`/ec2/describe-vpcs/$id`,children:t}):(0,L.jsx)(`span`,{className:`text-muted-foreground`,children:`—`})}},{id:`owner`,header:`Owner`,sortValue:e=>e.OwnerId,hideBelow:`lg`,cell:e=>(0,L.jsx)(`span`,{className:`font-mono text-xs tabular-nums`,children:e.OwnerId})}];function V(e){let t=(0,I.c)(45),{internetGateways:n,search:r,onSearchChange:i}=e,[a,u]=(0,F.useState)(),d;if(t[0]!==n||t[1]!==r.selected){let e;t[3]===r.selected?e=t[4]:(e=e=>r.selected!==``&&e.InternetGatewayId===r.selected,t[3]=r.selected,t[4]=e),d=n.find(e),t[0]=n,t[1]=r.selected,t[2]=d}else d=t[2];let p=d,m;t[5]===i?m=t[6]:(m=function(){i(c)},t[5]=i,t[6]=m);let h=m,g;t[7]===n?g=t[8]:(g=n.map(G),t[7]=n,t[8]=g);let v=w(g,p!==void 0,h),C=_(),T;t[9]===n?T=t[10]:(T=new Map(n.map(W)),t[9]=n,t[10]=T);let E=T,D=null;if(r.create){let e;t[11]===i?e=t[12]:(e=(0,L.jsx)(R,{onClose:e=>{i({...c,selected:e??``})}}),t[11]=i,t[12]=e);let n;t[13]!==h||t[14]!==e?(n=(0,L.jsx)(y,{onClose:h,title:`Create internet gateway`,children:e}),t[13]=h,t[14]=e,t[15]=n):n=t[15],D=n}else if(p?.InternetGatewayId){let e;t[16]===i?e=t[17]:(e=e=>{i({tab:e})},t[16]=i,t[17]=e);let a;t[18]!==h||t[19]!==n||t[20]!==p||t[21]!==r.tab||t[22]!==e?(a=(0,L.jsx)(P,{gateways:n,igw:p,onClose:h,onTabChange:e,tab:r.tab},p.InternetGatewayId),t[18]=h,t[19]=n,t[20]=p,t[21]=r.tab,t[22]=e,t[23]=a):a=t[23],D=a}let O=D,k;t[24]===Symbol.for(`react.memo_cache_sentinel`)?(k=f(),t[24]=k):k=t[24];let A;t[25]===Symbol.for(`react.memo_cache_sentinel`)?(A=(0,L.jsx)(b,{actions:(0,L.jsxs)(o,{className:k,from:`/ec2/describe-internet-gateways/`,search:U,to:`.`,children:[(0,L.jsx)(l,{"data-icon":`inline-start`}),`Create internet gateway`]}),subtitle:`Connect a VPC to the outside world.`,title:`Internet Gateways`}),t[25]=A):A=t[25];let j;t[26]!==C||t[27]!==E||t[28]!==v.clear||t[29]!==v.drop||t[30]!==v.ids?(j=v.ids.length>0&&(0,L.jsx)(S,{count:v.ids.length,onClear:v.clear,children:(0,L.jsx)(M,{deleteOne:C.mutateAsync,description:`Each must be detached from its VPC first. This cannot be undone.`,ids:v.ids,label:e=>{let t=E.get(e);return t?`${t} (${e})`:e},noun:[`internet gateway`,`internet gateways`],onDeleted:v.drop})}),t[26]=C,t[27]=E,t[28]=v.clear,t[29]=v.drop,t[30]=v.ids,t[31]=j):j=t[31];let N=p?.InternetGatewayId,z;t[32]===i?z=t[33]:(z=e=>{i({...c,selected:e})},t[32]=i,t[33]=z);let V;t[34]!==n||t[35]!==v.onSelectedChange||t[36]!==v.selected||t[37]!==a||t[38]!==z||t[39]!==N?(V=(0,L.jsx)(s,{activeId:N,caption:`Internet gateways`,columns:B,empty:`No internet gateways yet.`,getRowId:H,onRowClick:z,onSelectedChange:v.onSelectedChange,onSortChange:u,rows:n,selected:v.selected,sort:a}),t[34]=n,t[35]=v.onSelectedChange,t[36]=v.selected,t[37]=a,t[38]=z,t[39]=N,t[40]=V):V=t[40];let K;return t[41]!==D||t[42]!==V||t[43]!==j?(K=(0,L.jsxs)(x,{panel:O,children:[A,j,V]}),t[41]=D,t[42]=V,t[43]=j,t[44]=K):K=t[44],K}function H(e){return e.InternetGatewayId??``}function U(e){return{...e,...c,create:!0}}function W(e){return[e.InternetGatewayId,i(e.Tags)]}function G(e){return e.InternetGatewayId??``}function K(){let e=(0,I.c)(8),{data:t}=a(g),n=j.useSearch(),r=j.useNavigate(),i;e[0]===r?i=e[1]:(i=function(e){r({search:t=>({...t,...e}),replace:!(`selected`in e||`create`in e)})},e[0]=r,e[1]=i);let o=i,s;e[2]===t.InternetGateways?s=e[3]:(s=t.InternetGateways??[],e[2]=t.InternetGateways,e[3]=s);let c;return e[4]!==o||e[5]!==n||e[6]!==s?(c=(0,L.jsx)(V,{internetGateways:s,onSearchChange:o,search:n}),e[4]=o,e[5]=n,e[6]=s,e[7]=c):c=e[7],c}export{K as component};
@@ -1,2 +1,2 @@
const __vite__mapDeps=(i,m=__vite__mapDeps,d=(m.f||(m.f=["assets/describe-internet-gateways-CdBk4HgY.js","assets/rolldown-runtime-XKyaHtl9.js","assets/schemas-BGv4hnp9.js","assets/useSuspenseQuery-C45dM0fa.js","assets/queryOptions-twzzLLsx.js","assets/QueryClientProvider-CTm_NoFP.js","assets/link-D5tcioHY.js","assets/matchContext-DBnoSZp3.js","assets/data-table-CqrUk0mI.js","assets/useSelector-Cvbg8b7C.js","assets/visuallyHidden-D4_nmHIB.js","assets/check-B7aLYzUb.js","assets/useRegisterFieldControl-2x4pEgaC.js","assets/useAriaLabelledBy-BMp0PcVw.js","assets/useControlled-BP9HwLGp.js","assets/table-BEe-3So1.js","assets/detail-panel-BBYLO5I7.js","assets/x-8n_Gyi12.js","assets/error-banner-DYkSEx5d.js","assets/chevron-down-By1UhB9P.js","assets/copy-BpriwVcd.js","assets/loader-circle-CiggOyRj.js","assets/useScrollLock-FIlKWKju.js","assets/useValueAsRef-BoFfo9R-.js","assets/ToolbarRootContext-B6mBfOj0.js","assets/isElementDisabled-CuK_3Aso.js","assets/store-BTg3TzdU.js","assets/collapsible-C7X1O8X9.js","assets/tabs-DFnnXw6G.js","assets/use-copy-to-clipboard-CAJ8gQCx.js","assets/plus-U2P-lQY5.js","assets/cli-command-panel-DBkpfo1W.js","assets/field-B2ItYtQp.js","assets/ec2-CkEARsnn.js","assets/auth-DA1pq184.js","assets/awsClient-pD_9VmT7.js","assets/index.browser-BEm-79SP.js","assets/ec2-B8367M9s.js","assets/useMutation-CutM3PJn.js","assets/subnet-calculator-DDrAb5xp.js","assets/tags-B-poldSB.js","assets/create-panel-DMDXu4FP.js","assets/page-heading-75JYUGDy.js","assets/resource-view-BXETZc8v.js","assets/selection-bar-MuN8z7Qc.js","assets/state-badge-DuJRo9eW.js","assets/use-row-selection-CZtmDppg.js","assets/form-lock-C6qPAte1.js","assets/index.esm-ClOaByEg.js","assets/zod-DmrlGH9Y.js","assets/ec2-59Y0OXl4.js","assets/bulk-delete-B7E-9nKZ.js","assets/circle-x-DHQkYhPI.js","assets/trash-REoRRV_c.js","assets/alert-dialog-DKUcMJKb.js","assets/DialogTitle-BC_80-Qv.js","assets/confirm-phrase-Bvuzco3W.js","assets/internet-gateway-details-Bw_-0Eks.js","assets/select-DY3fKHla.js","assets/clamp-D0kAXd-u.js","assets/useQuery-CZvSQig-.js","assets/sliders-horizontal-DZ-kpCNX.js","assets/link-2--DFHkFuI.js","assets/maximize-2-bpbTyiq2.js","assets/unlink-CG1xDggt.js","assets/delete-confirmation-dialog-DRnnnG6z.js","assets/resource-delete-dialog-DgcEsN9Y.js"])))=>i.map(i=>d[i]);
import{s as e}from"./schemas-BGv4hnp9.js";import{n as t,t as n}from"./lazyRouteComponent-BnnVgQVQ.js";import{t as r}from"./searchMiddleware-VYy8PwPp.js";import{h as i,m as a}from"./detail-panel-BBYLO5I7.js";import{Mo as o}from"./awsClient-pD_9VmT7.js";import{f as s}from"./ec2-CkEARsnn.js";var c=()=>o(()=>import(`./describe-internet-gateways-CdBk4HgY.js`),__vite__mapDeps([0,1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64,65,66])),l=e(i),u=t(`/_auth/ec2/(internet-gateways)/describe-internet-gateways/`)({validateSearch:l,search:{middlewares:[r(a)]},loader:async({context:e})=>{await e.queryClient.query({...s,staleTime:`static`})},head:()=>({meta:[{title:`Internet Gateways | EC2 | Mulga`}]}),component:n(c,`component`)});export{u as t};
const __vite__mapDeps=(i,m=__vite__mapDeps,d=(m.f||(m.f=["assets/describe-internet-gateways-Cg-lYcxb.js","assets/rolldown-runtime-XKyaHtl9.js","assets/schemas-BGv4hnp9.js","assets/useSuspenseQuery-C45dM0fa.js","assets/queryOptions-twzzLLsx.js","assets/QueryClientProvider-CTm_NoFP.js","assets/link-D5tcioHY.js","assets/matchContext-DBnoSZp3.js","assets/data-table-CqrUk0mI.js","assets/useSelector-Cvbg8b7C.js","assets/visuallyHidden-D4_nmHIB.js","assets/check-B7aLYzUb.js","assets/useRegisterFieldControl-2x4pEgaC.js","assets/useAriaLabelledBy-BMp0PcVw.js","assets/useControlled-BP9HwLGp.js","assets/table-BEe-3So1.js","assets/detail-panel-BBYLO5I7.js","assets/x-8n_Gyi12.js","assets/error-banner-DYkSEx5d.js","assets/chevron-down-By1UhB9P.js","assets/copy-BpriwVcd.js","assets/loader-circle-CiggOyRj.js","assets/useScrollLock-FIlKWKju.js","assets/useValueAsRef-BoFfo9R-.js","assets/ToolbarRootContext-B6mBfOj0.js","assets/isElementDisabled-CuK_3Aso.js","assets/store-BTg3TzdU.js","assets/collapsible-C7X1O8X9.js","assets/tabs-DFnnXw6G.js","assets/use-copy-to-clipboard-CAJ8gQCx.js","assets/plus-U2P-lQY5.js","assets/cli-command-panel-DBkpfo1W.js","assets/field-B2ItYtQp.js","assets/ec2-CkEARsnn.js","assets/auth-DA1pq184.js","assets/awsClient-pD_9VmT7.js","assets/index.browser-BEm-79SP.js","assets/ec2-B8367M9s.js","assets/useMutation-CutM3PJn.js","assets/subnet-calculator-DDrAb5xp.js","assets/tags-B-poldSB.js","assets/create-panel-DMDXu4FP.js","assets/page-heading-75JYUGDy.js","assets/resource-view-BXETZc8v.js","assets/selection-bar-MuN8z7Qc.js","assets/state-badge-DuJRo9eW.js","assets/use-row-selection-CZtmDppg.js","assets/form-lock-C6qPAte1.js","assets/index.esm-ClOaByEg.js","assets/zod-DmrlGH9Y.js","assets/ec2-59Y0OXl4.js","assets/bulk-delete-B7E-9nKZ.js","assets/circle-x-DHQkYhPI.js","assets/trash-REoRRV_c.js","assets/alert-dialog-DKUcMJKb.js","assets/DialogTitle-BC_80-Qv.js","assets/confirm-phrase-Bvuzco3W.js","assets/internet-gateway-details-Bw_-0Eks.js","assets/select-DY3fKHla.js","assets/clamp-D0kAXd-u.js","assets/useQuery-CZvSQig-.js","assets/sliders-horizontal-DZ-kpCNX.js","assets/link-2--DFHkFuI.js","assets/maximize-2-bpbTyiq2.js","assets/unlink-CG1xDggt.js","assets/delete-confirmation-dialog-DRnnnG6z.js","assets/resource-delete-dialog-DgcEsN9Y.js"])))=>i.map(i=>d[i]);
import{s as e}from"./schemas-BGv4hnp9.js";import{n as t,t as n}from"./lazyRouteComponent-BnnVgQVQ.js";import{t as r}from"./searchMiddleware-VYy8PwPp.js";import{h as i,m as a}from"./detail-panel-BBYLO5I7.js";import{Mo as o}from"./awsClient-pD_9VmT7.js";import{f as s}from"./ec2-CkEARsnn.js";var c=()=>o(()=>import(`./describe-internet-gateways-Cg-lYcxb.js`),__vite__mapDeps([0,1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64,65,66])),l=e(i),u=t(`/_auth/ec2/(internet-gateways)/describe-internet-gateways/`)({validateSearch:l,search:{middlewares:[r(a)]},loader:async({context:e})=>{await e.queryClient.query({...s,staleTime:`static`})},head:()=>({meta:[{title:`Internet Gateways | EC2 | Mulga`}]}),component:n(c,`component`)});export{u as t};
@@ -1 +1 @@
import{Ht as e,Rt as t}from"./schemas-BGv4hnp9.js";import{t as n}from"./useSuspenseQuery-C45dM0fa.js";import{m as r}from"./ec2-CkEARsnn.js";import{n as i,t as a}from"./describe-key-pairs-B9Q4WsiA.js";var o=t(),s=e();function c(){let e=(0,o.c)(8),{data:t}=n(r),c=a.useSearch(),l=a.useNavigate(),u;e[0]===l?u=e[1]:(u=function(e){l({search:t=>({...t,...e}),replace:!(`selected`in e||`create`in e||`import`in e)})},e[0]=l,e[1]=u);let d=u,f;e[2]===t.KeyPairs?f=e[3]:(f=t.KeyPairs??[],e[2]=t.KeyPairs,e[3]=f);let p;return e[4]!==d||e[5]!==c||e[6]!==f?(p=(0,s.jsx)(i,{keyPairs:f,onSearchChange:d,search:c}),e[4]=d,e[5]=c,e[6]=f,e[7]=p):p=e[7],p}export{c as component};
import{Ht as e,Rt as t}from"./schemas-BGv4hnp9.js";import{t as n}from"./useSuspenseQuery-C45dM0fa.js";import{m as r}from"./ec2-CkEARsnn.js";import{n as i,t as a}from"./describe-key-pairs-DatvSSUV.js";var o=t(),s=e();function c(){let e=(0,o.c)(8),{data:t}=n(r),c=a.useSearch(),l=a.useNavigate(),u;e[0]===l?u=e[1]:(u=function(e){l({search:t=>({...t,...e}),replace:!(`selected`in e||`create`in e||`import`in e)})},e[0]=l,e[1]=u);let d=u,f;e[2]===t.KeyPairs?f=e[3]:(f=t.KeyPairs??[],e[2]=t.KeyPairs,e[3]=f);let p;return e[4]!==d||e[5]!==c||e[6]!==f?(p=(0,s.jsx)(i,{keyPairs:f,onSearchChange:d,search:c}),e[4]=d,e[5]=c,e[6]=f,e[7]=p):p=e[7],p}export{c as component};
@@ -1,4 +1,4 @@
import{r as e}from"./rolldown-runtime-XKyaHtl9.js";import{Ht as t,Rt as n,Ut as r,q as i}from"./schemas-BGv4hnp9.js";import{t as a}from"./useSuspenseQuery-C45dM0fa.js";import{t as o}from"./link-D5tcioHY.js";import{t as s}from"./data-table-CqrUk0mI.js";import{m as c}from"./detail-panel-BBYLO5I7.js";import{t as l}from"./plus-U2P-lQY5.js";import{n as u,t as d}from"./launch-template-details-k-X7LTBF.js";import{a as f,i as p,o as m,t as h}from"./cli-command-panel-DBkpfo1W.js";import{o as g}from"./useValueAsRef-BoFfo9R-.js";import{c as _,f as v,r as y,t as b}from"./field-B2ItYtQp.js";import{g as x}from"./ec2-CkEARsnn.js";import{h as S,k as C,t as w}from"./ec2-B8367M9s.js";import{t as T}from"./create-panel-DMDXu4FP.js";import{t as E}from"./page-heading-75JYUGDy.js";import{t as D}from"./resource-view-BXETZc8v.js";import{t as O}from"./selection-bar-MuN8z7Qc.js";import{t as k}from"./use-row-selection-CZtmDppg.js";import{n as A,t as j}from"./form-lock-C6qPAte1.js";import{o as M,s as N}from"./index.esm-ClOaByEg.js";import{t as P}from"./zod-DmrlGH9Y.js";import{s as F}from"./ec2-59Y0OXl4.js";import{t as I}from"./bulk-delete-B7E-9nKZ.js";import{t as L}from"./describe-launch-templates-B51DQpNO.js";var R=e(r(),1),z=n(),B=t();function V(e){let t=(0,z.c)(53),{onClose:n}=e,r=S(),i;t[0]===Symbol.for(`react.memo_cache_sentinel`)?(i=P(F),t[0]=i):i=t[0];let a;t[1]===Symbol.for(`react.memo_cache_sentinel`)?(a={resolver:i,defaultValues:{launchTemplateName:``,versionDescription:``,imageId:``,instanceType:``,securityGroupIds:[],userData:``}},t[1]=a):a=t[1];let{control:o,handleSubmit:s,register:c,formState:l}=M(a),{errors:d,isSubmitting:f}=l,p;t[2]===o?p=t[3]:(p={control:o},t[2]=o,t[3]=p);let m=N(p),g;t[4]!==r||t[5]!==n?(g=async e=>{let t;try{t=await r.mutateAsync(e)}catch{return}n(t.LaunchTemplate?.LaunchTemplateId)},t[4]=r,t[5]=n,t[6]=g):g=t[6];let x=g,C;t[7]!==s||t[8]!==x?(C=s(x),t[7]=s,t[8]=x,t[9]=C):C=t[9];let T=r.isPending||f,E;t[10]===Symbol.for(`react.memo_cache_sentinel`)?(E=(0,B.jsx)(_,{children:(0,B.jsx)(`label`,{htmlFor:`launchTemplateName`,children:`Name`})}),t[10]=E):E=t[10];let D=!!d.launchTemplateName,O;t[11]===c?O=t[12]:(O=c(`launchTemplateName`),t[11]=c,t[12]=O);let k;t[13]!==O||t[14]!==D?(k=(0,B.jsx)(v,{"aria-invalid":D,id:`launchTemplateName`,placeholder:`my-launch-template`,...O}),t[13]=O,t[14]=D,t[15]=k):k=t[15];let I;t[16]===d.launchTemplateName?I=t[17]:(I=(0,B.jsx)(y,{errors:[d.launchTemplateName]}),t[16]=d.launchTemplateName,t[17]=I);let L;t[18]!==k||t[19]!==I?(L=(0,B.jsxs)(b,{children:[E,k,I]}),t[18]=k,t[19]=I,t[20]=L):L=t[20];let R;t[21]===Symbol.for(`react.memo_cache_sentinel`)?(R=(0,B.jsx)(_,{children:(0,B.jsx)(`label`,{htmlFor:`versionDescription`,children:`Version Description`})}),t[21]=R):R=t[21];let V;t[22]===c?V=t[23]:(V=c(`versionDescription`),t[22]=c,t[23]=V);let U;t[24]===V?U=t[25]:(U=(0,B.jsxs)(b,{children:[R,(0,B.jsx)(v,{id:`versionDescription`,placeholder:`Initial version`,...V})]}),t[24]=V,t[25]=U);let W;t[26]!==o||t[27]!==d||t[28]!==c?(W=(0,B.jsx)(u,{control:o,errors:d,register:c}),t[26]=o,t[27]=d,t[28]=c,t[29]=W):W=t[29];let G=m.launchTemplateName??``,K=m.versionDescription??``,q;t[30]!==G||t[31]!==K||t[32]!==m?(q=H(G,K,w(m)),t[30]=G,t[31]=K,t[32]=m,t[33]=q):q=t[33];let J;t[34]===q?J=t[35]:(J=(0,B.jsx)(h,{commands:q}),t[34]=q,t[35]=J);let Y;t[36]===n?Y=t[37]:(Y=()=>{n()},t[36]=n,t[37]=Y);let X;t[38]!==r.error||t[39]!==r.isPending||t[40]!==f||t[41]!==Y?(X=(0,B.jsx)(A,{error:r.error,errorMsg:`Failed to create launch template`,isPending:r.isPending,isSubmitting:f,onCancel:Y,pendingLabel:`Creating…`,submitLabel:`Create Launch Template`}),t[38]=r.error,t[39]=r.isPending,t[40]=f,t[41]=Y,t[42]=X):X=t[42];let Z;t[43]!==L||t[44]!==U||t[45]!==W||t[46]!==J||t[47]!==X||t[48]!==T?(Z=(0,B.jsxs)(j,{className:`space-y-6`,locked:T,children:[L,U,W,J,X]}),t[43]=L,t[44]=U,t[45]=W,t[46]=J,t[47]=X,t[48]=T,t[49]=Z):Z=t[49];let Q;return t[50]!==Z||t[51]!==C?(Q=(0,B.jsx)(`form`,{onSubmit:C,children:Z}),t[50]=Z,t[51]=C,t[52]=Q):Q=t[52],Q}function H(e,t,n){return[{label:`Create Launch Template`,parts:[{type:`bin`,value:`AWS_PROFILE=spinifex aws ec2 create-launch-template`},...p(`\\
import{r as e}from"./rolldown-runtime-XKyaHtl9.js";import{Ht as t,Rt as n,Ut as r,q as i}from"./schemas-BGv4hnp9.js";import{t as a}from"./useSuspenseQuery-C45dM0fa.js";import{t as o}from"./link-D5tcioHY.js";import{t as s}from"./data-table-CqrUk0mI.js";import{m as c}from"./detail-panel-BBYLO5I7.js";import{t as l}from"./plus-U2P-lQY5.js";import{n as u,t as d}from"./launch-template-details-k-X7LTBF.js";import{a as f,i as p,o as m,t as h}from"./cli-command-panel-DBkpfo1W.js";import{o as g}from"./useValueAsRef-BoFfo9R-.js";import{c as _,f as v,r as y,t as b}from"./field-B2ItYtQp.js";import{g as x}from"./ec2-CkEARsnn.js";import{h as S,k as C,t as w}from"./ec2-B8367M9s.js";import{t as T}from"./create-panel-DMDXu4FP.js";import{t as E}from"./page-heading-75JYUGDy.js";import{t as D}from"./resource-view-BXETZc8v.js";import{t as O}from"./selection-bar-MuN8z7Qc.js";import{t as k}from"./use-row-selection-CZtmDppg.js";import{n as A,t as j}from"./form-lock-C6qPAte1.js";import{o as M,s as N}from"./index.esm-ClOaByEg.js";import{t as P}from"./zod-DmrlGH9Y.js";import{s as F}from"./ec2-59Y0OXl4.js";import{t as I}from"./bulk-delete-B7E-9nKZ.js";import{t as L}from"./describe-launch-templates-PwXwgU82.js";var R=e(r(),1),z=n(),B=t();function V(e){let t=(0,z.c)(53),{onClose:n}=e,r=S(),i;t[0]===Symbol.for(`react.memo_cache_sentinel`)?(i=P(F),t[0]=i):i=t[0];let a;t[1]===Symbol.for(`react.memo_cache_sentinel`)?(a={resolver:i,defaultValues:{launchTemplateName:``,versionDescription:``,imageId:``,instanceType:``,securityGroupIds:[],userData:``}},t[1]=a):a=t[1];let{control:o,handleSubmit:s,register:c,formState:l}=M(a),{errors:d,isSubmitting:f}=l,p;t[2]===o?p=t[3]:(p={control:o},t[2]=o,t[3]=p);let m=N(p),g;t[4]!==r||t[5]!==n?(g=async e=>{let t;try{t=await r.mutateAsync(e)}catch{return}n(t.LaunchTemplate?.LaunchTemplateId)},t[4]=r,t[5]=n,t[6]=g):g=t[6];let x=g,C;t[7]!==s||t[8]!==x?(C=s(x),t[7]=s,t[8]=x,t[9]=C):C=t[9];let T=r.isPending||f,E;t[10]===Symbol.for(`react.memo_cache_sentinel`)?(E=(0,B.jsx)(_,{children:(0,B.jsx)(`label`,{htmlFor:`launchTemplateName`,children:`Name`})}),t[10]=E):E=t[10];let D=!!d.launchTemplateName,O;t[11]===c?O=t[12]:(O=c(`launchTemplateName`),t[11]=c,t[12]=O);let k;t[13]!==O||t[14]!==D?(k=(0,B.jsx)(v,{"aria-invalid":D,id:`launchTemplateName`,placeholder:`my-launch-template`,...O}),t[13]=O,t[14]=D,t[15]=k):k=t[15];let I;t[16]===d.launchTemplateName?I=t[17]:(I=(0,B.jsx)(y,{errors:[d.launchTemplateName]}),t[16]=d.launchTemplateName,t[17]=I);let L;t[18]!==k||t[19]!==I?(L=(0,B.jsxs)(b,{children:[E,k,I]}),t[18]=k,t[19]=I,t[20]=L):L=t[20];let R;t[21]===Symbol.for(`react.memo_cache_sentinel`)?(R=(0,B.jsx)(_,{children:(0,B.jsx)(`label`,{htmlFor:`versionDescription`,children:`Version Description`})}),t[21]=R):R=t[21];let V;t[22]===c?V=t[23]:(V=c(`versionDescription`),t[22]=c,t[23]=V);let U;t[24]===V?U=t[25]:(U=(0,B.jsxs)(b,{children:[R,(0,B.jsx)(v,{id:`versionDescription`,placeholder:`Initial version`,...V})]}),t[24]=V,t[25]=U);let W;t[26]!==o||t[27]!==d||t[28]!==c?(W=(0,B.jsx)(u,{control:o,errors:d,register:c}),t[26]=o,t[27]=d,t[28]=c,t[29]=W):W=t[29];let G=m.launchTemplateName??``,K=m.versionDescription??``,q;t[30]!==G||t[31]!==K||t[32]!==m?(q=H(G,K,w(m)),t[30]=G,t[31]=K,t[32]=m,t[33]=q):q=t[33];let J;t[34]===q?J=t[35]:(J=(0,B.jsx)(h,{commands:q}),t[34]=q,t[35]=J);let Y;t[36]===n?Y=t[37]:(Y=()=>{n()},t[36]=n,t[37]=Y);let X;t[38]!==r.error||t[39]!==r.isPending||t[40]!==f||t[41]!==Y?(X=(0,B.jsx)(A,{error:r.error,errorMsg:`Failed to create launch template`,isPending:r.isPending,isSubmitting:f,onCancel:Y,pendingLabel:`Creating…`,submitLabel:`Create Launch Template`}),t[38]=r.error,t[39]=r.isPending,t[40]=f,t[41]=Y,t[42]=X):X=t[42];let Z;t[43]!==L||t[44]!==U||t[45]!==W||t[46]!==J||t[47]!==X||t[48]!==T?(Z=(0,B.jsxs)(j,{className:`space-y-6`,locked:T,children:[L,U,W,J,X]}),t[43]=L,t[44]=U,t[45]=W,t[46]=J,t[47]=X,t[48]=T,t[49]=Z):Z=t[49];let Q;return t[50]!==Z||t[51]!==C?(Q=(0,B.jsx)(`form`,{onSubmit:C,children:Z}),t[50]=Z,t[51]=C,t[52]=Q):Q=t[52],Q}function H(e,t,n){return[{label:`Create Launch Template`,parts:[{type:`bin`,value:`AWS_PROFILE=spinifex aws ec2 create-launch-template`},...p(`\\
--launch-template-name`,e?m(e):`<name>`),...f(`\\
--version-description`,t&&m(t)),...p(`\\
--launch-template-data`,m(JSON.stringify(n)))]}]}var U=[{id:`template`,header:`Launch template`,sortValue:e=>e.LaunchTemplateName??e.LaunchTemplateId,cell:e=>{let t=e.LaunchTemplateName,n=e.LaunchTemplateId??``;return(0,B.jsxs)(o,{className:`group block max-w-72 min-w-0 rounded-sm focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none`,from:`/ec2/describe-launch-templates/`,search:e=>({...e,...c,selected:n}),to:`.`,children:[(0,B.jsx)(`span`,{className:`block truncate font-medium text-primary group-hover:underline`,children:t??n}),t&&(0,B.jsx)(`span`,{className:`block truncate font-mono text-xs text-muted-foreground`,children:n})]})}},{id:`default`,header:`Default version`,sortValue:e=>e.DefaultVersionNumber,cell:e=>(0,B.jsx)(`span`,{className:`tabular-nums`,children:e.DefaultVersionNumber})},{id:`latest`,header:`Latest version`,sortValue:e=>e.LatestVersionNumber,hideBelow:`sm`,cell:e=>(0,B.jsx)(`span`,{className:`tabular-nums`,children:e.LatestVersionNumber})},{id:`created`,header:`Created`,sortValue:e=>e.CreateTime?.getTime(),hideBelow:`lg`,cell:e=>i(e.CreateTime)}];function W(e){let t=(0,z.c)(47),{launchTemplates:n,search:r,onSearchChange:i}=e,[a,u]=(0,R.useState)(),f;if(t[0]!==n||t[1]!==r.selected){let e;t[3]===r.selected?e=t[4]:(e=e=>r.selected!==``&&e.LaunchTemplateId===r.selected,t[3]=r.selected,t[4]=e),f=n.find(e),t[0]=n,t[1]=r.selected,t[2]=f}else f=t[2];let p=f,m;t[5]===n?m=t[6]:(m=n.map(J),t[5]=n,t[6]=m);let h;t[7]===i?h=t[8]:(h=()=>{i(c)},t[7]=i,t[8]=h);let _=k(m,p!==void 0,h),v=C(),y;t[9]===n?y=t[10]:(y=new Map(n.map(q)),t[9]=n,t[10]=y);let b=y,x=null;if(r.create){let e;t[11]===i?e=t[12]:(e=()=>{i(c)},t[11]=i,t[12]=e);let n;t[13]===i?n=t[14]:(n=(0,B.jsx)(V,{onClose:e=>{i({...c,selected:e??``})}}),t[13]=i,t[14]=n);let r;t[15]!==e||t[16]!==n?(r=(0,B.jsx)(T,{onClose:e,title:`Create launch template`,children:n}),t[15]=e,t[16]=n,t[17]=r):r=t[17],x=r}else if(p?.LaunchTemplateId){let e,n;t[18]===i?(e=t[19],n=t[20]):(e=()=>{i(c)},n=e=>{i({tab:e})},t[18]=i,t[19]=e,t[20]=n);let a;t[21]!==p||t[22]!==r.tab||t[23]!==e||t[24]!==n?(a=(0,B.jsx)(d,{launchTemplate:p,onClose:e,onTabChange:n,tab:r.tab},p.LaunchTemplateId),t[21]=p,t[22]=r.tab,t[23]=e,t[24]=n,t[25]=a):a=t[25],x=a}let S=x,w;t[26]===Symbol.for(`react.memo_cache_sentinel`)?(w=g(),t[26]=w):w=t[26];let A;t[27]===Symbol.for(`react.memo_cache_sentinel`)?(A=(0,B.jsx)(E,{actions:(0,B.jsxs)(o,{className:w,from:`/ec2/describe-launch-templates/`,search:K,to:`.`,children:[(0,B.jsx)(l,{"data-icon":`inline-start`}),`Create launch template`]}),title:`Launch templates`}),t[27]=A):A=t[27];let j;t[28]!==v||t[29]!==b||t[30]!==_.clear||t[31]!==_.drop||t[32]!==_.ids?(j=_.ids.length>0&&(0,B.jsx)(O,{count:_.ids.length,onClear:_.clear,children:(0,B.jsx)(I,{deleteOne:v.mutateAsync,description:`All of their versions will be deleted. This action cannot be undone.`,ids:_.ids,label:e=>{let t=b.get(e);return t?`${t} (${e})`:e},noun:[`launch template`,`launch templates`],onDeleted:_.drop})}),t[28]=v,t[29]=b,t[30]=_.clear,t[31]=_.drop,t[32]=_.ids,t[33]=j):j=t[33];let M=p?.LaunchTemplateId,N;t[34]===i?N=t[35]:(N=e=>{i({...c,selected:e})},t[34]=i,t[35]=N);let P;t[36]!==n||t[37]!==_.onSelectedChange||t[38]!==_.selected||t[39]!==a||t[40]!==N||t[41]!==M?(P=(0,B.jsx)(s,{activeId:M,caption:`Launch templates`,columns:U,empty:`No launch templates found.`,getRowId:G,onRowClick:N,onSelectedChange:_.onSelectedChange,onSortChange:u,rows:n,selected:_.selected,sort:a}),t[36]=n,t[37]=_.onSelectedChange,t[38]=_.selected,t[39]=a,t[40]=N,t[41]=M,t[42]=P):P=t[42];let F;return t[43]!==x||t[44]!==P||t[45]!==j?(F=(0,B.jsxs)(D,{panel:S,children:[A,j,P]}),t[43]=x,t[44]=P,t[45]=j,t[46]=F):F=t[46],F}function G(e){return e.LaunchTemplateId??``}function K(e){return{...e,...c,create:!0}}function q(e){return[e.LaunchTemplateId,e.LaunchTemplateName]}function J(e){return e.LaunchTemplateId??``}function Y(){let e=(0,z.c)(8),{data:t}=a(x),n=L.useSearch(),r=L.useNavigate(),i;e[0]===r?i=e[1]:(i=function(e){r({search:t=>({...t,...e}),replace:!(`selected`in e||`create`in e)})},e[0]=r,e[1]=i);let o=i,s;e[2]===t.LaunchTemplates?s=e[3]:(s=t.LaunchTemplates??[],e[2]=t.LaunchTemplates,e[3]=s);let c;return e[4]!==o||e[5]!==n||e[6]!==s?(c=(0,B.jsx)(W,{launchTemplates:s,onSearchChange:o,search:n}),e[4]=o,e[5]=n,e[6]=s,e[7]=c):c=e[7],c}export{Y as component};
--launch-template-data`,m(JSON.stringify(n)))]}]}var U=[{id:`template`,header:`Launch template`,sortValue:e=>e.LaunchTemplateName??e.LaunchTemplateId,cell:e=>{let t=e.LaunchTemplateName,n=e.LaunchTemplateId??``;return(0,B.jsxs)(o,{className:`group block max-w-72 min-w-0 rounded-sm focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none`,from:`/ec2/describe-launch-templates/`,search:e=>({...e,...c,selected:n}),to:`.`,children:[(0,B.jsx)(`span`,{className:`block truncate font-medium text-primary group-hover:underline`,children:t??n}),t&&(0,B.jsx)(`span`,{className:`block truncate font-mono text-xs text-muted-foreground`,children:n})]})}},{id:`default`,header:`Default version`,sortValue:e=>e.DefaultVersionNumber,cell:e=>(0,B.jsx)(`span`,{className:`tabular-nums`,children:e.DefaultVersionNumber})},{id:`latest`,header:`Latest version`,sortValue:e=>e.LatestVersionNumber,hideBelow:`sm`,cell:e=>(0,B.jsx)(`span`,{className:`tabular-nums`,children:e.LatestVersionNumber})},{id:`created`,header:`Created`,sortValue:e=>e.CreateTime?.getTime(),hideBelow:`lg`,cell:e=>i(e.CreateTime)}];function W(e){let t=(0,z.c)(47),{launchTemplates:n,search:r,onSearchChange:i}=e,[a,u]=(0,R.useState)(),f;if(t[0]!==n||t[1]!==r.selected){let e;t[3]===r.selected?e=t[4]:(e=e=>r.selected!==``&&e.LaunchTemplateId===r.selected,t[3]=r.selected,t[4]=e),f=n.find(e),t[0]=n,t[1]=r.selected,t[2]=f}else f=t[2];let p=f,m;t[5]===n?m=t[6]:(m=n.map(J),t[5]=n,t[6]=m);let h;t[7]===i?h=t[8]:(h=()=>{i(c)},t[7]=i,t[8]=h);let _=k(m,p!==void 0,h),v=C(),y;t[9]===n?y=t[10]:(y=new Map(n.map(q)),t[9]=n,t[10]=y);let b=y,x=null;if(r.create){let e;t[11]===i?e=t[12]:(e=()=>{i(c)},t[11]=i,t[12]=e);let n;t[13]===i?n=t[14]:(n=(0,B.jsx)(V,{onClose:e=>{i({...c,selected:e??``})}}),t[13]=i,t[14]=n);let r;t[15]!==e||t[16]!==n?(r=(0,B.jsx)(T,{onClose:e,title:`Create launch template`,children:n}),t[15]=e,t[16]=n,t[17]=r):r=t[17],x=r}else if(p?.LaunchTemplateId){let e,n;t[18]===i?(e=t[19],n=t[20]):(e=()=>{i(c)},n=e=>{i({tab:e})},t[18]=i,t[19]=e,t[20]=n);let a;t[21]!==p||t[22]!==r.tab||t[23]!==e||t[24]!==n?(a=(0,B.jsx)(d,{launchTemplate:p,onClose:e,onTabChange:n,tab:r.tab},p.LaunchTemplateId),t[21]=p,t[22]=r.tab,t[23]=e,t[24]=n,t[25]=a):a=t[25],x=a}let S=x,w;t[26]===Symbol.for(`react.memo_cache_sentinel`)?(w=g(),t[26]=w):w=t[26];let A;t[27]===Symbol.for(`react.memo_cache_sentinel`)?(A=(0,B.jsx)(E,{actions:(0,B.jsxs)(o,{className:w,from:`/ec2/describe-launch-templates/`,search:K,to:`.`,children:[(0,B.jsx)(l,{"data-icon":`inline-start`}),`Create launch template`]}),title:`Launch Templates`}),t[27]=A):A=t[27];let j;t[28]!==v||t[29]!==b||t[30]!==_.clear||t[31]!==_.drop||t[32]!==_.ids?(j=_.ids.length>0&&(0,B.jsx)(O,{count:_.ids.length,onClear:_.clear,children:(0,B.jsx)(I,{deleteOne:v.mutateAsync,description:`All of their versions will be deleted. This action cannot be undone.`,ids:_.ids,label:e=>{let t=b.get(e);return t?`${t} (${e})`:e},noun:[`launch template`,`launch templates`],onDeleted:_.drop})}),t[28]=v,t[29]=b,t[30]=_.clear,t[31]=_.drop,t[32]=_.ids,t[33]=j):j=t[33];let M=p?.LaunchTemplateId,N;t[34]===i?N=t[35]:(N=e=>{i({...c,selected:e})},t[34]=i,t[35]=N);let P;t[36]!==n||t[37]!==_.onSelectedChange||t[38]!==_.selected||t[39]!==a||t[40]!==N||t[41]!==M?(P=(0,B.jsx)(s,{activeId:M,caption:`Launch templates`,columns:U,empty:`No launch templates found.`,getRowId:G,onRowClick:N,onSelectedChange:_.onSelectedChange,onSortChange:u,rows:n,selected:_.selected,sort:a}),t[36]=n,t[37]=_.onSelectedChange,t[38]=_.selected,t[39]=a,t[40]=N,t[41]=M,t[42]=P):P=t[42];let F;return t[43]!==x||t[44]!==P||t[45]!==j?(F=(0,B.jsxs)(D,{panel:S,children:[A,j,P]}),t[43]=x,t[44]=P,t[45]=j,t[46]=F):F=t[46],F}function G(e){return e.LaunchTemplateId??``}function K(e){return{...e,...c,create:!0}}function q(e){return[e.LaunchTemplateId,e.LaunchTemplateName]}function J(e){return e.LaunchTemplateId??``}function Y(){let e=(0,z.c)(8),{data:t}=a(x),n=L.useSearch(),r=L.useNavigate(),i;e[0]===r?i=e[1]:(i=function(e){r({search:t=>({...t,...e}),replace:!(`selected`in e||`create`in e)})},e[0]=r,e[1]=i);let o=i,s;e[2]===t.LaunchTemplates?s=e[3]:(s=t.LaunchTemplates??[],e[2]=t.LaunchTemplates,e[3]=s);let c;return e[4]!==o||e[5]!==n||e[6]!==s?(c=(0,B.jsx)(W,{launchTemplates:s,onSearchChange:o,search:n}),e[4]=o,e[5]=n,e[6]=s,e[7]=c):c=e[7],c}export{Y as component};
@@ -1,2 +1,2 @@
const __vite__mapDeps=(i,m=__vite__mapDeps,d=(m.f||(m.f=["assets/describe-launch-templates-BvSDtVPg.js","assets/rolldown-runtime-XKyaHtl9.js","assets/schemas-BGv4hnp9.js","assets/useSuspenseQuery-C45dM0fa.js","assets/queryOptions-twzzLLsx.js","assets/QueryClientProvider-CTm_NoFP.js","assets/link-D5tcioHY.js","assets/matchContext-DBnoSZp3.js","assets/data-table-CqrUk0mI.js","assets/useSelector-Cvbg8b7C.js","assets/visuallyHidden-D4_nmHIB.js","assets/check-B7aLYzUb.js","assets/useRegisterFieldControl-2x4pEgaC.js","assets/useAriaLabelledBy-BMp0PcVw.js","assets/useControlled-BP9HwLGp.js","assets/table-BEe-3So1.js","assets/detail-panel-BBYLO5I7.js","assets/x-8n_Gyi12.js","assets/error-banner-DYkSEx5d.js","assets/chevron-down-By1UhB9P.js","assets/copy-BpriwVcd.js","assets/loader-circle-CiggOyRj.js","assets/useScrollLock-FIlKWKju.js","assets/useValueAsRef-BoFfo9R-.js","assets/ToolbarRootContext-B6mBfOj0.js","assets/isElementDisabled-CuK_3Aso.js","assets/store-BTg3TzdU.js","assets/collapsible-C7X1O8X9.js","assets/tabs-DFnnXw6G.js","assets/use-copy-to-clipboard-CAJ8gQCx.js","assets/plus-U2P-lQY5.js","assets/launch-template-details-k-X7LTBF.js","assets/select-DY3fKHla.js","assets/clamp-D0kAXd-u.js","assets/sliders-horizontal-DZ-kpCNX.js","assets/maximize-2-bpbTyiq2.js","assets/trash-REoRRV_c.js","assets/field-B2ItYtQp.js","assets/ec2-CkEARsnn.js","assets/auth-DA1pq184.js","assets/awsClient-pD_9VmT7.js","assets/index.browser-BEm-79SP.js","assets/ec2-B8367M9s.js","assets/useMutation-CutM3PJn.js","assets/subnet-calculator-DDrAb5xp.js","assets/tags-B-poldSB.js","assets/textarea-CUIsQWob.js","assets/delete-confirmation-dialog-DRnnnG6z.js","assets/alert-dialog-DKUcMJKb.js","assets/DialogTitle-BC_80-Qv.js","assets/confirm-phrase-Bvuzco3W.js","assets/index.esm-ClOaByEg.js","assets/zod-DmrlGH9Y.js","assets/ec2-59Y0OXl4.js","assets/security-group-picker-O9E3DG1f.js","assets/switch-list-1VM_9QuR.js","assets/switch-CBUz4s9f.js","assets/resource-delete-dialog-DgcEsN9Y.js","assets/badge-BTkZUPtC.js","assets/useRender-Bj0OT-hO.js","assets/cli-command-panel-DBkpfo1W.js","assets/create-panel-DMDXu4FP.js","assets/page-heading-75JYUGDy.js","assets/resource-view-BXETZc8v.js","assets/selection-bar-MuN8z7Qc.js","assets/use-row-selection-CZtmDppg.js","assets/form-lock-C6qPAte1.js","assets/bulk-delete-B7E-9nKZ.js","assets/circle-x-DHQkYhPI.js"])))=>i.map(i=>d[i]);
import{s as e}from"./schemas-BGv4hnp9.js";import{n as t,t as n}from"./lazyRouteComponent-BnnVgQVQ.js";import{t as r}from"./searchMiddleware-VYy8PwPp.js";import{h as i,m as a}from"./detail-panel-BBYLO5I7.js";import{Mo as o}from"./awsClient-pD_9VmT7.js";import{g as s}from"./ec2-CkEARsnn.js";var c=()=>o(()=>import(`./describe-launch-templates-BvSDtVPg.js`),__vite__mapDeps([0,1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64,65,66,67,68])),l=e(i),u=t(`/_auth/ec2/(launch-templates)/describe-launch-templates/`)({validateSearch:l,search:{middlewares:[r(a)]},loader:async({context:e})=>{await e.queryClient.query({...s,staleTime:`static`})},head:()=>({meta:[{title:`Launch Templates | EC2 | Mulga`}]}),component:n(c,`component`)});export{u as t};
const __vite__mapDeps=(i,m=__vite__mapDeps,d=(m.f||(m.f=["assets/describe-launch-templates-BK4Ki85d.js","assets/rolldown-runtime-XKyaHtl9.js","assets/schemas-BGv4hnp9.js","assets/useSuspenseQuery-C45dM0fa.js","assets/queryOptions-twzzLLsx.js","assets/QueryClientProvider-CTm_NoFP.js","assets/link-D5tcioHY.js","assets/matchContext-DBnoSZp3.js","assets/data-table-CqrUk0mI.js","assets/useSelector-Cvbg8b7C.js","assets/visuallyHidden-D4_nmHIB.js","assets/check-B7aLYzUb.js","assets/useRegisterFieldControl-2x4pEgaC.js","assets/useAriaLabelledBy-BMp0PcVw.js","assets/useControlled-BP9HwLGp.js","assets/table-BEe-3So1.js","assets/detail-panel-BBYLO5I7.js","assets/x-8n_Gyi12.js","assets/error-banner-DYkSEx5d.js","assets/chevron-down-By1UhB9P.js","assets/copy-BpriwVcd.js","assets/loader-circle-CiggOyRj.js","assets/useScrollLock-FIlKWKju.js","assets/useValueAsRef-BoFfo9R-.js","assets/ToolbarRootContext-B6mBfOj0.js","assets/isElementDisabled-CuK_3Aso.js","assets/store-BTg3TzdU.js","assets/collapsible-C7X1O8X9.js","assets/tabs-DFnnXw6G.js","assets/use-copy-to-clipboard-CAJ8gQCx.js","assets/plus-U2P-lQY5.js","assets/launch-template-details-k-X7LTBF.js","assets/select-DY3fKHla.js","assets/clamp-D0kAXd-u.js","assets/sliders-horizontal-DZ-kpCNX.js","assets/maximize-2-bpbTyiq2.js","assets/trash-REoRRV_c.js","assets/field-B2ItYtQp.js","assets/ec2-CkEARsnn.js","assets/auth-DA1pq184.js","assets/awsClient-pD_9VmT7.js","assets/index.browser-BEm-79SP.js","assets/ec2-B8367M9s.js","assets/useMutation-CutM3PJn.js","assets/subnet-calculator-DDrAb5xp.js","assets/tags-B-poldSB.js","assets/textarea-CUIsQWob.js","assets/delete-confirmation-dialog-DRnnnG6z.js","assets/alert-dialog-DKUcMJKb.js","assets/DialogTitle-BC_80-Qv.js","assets/confirm-phrase-Bvuzco3W.js","assets/index.esm-ClOaByEg.js","assets/zod-DmrlGH9Y.js","assets/ec2-59Y0OXl4.js","assets/security-group-picker-O9E3DG1f.js","assets/switch-list-1VM_9QuR.js","assets/switch-CBUz4s9f.js","assets/resource-delete-dialog-DgcEsN9Y.js","assets/badge-BTkZUPtC.js","assets/useRender-Bj0OT-hO.js","assets/cli-command-panel-DBkpfo1W.js","assets/create-panel-DMDXu4FP.js","assets/page-heading-75JYUGDy.js","assets/resource-view-BXETZc8v.js","assets/selection-bar-MuN8z7Qc.js","assets/use-row-selection-CZtmDppg.js","assets/form-lock-C6qPAte1.js","assets/bulk-delete-B7E-9nKZ.js","assets/circle-x-DHQkYhPI.js"])))=>i.map(i=>d[i]);
import{s as e}from"./schemas-BGv4hnp9.js";import{n as t,t as n}from"./lazyRouteComponent-BnnVgQVQ.js";import{t as r}from"./searchMiddleware-VYy8PwPp.js";import{h as i,m as a}from"./detail-panel-BBYLO5I7.js";import{Mo as o}from"./awsClient-pD_9VmT7.js";import{g as s}from"./ec2-CkEARsnn.js";var c=()=>o(()=>import(`./describe-launch-templates-BK4Ki85d.js`),__vite__mapDeps([0,1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64,65,66,67,68])),l=e(i),u=t(`/_auth/ec2/(launch-templates)/describe-launch-templates/`)({validateSearch:l,search:{middlewares:[r(a)]},loader:async({context:e})=>{await e.queryClient.query({...s,staleTime:`static`})},head:()=>({meta:[{title:`Launch Templates | EC2 | Mulga`}]}),component:n(c,`component`)});export{u as t};
@@ -1,2 +1,2 @@
const __vite__mapDeps=(i,m=__vite__mapDeps,d=(m.f||(m.f=["assets/describe-load-balancers-CrAfiK03.js","assets/rolldown-runtime-XKyaHtl9.js","assets/schemas-BGv4hnp9.js","assets/select-DY3fKHla.js","assets/visuallyHidden-D4_nmHIB.js","assets/check-B7aLYzUb.js","assets/chevron-down-By1UhB9P.js","assets/useScrollLock-FIlKWKju.js","assets/useValueAsRef-BoFfo9R-.js","assets/useRegisterFieldControl-2x4pEgaC.js","assets/ToolbarRootContext-B6mBfOj0.js","assets/useControlled-BP9HwLGp.js","assets/isElementDisabled-CuK_3Aso.js","assets/clamp-D0kAXd-u.js","assets/useSuspenseQuery-C45dM0fa.js","assets/queryOptions-twzzLLsx.js","assets/QueryClientProvider-CTm_NoFP.js","assets/link-D5tcioHY.js","assets/matchContext-DBnoSZp3.js","assets/data-table-CqrUk0mI.js","assets/useSelector-Cvbg8b7C.js","assets/useAriaLabelledBy-BMp0PcVw.js","assets/table-BEe-3So1.js","assets/detail-panel-BBYLO5I7.js","assets/x-8n_Gyi12.js","assets/error-banner-DYkSEx5d.js","assets/copy-BpriwVcd.js","assets/loader-circle-CiggOyRj.js","assets/store-BTg3TzdU.js","assets/collapsible-C7X1O8X9.js","assets/tabs-DFnnXw6G.js","assets/use-copy-to-clipboard-CAJ8gQCx.js","assets/load-balancer-details-2GGr00Sr.js","assets/useMutation-CutM3PJn.js","assets/sliders-horizontal-DZ-kpCNX.js","assets/maximize-2-bpbTyiq2.js","assets/pencil-CObOlpZY.js","assets/cli-command-panel-DBkpfo1W.js","assets/trash-REoRRV_c.js","assets/waypoints-D7e51Jvw.js","assets/field-B2ItYtQp.js","assets/awsClient-pD_9VmT7.js","assets/auth-DA1pq184.js","assets/index.browser-BEm-79SP.js","assets/ec2-CkEARsnn.js","assets/elbv2-h6KlGbUo.js","assets/tags-section-DcIDRNnq.js","assets/tags-B-poldSB.js","assets/switch-CBUz4s9f.js","assets/tags-editor-Cpldzu_C.js","assets/index.esm-ClOaByEg.js","assets/zod-DmrlGH9Y.js","assets/tags-field-array-HYS96MMF.js","assets/plus-U2P-lQY5.js","assets/textarea-CUIsQWob.js","assets/delete-confirmation-dialog-DRnnnG6z.js","assets/alert-dialog-DKUcMJKb.js","assets/DialogTitle-BC_80-Qv.js","assets/confirm-phrase-Bvuzco3W.js","assets/state-badge-DuJRo9eW.js","assets/form-lock-C6qPAte1.js","assets/form-panel-rAQfOvhU.js","assets/switch-list-1VM_9QuR.js","assets/security-group-picker-O9E3DG1f.js","assets/resource-delete-dialog-DgcEsN9Y.js","assets/radio-group-CusDfuD4.js","assets/CompositeItem-CtFYX_Ap.js","assets/create-panel-DMDXu4FP.js","assets/page-heading-75JYUGDy.js","assets/resource-view-BXETZc8v.js","assets/selection-bar-MuN8z7Qc.js","assets/use-row-selection-CZtmDppg.js","assets/bulk-delete-B7E-9nKZ.js","assets/circle-x-DHQkYhPI.js","assets/wizard-failure-banner-DsBzs9M_.js","assets/vpc-select-field-CMKlzSpr.js","assets/form-fields-C1aPNl6R.js","assets/target-group-form-BJYeBOHj.js"])))=>i.map(i=>d[i]);
import{s as e}from"./schemas-BGv4hnp9.js";import{n as t,t as n}from"./lazyRouteComponent-BnnVgQVQ.js";import{t as r}from"./searchMiddleware-VYy8PwPp.js";import{h as i,m as a}from"./detail-panel-BBYLO5I7.js";import{Mo as o}from"./awsClient-pD_9VmT7.js";import{a as s}from"./elbv2-h6KlGbUo.js";var c=()=>o(()=>import(`./describe-load-balancers-CrAfiK03.js`),__vite__mapDeps([0,1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64,65,66,67,68,69,70,71,72,73,74,75,76,77])),l=e(i),u=t(`/_auth/ec2/(load-balancers)/describe-load-balancers/`)({validateSearch:l,search:{middlewares:[r(a)]},loader:async({context:e})=>{await e.queryClient.query({...s,staleTime:`static`})},head:()=>({meta:[{title:`Load Balancers | EC2 | Mulga`}]}),component:n(c,`component`)});export{u as t};
const __vite__mapDeps=(i,m=__vite__mapDeps,d=(m.f||(m.f=["assets/describe-load-balancers-DSdYZWIJ.js","assets/rolldown-runtime-XKyaHtl9.js","assets/schemas-BGv4hnp9.js","assets/select-DY3fKHla.js","assets/visuallyHidden-D4_nmHIB.js","assets/check-B7aLYzUb.js","assets/chevron-down-By1UhB9P.js","assets/useScrollLock-FIlKWKju.js","assets/useValueAsRef-BoFfo9R-.js","assets/useRegisterFieldControl-2x4pEgaC.js","assets/ToolbarRootContext-B6mBfOj0.js","assets/useControlled-BP9HwLGp.js","assets/isElementDisabled-CuK_3Aso.js","assets/clamp-D0kAXd-u.js","assets/useSuspenseQuery-C45dM0fa.js","assets/queryOptions-twzzLLsx.js","assets/QueryClientProvider-CTm_NoFP.js","assets/link-D5tcioHY.js","assets/matchContext-DBnoSZp3.js","assets/data-table-CqrUk0mI.js","assets/useSelector-Cvbg8b7C.js","assets/useAriaLabelledBy-BMp0PcVw.js","assets/table-BEe-3So1.js","assets/detail-panel-BBYLO5I7.js","assets/x-8n_Gyi12.js","assets/error-banner-DYkSEx5d.js","assets/copy-BpriwVcd.js","assets/loader-circle-CiggOyRj.js","assets/store-BTg3TzdU.js","assets/collapsible-C7X1O8X9.js","assets/tabs-DFnnXw6G.js","assets/use-copy-to-clipboard-CAJ8gQCx.js","assets/load-balancer-details-2GGr00Sr.js","assets/useMutation-CutM3PJn.js","assets/sliders-horizontal-DZ-kpCNX.js","assets/maximize-2-bpbTyiq2.js","assets/pencil-CObOlpZY.js","assets/cli-command-panel-DBkpfo1W.js","assets/trash-REoRRV_c.js","assets/waypoints-D7e51Jvw.js","assets/field-B2ItYtQp.js","assets/awsClient-pD_9VmT7.js","assets/auth-DA1pq184.js","assets/index.browser-BEm-79SP.js","assets/ec2-CkEARsnn.js","assets/elbv2-h6KlGbUo.js","assets/tags-section-DcIDRNnq.js","assets/tags-B-poldSB.js","assets/switch-CBUz4s9f.js","assets/tags-editor-Cpldzu_C.js","assets/index.esm-ClOaByEg.js","assets/zod-DmrlGH9Y.js","assets/tags-field-array-HYS96MMF.js","assets/plus-U2P-lQY5.js","assets/textarea-CUIsQWob.js","assets/delete-confirmation-dialog-DRnnnG6z.js","assets/alert-dialog-DKUcMJKb.js","assets/DialogTitle-BC_80-Qv.js","assets/confirm-phrase-Bvuzco3W.js","assets/state-badge-DuJRo9eW.js","assets/form-lock-C6qPAte1.js","assets/form-panel-rAQfOvhU.js","assets/switch-list-1VM_9QuR.js","assets/security-group-picker-O9E3DG1f.js","assets/resource-delete-dialog-DgcEsN9Y.js","assets/radio-group-CusDfuD4.js","assets/CompositeItem-CtFYX_Ap.js","assets/create-panel-DMDXu4FP.js","assets/page-heading-75JYUGDy.js","assets/resource-view-BXETZc8v.js","assets/selection-bar-MuN8z7Qc.js","assets/use-row-selection-CZtmDppg.js","assets/bulk-delete-B7E-9nKZ.js","assets/circle-x-DHQkYhPI.js","assets/wizard-failure-banner-DsBzs9M_.js","assets/vpc-select-field-CMKlzSpr.js","assets/form-fields-C1aPNl6R.js","assets/target-group-form-BJYeBOHj.js"])))=>i.map(i=>d[i]);
import{s as e}from"./schemas-BGv4hnp9.js";import{n as t,t as n}from"./lazyRouteComponent-BnnVgQVQ.js";import{t as r}from"./searchMiddleware-VYy8PwPp.js";import{h as i,m as a}from"./detail-panel-BBYLO5I7.js";import{Mo as o}from"./awsClient-pD_9VmT7.js";import{a as s}from"./elbv2-h6KlGbUo.js";var c=()=>o(()=>import(`./describe-load-balancers-DSdYZWIJ.js`),__vite__mapDeps([0,1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64,65,66,67,68,69,70,71,72,73,74,75,76,77])),l=e(i),u=t(`/_auth/ec2/(load-balancers)/describe-load-balancers/`)({validateSearch:l,search:{middlewares:[r(a)]},loader:async({context:e})=>{await e.queryClient.query({...s,staleTime:`static`})},head:()=>({meta:[{title:`Load Balancers | EC2 | Mulga`}]}),component:n(c,`component`)});export{u as t};
@@ -1,2 +1,2 @@
const __vite__mapDeps=(i,m=__vite__mapDeps,d=(m.f||(m.f=["assets/describe-nat-gateways-B8Ubz3S2.js","assets/rolldown-runtime-XKyaHtl9.js","assets/schemas-BGv4hnp9.js","assets/select-DY3fKHla.js","assets/visuallyHidden-D4_nmHIB.js","assets/check-B7aLYzUb.js","assets/chevron-down-By1UhB9P.js","assets/useScrollLock-FIlKWKju.js","assets/useValueAsRef-BoFfo9R-.js","assets/useRegisterFieldControl-2x4pEgaC.js","assets/ToolbarRootContext-B6mBfOj0.js","assets/useControlled-BP9HwLGp.js","assets/isElementDisabled-CuK_3Aso.js","assets/clamp-D0kAXd-u.js","assets/useSuspenseQuery-C45dM0fa.js","assets/queryOptions-twzzLLsx.js","assets/QueryClientProvider-CTm_NoFP.js","assets/link-D5tcioHY.js","assets/matchContext-DBnoSZp3.js","assets/data-table-CqrUk0mI.js","assets/useSelector-Cvbg8b7C.js","assets/useAriaLabelledBy-BMp0PcVw.js","assets/table-BEe-3So1.js","assets/detail-panel-BBYLO5I7.js","assets/x-8n_Gyi12.js","assets/error-banner-DYkSEx5d.js","assets/copy-BpriwVcd.js","assets/loader-circle-CiggOyRj.js","assets/store-BTg3TzdU.js","assets/collapsible-C7X1O8X9.js","assets/tabs-DFnnXw6G.js","assets/use-copy-to-clipboard-CAJ8gQCx.js","assets/plus-U2P-lQY5.js","assets/cli-command-panel-DBkpfo1W.js","assets/field-B2ItYtQp.js","assets/ec2-CkEARsnn.js","assets/auth-DA1pq184.js","assets/awsClient-pD_9VmT7.js","assets/index.browser-BEm-79SP.js","assets/ec2-B8367M9s.js","assets/useMutation-CutM3PJn.js","assets/subnet-calculator-DDrAb5xp.js","assets/tags-B-poldSB.js","assets/create-panel-DMDXu4FP.js","assets/page-heading-75JYUGDy.js","assets/resource-view-BXETZc8v.js","assets/selection-bar-MuN8z7Qc.js","assets/state-badge-DuJRo9eW.js","assets/use-row-selection-CZtmDppg.js","assets/form-lock-C6qPAte1.js","assets/index.esm-ClOaByEg.js","assets/zod-DmrlGH9Y.js","assets/ec2-59Y0OXl4.js","assets/bulk-delete-B7E-9nKZ.js","assets/circle-x-DHQkYhPI.js","assets/trash-REoRRV_c.js","assets/alert-dialog-DKUcMJKb.js","assets/DialogTitle-BC_80-Qv.js","assets/confirm-phrase-Bvuzco3W.js","assets/nat-gateway-details-DIUjWG75.js","assets/sliders-horizontal-DZ-kpCNX.js","assets/maximize-2-bpbTyiq2.js","assets/resource-delete-dialog-DgcEsN9Y.js","assets/delete-confirmation-dialog-DRnnnG6z.js"])))=>i.map(i=>d[i]);
import{s as e}from"./schemas-BGv4hnp9.js";import{n as t,t as n}from"./lazyRouteComponent-BnnVgQVQ.js";import{t as r}from"./searchMiddleware-VYy8PwPp.js";import{h as i,m as a}from"./detail-panel-BBYLO5I7.js";import{Mo as o}from"./awsClient-pD_9VmT7.js";import{v as s}from"./ec2-CkEARsnn.js";var c=()=>o(()=>import(`./describe-nat-gateways-B8Ubz3S2.js`),__vite__mapDeps([0,1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63])),l=e(i),u=t(`/_auth/ec2/(nat-gateways)/describe-nat-gateways/`)({validateSearch:l,search:{middlewares:[r(a)]},loader:async({context:e})=>{await e.queryClient.query({...s,staleTime:`static`})},head:()=>({meta:[{title:`NAT Gateways | EC2 | Mulga`}]}),component:n(c,`component`)});export{u as t};
const __vite__mapDeps=(i,m=__vite__mapDeps,d=(m.f||(m.f=["assets/describe-nat-gateways-BBK0n91T.js","assets/rolldown-runtime-XKyaHtl9.js","assets/schemas-BGv4hnp9.js","assets/select-DY3fKHla.js","assets/visuallyHidden-D4_nmHIB.js","assets/check-B7aLYzUb.js","assets/chevron-down-By1UhB9P.js","assets/useScrollLock-FIlKWKju.js","assets/useValueAsRef-BoFfo9R-.js","assets/useRegisterFieldControl-2x4pEgaC.js","assets/ToolbarRootContext-B6mBfOj0.js","assets/useControlled-BP9HwLGp.js","assets/isElementDisabled-CuK_3Aso.js","assets/clamp-D0kAXd-u.js","assets/useSuspenseQuery-C45dM0fa.js","assets/queryOptions-twzzLLsx.js","assets/QueryClientProvider-CTm_NoFP.js","assets/link-D5tcioHY.js","assets/matchContext-DBnoSZp3.js","assets/data-table-CqrUk0mI.js","assets/useSelector-Cvbg8b7C.js","assets/useAriaLabelledBy-BMp0PcVw.js","assets/table-BEe-3So1.js","assets/detail-panel-BBYLO5I7.js","assets/x-8n_Gyi12.js","assets/error-banner-DYkSEx5d.js","assets/copy-BpriwVcd.js","assets/loader-circle-CiggOyRj.js","assets/store-BTg3TzdU.js","assets/collapsible-C7X1O8X9.js","assets/tabs-DFnnXw6G.js","assets/use-copy-to-clipboard-CAJ8gQCx.js","assets/plus-U2P-lQY5.js","assets/cli-command-panel-DBkpfo1W.js","assets/field-B2ItYtQp.js","assets/ec2-CkEARsnn.js","assets/auth-DA1pq184.js","assets/awsClient-pD_9VmT7.js","assets/index.browser-BEm-79SP.js","assets/ec2-B8367M9s.js","assets/useMutation-CutM3PJn.js","assets/subnet-calculator-DDrAb5xp.js","assets/tags-B-poldSB.js","assets/create-panel-DMDXu4FP.js","assets/page-heading-75JYUGDy.js","assets/resource-view-BXETZc8v.js","assets/selection-bar-MuN8z7Qc.js","assets/state-badge-DuJRo9eW.js","assets/use-row-selection-CZtmDppg.js","assets/form-lock-C6qPAte1.js","assets/index.esm-ClOaByEg.js","assets/zod-DmrlGH9Y.js","assets/ec2-59Y0OXl4.js","assets/bulk-delete-B7E-9nKZ.js","assets/circle-x-DHQkYhPI.js","assets/trash-REoRRV_c.js","assets/alert-dialog-DKUcMJKb.js","assets/DialogTitle-BC_80-Qv.js","assets/confirm-phrase-Bvuzco3W.js","assets/nat-gateway-details-DIUjWG75.js","assets/sliders-horizontal-DZ-kpCNX.js","assets/maximize-2-bpbTyiq2.js","assets/resource-delete-dialog-DgcEsN9Y.js","assets/delete-confirmation-dialog-DRnnnG6z.js"])))=>i.map(i=>d[i]);
import{s as e}from"./schemas-BGv4hnp9.js";import{n as t,t as n}from"./lazyRouteComponent-BnnVgQVQ.js";import{t as r}from"./searchMiddleware-VYy8PwPp.js";import{h as i,m as a}from"./detail-panel-BBYLO5I7.js";import{Mo as o}from"./awsClient-pD_9VmT7.js";import{v as s}from"./ec2-CkEARsnn.js";var c=()=>o(()=>import(`./describe-nat-gateways-BBK0n91T.js`),__vite__mapDeps([0,1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63])),l=e(i),u=t(`/_auth/ec2/(nat-gateways)/describe-nat-gateways/`)({validateSearch:l,search:{middlewares:[r(a)]},loader:async({context:e})=>{await e.queryClient.query({...s,staleTime:`static`})},head:()=>({meta:[{title:`NAT Gateways | EC2 | Mulga`}]}),component:n(c,`component`)});export{u as t};
@@ -1,3 +1,3 @@
import{r as e}from"./rolldown-runtime-XKyaHtl9.js";import{Ht as t,Rt as n,Ut as r}from"./schemas-BGv4hnp9.js";import{c as i,i as a,n as o,s,t as c}from"./select-DY3fKHla.js";import{t as l}from"./useSuspenseQuery-C45dM0fa.js";import{t as u}from"./link-D5tcioHY.js";import{t as d}from"./data-table-CqrUk0mI.js";import{m as f}from"./detail-panel-BBYLO5I7.js";import{t as p}from"./plus-U2P-lQY5.js";import{i as m,o as h,t as g}from"./cli-command-panel-DBkpfo1W.js";import{o as _}from"./useValueAsRef-BoFfo9R-.js";import{c as v,f as y,r as b,t as x}from"./field-B2ItYtQp.js";import{b as S}from"./ec2-CkEARsnn.js";import{M as C,v as w}from"./ec2-B8367M9s.js";import{t as T}from"./create-panel-DMDXu4FP.js";import{t as E}from"./page-heading-75JYUGDy.js";import{t as D}from"./resource-view-BXETZc8v.js";import{t as O}from"./selection-bar-MuN8z7Qc.js";import{t as k}from"./state-badge-DuJRo9eW.js";import{t as A}from"./use-row-selection-CZtmDppg.js";import{n as j,t as M}from"./form-lock-C6qPAte1.js";import{o as N,s as P,t as F}from"./index.esm-ClOaByEg.js";import{t as I}from"./zod-DmrlGH9Y.js";import{u as ee}from"./ec2-59Y0OXl4.js";import{t as L}from"./bulk-delete-B7E-9nKZ.js";import{t as R}from"./describe-placement-groups-DqxmWA_w.js";import{t as z}from"./placement-group-details-BG1l0OeY.js";var B=e(r(),1),V=n(),H=t();function U(e){let t=(0,V.c)(51),{onClose:n}=e,r=n,l=w(),u;t[0]===Symbol.for(`react.memo_cache_sentinel`)?(u={resolver:I(ee),defaultValues:{groupName:``,strategy:`spread`}},t[0]=u):u=t[0];let{control:d,handleSubmit:f,register:p,formState:m}=N(u),{errors:h,isSubmitting:_}=m,S;t[1]===d?S=t[2]:(S={control:d},t[1]=d,t[2]=S);let C=P(S),T;t[3]!==l||t[4]!==r?(T=async e=>{let t;try{t=await l.mutateAsync(e)}catch{return}r(t.PlacementGroup?.GroupId)},t[3]=l,t[4]=r,t[5]=T):T=t[5];let E=T,D;t[6]!==f||t[7]!==E?(D=f(E),t[6]=f,t[7]=E,t[8]=D):D=t[8];let O=l.isPending||_,k;t[9]===Symbol.for(`react.memo_cache_sentinel`)?(k=(0,H.jsx)(v,{children:(0,H.jsx)(`label`,{htmlFor:`groupName`,children:`Group Name`})}),t[9]=k):k=t[9];let A=!!h.groupName,L;t[10]===p?L=t[11]:(L=p(`groupName`),t[10]=p,t[11]=L);let R;t[12]!==A||t[13]!==L?(R=(0,H.jsx)(y,{"aria-invalid":A,id:`groupName`,placeholder:`my-placement-group`,...L}),t[12]=A,t[13]=L,t[14]=R):R=t[14];let z;t[15]===h.groupName?z=t[16]:(z=(0,H.jsx)(b,{errors:[h.groupName]}),t[15]=h.groupName,t[16]=z);let B;t[17]!==R||t[18]!==z?(B=(0,H.jsxs)(x,{children:[k,R,z]}),t[17]=R,t[18]=z,t[19]=B):B=t[19];let U;t[20]===Symbol.for(`react.memo_cache_sentinel`)?(U=(0,H.jsx)(v,{children:(0,H.jsx)(`label`,{htmlFor:`strategy`,children:`Strategy`})}),t[20]=U):U=t[20];let W;t[21]===h.strategy?W=t[22]:(W=e=>{let{field:t}=e;return(0,H.jsxs)(c,{onValueChange:t.onChange,value:t.value??``,children:[(0,H.jsx)(s,{"aria-invalid":!!h.strategy,className:`w-full`,id:`strategy`,children:(0,H.jsx)(i,{})}),(0,H.jsxs)(o,{children:[(0,H.jsx)(a,{value:`spread`,children:`spread`}),(0,H.jsx)(a,{value:`cluster`,children:`cluster`})]})]})},t[21]=h.strategy,t[22]=W);let G;t[23]!==d||t[24]!==W?(G=(0,H.jsx)(F,{control:d,name:`strategy`,render:W}),t[23]=d,t[24]=W,t[25]=G):G=t[25];let K;t[26]===h.strategy?K=t[27]:(K=(0,H.jsx)(b,{errors:[h.strategy]}),t[26]=h.strategy,t[27]=K);let q;t[28]!==G||t[29]!==K?(q=(0,H.jsxs)(x,{children:[U,G,K]}),t[28]=G,t[29]=K,t[30]=q):q=t[30];let J;t[31]===C?J=t[32]:(J=te(C),t[31]=C,t[32]=J);let Y;t[33]===J?Y=t[34]:(Y=(0,H.jsx)(g,{commands:J}),t[33]=J,t[34]=Y);let X;t[35]===r?X=t[36]:(X=()=>{r()},t[35]=r,t[36]=X);let Z;t[37]!==l.error||t[38]!==l.isPending||t[39]!==_||t[40]!==X?(Z=(0,H.jsx)(j,{error:l.error,errorMsg:`Failed to create placement group`,isPending:l.isPending,isSubmitting:_,onCancel:X,pendingLabel:`Creating…`,submitLabel:`Create Placement Group`}),t[37]=l.error,t[38]=l.isPending,t[39]=_,t[40]=X,t[41]=Z):Z=t[41];let Q;t[42]!==B||t[43]!==q||t[44]!==Y||t[45]!==Z||t[46]!==O?(Q=(0,H.jsxs)(M,{className:`space-y-6`,locked:O,children:[B,q,Y,Z]}),t[42]=B,t[43]=q,t[44]=Y,t[45]=Z,t[46]=O,t[47]=Q):Q=t[47];let $;return t[48]!==Q||t[49]!==D?($=(0,H.jsx)(`form`,{onSubmit:D,children:Q}),t[48]=Q,t[49]=D,t[50]=$):$=t[50],$}function te(e){let t=e.groupName??``;return[{label:`Create Placement Group`,parts:[{type:`bin`,value:`AWS_PROFILE=spinifex aws ec2 create-placement-group`},...m(`\\
import{r as e}from"./rolldown-runtime-XKyaHtl9.js";import{Ht as t,Rt as n,Ut as r}from"./schemas-BGv4hnp9.js";import{c as i,i as a,n as o,s,t as c}from"./select-DY3fKHla.js";import{t as l}from"./useSuspenseQuery-C45dM0fa.js";import{t as u}from"./link-D5tcioHY.js";import{t as d}from"./data-table-CqrUk0mI.js";import{m as f}from"./detail-panel-BBYLO5I7.js";import{t as p}from"./plus-U2P-lQY5.js";import{i as m,o as h,t as g}from"./cli-command-panel-DBkpfo1W.js";import{o as _}from"./useValueAsRef-BoFfo9R-.js";import{c as v,f as y,r as b,t as x}from"./field-B2ItYtQp.js";import{b as S}from"./ec2-CkEARsnn.js";import{M as C,v as w}from"./ec2-B8367M9s.js";import{t as T}from"./create-panel-DMDXu4FP.js";import{t as E}from"./page-heading-75JYUGDy.js";import{t as D}from"./resource-view-BXETZc8v.js";import{t as O}from"./selection-bar-MuN8z7Qc.js";import{t as k}from"./state-badge-DuJRo9eW.js";import{t as A}from"./use-row-selection-CZtmDppg.js";import{n as j,t as M}from"./form-lock-C6qPAte1.js";import{o as N,s as P,t as F}from"./index.esm-ClOaByEg.js";import{t as I}from"./zod-DmrlGH9Y.js";import{u as ee}from"./ec2-59Y0OXl4.js";import{t as L}from"./bulk-delete-B7E-9nKZ.js";import{t as R}from"./describe-placement-groups-db4OW-IE.js";import{t as z}from"./placement-group-details-BG1l0OeY.js";var B=e(r(),1),V=n(),H=t();function U(e){let t=(0,V.c)(51),{onClose:n}=e,r=n,l=w(),u;t[0]===Symbol.for(`react.memo_cache_sentinel`)?(u={resolver:I(ee),defaultValues:{groupName:``,strategy:`spread`}},t[0]=u):u=t[0];let{control:d,handleSubmit:f,register:p,formState:m}=N(u),{errors:h,isSubmitting:_}=m,S;t[1]===d?S=t[2]:(S={control:d},t[1]=d,t[2]=S);let C=P(S),T;t[3]!==l||t[4]!==r?(T=async e=>{let t;try{t=await l.mutateAsync(e)}catch{return}r(t.PlacementGroup?.GroupId)},t[3]=l,t[4]=r,t[5]=T):T=t[5];let E=T,D;t[6]!==f||t[7]!==E?(D=f(E),t[6]=f,t[7]=E,t[8]=D):D=t[8];let O=l.isPending||_,k;t[9]===Symbol.for(`react.memo_cache_sentinel`)?(k=(0,H.jsx)(v,{children:(0,H.jsx)(`label`,{htmlFor:`groupName`,children:`Group Name`})}),t[9]=k):k=t[9];let A=!!h.groupName,L;t[10]===p?L=t[11]:(L=p(`groupName`),t[10]=p,t[11]=L);let R;t[12]!==A||t[13]!==L?(R=(0,H.jsx)(y,{"aria-invalid":A,id:`groupName`,placeholder:`my-placement-group`,...L}),t[12]=A,t[13]=L,t[14]=R):R=t[14];let z;t[15]===h.groupName?z=t[16]:(z=(0,H.jsx)(b,{errors:[h.groupName]}),t[15]=h.groupName,t[16]=z);let B;t[17]!==R||t[18]!==z?(B=(0,H.jsxs)(x,{children:[k,R,z]}),t[17]=R,t[18]=z,t[19]=B):B=t[19];let U;t[20]===Symbol.for(`react.memo_cache_sentinel`)?(U=(0,H.jsx)(v,{children:(0,H.jsx)(`label`,{htmlFor:`strategy`,children:`Strategy`})}),t[20]=U):U=t[20];let W;t[21]===h.strategy?W=t[22]:(W=e=>{let{field:t}=e;return(0,H.jsxs)(c,{onValueChange:t.onChange,value:t.value??``,children:[(0,H.jsx)(s,{"aria-invalid":!!h.strategy,className:`w-full`,id:`strategy`,children:(0,H.jsx)(i,{})}),(0,H.jsxs)(o,{children:[(0,H.jsx)(a,{value:`spread`,children:`spread`}),(0,H.jsx)(a,{value:`cluster`,children:`cluster`})]})]})},t[21]=h.strategy,t[22]=W);let G;t[23]!==d||t[24]!==W?(G=(0,H.jsx)(F,{control:d,name:`strategy`,render:W}),t[23]=d,t[24]=W,t[25]=G):G=t[25];let K;t[26]===h.strategy?K=t[27]:(K=(0,H.jsx)(b,{errors:[h.strategy]}),t[26]=h.strategy,t[27]=K);let q;t[28]!==G||t[29]!==K?(q=(0,H.jsxs)(x,{children:[U,G,K]}),t[28]=G,t[29]=K,t[30]=q):q=t[30];let J;t[31]===C?J=t[32]:(J=te(C),t[31]=C,t[32]=J);let Y;t[33]===J?Y=t[34]:(Y=(0,H.jsx)(g,{commands:J}),t[33]=J,t[34]=Y);let X;t[35]===r?X=t[36]:(X=()=>{r()},t[35]=r,t[36]=X);let Z;t[37]!==l.error||t[38]!==l.isPending||t[39]!==_||t[40]!==X?(Z=(0,H.jsx)(j,{error:l.error,errorMsg:`Failed to create placement group`,isPending:l.isPending,isSubmitting:_,onCancel:X,pendingLabel:`Creating…`,submitLabel:`Create Placement Group`}),t[37]=l.error,t[38]=l.isPending,t[39]=_,t[40]=X,t[41]=Z):Z=t[41];let Q;t[42]!==B||t[43]!==q||t[44]!==Y||t[45]!==Z||t[46]!==O?(Q=(0,H.jsxs)(M,{className:`space-y-6`,locked:O,children:[B,q,Y,Z]}),t[42]=B,t[43]=q,t[44]=Y,t[45]=Z,t[46]=O,t[47]=Q):Q=t[47];let $;return t[48]!==Q||t[49]!==D?($=(0,H.jsx)(`form`,{onSubmit:D,children:Q}),t[48]=Q,t[49]=D,t[50]=$):$=t[50],$}function te(e){let t=e.groupName??``;return[{label:`Create Placement Group`,parts:[{type:`bin`,value:`AWS_PROFILE=spinifex aws ec2 create-placement-group`},...m(`\\
--group-name`,t?h(t):`<GroupName>`),...m(`\\
--strategy`,e.strategy||`spread`)]}]}var W=[{id:`group`,header:`Placement group`,sortValue:e=>e.GroupName??e.GroupId,cell:e=>{let t=e.GroupId??``;return(0,H.jsxs)(u,{className:`group block max-w-72 min-w-0 rounded-sm focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none`,from:`/ec2/describe-placement-groups/`,search:e=>({...e,...f,selected:t}),to:`.`,children:[(0,H.jsx)(`span`,{className:`block truncate font-medium text-primary group-hover:underline`,children:e.GroupName??t}),e.GroupName&&(0,H.jsx)(`span`,{className:`block truncate font-mono text-xs text-muted-foreground`,children:t})]})}},{id:`state`,header:`State`,sortValue:e=>e.State,cell:e=>(0,H.jsx)(k,{state:e.State})},{id:`strategy`,header:`Strategy`,sortValue:e=>e.Strategy,hideBelow:`sm`,cell:e=>e.Strategy},{id:`spread`,header:`Spread level`,sortValue:e=>e.SpreadLevel,hideBelow:`lg`,cell:e=>e.SpreadLevel??`—`}];function G(e){let t=(0,V.c)(46),{placementGroups:n,search:r,onSearchChange:i}=e,[a,o]=(0,B.useState)(),s;if(t[0]!==n||t[1]!==r.selected){let e;t[3]===r.selected?e=t[4]:(e=e=>r.selected!==``&&e.GroupId===r.selected,t[3]=r.selected,t[4]=e),s=n.find(e),t[0]=n,t[1]=r.selected,t[2]=s}else s=t[2];let c=s,l;t[5]===n?l=t[6]:(l=n.map(Y),t[5]=n,t[6]=l);let m;t[7]===i?m=t[8]:(m=()=>{i(f)},t[7]=i,t[8]=m);let h=A(l,c!==void 0,m),g=C(),v;t[9]===n?v=t[10]:(v=new Map(n.map(J)),t[9]=n,t[10]=v);let y=v,b;t[11]===i?b=t[12]:(b=function(){i(f)},t[11]=i,t[12]=b);let x=b,S=null;if(r.create){let e;t[13]===i?e=t[14]:(e=(0,H.jsx)(U,{onClose:e=>{i({...f,selected:e??``})}}),t[13]=i,t[14]=e);let n;t[15]!==x||t[16]!==e?(n=(0,H.jsx)(T,{onClose:x,title:`Create placement group`,children:e}),t[15]=x,t[16]=e,t[17]=n):n=t[17],S=n}else if(c?.GroupId){let e;t[18]===i?e=t[19]:(e=e=>{i({tab:e})},t[18]=i,t[19]=e);let n;t[20]!==x||t[21]!==c||t[22]!==r.tab||t[23]!==e?(n=(0,H.jsx)(z,{onClose:x,onTabChange:e,pg:c,tab:r.tab},c.GroupId),t[20]=x,t[21]=c,t[22]=r.tab,t[23]=e,t[24]=n):n=t[24],S=n}let w=S,k;t[25]===Symbol.for(`react.memo_cache_sentinel`)?(k=_(),t[25]=k):k=t[25];let j;t[26]===Symbol.for(`react.memo_cache_sentinel`)?(j=(0,H.jsx)(E,{actions:(0,H.jsxs)(u,{className:k,from:`/ec2/describe-placement-groups/`,search:q,to:`.`,children:[(0,H.jsx)(p,{"data-icon":`inline-start`}),`Create placement group`]}),subtitle:`Control how instances are spread across hosts.`,title:`Placement groups`}),t[26]=j):j=t[26];let M;t[27]!==g||t[28]!==y||t[29]!==h.clear||t[30]!==h.drop||t[31]!==h.ids?(M=h.ids.length>0&&(0,H.jsx)(O,{count:h.ids.length,onClear:h.clear,children:(0,H.jsx)(L,{deleteOne:async e=>await g.mutateAsync(y.get(e)??``),description:`They must have no instances in them. This cannot be undone.`,ids:h.ids,label:e=>{let t=y.get(e);return t?`${t} (${e})`:e},noun:[`placement group`,`placement groups`],onDeleted:h.drop})}),t[27]=g,t[28]=y,t[29]=h.clear,t[30]=h.drop,t[31]=h.ids,t[32]=M):M=t[32];let N=c?.GroupId,P;t[33]===i?P=t[34]:(P=e=>{i({...f,selected:e})},t[33]=i,t[34]=P);let F;t[35]!==n||t[36]!==h.onSelectedChange||t[37]!==h.selected||t[38]!==a||t[39]!==N||t[40]!==P?(F=(0,H.jsx)(d,{activeId:N,caption:`Placement groups`,columns:W,empty:`No placement groups yet.`,getRowId:K,onRowClick:P,onSelectedChange:h.onSelectedChange,onSortChange:o,rows:n,selected:h.selected,sort:a}),t[35]=n,t[36]=h.onSelectedChange,t[37]=h.selected,t[38]=a,t[39]=N,t[40]=P,t[41]=F):F=t[41];let I;return t[42]!==S||t[43]!==F||t[44]!==M?(I=(0,H.jsxs)(D,{panel:w,children:[j,M,F]}),t[42]=S,t[43]=F,t[44]=M,t[45]=I):I=t[45],I}function K(e){return e.GroupId??``}function q(e){return{...e,...f,create:!0}}function J(e){return[e.GroupId,e.GroupName]}function Y(e){return e.GroupId??``}function X(){let e=(0,V.c)(8),{data:t}=l(S),n=R.useSearch(),r=R.useNavigate(),i;e[0]===r?i=e[1]:(i=function(e){r({search:t=>({...t,...e}),replace:!(`selected`in e||`create`in e)})},e[0]=r,e[1]=i);let a=i,o;e[2]===t.PlacementGroups?o=e[3]:(o=t.PlacementGroups??[],e[2]=t.PlacementGroups,e[3]=o);let s;return e[4]!==a||e[5]!==n||e[6]!==o?(s=(0,H.jsx)(G,{onSearchChange:a,placementGroups:o,search:n}),e[4]=a,e[5]=n,e[6]=o,e[7]=s):s=e[7],s}export{X as component};
--strategy`,e.strategy||`spread`)]}]}var W=[{id:`group`,header:`Placement group`,sortValue:e=>e.GroupName??e.GroupId,cell:e=>{let t=e.GroupId??``;return(0,H.jsxs)(u,{className:`group block max-w-72 min-w-0 rounded-sm focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none`,from:`/ec2/describe-placement-groups/`,search:e=>({...e,...f,selected:t}),to:`.`,children:[(0,H.jsx)(`span`,{className:`block truncate font-medium text-primary group-hover:underline`,children:e.GroupName??t}),e.GroupName&&(0,H.jsx)(`span`,{className:`block truncate font-mono text-xs text-muted-foreground`,children:t})]})}},{id:`state`,header:`State`,sortValue:e=>e.State,cell:e=>(0,H.jsx)(k,{state:e.State})},{id:`strategy`,header:`Strategy`,sortValue:e=>e.Strategy,hideBelow:`sm`,cell:e=>e.Strategy},{id:`spread`,header:`Spread level`,sortValue:e=>e.SpreadLevel,hideBelow:`lg`,cell:e=>e.SpreadLevel??`—`}];function G(e){let t=(0,V.c)(46),{placementGroups:n,search:r,onSearchChange:i}=e,[a,o]=(0,B.useState)(),s;if(t[0]!==n||t[1]!==r.selected){let e;t[3]===r.selected?e=t[4]:(e=e=>r.selected!==``&&e.GroupId===r.selected,t[3]=r.selected,t[4]=e),s=n.find(e),t[0]=n,t[1]=r.selected,t[2]=s}else s=t[2];let c=s,l;t[5]===n?l=t[6]:(l=n.map(Y),t[5]=n,t[6]=l);let m;t[7]===i?m=t[8]:(m=()=>{i(f)},t[7]=i,t[8]=m);let h=A(l,c!==void 0,m),g=C(),v;t[9]===n?v=t[10]:(v=new Map(n.map(J)),t[9]=n,t[10]=v);let y=v,b;t[11]===i?b=t[12]:(b=function(){i(f)},t[11]=i,t[12]=b);let x=b,S=null;if(r.create){let e;t[13]===i?e=t[14]:(e=(0,H.jsx)(U,{onClose:e=>{i({...f,selected:e??``})}}),t[13]=i,t[14]=e);let n;t[15]!==x||t[16]!==e?(n=(0,H.jsx)(T,{onClose:x,title:`Create placement group`,children:e}),t[15]=x,t[16]=e,t[17]=n):n=t[17],S=n}else if(c?.GroupId){let e;t[18]===i?e=t[19]:(e=e=>{i({tab:e})},t[18]=i,t[19]=e);let n;t[20]!==x||t[21]!==c||t[22]!==r.tab||t[23]!==e?(n=(0,H.jsx)(z,{onClose:x,onTabChange:e,pg:c,tab:r.tab},c.GroupId),t[20]=x,t[21]=c,t[22]=r.tab,t[23]=e,t[24]=n):n=t[24],S=n}let w=S,k;t[25]===Symbol.for(`react.memo_cache_sentinel`)?(k=_(),t[25]=k):k=t[25];let j;t[26]===Symbol.for(`react.memo_cache_sentinel`)?(j=(0,H.jsx)(E,{actions:(0,H.jsxs)(u,{className:k,from:`/ec2/describe-placement-groups/`,search:q,to:`.`,children:[(0,H.jsx)(p,{"data-icon":`inline-start`}),`Create placement group`]}),subtitle:`Control how instances are spread across hosts.`,title:`Placement Groups`}),t[26]=j):j=t[26];let M;t[27]!==g||t[28]!==y||t[29]!==h.clear||t[30]!==h.drop||t[31]!==h.ids?(M=h.ids.length>0&&(0,H.jsx)(O,{count:h.ids.length,onClear:h.clear,children:(0,H.jsx)(L,{deleteOne:async e=>await g.mutateAsync(y.get(e)??``),description:`They must have no instances in them. This cannot be undone.`,ids:h.ids,label:e=>{let t=y.get(e);return t?`${t} (${e})`:e},noun:[`placement group`,`placement groups`],onDeleted:h.drop})}),t[27]=g,t[28]=y,t[29]=h.clear,t[30]=h.drop,t[31]=h.ids,t[32]=M):M=t[32];let N=c?.GroupId,P;t[33]===i?P=t[34]:(P=e=>{i({...f,selected:e})},t[33]=i,t[34]=P);let F;t[35]!==n||t[36]!==h.onSelectedChange||t[37]!==h.selected||t[38]!==a||t[39]!==N||t[40]!==P?(F=(0,H.jsx)(d,{activeId:N,caption:`Placement groups`,columns:W,empty:`No placement groups yet.`,getRowId:K,onRowClick:P,onSelectedChange:h.onSelectedChange,onSortChange:o,rows:n,selected:h.selected,sort:a}),t[35]=n,t[36]=h.onSelectedChange,t[37]=h.selected,t[38]=a,t[39]=N,t[40]=P,t[41]=F):F=t[41];let I;return t[42]!==S||t[43]!==F||t[44]!==M?(I=(0,H.jsxs)(D,{panel:w,children:[j,M,F]}),t[42]=S,t[43]=F,t[44]=M,t[45]=I):I=t[45],I}function K(e){return e.GroupId??``}function q(e){return{...e,...f,create:!0}}function J(e){return[e.GroupId,e.GroupName]}function Y(e){return e.GroupId??``}function X(){let e=(0,V.c)(8),{data:t}=l(S),n=R.useSearch(),r=R.useNavigate(),i;e[0]===r?i=e[1]:(i=function(e){r({search:t=>({...t,...e}),replace:!(`selected`in e||`create`in e)})},e[0]=r,e[1]=i);let a=i,o;e[2]===t.PlacementGroups?o=e[3]:(o=t.PlacementGroups??[],e[2]=t.PlacementGroups,e[3]=o);let s;return e[4]!==a||e[5]!==n||e[6]!==o?(s=(0,H.jsx)(G,{onSearchChange:a,placementGroups:o,search:n}),e[4]=a,e[5]=n,e[6]=o,e[7]=s):s=e[7],s}export{X as component};
@@ -1,2 +1,2 @@
const __vite__mapDeps=(i,m=__vite__mapDeps,d=(m.f||(m.f=["assets/describe-placement-groups-CliYAwGl.js","assets/rolldown-runtime-XKyaHtl9.js","assets/schemas-BGv4hnp9.js","assets/select-DY3fKHla.js","assets/visuallyHidden-D4_nmHIB.js","assets/check-B7aLYzUb.js","assets/chevron-down-By1UhB9P.js","assets/useScrollLock-FIlKWKju.js","assets/useValueAsRef-BoFfo9R-.js","assets/useRegisterFieldControl-2x4pEgaC.js","assets/ToolbarRootContext-B6mBfOj0.js","assets/useControlled-BP9HwLGp.js","assets/isElementDisabled-CuK_3Aso.js","assets/clamp-D0kAXd-u.js","assets/useSuspenseQuery-C45dM0fa.js","assets/queryOptions-twzzLLsx.js","assets/QueryClientProvider-CTm_NoFP.js","assets/link-D5tcioHY.js","assets/matchContext-DBnoSZp3.js","assets/data-table-CqrUk0mI.js","assets/useSelector-Cvbg8b7C.js","assets/useAriaLabelledBy-BMp0PcVw.js","assets/table-BEe-3So1.js","assets/detail-panel-BBYLO5I7.js","assets/x-8n_Gyi12.js","assets/error-banner-DYkSEx5d.js","assets/copy-BpriwVcd.js","assets/loader-circle-CiggOyRj.js","assets/store-BTg3TzdU.js","assets/collapsible-C7X1O8X9.js","assets/tabs-DFnnXw6G.js","assets/use-copy-to-clipboard-CAJ8gQCx.js","assets/plus-U2P-lQY5.js","assets/cli-command-panel-DBkpfo1W.js","assets/field-B2ItYtQp.js","assets/ec2-CkEARsnn.js","assets/auth-DA1pq184.js","assets/awsClient-pD_9VmT7.js","assets/index.browser-BEm-79SP.js","assets/ec2-B8367M9s.js","assets/useMutation-CutM3PJn.js","assets/subnet-calculator-DDrAb5xp.js","assets/tags-B-poldSB.js","assets/create-panel-DMDXu4FP.js","assets/page-heading-75JYUGDy.js","assets/resource-view-BXETZc8v.js","assets/selection-bar-MuN8z7Qc.js","assets/state-badge-DuJRo9eW.js","assets/use-row-selection-CZtmDppg.js","assets/form-lock-C6qPAte1.js","assets/index.esm-ClOaByEg.js","assets/zod-DmrlGH9Y.js","assets/ec2-59Y0OXl4.js","assets/bulk-delete-B7E-9nKZ.js","assets/circle-x-DHQkYhPI.js","assets/trash-REoRRV_c.js","assets/alert-dialog-DKUcMJKb.js","assets/DialogTitle-BC_80-Qv.js","assets/confirm-phrase-Bvuzco3W.js","assets/placement-group-details-BG1l0OeY.js","assets/useQuery-CZvSQig-.js","assets/sliders-horizontal-DZ-kpCNX.js","assets/maximize-2-bpbTyiq2.js","assets/resource-delete-dialog-DgcEsN9Y.js","assets/delete-confirmation-dialog-DRnnnG6z.js"])))=>i.map(i=>d[i]);
import{s as e}from"./schemas-BGv4hnp9.js";import{n as t,t as n}from"./lazyRouteComponent-BnnVgQVQ.js";import{t as r}from"./searchMiddleware-VYy8PwPp.js";import{h as i,m as a}from"./detail-panel-BBYLO5I7.js";import{Mo as o}from"./awsClient-pD_9VmT7.js";import{b as s}from"./ec2-CkEARsnn.js";var c=()=>o(()=>import(`./describe-placement-groups-CliYAwGl.js`),__vite__mapDeps([0,1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64])),l=e(i),u=t(`/_auth/ec2/(placement-groups)/describe-placement-groups/`)({validateSearch:l,search:{middlewares:[r(a)]},loader:async({context:e})=>{await e.queryClient.query({...s,staleTime:`static`})},head:()=>({meta:[{title:`Placement Groups | EC2 | Mulga`}]}),component:n(c,`component`)});export{u as t};
const __vite__mapDeps=(i,m=__vite__mapDeps,d=(m.f||(m.f=["assets/describe-placement-groups-D4wxHP1y.js","assets/rolldown-runtime-XKyaHtl9.js","assets/schemas-BGv4hnp9.js","assets/select-DY3fKHla.js","assets/visuallyHidden-D4_nmHIB.js","assets/check-B7aLYzUb.js","assets/chevron-down-By1UhB9P.js","assets/useScrollLock-FIlKWKju.js","assets/useValueAsRef-BoFfo9R-.js","assets/useRegisterFieldControl-2x4pEgaC.js","assets/ToolbarRootContext-B6mBfOj0.js","assets/useControlled-BP9HwLGp.js","assets/isElementDisabled-CuK_3Aso.js","assets/clamp-D0kAXd-u.js","assets/useSuspenseQuery-C45dM0fa.js","assets/queryOptions-twzzLLsx.js","assets/QueryClientProvider-CTm_NoFP.js","assets/link-D5tcioHY.js","assets/matchContext-DBnoSZp3.js","assets/data-table-CqrUk0mI.js","assets/useSelector-Cvbg8b7C.js","assets/useAriaLabelledBy-BMp0PcVw.js","assets/table-BEe-3So1.js","assets/detail-panel-BBYLO5I7.js","assets/x-8n_Gyi12.js","assets/error-banner-DYkSEx5d.js","assets/copy-BpriwVcd.js","assets/loader-circle-CiggOyRj.js","assets/store-BTg3TzdU.js","assets/collapsible-C7X1O8X9.js","assets/tabs-DFnnXw6G.js","assets/use-copy-to-clipboard-CAJ8gQCx.js","assets/plus-U2P-lQY5.js","assets/cli-command-panel-DBkpfo1W.js","assets/field-B2ItYtQp.js","assets/ec2-CkEARsnn.js","assets/auth-DA1pq184.js","assets/awsClient-pD_9VmT7.js","assets/index.browser-BEm-79SP.js","assets/ec2-B8367M9s.js","assets/useMutation-CutM3PJn.js","assets/subnet-calculator-DDrAb5xp.js","assets/tags-B-poldSB.js","assets/create-panel-DMDXu4FP.js","assets/page-heading-75JYUGDy.js","assets/resource-view-BXETZc8v.js","assets/selection-bar-MuN8z7Qc.js","assets/state-badge-DuJRo9eW.js","assets/use-row-selection-CZtmDppg.js","assets/form-lock-C6qPAte1.js","assets/index.esm-ClOaByEg.js","assets/zod-DmrlGH9Y.js","assets/ec2-59Y0OXl4.js","assets/bulk-delete-B7E-9nKZ.js","assets/circle-x-DHQkYhPI.js","assets/trash-REoRRV_c.js","assets/alert-dialog-DKUcMJKb.js","assets/DialogTitle-BC_80-Qv.js","assets/confirm-phrase-Bvuzco3W.js","assets/placement-group-details-BG1l0OeY.js","assets/useQuery-CZvSQig-.js","assets/sliders-horizontal-DZ-kpCNX.js","assets/maximize-2-bpbTyiq2.js","assets/resource-delete-dialog-DgcEsN9Y.js","assets/delete-confirmation-dialog-DRnnnG6z.js"])))=>i.map(i=>d[i]);
import{s as e}from"./schemas-BGv4hnp9.js";import{n as t,t as n}from"./lazyRouteComponent-BnnVgQVQ.js";import{t as r}from"./searchMiddleware-VYy8PwPp.js";import{h as i,m as a}from"./detail-panel-BBYLO5I7.js";import{Mo as o}from"./awsClient-pD_9VmT7.js";import{b as s}from"./ec2-CkEARsnn.js";var c=()=>o(()=>import(`./describe-placement-groups-D4wxHP1y.js`),__vite__mapDeps([0,1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64])),l=e(i),u=t(`/_auth/ec2/(placement-groups)/describe-placement-groups/`)({validateSearch:l,search:{middlewares:[r(a)]},loader:async({context:e})=>{await e.queryClient.query({...s,staleTime:`static`})},head:()=>({meta:[{title:`Placement Groups | EC2 | Mulga`}]}),component:n(c,`component`)});export{u as t};
@@ -1,2 +1,2 @@
const __vite__mapDeps=(i,m=__vite__mapDeps,d=(m.f||(m.f=["assets/describe-route-tables-sjVnCTdN.js","assets/rolldown-runtime-XKyaHtl9.js","assets/schemas-BGv4hnp9.js","assets/useSuspenseQuery-C45dM0fa.js","assets/queryOptions-twzzLLsx.js","assets/QueryClientProvider-CTm_NoFP.js","assets/link-D5tcioHY.js","assets/matchContext-DBnoSZp3.js","assets/data-table-CqrUk0mI.js","assets/useSelector-Cvbg8b7C.js","assets/visuallyHidden-D4_nmHIB.js","assets/check-B7aLYzUb.js","assets/useRegisterFieldControl-2x4pEgaC.js","assets/useAriaLabelledBy-BMp0PcVw.js","assets/useControlled-BP9HwLGp.js","assets/table-BEe-3So1.js","assets/detail-panel-BBYLO5I7.js","assets/x-8n_Gyi12.js","assets/error-banner-DYkSEx5d.js","assets/chevron-down-By1UhB9P.js","assets/copy-BpriwVcd.js","assets/loader-circle-CiggOyRj.js","assets/useScrollLock-FIlKWKju.js","assets/useValueAsRef-BoFfo9R-.js","assets/ToolbarRootContext-B6mBfOj0.js","assets/isElementDisabled-CuK_3Aso.js","assets/store-BTg3TzdU.js","assets/collapsible-C7X1O8X9.js","assets/tabs-DFnnXw6G.js","assets/use-copy-to-clipboard-CAJ8gQCx.js","assets/plus-U2P-lQY5.js","assets/cli-command-panel-DBkpfo1W.js","assets/ec2-CkEARsnn.js","assets/auth-DA1pq184.js","assets/awsClient-pD_9VmT7.js","assets/index.browser-BEm-79SP.js","assets/ec2-B8367M9s.js","assets/useMutation-CutM3PJn.js","assets/subnet-calculator-DDrAb5xp.js","assets/tags-B-poldSB.js","assets/create-panel-DMDXu4FP.js","assets/page-heading-75JYUGDy.js","assets/resource-view-BXETZc8v.js","assets/selection-bar-MuN8z7Qc.js","assets/use-row-selection-CZtmDppg.js","assets/form-lock-C6qPAte1.js","assets/index.esm-ClOaByEg.js","assets/zod-DmrlGH9Y.js","assets/ec2-59Y0OXl4.js","assets/bulk-delete-B7E-9nKZ.js","assets/circle-x-DHQkYhPI.js","assets/trash-REoRRV_c.js","assets/alert-dialog-DKUcMJKb.js","assets/DialogTitle-BC_80-Qv.js","assets/confirm-phrase-Bvuzco3W.js","assets/field-B2ItYtQp.js","assets/badge-BTkZUPtC.js","assets/useRender-Bj0OT-hO.js","assets/form-fields-C1aPNl6R.js","assets/select-DY3fKHla.js","assets/clamp-D0kAXd-u.js","assets/vpc-select-field-CMKlzSpr.js","assets/route-table-details-CZQf-ZgX.js","assets/useQuery-CZvSQig-.js","assets/sliders-horizontal-DZ-kpCNX.js","assets/link-2--DFHkFuI.js","assets/maximize-2-bpbTyiq2.js","assets/unlink-CG1xDggt.js","assets/delete-confirmation-dialog-DRnnnG6z.js","assets/resource-delete-dialog-DgcEsN9Y.js"])))=>i.map(i=>d[i]);
import{s as e}from"./schemas-BGv4hnp9.js";import{n as t,t as n}from"./lazyRouteComponent-BnnVgQVQ.js";import{t as r}from"./searchMiddleware-VYy8PwPp.js";import{h as i,m as a}from"./detail-panel-BBYLO5I7.js";import{Mo as o}from"./awsClient-pD_9VmT7.js";import{C as s}from"./ec2-CkEARsnn.js";var c=()=>o(()=>import(`./describe-route-tables-sjVnCTdN.js`),__vite__mapDeps([0,1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64,65,66,67,68,69])),l=e(i),u=t(`/_auth/ec2/(route-tables)/describe-route-tables/`)({validateSearch:l,search:{middlewares:[r(a)]},loader:async({context:e})=>{await e.queryClient.query({...s,staleTime:`static`})},head:()=>({meta:[{title:`Route Tables | EC2 | Mulga`}]}),component:n(c,`component`)});export{u as t};
const __vite__mapDeps=(i,m=__vite__mapDeps,d=(m.f||(m.f=["assets/describe-route-tables-D2vhdRp7.js","assets/rolldown-runtime-XKyaHtl9.js","assets/schemas-BGv4hnp9.js","assets/useSuspenseQuery-C45dM0fa.js","assets/queryOptions-twzzLLsx.js","assets/QueryClientProvider-CTm_NoFP.js","assets/link-D5tcioHY.js","assets/matchContext-DBnoSZp3.js","assets/data-table-CqrUk0mI.js","assets/useSelector-Cvbg8b7C.js","assets/visuallyHidden-D4_nmHIB.js","assets/check-B7aLYzUb.js","assets/useRegisterFieldControl-2x4pEgaC.js","assets/useAriaLabelledBy-BMp0PcVw.js","assets/useControlled-BP9HwLGp.js","assets/table-BEe-3So1.js","assets/detail-panel-BBYLO5I7.js","assets/x-8n_Gyi12.js","assets/error-banner-DYkSEx5d.js","assets/chevron-down-By1UhB9P.js","assets/copy-BpriwVcd.js","assets/loader-circle-CiggOyRj.js","assets/useScrollLock-FIlKWKju.js","assets/useValueAsRef-BoFfo9R-.js","assets/ToolbarRootContext-B6mBfOj0.js","assets/isElementDisabled-CuK_3Aso.js","assets/store-BTg3TzdU.js","assets/collapsible-C7X1O8X9.js","assets/tabs-DFnnXw6G.js","assets/use-copy-to-clipboard-CAJ8gQCx.js","assets/plus-U2P-lQY5.js","assets/cli-command-panel-DBkpfo1W.js","assets/ec2-CkEARsnn.js","assets/auth-DA1pq184.js","assets/awsClient-pD_9VmT7.js","assets/index.browser-BEm-79SP.js","assets/ec2-B8367M9s.js","assets/useMutation-CutM3PJn.js","assets/subnet-calculator-DDrAb5xp.js","assets/tags-B-poldSB.js","assets/create-panel-DMDXu4FP.js","assets/page-heading-75JYUGDy.js","assets/resource-view-BXETZc8v.js","assets/selection-bar-MuN8z7Qc.js","assets/use-row-selection-CZtmDppg.js","assets/form-lock-C6qPAte1.js","assets/index.esm-ClOaByEg.js","assets/zod-DmrlGH9Y.js","assets/ec2-59Y0OXl4.js","assets/bulk-delete-B7E-9nKZ.js","assets/circle-x-DHQkYhPI.js","assets/trash-REoRRV_c.js","assets/alert-dialog-DKUcMJKb.js","assets/DialogTitle-BC_80-Qv.js","assets/confirm-phrase-Bvuzco3W.js","assets/field-B2ItYtQp.js","assets/badge-BTkZUPtC.js","assets/useRender-Bj0OT-hO.js","assets/form-fields-C1aPNl6R.js","assets/select-DY3fKHla.js","assets/clamp-D0kAXd-u.js","assets/vpc-select-field-CMKlzSpr.js","assets/route-table-details-CZQf-ZgX.js","assets/useQuery-CZvSQig-.js","assets/sliders-horizontal-DZ-kpCNX.js","assets/link-2--DFHkFuI.js","assets/maximize-2-bpbTyiq2.js","assets/unlink-CG1xDggt.js","assets/delete-confirmation-dialog-DRnnnG6z.js","assets/resource-delete-dialog-DgcEsN9Y.js"])))=>i.map(i=>d[i]);
import{s as e}from"./schemas-BGv4hnp9.js";import{n as t,t as n}from"./lazyRouteComponent-BnnVgQVQ.js";import{t as r}from"./searchMiddleware-VYy8PwPp.js";import{h as i,m as a}from"./detail-panel-BBYLO5I7.js";import{Mo as o}from"./awsClient-pD_9VmT7.js";import{C as s}from"./ec2-CkEARsnn.js";var c=()=>o(()=>import(`./describe-route-tables-D2vhdRp7.js`),__vite__mapDeps([0,1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64,65,66,67,68,69])),l=e(i),u=t(`/_auth/ec2/(route-tables)/describe-route-tables/`)({validateSearch:l,search:{middlewares:[r(a)]},loader:async({context:e})=>{await e.queryClient.query({...s,staleTime:`static`})},head:()=>({meta:[{title:`Route Tables | EC2 | Mulga`}]}),component:n(c,`component`)});export{u as t};
@@ -1,3 +1,3 @@
import{r as e}from"./rolldown-runtime-XKyaHtl9.js";import{Ht as t,Rt as n,Ut as r,X as i}from"./schemas-BGv4hnp9.js";import{t as a}from"./useSuspenseQuery-C45dM0fa.js";import{t as o}from"./link-D5tcioHY.js";import{t as s}from"./data-table-CqrUk0mI.js";import{m as c}from"./detail-panel-BBYLO5I7.js";import{t as l}from"./plus-U2P-lQY5.js";import{a as u,i as d,o as f,r as p,t as m}from"./cli-command-panel-DBkpfo1W.js";import{o as h}from"./useValueAsRef-BoFfo9R-.js";import{C as g,N as _}from"./ec2-CkEARsnn.js";import{P as v,b as y}from"./ec2-B8367M9s.js";import{t as b}from"./create-panel-DMDXu4FP.js";import{t as x}from"./page-heading-75JYUGDy.js";import{t as S}from"./resource-view-BXETZc8v.js";import{t as C}from"./selection-bar-MuN8z7Qc.js";import{t as w}from"./use-row-selection-CZtmDppg.js";import{n as T,t as E}from"./form-lock-C6qPAte1.js";import{o as D,s as O}from"./index.esm-ClOaByEg.js";import{t as k}from"./zod-DmrlGH9Y.js";import{f as A}from"./ec2-59Y0OXl4.js";import{t as j}from"./bulk-delete-B7E-9nKZ.js";import{t as M}from"./describe-route-tables-ChCFnUsY.js";import{t as N}from"./badge-BTkZUPtC.js";import{n as P}from"./form-fields-C1aPNl6R.js";import{t as F}from"./vpc-select-field-CMKlzSpr.js";import{n as I,t as L}from"./route-table-details-CZQf-ZgX.js";var R=e(r(),1),z=n(),B=t();function V(e){let t=(0,z.c)(39),{onClose:n}=e,{data:r}=a(_),i=y(),o;t[0]===r.Vpcs?o=t[1]:(o=r.Vpcs??[],t[0]=r.Vpcs,t[1]=o);let s=o,c;t[2]===Symbol.for(`react.memo_cache_sentinel`)?(c={resolver:k(A),defaultValues:{vpcId:``,name:``}},t[2]=c):c=t[2];let{control:l,handleSubmit:u,register:d,formState:f}=D(c),{isSubmitting:p}=f,h;t[3]===l?h=t[4]:(h={control:l},t[3]=l,t[4]=h);let g=O(h),v;t[5]!==i||t[6]!==n?(v=async e=>{let t;try{t=await i.mutateAsync({vpcId:e.vpcId,name:e.name?.trim()?e.name.trim():void 0})}catch{return}n(t.RouteTable?.RouteTableId)},t[5]=i,t[6]=n,t[7]=v):v=t[7];let b=v,x;t[8]!==u||t[9]!==b?(x=u(b),t[8]=u,t[9]=b,t[10]=x):x=t[10];let S=i.isPending||p,C;t[11]===d?C=t[12]:(C=d(`name`),t[11]=d,t[12]=C);let w;t[13]===C?w=t[14]:(w=(0,B.jsx)(P,{id:`name`,label:`Name (optional)`,placeholder:`my-route-table`,...C}),t[13]=C,t[14]=w);let j;t[15]!==l||t[16]!==s?(j=(0,B.jsx)(F,{control:l,id:`vpcId`,name:`vpcId`,placeholder:`Select a VPC`,vpcs:s}),t[15]=l,t[16]=s,t[17]=j):j=t[17];let M=g.vpcId??``,N=g.name??``,I;t[18]!==M||t[19]!==N?(I=H(M,N),t[18]=M,t[19]=N,t[20]=I):I=t[20];let L;t[21]===I?L=t[22]:(L=(0,B.jsx)(m,{commands:I}),t[21]=I,t[22]=L);let R;t[23]===n?R=t[24]:(R=()=>{n()},t[23]=n,t[24]=R);let V;t[25]!==i.error||t[26]!==i.isPending||t[27]!==p||t[28]!==R?(V=(0,B.jsx)(T,{error:i.error,errorMsg:`Failed to create Route Table`,isPending:i.isPending,isSubmitting:p,onCancel:R,pendingLabel:`Creating…`,submitLabel:`Create Route Table`}),t[25]=i.error,t[26]=i.isPending,t[27]=p,t[28]=R,t[29]=V):V=t[29];let U;t[30]!==j||t[31]!==L||t[32]!==V||t[33]!==S||t[34]!==w?(U=(0,B.jsxs)(E,{className:`space-y-6`,locked:S,children:[w,j,L,V]}),t[30]=j,t[31]=L,t[32]=V,t[33]=S,t[34]=w,t[35]=U):U=t[35];let W;return t[36]!==U||t[37]!==x?(W=(0,B.jsx)(`form`,{onSubmit:x,children:U}),t[36]=U,t[37]=x,t[38]=W):W=t[38],W}function H(e,t){let n=t.trim();return[{label:`Create Route Table`,parts:[{type:`bin`,value:`AWS_PROFILE=spinifex aws ec2 create-route-table`},...d(`\\
import{r as e}from"./rolldown-runtime-XKyaHtl9.js";import{Ht as t,Rt as n,Ut as r,X as i}from"./schemas-BGv4hnp9.js";import{t as a}from"./useSuspenseQuery-C45dM0fa.js";import{t as o}from"./link-D5tcioHY.js";import{t as s}from"./data-table-CqrUk0mI.js";import{m as c}from"./detail-panel-BBYLO5I7.js";import{t as l}from"./plus-U2P-lQY5.js";import{a as u,i as d,o as f,r as p,t as m}from"./cli-command-panel-DBkpfo1W.js";import{o as h}from"./useValueAsRef-BoFfo9R-.js";import{C as g,N as _}from"./ec2-CkEARsnn.js";import{P as v,b as y}from"./ec2-B8367M9s.js";import{t as b}from"./create-panel-DMDXu4FP.js";import{t as x}from"./page-heading-75JYUGDy.js";import{t as S}from"./resource-view-BXETZc8v.js";import{t as C}from"./selection-bar-MuN8z7Qc.js";import{t as w}from"./use-row-selection-CZtmDppg.js";import{n as T,t as E}from"./form-lock-C6qPAte1.js";import{o as D,s as O}from"./index.esm-ClOaByEg.js";import{t as k}from"./zod-DmrlGH9Y.js";import{f as A}from"./ec2-59Y0OXl4.js";import{t as j}from"./bulk-delete-B7E-9nKZ.js";import{t as M}from"./describe-route-tables-Cl96qDiQ.js";import{t as N}from"./badge-BTkZUPtC.js";import{n as P}from"./form-fields-C1aPNl6R.js";import{t as F}from"./vpc-select-field-CMKlzSpr.js";import{n as I,t as L}from"./route-table-details-CZQf-ZgX.js";var R=e(r(),1),z=n(),B=t();function V(e){let t=(0,z.c)(39),{onClose:n}=e,{data:r}=a(_),i=y(),o;t[0]===r.Vpcs?o=t[1]:(o=r.Vpcs??[],t[0]=r.Vpcs,t[1]=o);let s=o,c;t[2]===Symbol.for(`react.memo_cache_sentinel`)?(c={resolver:k(A),defaultValues:{vpcId:``,name:``}},t[2]=c):c=t[2];let{control:l,handleSubmit:u,register:d,formState:f}=D(c),{isSubmitting:p}=f,h;t[3]===l?h=t[4]:(h={control:l},t[3]=l,t[4]=h);let g=O(h),v;t[5]!==i||t[6]!==n?(v=async e=>{let t;try{t=await i.mutateAsync({vpcId:e.vpcId,name:e.name?.trim()?e.name.trim():void 0})}catch{return}n(t.RouteTable?.RouteTableId)},t[5]=i,t[6]=n,t[7]=v):v=t[7];let b=v,x;t[8]!==u||t[9]!==b?(x=u(b),t[8]=u,t[9]=b,t[10]=x):x=t[10];let S=i.isPending||p,C;t[11]===d?C=t[12]:(C=d(`name`),t[11]=d,t[12]=C);let w;t[13]===C?w=t[14]:(w=(0,B.jsx)(P,{id:`name`,label:`Name (optional)`,placeholder:`my-route-table`,...C}),t[13]=C,t[14]=w);let j;t[15]!==l||t[16]!==s?(j=(0,B.jsx)(F,{control:l,id:`vpcId`,name:`vpcId`,placeholder:`Select a VPC`,vpcs:s}),t[15]=l,t[16]=s,t[17]=j):j=t[17];let M=g.vpcId??``,N=g.name??``,I;t[18]!==M||t[19]!==N?(I=H(M,N),t[18]=M,t[19]=N,t[20]=I):I=t[20];let L;t[21]===I?L=t[22]:(L=(0,B.jsx)(m,{commands:I}),t[21]=I,t[22]=L);let R;t[23]===n?R=t[24]:(R=()=>{n()},t[23]=n,t[24]=R);let V;t[25]!==i.error||t[26]!==i.isPending||t[27]!==p||t[28]!==R?(V=(0,B.jsx)(T,{error:i.error,errorMsg:`Failed to create Route Table`,isPending:i.isPending,isSubmitting:p,onCancel:R,pendingLabel:`Creating…`,submitLabel:`Create Route Table`}),t[25]=i.error,t[26]=i.isPending,t[27]=p,t[28]=R,t[29]=V):V=t[29];let U;t[30]!==j||t[31]!==L||t[32]!==V||t[33]!==S||t[34]!==w?(U=(0,B.jsxs)(E,{className:`space-y-6`,locked:S,children:[w,j,L,V]}),t[30]=j,t[31]=L,t[32]=V,t[33]=S,t[34]=w,t[35]=U):U=t[35];let W;return t[36]!==U||t[37]!==x?(W=(0,B.jsx)(`form`,{onSubmit:x,children:U}),t[36]=U,t[37]=x,t[38]=W):W=t[38],W}function H(e,t){let n=t.trim();return[{label:`Create Route Table`,parts:[{type:`bin`,value:`AWS_PROFILE=spinifex aws ec2 create-route-table`},...d(`\\
--vpc-id`,p(e,`vpc-id`)),...u(`\\
--tag-specifications`,n&&f(`ResourceType=route-table,Tags=[{Key=Name,Value=${n}}]`))]}]}function U(e){return e.Associations?.filter(e=>e.SubnetId).length??0}var W=[{id:`routeTable`,header:`Route table`,sortValue:e=>i(e.Tags)??e.RouteTableId,cell:e=>{let t=i(e.Tags),n=e.RouteTableId??``;return(0,B.jsxs)(o,{className:`group block max-w-72 min-w-0 rounded-sm focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none`,from:`/ec2/describe-route-tables/`,search:e=>({...e,...c,selected:n}),to:`.`,children:[(0,B.jsx)(`span`,{className:`block truncate font-medium text-primary group-hover:underline`,children:t??n}),t&&(0,B.jsx)(`span`,{className:`block truncate font-mono text-xs text-muted-foreground`,children:n})]})}},{id:`main`,header:`Main`,sortValue:e=>+!!I(e),cell:e=>I(e)?(0,B.jsx)(N,{variant:`secondary`,children:`Main`}):(0,B.jsx)(`span`,{className:`text-muted-foreground`,children:`No`})},{id:`vpc`,header:`VPC`,sortValue:e=>e.VpcId,hideBelow:`sm`,cell:e=>e.VpcId?(0,B.jsx)(o,{className:`font-mono text-xs text-primary hover:underline`,params:{id:e.VpcId},to:`/ec2/describe-vpcs/$id`,children:e.VpcId}):(0,B.jsx)(`span`,{className:`text-muted-foreground`,children:`—`})},{id:`subnets`,header:`Subnets`,sortValue:U,hideBelow:`md`,align:`end`,cell:e=>(0,B.jsx)(`span`,{className:`tabular-nums`,children:U(e)})},{id:`routes`,header:`Routes`,sortValue:e=>e.Routes?.length??0,hideBelow:`lg`,align:`end`,cell:e=>(0,B.jsx)(`span`,{className:`tabular-nums`,children:e.Routes?.length??0})}];function G(e){let t=(0,z.c)(47),{routeTables:n,search:r,onSearchChange:i}=e,[a,u]=(0,R.useState)(),d;if(t[0]!==n||t[1]!==r.selected){let e;t[3]===r.selected?e=t[4]:(e=e=>r.selected!==``&&e.RouteTableId===r.selected,t[3]=r.selected,t[4]=e),d=n.find(e),t[0]=n,t[1]=r.selected,t[2]=d}else d=t[2];let f=d,p;t[5]===n?p=t[6]:(p=n.map(Y),t[5]=n,t[6]=p);let m;t[7]===i?m=t[8]:(m=()=>{i(c)},t[7]=i,t[8]=m);let g=w(p,f!==void 0,m),_=v(),y;t[9]===n?y=t[10]:(y=new Map(n.map(J)),t[9]=n,t[10]=y);let T=y,E=null;if(r.create){let e;t[11]===i?e=t[12]:(e=()=>{i(c)},t[11]=i,t[12]=e);let n;t[13]===i?n=t[14]:(n=(0,B.jsx)(V,{onClose:e=>{i({...c,selected:e??``})}}),t[13]=i,t[14]=n);let r;t[15]!==e||t[16]!==n?(r=(0,B.jsx)(b,{onClose:e,title:`Create route table`,children:n}),t[15]=e,t[16]=n,t[17]=r):r=t[17],E=r}else if(f?.RouteTableId){let e,n;t[18]===i?(e=t[19],n=t[20]):(e=()=>{i(c)},n=e=>{i({tab:e})},t[18]=i,t[19]=e,t[20]=n);let a;t[21]!==f||t[22]!==r.tab||t[23]!==e||t[24]!==n?(a=(0,B.jsx)(L,{onClose:e,onTabChange:n,rtb:f,tab:r.tab},f.RouteTableId),t[21]=f,t[22]=r.tab,t[23]=e,t[24]=n,t[25]=a):a=t[25],E=a}let D=E,O;t[26]===Symbol.for(`react.memo_cache_sentinel`)?(O=h(),t[26]=O):O=t[26];let k;t[27]===Symbol.for(`react.memo_cache_sentinel`)?(k=(0,B.jsx)(x,{actions:(0,B.jsxs)(o,{className:O,from:`/ec2/describe-route-tables/`,search:q,to:`.`,children:[(0,B.jsx)(l,{"data-icon":`inline-start`}),`Create route table`]}),subtitle:`Decide where traffic from each subnet goes.`,title:`Route tables`}),t[27]=k):k=t[27];let A;t[28]!==_||t[29]!==T||t[30]!==g.clear||t[31]!==g.drop||t[32]!==g.ids?(A=g.ids.length>0&&(0,B.jsx)(C,{count:g.ids.length,onClear:g.clear,children:(0,B.jsx)(j,{deleteOne:_.mutateAsync,description:`This cannot be undone.`,ids:g.ids,label:e=>{let t=T.get(e);return t?`${t} (${e})`:e},noun:[`route table`,`route tables`],onDeleted:g.drop})}),t[28]=_,t[29]=T,t[30]=g.clear,t[31]=g.drop,t[32]=g.ids,t[33]=A):A=t[33];let M=f?.RouteTableId,N;t[34]===i?N=t[35]:(N=e=>{i({...c,selected:e})},t[34]=i,t[35]=N);let P;t[36]!==n||t[37]!==g.onSelectedChange||t[38]!==g.selected||t[39]!==a||t[40]!==N||t[41]!==M?(P=(0,B.jsx)(s,{activeId:M,caption:`Route tables`,columns:W,empty:`No route tables yet.`,getRowId:K,onRowClick:N,onSelectedChange:g.onSelectedChange,onSortChange:u,rows:n,selected:g.selected,sort:a}),t[36]=n,t[37]=g.onSelectedChange,t[38]=g.selected,t[39]=a,t[40]=N,t[41]=M,t[42]=P):P=t[42];let F;return t[43]!==E||t[44]!==P||t[45]!==A?(F=(0,B.jsxs)(S,{panel:D,children:[k,A,P]}),t[43]=E,t[44]=P,t[45]=A,t[46]=F):F=t[46],F}function K(e){return e.RouteTableId??``}function q(e){return{...e,...c,create:!0}}function J(e){return[e.RouteTableId,i(e.Tags)]}function Y(e){return e.RouteTableId??``}function X(){let e=(0,z.c)(8),{data:t}=a(g),n=M.useSearch(),r=M.useNavigate(),i;e[0]===r?i=e[1]:(i=function(e){r({search:t=>({...t,...e}),replace:!(`selected`in e||`create`in e)})},e[0]=r,e[1]=i);let o=i,s;e[2]===t.RouteTables?s=e[3]:(s=t.RouteTables??[],e[2]=t.RouteTables,e[3]=s);let c;return e[4]!==o||e[5]!==n||e[6]!==s?(c=(0,B.jsx)(G,{onSearchChange:o,routeTables:s,search:n}),e[4]=o,e[5]=n,e[6]=s,e[7]=c):c=e[7],c}export{X as component};
--tag-specifications`,n&&f(`ResourceType=route-table,Tags=[{Key=Name,Value=${n}}]`))]}]}function U(e){return e.Associations?.filter(e=>e.SubnetId).length??0}var W=[{id:`routeTable`,header:`Route table`,sortValue:e=>i(e.Tags)??e.RouteTableId,cell:e=>{let t=i(e.Tags),n=e.RouteTableId??``;return(0,B.jsxs)(o,{className:`group block max-w-72 min-w-0 rounded-sm focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none`,from:`/ec2/describe-route-tables/`,search:e=>({...e,...c,selected:n}),to:`.`,children:[(0,B.jsx)(`span`,{className:`block truncate font-medium text-primary group-hover:underline`,children:t??n}),t&&(0,B.jsx)(`span`,{className:`block truncate font-mono text-xs text-muted-foreground`,children:n})]})}},{id:`main`,header:`Main`,sortValue:e=>+!!I(e),cell:e=>I(e)?(0,B.jsx)(N,{variant:`secondary`,children:`Main`}):(0,B.jsx)(`span`,{className:`text-muted-foreground`,children:`No`})},{id:`vpc`,header:`VPC`,sortValue:e=>e.VpcId,hideBelow:`sm`,cell:e=>e.VpcId?(0,B.jsx)(o,{className:`font-mono text-xs text-primary hover:underline`,params:{id:e.VpcId},to:`/ec2/describe-vpcs/$id`,children:e.VpcId}):(0,B.jsx)(`span`,{className:`text-muted-foreground`,children:`—`})},{id:`subnets`,header:`Subnets`,sortValue:U,hideBelow:`md`,align:`end`,cell:e=>(0,B.jsx)(`span`,{className:`tabular-nums`,children:U(e)})},{id:`routes`,header:`Routes`,sortValue:e=>e.Routes?.length??0,hideBelow:`lg`,align:`end`,cell:e=>(0,B.jsx)(`span`,{className:`tabular-nums`,children:e.Routes?.length??0})}];function G(e){let t=(0,z.c)(47),{routeTables:n,search:r,onSearchChange:i}=e,[a,u]=(0,R.useState)(),d;if(t[0]!==n||t[1]!==r.selected){let e;t[3]===r.selected?e=t[4]:(e=e=>r.selected!==``&&e.RouteTableId===r.selected,t[3]=r.selected,t[4]=e),d=n.find(e),t[0]=n,t[1]=r.selected,t[2]=d}else d=t[2];let f=d,p;t[5]===n?p=t[6]:(p=n.map(Y),t[5]=n,t[6]=p);let m;t[7]===i?m=t[8]:(m=()=>{i(c)},t[7]=i,t[8]=m);let g=w(p,f!==void 0,m),_=v(),y;t[9]===n?y=t[10]:(y=new Map(n.map(J)),t[9]=n,t[10]=y);let T=y,E=null;if(r.create){let e;t[11]===i?e=t[12]:(e=()=>{i(c)},t[11]=i,t[12]=e);let n;t[13]===i?n=t[14]:(n=(0,B.jsx)(V,{onClose:e=>{i({...c,selected:e??``})}}),t[13]=i,t[14]=n);let r;t[15]!==e||t[16]!==n?(r=(0,B.jsx)(b,{onClose:e,title:`Create route table`,children:n}),t[15]=e,t[16]=n,t[17]=r):r=t[17],E=r}else if(f?.RouteTableId){let e,n;t[18]===i?(e=t[19],n=t[20]):(e=()=>{i(c)},n=e=>{i({tab:e})},t[18]=i,t[19]=e,t[20]=n);let a;t[21]!==f||t[22]!==r.tab||t[23]!==e||t[24]!==n?(a=(0,B.jsx)(L,{onClose:e,onTabChange:n,rtb:f,tab:r.tab},f.RouteTableId),t[21]=f,t[22]=r.tab,t[23]=e,t[24]=n,t[25]=a):a=t[25],E=a}let D=E,O;t[26]===Symbol.for(`react.memo_cache_sentinel`)?(O=h(),t[26]=O):O=t[26];let k;t[27]===Symbol.for(`react.memo_cache_sentinel`)?(k=(0,B.jsx)(x,{actions:(0,B.jsxs)(o,{className:O,from:`/ec2/describe-route-tables/`,search:q,to:`.`,children:[(0,B.jsx)(l,{"data-icon":`inline-start`}),`Create route table`]}),subtitle:`Decide where traffic from each subnet goes.`,title:`Route Tables`}),t[27]=k):k=t[27];let A;t[28]!==_||t[29]!==T||t[30]!==g.clear||t[31]!==g.drop||t[32]!==g.ids?(A=g.ids.length>0&&(0,B.jsx)(C,{count:g.ids.length,onClear:g.clear,children:(0,B.jsx)(j,{deleteOne:_.mutateAsync,description:`This cannot be undone.`,ids:g.ids,label:e=>{let t=T.get(e);return t?`${t} (${e})`:e},noun:[`route table`,`route tables`],onDeleted:g.drop})}),t[28]=_,t[29]=T,t[30]=g.clear,t[31]=g.drop,t[32]=g.ids,t[33]=A):A=t[33];let M=f?.RouteTableId,N;t[34]===i?N=t[35]:(N=e=>{i({...c,selected:e})},t[34]=i,t[35]=N);let P;t[36]!==n||t[37]!==g.onSelectedChange||t[38]!==g.selected||t[39]!==a||t[40]!==N||t[41]!==M?(P=(0,B.jsx)(s,{activeId:M,caption:`Route tables`,columns:W,empty:`No route tables yet.`,getRowId:K,onRowClick:N,onSelectedChange:g.onSelectedChange,onSortChange:u,rows:n,selected:g.selected,sort:a}),t[36]=n,t[37]=g.onSelectedChange,t[38]=g.selected,t[39]=a,t[40]=N,t[41]=M,t[42]=P):P=t[42];let F;return t[43]!==E||t[44]!==P||t[45]!==A?(F=(0,B.jsxs)(S,{panel:D,children:[k,A,P]}),t[43]=E,t[44]=P,t[45]=A,t[46]=F):F=t[46],F}function K(e){return e.RouteTableId??``}function q(e){return{...e,...c,create:!0}}function J(e){return[e.RouteTableId,i(e.Tags)]}function Y(e){return e.RouteTableId??``}function X(){let e=(0,z.c)(8),{data:t}=a(g),n=M.useSearch(),r=M.useNavigate(),i;e[0]===r?i=e[1]:(i=function(e){r({search:t=>({...t,...e}),replace:!(`selected`in e||`create`in e)})},e[0]=r,e[1]=i);let o=i,s;e[2]===t.RouteTables?s=e[3]:(s=t.RouteTables??[],e[2]=t.RouteTables,e[3]=s);let c;return e[4]!==o||e[5]!==n||e[6]!==s?(c=(0,B.jsx)(G,{onSearchChange:o,routeTables:s,search:n}),e[4]=o,e[5]=n,e[6]=s,e[7]=c):c=e[7],c}export{X as component};
@@ -1,2 +1,2 @@
const __vite__mapDeps=(i,m=__vite__mapDeps,d=(m.f||(m.f=["assets/describe-security-groups-C4-bkHwi.js","assets/rolldown-runtime-XKyaHtl9.js","assets/schemas-BGv4hnp9.js","assets/useSuspenseQuery-C45dM0fa.js","assets/queryOptions-twzzLLsx.js","assets/QueryClientProvider-CTm_NoFP.js","assets/link-D5tcioHY.js","assets/matchContext-DBnoSZp3.js","assets/data-table-CqrUk0mI.js","assets/useSelector-Cvbg8b7C.js","assets/visuallyHidden-D4_nmHIB.js","assets/check-B7aLYzUb.js","assets/useRegisterFieldControl-2x4pEgaC.js","assets/useAriaLabelledBy-BMp0PcVw.js","assets/useControlled-BP9HwLGp.js","assets/table-BEe-3So1.js","assets/detail-panel-BBYLO5I7.js","assets/x-8n_Gyi12.js","assets/error-banner-DYkSEx5d.js","assets/chevron-down-By1UhB9P.js","assets/copy-BpriwVcd.js","assets/loader-circle-CiggOyRj.js","assets/useScrollLock-FIlKWKju.js","assets/useValueAsRef-BoFfo9R-.js","assets/ToolbarRootContext-B6mBfOj0.js","assets/isElementDisabled-CuK_3Aso.js","assets/store-BTg3TzdU.js","assets/collapsible-C7X1O8X9.js","assets/tabs-DFnnXw6G.js","assets/use-copy-to-clipboard-CAJ8gQCx.js","assets/plus-U2P-lQY5.js","assets/cli-command-panel-DBkpfo1W.js","assets/ec2-CkEARsnn.js","assets/auth-DA1pq184.js","assets/awsClient-pD_9VmT7.js","assets/index.browser-BEm-79SP.js","assets/ec2-B8367M9s.js","assets/useMutation-CutM3PJn.js","assets/subnet-calculator-DDrAb5xp.js","assets/tags-B-poldSB.js","assets/create-panel-DMDXu4FP.js","assets/page-heading-75JYUGDy.js","assets/resource-view-BXETZc8v.js","assets/selection-bar-MuN8z7Qc.js","assets/use-row-selection-CZtmDppg.js","assets/form-lock-C6qPAte1.js","assets/index.esm-ClOaByEg.js","assets/zod-DmrlGH9Y.js","assets/ec2-59Y0OXl4.js","assets/bulk-delete-B7E-9nKZ.js","assets/circle-x-DHQkYhPI.js","assets/trash-REoRRV_c.js","assets/alert-dialog-DKUcMJKb.js","assets/DialogTitle-BC_80-Qv.js","assets/confirm-phrase-Bvuzco3W.js","assets/field-B2ItYtQp.js","assets/form-fields-C1aPNl6R.js","assets/select-DY3fKHla.js","assets/clamp-D0kAXd-u.js","assets/vpc-select-field-CMKlzSpr.js","assets/security-group-details-goA3V3LX.js","assets/sliders-horizontal-DZ-kpCNX.js","assets/maximize-2-bpbTyiq2.js","assets/delete-confirmation-dialog-DRnnnG6z.js","assets/form-panel-rAQfOvhU.js","assets/instance-overview-CGP0dSaw.js","assets/useQuery-CZvSQig-.js","assets/iam-Uvcozs2Q.js","assets/json-CS_V3sox.js","assets/admin-B6KlrWRj.js","assets/signed-fetch-C7xQR7ap.js","assets/tslib.es6-CT3DuY1l.js","assets/use-admin-CMdKtYKg.js","assets/sts-CNfMTCfB.js","assets/state-badge-DuJRo9eW.js","assets/resource-delete-dialog-DgcEsN9Y.js"])))=>i.map(i=>d[i]);
import{s as e}from"./schemas-BGv4hnp9.js";import{n as t,t as n}from"./lazyRouteComponent-BnnVgQVQ.js";import{t as r}from"./searchMiddleware-VYy8PwPp.js";import{h as i,m as a}from"./detail-panel-BBYLO5I7.js";import{Mo as o}from"./awsClient-pD_9VmT7.js";import{T as s}from"./ec2-CkEARsnn.js";var c=()=>o(()=>import(`./describe-security-groups-C4-bkHwi.js`),__vite__mapDeps([0,1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64,65,66,67,68,69,70,71,72,73,74,75])),l=e(i),u=t(`/_auth/ec2/(security-groups)/describe-security-groups/`)({validateSearch:l,search:{middlewares:[r(a)]},loader:async({context:e})=>{await e.queryClient.query({...s,staleTime:`static`})},head:()=>({meta:[{title:`Security Groups | EC2 | Mulga`}]}),component:n(c,`component`)});export{u as t};
const __vite__mapDeps=(i,m=__vite__mapDeps,d=(m.f||(m.f=["assets/describe-security-groups-DL8QX8TB.js","assets/rolldown-runtime-XKyaHtl9.js","assets/schemas-BGv4hnp9.js","assets/useSuspenseQuery-C45dM0fa.js","assets/queryOptions-twzzLLsx.js","assets/QueryClientProvider-CTm_NoFP.js","assets/link-D5tcioHY.js","assets/matchContext-DBnoSZp3.js","assets/data-table-CqrUk0mI.js","assets/useSelector-Cvbg8b7C.js","assets/visuallyHidden-D4_nmHIB.js","assets/check-B7aLYzUb.js","assets/useRegisterFieldControl-2x4pEgaC.js","assets/useAriaLabelledBy-BMp0PcVw.js","assets/useControlled-BP9HwLGp.js","assets/table-BEe-3So1.js","assets/detail-panel-BBYLO5I7.js","assets/x-8n_Gyi12.js","assets/error-banner-DYkSEx5d.js","assets/chevron-down-By1UhB9P.js","assets/copy-BpriwVcd.js","assets/loader-circle-CiggOyRj.js","assets/useScrollLock-FIlKWKju.js","assets/useValueAsRef-BoFfo9R-.js","assets/ToolbarRootContext-B6mBfOj0.js","assets/isElementDisabled-CuK_3Aso.js","assets/store-BTg3TzdU.js","assets/collapsible-C7X1O8X9.js","assets/tabs-DFnnXw6G.js","assets/use-copy-to-clipboard-CAJ8gQCx.js","assets/plus-U2P-lQY5.js","assets/cli-command-panel-DBkpfo1W.js","assets/ec2-CkEARsnn.js","assets/auth-DA1pq184.js","assets/awsClient-pD_9VmT7.js","assets/index.browser-BEm-79SP.js","assets/ec2-B8367M9s.js","assets/useMutation-CutM3PJn.js","assets/subnet-calculator-DDrAb5xp.js","assets/tags-B-poldSB.js","assets/create-panel-DMDXu4FP.js","assets/page-heading-75JYUGDy.js","assets/resource-view-BXETZc8v.js","assets/selection-bar-MuN8z7Qc.js","assets/use-row-selection-CZtmDppg.js","assets/form-lock-C6qPAte1.js","assets/index.esm-ClOaByEg.js","assets/zod-DmrlGH9Y.js","assets/ec2-59Y0OXl4.js","assets/bulk-delete-B7E-9nKZ.js","assets/circle-x-DHQkYhPI.js","assets/trash-REoRRV_c.js","assets/alert-dialog-DKUcMJKb.js","assets/DialogTitle-BC_80-Qv.js","assets/confirm-phrase-Bvuzco3W.js","assets/field-B2ItYtQp.js","assets/form-fields-C1aPNl6R.js","assets/select-DY3fKHla.js","assets/clamp-D0kAXd-u.js","assets/vpc-select-field-CMKlzSpr.js","assets/security-group-details-goA3V3LX.js","assets/sliders-horizontal-DZ-kpCNX.js","assets/maximize-2-bpbTyiq2.js","assets/delete-confirmation-dialog-DRnnnG6z.js","assets/form-panel-rAQfOvhU.js","assets/instance-overview-CGP0dSaw.js","assets/useQuery-CZvSQig-.js","assets/iam-Uvcozs2Q.js","assets/json-CS_V3sox.js","assets/admin-B6KlrWRj.js","assets/signed-fetch-C7xQR7ap.js","assets/tslib.es6-CT3DuY1l.js","assets/use-admin-CMdKtYKg.js","assets/sts-CNfMTCfB.js","assets/state-badge-DuJRo9eW.js","assets/resource-delete-dialog-DgcEsN9Y.js"])))=>i.map(i=>d[i]);
import{s as e}from"./schemas-BGv4hnp9.js";import{n as t,t as n}from"./lazyRouteComponent-BnnVgQVQ.js";import{t as r}from"./searchMiddleware-VYy8PwPp.js";import{h as i,m as a}from"./detail-panel-BBYLO5I7.js";import{Mo as o}from"./awsClient-pD_9VmT7.js";import{T as s}from"./ec2-CkEARsnn.js";var c=()=>o(()=>import(`./describe-security-groups-DL8QX8TB.js`),__vite__mapDeps([0,1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64,65,66,67,68,69,70,71,72,73,74,75])),l=e(i),u=t(`/_auth/ec2/(security-groups)/describe-security-groups/`)({validateSearch:l,search:{middlewares:[r(a)]},loader:async({context:e})=>{await e.queryClient.query({...s,staleTime:`static`})},head:()=>({meta:[{title:`Security Groups | EC2 | Mulga`}]}),component:n(c,`component`)});export{u as t};
@@ -1,4 +1,4 @@
import{r as e}from"./rolldown-runtime-XKyaHtl9.js";import{Ht as t,Rt as n,Ut as r}from"./schemas-BGv4hnp9.js";import{t as i}from"./useSuspenseQuery-C45dM0fa.js";import{t as a}from"./link-D5tcioHY.js";import{t as o}from"./data-table-CqrUk0mI.js";import{m as s}from"./detail-panel-BBYLO5I7.js";import{t as c}from"./plus-U2P-lQY5.js";import{a as l,i as u,r as d,t as f}from"./cli-command-panel-DBkpfo1W.js";import{o as p}from"./useValueAsRef-BoFfo9R-.js";import{N as m,T as h}from"./ec2-CkEARsnn.js";import{F as g,x as _}from"./ec2-B8367M9s.js";import{t as v}from"./create-panel-DMDXu4FP.js";import{t as y}from"./page-heading-75JYUGDy.js";import{t as b}from"./resource-view-BXETZc8v.js";import{t as x}from"./selection-bar-MuN8z7Qc.js";import{t as S}from"./use-row-selection-CZtmDppg.js";import{n as C,t as w}from"./form-lock-C6qPAte1.js";import{o as T,s as E}from"./index.esm-ClOaByEg.js";import{t as D}from"./zod-DmrlGH9Y.js";import{p as O}from"./ec2-59Y0OXl4.js";import{t as k}from"./bulk-delete-B7E-9nKZ.js";import{t as A}from"./describe-security-groups-BHD89ybN.js";import{n as j}from"./form-fields-C1aPNl6R.js";import{t as M}from"./vpc-select-field-CMKlzSpr.js";import{t as N}from"./security-group-details-goA3V3LX.js";var P=e(r(),1),F=n(),I=t();function L(e){let t=(0,F.c)(47),{onClose:n}=e,{data:r}=i(m),a=_(),o;t[0]===r.Vpcs?o=t[1]:(o=r.Vpcs??[],t[0]=r.Vpcs,t[1]=o);let s=o,c;t[2]===Symbol.for(`react.memo_cache_sentinel`)?(c=D(O),t[2]=c):c=t[2];let l=s[0]?.VpcId??``,u;t[3]===l?u=t[4]:(u={resolver:c,defaultValues:{groupName:``,description:``,vpcId:l}},t[3]=l,t[4]=u);let{control:d,handleSubmit:p,register:h,formState:g}=T(u),{errors:v,isSubmitting:y}=g,b;t[5]===d?b=t[6]:(b={control:d},t[5]=d,t[6]=b);let x=E(b),S;t[7]!==a||t[8]!==n?(S=async e=>{let t;try{t=await a.mutateAsync(e)}catch{return}n(t.GroupId)},t[7]=a,t[8]=n,t[9]=S):S=t[9];let k=S,A;t[10]!==p||t[11]!==k?(A=p(k),t[10]=p,t[11]=k,t[12]=A):A=t[12];let N=a.isPending||y,P=v.groupName,L;t[13]===h?L=t[14]:(L=h(`groupName`),t[13]=h,t[14]=L);let z;t[15]!==v.groupName||t[16]!==L?(z=(0,I.jsx)(j,{error:P,id:`groupName`,label:`Group Name`,placeholder:`my-security-group`,...L}),t[15]=v.groupName,t[16]=L,t[17]=z):z=t[17];let B=v.description,V;t[18]===h?V=t[19]:(V=h(`description`),t[18]=h,t[19]=V);let H;t[20]!==v.description||t[21]!==V?(H=(0,I.jsx)(j,{error:B,id:`description`,label:`Description`,placeholder:`My security group description`,...V}),t[20]=v.description,t[21]=V,t[22]=H):H=t[22];let U;t[23]!==d||t[24]!==s?(U=(0,I.jsx)(M,{control:d,id:`vpcId`,name:`vpcId`,vpcs:s}),t[23]=d,t[24]=s,t[25]=U):U=t[25];let W;t[26]===x?W=t[27]:(W=R(x),t[26]=x,t[27]=W);let G;t[28]===W?G=t[29]:(G=(0,I.jsx)(f,{commands:W}),t[28]=W,t[29]=G);let K;t[30]===n?K=t[31]:(K=()=>{n()},t[30]=n,t[31]=K);let q;t[32]!==a.error||t[33]!==a.isPending||t[34]!==y||t[35]!==K?(q=(0,I.jsx)(C,{error:a.error,errorMsg:`Failed to create security group`,isPending:a.isPending,isSubmitting:y,onCancel:K,pendingLabel:`Creating…`,submitLabel:`Create Security Group`}),t[32]=a.error,t[33]=a.isPending,t[34]=y,t[35]=K,t[36]=q):q=t[36];let J;t[37]!==z||t[38]!==H||t[39]!==U||t[40]!==G||t[41]!==q||t[42]!==N?(J=(0,I.jsxs)(w,{className:`space-y-6`,locked:N,children:[z,H,U,G,q]}),t[37]=z,t[38]=H,t[39]=U,t[40]=G,t[41]=q,t[42]=N,t[43]=J):J=t[43];let Y;return t[44]!==J||t[45]!==A?(Y=(0,I.jsx)(`form`,{onSubmit:A,children:J}),t[44]=J,t[45]=A,t[46]=Y):Y=t[46],Y}function R(e){let t=d(e.description??``,`Description`);return[{label:`Create Security Group`,parts:[{type:`bin`,value:`AWS_PROFILE=spinifex aws ec2 create-security-group`},...u(`\\
import{r as e}from"./rolldown-runtime-XKyaHtl9.js";import{Ht as t,Rt as n,Ut as r}from"./schemas-BGv4hnp9.js";import{t as i}from"./useSuspenseQuery-C45dM0fa.js";import{t as a}from"./link-D5tcioHY.js";import{t as o}from"./data-table-CqrUk0mI.js";import{m as s}from"./detail-panel-BBYLO5I7.js";import{t as c}from"./plus-U2P-lQY5.js";import{a as l,i as u,r as d,t as f}from"./cli-command-panel-DBkpfo1W.js";import{o as p}from"./useValueAsRef-BoFfo9R-.js";import{N as m,T as h}from"./ec2-CkEARsnn.js";import{F as g,x as _}from"./ec2-B8367M9s.js";import{t as v}from"./create-panel-DMDXu4FP.js";import{t as y}from"./page-heading-75JYUGDy.js";import{t as b}from"./resource-view-BXETZc8v.js";import{t as x}from"./selection-bar-MuN8z7Qc.js";import{t as S}from"./use-row-selection-CZtmDppg.js";import{n as C,t as w}from"./form-lock-C6qPAte1.js";import{o as T,s as E}from"./index.esm-ClOaByEg.js";import{t as D}from"./zod-DmrlGH9Y.js";import{p as O}from"./ec2-59Y0OXl4.js";import{t as k}from"./bulk-delete-B7E-9nKZ.js";import{t as A}from"./describe-security-groups-D0zCCxrp.js";import{n as j}from"./form-fields-C1aPNl6R.js";import{t as M}from"./vpc-select-field-CMKlzSpr.js";import{t as N}from"./security-group-details-goA3V3LX.js";var P=e(r(),1),F=n(),I=t();function L(e){let t=(0,F.c)(47),{onClose:n}=e,{data:r}=i(m),a=_(),o;t[0]===r.Vpcs?o=t[1]:(o=r.Vpcs??[],t[0]=r.Vpcs,t[1]=o);let s=o,c;t[2]===Symbol.for(`react.memo_cache_sentinel`)?(c=D(O),t[2]=c):c=t[2];let l=s[0]?.VpcId??``,u;t[3]===l?u=t[4]:(u={resolver:c,defaultValues:{groupName:``,description:``,vpcId:l}},t[3]=l,t[4]=u);let{control:d,handleSubmit:p,register:h,formState:g}=T(u),{errors:v,isSubmitting:y}=g,b;t[5]===d?b=t[6]:(b={control:d},t[5]=d,t[6]=b);let x=E(b),S;t[7]!==a||t[8]!==n?(S=async e=>{let t;try{t=await a.mutateAsync(e)}catch{return}n(t.GroupId)},t[7]=a,t[8]=n,t[9]=S):S=t[9];let k=S,A;t[10]!==p||t[11]!==k?(A=p(k),t[10]=p,t[11]=k,t[12]=A):A=t[12];let N=a.isPending||y,P=v.groupName,L;t[13]===h?L=t[14]:(L=h(`groupName`),t[13]=h,t[14]=L);let z;t[15]!==v.groupName||t[16]!==L?(z=(0,I.jsx)(j,{error:P,id:`groupName`,label:`Group Name`,placeholder:`my-security-group`,...L}),t[15]=v.groupName,t[16]=L,t[17]=z):z=t[17];let B=v.description,V;t[18]===h?V=t[19]:(V=h(`description`),t[18]=h,t[19]=V);let H;t[20]!==v.description||t[21]!==V?(H=(0,I.jsx)(j,{error:B,id:`description`,label:`Description`,placeholder:`My security group description`,...V}),t[20]=v.description,t[21]=V,t[22]=H):H=t[22];let U;t[23]!==d||t[24]!==s?(U=(0,I.jsx)(M,{control:d,id:`vpcId`,name:`vpcId`,vpcs:s}),t[23]=d,t[24]=s,t[25]=U):U=t[25];let W;t[26]===x?W=t[27]:(W=R(x),t[26]=x,t[27]=W);let G;t[28]===W?G=t[29]:(G=(0,I.jsx)(f,{commands:W}),t[28]=W,t[29]=G);let K;t[30]===n?K=t[31]:(K=()=>{n()},t[30]=n,t[31]=K);let q;t[32]!==a.error||t[33]!==a.isPending||t[34]!==y||t[35]!==K?(q=(0,I.jsx)(C,{error:a.error,errorMsg:`Failed to create security group`,isPending:a.isPending,isSubmitting:y,onCancel:K,pendingLabel:`Creating…`,submitLabel:`Create Security Group`}),t[32]=a.error,t[33]=a.isPending,t[34]=y,t[35]=K,t[36]=q):q=t[36];let J;t[37]!==z||t[38]!==H||t[39]!==U||t[40]!==G||t[41]!==q||t[42]!==N?(J=(0,I.jsxs)(w,{className:`space-y-6`,locked:N,children:[z,H,U,G,q]}),t[37]=z,t[38]=H,t[39]=U,t[40]=G,t[41]=q,t[42]=N,t[43]=J):J=t[43];let Y;return t[44]!==J||t[45]!==A?(Y=(0,I.jsx)(`form`,{onSubmit:A,children:J}),t[44]=J,t[45]=A,t[46]=Y):Y=t[46],Y}function R(e){let t=d(e.description??``,`Description`);return[{label:`Create Security Group`,parts:[{type:`bin`,value:`AWS_PROFILE=spinifex aws ec2 create-security-group`},...u(`\\
--group-name`,d(e.groupName??``,`GroupName`)),...u(`\\
--description`,`"${t}"`),...l(`\\
--vpc-id`,e.vpcId)]}]}function z(e){return(e??[]).reduce((e,t)=>e+(t.IpRanges?.length??0)+(t.Ipv6Ranges?.length??0)+(t.UserIdGroupPairs?.length??0)+(t.PrefixListIds?.length??0),0)}var B=[{id:`group`,header:`Security group`,sortValue:e=>e.GroupName??e.GroupId,cell:e=>{let t=e.GroupId??``;return(0,I.jsxs)(a,{className:`group block max-w-72 min-w-0 rounded-sm focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none`,from:`/ec2/describe-security-groups/`,search:e=>({...e,...s,selected:t}),to:`.`,children:[(0,I.jsx)(`span`,{className:`block truncate font-medium text-primary group-hover:underline`,children:e.GroupName??t}),e.GroupName&&(0,I.jsx)(`span`,{className:`block truncate font-mono text-xs text-muted-foreground`,children:t})]})}},{id:`vpc`,header:`VPC`,sortValue:e=>e.VpcId,hideBelow:`sm`,cell:e=>e.VpcId?(0,I.jsx)(a,{className:`font-mono text-xs text-primary hover:underline`,params:{id:e.VpcId},to:`/ec2/describe-vpcs/$id`,children:e.VpcId}):(0,I.jsx)(`span`,{className:`text-muted-foreground`,children:`—`})},{id:`description`,header:`Description`,sortValue:e=>e.Description,hideBelow:`lg`,cell:e=>(0,I.jsx)(`span`,{className:`block max-w-80 truncate`,title:e.Description,children:e.Description})},{id:`inbound`,header:`Inbound`,sortValue:e=>z(e.IpPermissions),hideBelow:`md`,align:`end`,cell:e=>(0,I.jsx)(`span`,{className:`tabular-nums`,children:z(e.IpPermissions)})},{id:`outbound`,header:`Outbound`,sortValue:e=>z(e.IpPermissionsEgress),hideBelow:`md`,align:`end`,cell:e=>(0,I.jsx)(`span`,{className:`tabular-nums`,children:z(e.IpPermissionsEgress)})}];function V(e){let t=(0,F.c)(47),{securityGroups:n,search:r,onSearchChange:i}=e,[l,u]=(0,P.useState)(),d;if(t[0]!==r.selected||t[1]!==n){let e;t[3]===r.selected?e=t[4]:(e=e=>r.selected!==``&&e.GroupId===r.selected,t[3]=r.selected,t[4]=e),d=n.find(e),t[0]=r.selected,t[1]=n,t[2]=d}else d=t[2];let f=d,m;t[5]===n?m=t[6]:(m=n.map(G),t[5]=n,t[6]=m);let h;t[7]===i?h=t[8]:(h=()=>{i(s)},t[7]=i,t[8]=h);let _=S(m,f!==void 0,h),C=g(),w;t[9]===n?w=t[10]:(w=new Map(n.map(W)),t[9]=n,t[10]=w);let T=w,E=null;if(r.create){let e;t[11]===i?e=t[12]:(e=()=>{i(s)},t[11]=i,t[12]=e);let n;t[13]===i?n=t[14]:(n=(0,I.jsx)(L,{onClose:e=>{i({...s,selected:e??``})}}),t[13]=i,t[14]=n);let r;t[15]!==e||t[16]!==n?(r=(0,I.jsx)(v,{onClose:e,title:`Create security group`,children:n}),t[15]=e,t[16]=n,t[17]=r):r=t[17],E=r}else if(f?.GroupId){let e,n;t[18]===i?(e=t[19],n=t[20]):(e=()=>{i(s)},n=e=>{i({tab:e})},t[18]=i,t[19]=e,t[20]=n);let a;t[21]!==f||t[22]!==r.tab||t[23]!==e||t[24]!==n?(a=(0,I.jsx)(N,{onClose:e,onTabChange:n,sg:f,tab:r.tab},f.GroupId),t[21]=f,t[22]=r.tab,t[23]=e,t[24]=n,t[25]=a):a=t[25],E=a}let D=E,O;t[26]===Symbol.for(`react.memo_cache_sentinel`)?(O=p(),t[26]=O):O=t[26];let A;t[27]===Symbol.for(`react.memo_cache_sentinel`)?(A=(0,I.jsx)(y,{actions:(0,I.jsxs)(a,{className:O,from:`/ec2/describe-security-groups/`,search:U,to:`.`,children:[(0,I.jsx)(c,{"data-icon":`inline-start`}),`Create security group`]}),subtitle:`Stateful firewalls for the instances in a VPC.`,title:`Security groups`}),t[27]=A):A=t[27];let j;t[28]!==C||t[29]!==T||t[30]!==_.clear||t[31]!==_.drop||t[32]!==_.ids?(j=_.ids.length>0&&(0,I.jsx)(x,{count:_.ids.length,onClear:_.clear,children:(0,I.jsx)(k,{deleteOne:C.mutateAsync,description:`No instance or other group may still reference them. This cannot be undone.`,ids:_.ids,label:e=>{let t=T.get(e);return t?`${t} (${e})`:e},noun:[`security group`,`security groups`],onDeleted:_.drop})}),t[28]=C,t[29]=T,t[30]=_.clear,t[31]=_.drop,t[32]=_.ids,t[33]=j):j=t[33];let M=f?.GroupId,R;t[34]===i?R=t[35]:(R=e=>{i({...s,selected:e})},t[34]=i,t[35]=R);let z;t[36]!==n||t[37]!==_.onSelectedChange||t[38]!==_.selected||t[39]!==l||t[40]!==R||t[41]!==M?(z=(0,I.jsx)(o,{activeId:M,caption:`Security groups`,columns:B,empty:`No security groups yet.`,getRowId:H,onRowClick:R,onSelectedChange:_.onSelectedChange,onSortChange:u,rows:n,selected:_.selected,sort:l}),t[36]=n,t[37]=_.onSelectedChange,t[38]=_.selected,t[39]=l,t[40]=R,t[41]=M,t[42]=z):z=t[42];let V;return t[43]!==E||t[44]!==z||t[45]!==j?(V=(0,I.jsxs)(b,{panel:D,children:[A,j,z]}),t[43]=E,t[44]=z,t[45]=j,t[46]=V):V=t[46],V}function H(e){return e.GroupId??``}function U(e){return{...e,...s,create:!0}}function W(e){return[e.GroupId,e.GroupName]}function G(e){return e.GroupId??``}function K(){let e=(0,F.c)(8),{data:t}=i(h),n=A.useSearch(),r=A.useNavigate(),a;e[0]===r?a=e[1]:(a=function(e){r({search:t=>({...t,...e}),replace:!(`selected`in e||`create`in e)})},e[0]=r,e[1]=a);let o=a,s;e[2]===t.SecurityGroups?s=e[3]:(s=t.SecurityGroups??[],e[2]=t.SecurityGroups,e[3]=s);let c;return e[4]!==o||e[5]!==n||e[6]!==s?(c=(0,I.jsx)(V,{onSearchChange:o,search:n,securityGroups:s}),e[4]=o,e[5]=n,e[6]=s,e[7]=c):c=e[7],c}export{K as component};
--vpc-id`,e.vpcId)]}]}function z(e){return(e??[]).reduce((e,t)=>e+(t.IpRanges?.length??0)+(t.Ipv6Ranges?.length??0)+(t.UserIdGroupPairs?.length??0)+(t.PrefixListIds?.length??0),0)}var B=[{id:`group`,header:`Security group`,sortValue:e=>e.GroupName??e.GroupId,cell:e=>{let t=e.GroupId??``;return(0,I.jsxs)(a,{className:`group block max-w-72 min-w-0 rounded-sm focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none`,from:`/ec2/describe-security-groups/`,search:e=>({...e,...s,selected:t}),to:`.`,children:[(0,I.jsx)(`span`,{className:`block truncate font-medium text-primary group-hover:underline`,children:e.GroupName??t}),e.GroupName&&(0,I.jsx)(`span`,{className:`block truncate font-mono text-xs text-muted-foreground`,children:t})]})}},{id:`vpc`,header:`VPC`,sortValue:e=>e.VpcId,hideBelow:`sm`,cell:e=>e.VpcId?(0,I.jsx)(a,{className:`font-mono text-xs text-primary hover:underline`,params:{id:e.VpcId},to:`/ec2/describe-vpcs/$id`,children:e.VpcId}):(0,I.jsx)(`span`,{className:`text-muted-foreground`,children:`—`})},{id:`description`,header:`Description`,sortValue:e=>e.Description,hideBelow:`lg`,cell:e=>(0,I.jsx)(`span`,{className:`block max-w-80 truncate`,title:e.Description,children:e.Description})},{id:`inbound`,header:`Inbound`,sortValue:e=>z(e.IpPermissions),hideBelow:`md`,align:`end`,cell:e=>(0,I.jsx)(`span`,{className:`tabular-nums`,children:z(e.IpPermissions)})},{id:`outbound`,header:`Outbound`,sortValue:e=>z(e.IpPermissionsEgress),hideBelow:`md`,align:`end`,cell:e=>(0,I.jsx)(`span`,{className:`tabular-nums`,children:z(e.IpPermissionsEgress)})}];function V(e){let t=(0,F.c)(47),{securityGroups:n,search:r,onSearchChange:i}=e,[l,u]=(0,P.useState)(),d;if(t[0]!==r.selected||t[1]!==n){let e;t[3]===r.selected?e=t[4]:(e=e=>r.selected!==``&&e.GroupId===r.selected,t[3]=r.selected,t[4]=e),d=n.find(e),t[0]=r.selected,t[1]=n,t[2]=d}else d=t[2];let f=d,m;t[5]===n?m=t[6]:(m=n.map(G),t[5]=n,t[6]=m);let h;t[7]===i?h=t[8]:(h=()=>{i(s)},t[7]=i,t[8]=h);let _=S(m,f!==void 0,h),C=g(),w;t[9]===n?w=t[10]:(w=new Map(n.map(W)),t[9]=n,t[10]=w);let T=w,E=null;if(r.create){let e;t[11]===i?e=t[12]:(e=()=>{i(s)},t[11]=i,t[12]=e);let n;t[13]===i?n=t[14]:(n=(0,I.jsx)(L,{onClose:e=>{i({...s,selected:e??``})}}),t[13]=i,t[14]=n);let r;t[15]!==e||t[16]!==n?(r=(0,I.jsx)(v,{onClose:e,title:`Create security group`,children:n}),t[15]=e,t[16]=n,t[17]=r):r=t[17],E=r}else if(f?.GroupId){let e,n;t[18]===i?(e=t[19],n=t[20]):(e=()=>{i(s)},n=e=>{i({tab:e})},t[18]=i,t[19]=e,t[20]=n);let a;t[21]!==f||t[22]!==r.tab||t[23]!==e||t[24]!==n?(a=(0,I.jsx)(N,{onClose:e,onTabChange:n,sg:f,tab:r.tab},f.GroupId),t[21]=f,t[22]=r.tab,t[23]=e,t[24]=n,t[25]=a):a=t[25],E=a}let D=E,O;t[26]===Symbol.for(`react.memo_cache_sentinel`)?(O=p(),t[26]=O):O=t[26];let A;t[27]===Symbol.for(`react.memo_cache_sentinel`)?(A=(0,I.jsx)(y,{actions:(0,I.jsxs)(a,{className:O,from:`/ec2/describe-security-groups/`,search:U,to:`.`,children:[(0,I.jsx)(c,{"data-icon":`inline-start`}),`Create security group`]}),subtitle:`Stateful firewalls for the instances in a VPC.`,title:`Security Groups`}),t[27]=A):A=t[27];let j;t[28]!==C||t[29]!==T||t[30]!==_.clear||t[31]!==_.drop||t[32]!==_.ids?(j=_.ids.length>0&&(0,I.jsx)(x,{count:_.ids.length,onClear:_.clear,children:(0,I.jsx)(k,{deleteOne:C.mutateAsync,description:`No instance or other group may still reference them. This cannot be undone.`,ids:_.ids,label:e=>{let t=T.get(e);return t?`${t} (${e})`:e},noun:[`security group`,`security groups`],onDeleted:_.drop})}),t[28]=C,t[29]=T,t[30]=_.clear,t[31]=_.drop,t[32]=_.ids,t[33]=j):j=t[33];let M=f?.GroupId,R;t[34]===i?R=t[35]:(R=e=>{i({...s,selected:e})},t[34]=i,t[35]=R);let z;t[36]!==n||t[37]!==_.onSelectedChange||t[38]!==_.selected||t[39]!==l||t[40]!==R||t[41]!==M?(z=(0,I.jsx)(o,{activeId:M,caption:`Security groups`,columns:B,empty:`No security groups yet.`,getRowId:H,onRowClick:R,onSelectedChange:_.onSelectedChange,onSortChange:u,rows:n,selected:_.selected,sort:l}),t[36]=n,t[37]=_.onSelectedChange,t[38]=_.selected,t[39]=l,t[40]=R,t[41]=M,t[42]=z):z=t[42];let V;return t[43]!==E||t[44]!==z||t[45]!==j?(V=(0,I.jsxs)(b,{panel:D,children:[A,j,z]}),t[43]=E,t[44]=z,t[45]=j,t[46]=V):V=t[46],V}function H(e){return e.GroupId??``}function U(e){return{...e,...s,create:!0}}function W(e){return[e.GroupId,e.GroupName]}function G(e){return e.GroupId??``}function K(){let e=(0,F.c)(8),{data:t}=i(h),n=A.useSearch(),r=A.useNavigate(),a;e[0]===r?a=e[1]:(a=function(e){r({search:t=>({...t,...e}),replace:!(`selected`in e||`create`in e)})},e[0]=r,e[1]=a);let o=a,s;e[2]===t.SecurityGroups?s=e[3]:(s=t.SecurityGroups??[],e[2]=t.SecurityGroups,e[3]=s);let c;return e[4]!==o||e[5]!==n||e[6]!==s?(c=(0,I.jsx)(V,{onSearchChange:o,search:n,securityGroups:s}),e[4]=o,e[5]=n,e[6]=s,e[7]=c):c=e[7],c}export{K as component};
@@ -1,2 +1,2 @@
const __vite__mapDeps=(i,m=__vite__mapDeps,d=(m.f||(m.f=["assets/describe-target-groups-CSHTMJFU.js","assets/rolldown-runtime-XKyaHtl9.js","assets/schemas-BGv4hnp9.js","assets/useQuery-CZvSQig-.js","assets/queryOptions-twzzLLsx.js","assets/QueryClientProvider-CTm_NoFP.js","assets/useSuspenseQuery-C45dM0fa.js","assets/link-D5tcioHY.js","assets/matchContext-DBnoSZp3.js","assets/data-table-CqrUk0mI.js","assets/useSelector-Cvbg8b7C.js","assets/visuallyHidden-D4_nmHIB.js","assets/check-B7aLYzUb.js","assets/useRegisterFieldControl-2x4pEgaC.js","assets/useAriaLabelledBy-BMp0PcVw.js","assets/useControlled-BP9HwLGp.js","assets/table-BEe-3So1.js","assets/detail-panel-BBYLO5I7.js","assets/x-8n_Gyi12.js","assets/error-banner-DYkSEx5d.js","assets/chevron-down-By1UhB9P.js","assets/copy-BpriwVcd.js","assets/loader-circle-CiggOyRj.js","assets/useScrollLock-FIlKWKju.js","assets/useValueAsRef-BoFfo9R-.js","assets/ToolbarRootContext-B6mBfOj0.js","assets/isElementDisabled-CuK_3Aso.js","assets/store-BTg3TzdU.js","assets/collapsible-C7X1O8X9.js","assets/tabs-DFnnXw6G.js","assets/use-copy-to-clipboard-CAJ8gQCx.js","assets/plus-U2P-lQY5.js","assets/cli-command-panel-DBkpfo1W.js","assets/ec2-CkEARsnn.js","assets/auth-DA1pq184.js","assets/awsClient-pD_9VmT7.js","assets/index.browser-BEm-79SP.js","assets/elbv2-h6KlGbUo.js","assets/tags-section-DcIDRNnq.js","assets/select-DY3fKHla.js","assets/clamp-D0kAXd-u.js","assets/useMutation-CutM3PJn.js","assets/field-B2ItYtQp.js","assets/tags-B-poldSB.js","assets/switch-CBUz4s9f.js","assets/tags-editor-Cpldzu_C.js","assets/index.esm-ClOaByEg.js","assets/zod-DmrlGH9Y.js","assets/tags-field-array-HYS96MMF.js","assets/trash-REoRRV_c.js","assets/create-panel-DMDXu4FP.js","assets/page-heading-75JYUGDy.js","assets/resource-view-BXETZc8v.js","assets/selection-bar-MuN8z7Qc.js","assets/use-row-selection-CZtmDppg.js","assets/form-lock-C6qPAte1.js","assets/bulk-delete-B7E-9nKZ.js","assets/circle-x-DHQkYhPI.js","assets/alert-dialog-DKUcMJKb.js","assets/DialogTitle-BC_80-Qv.js","assets/confirm-phrase-Bvuzco3W.js","assets/target-group-form-BJYeBOHj.js","assets/target-group-details-CQWQ-gcy.js","assets/sliders-horizontal-DZ-kpCNX.js","assets/maximize-2-bpbTyiq2.js","assets/delete-confirmation-dialog-DRnnnG6z.js","assets/state-badge-DuJRo9eW.js","assets/form-panel-rAQfOvhU.js","assets/switch-list-1VM_9QuR.js","assets/resource-delete-dialog-DgcEsN9Y.js"])))=>i.map(i=>d[i]);
import{s as e}from"./schemas-BGv4hnp9.js";import{n as t,t as n}from"./lazyRouteComponent-BnnVgQVQ.js";import{t as r}from"./searchMiddleware-VYy8PwPp.js";import{h as i,m as a}from"./detail-panel-BBYLO5I7.js";import{Mo as o}from"./awsClient-pD_9VmT7.js";import{u as s}from"./elbv2-h6KlGbUo.js";var c=()=>o(()=>import(`./describe-target-groups-CSHTMJFU.js`),__vite__mapDeps([0,1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64,65,66,67,68,69])),l=e(i),u=t(`/_auth/ec2/(target-groups)/describe-target-groups/`)({validateSearch:l,search:{middlewares:[r(a)]},loader:async({context:e})=>{await e.queryClient.query({...s,staleTime:`static`})},head:()=>({meta:[{title:`Target Groups | EC2 | Mulga`}]}),component:n(c,`component`)});export{u as t};
const __vite__mapDeps=(i,m=__vite__mapDeps,d=(m.f||(m.f=["assets/describe-target-groups-ByyacAKF.js","assets/rolldown-runtime-XKyaHtl9.js","assets/schemas-BGv4hnp9.js","assets/useQuery-CZvSQig-.js","assets/queryOptions-twzzLLsx.js","assets/QueryClientProvider-CTm_NoFP.js","assets/useSuspenseQuery-C45dM0fa.js","assets/link-D5tcioHY.js","assets/matchContext-DBnoSZp3.js","assets/data-table-CqrUk0mI.js","assets/useSelector-Cvbg8b7C.js","assets/visuallyHidden-D4_nmHIB.js","assets/check-B7aLYzUb.js","assets/useRegisterFieldControl-2x4pEgaC.js","assets/useAriaLabelledBy-BMp0PcVw.js","assets/useControlled-BP9HwLGp.js","assets/table-BEe-3So1.js","assets/detail-panel-BBYLO5I7.js","assets/x-8n_Gyi12.js","assets/error-banner-DYkSEx5d.js","assets/chevron-down-By1UhB9P.js","assets/copy-BpriwVcd.js","assets/loader-circle-CiggOyRj.js","assets/useScrollLock-FIlKWKju.js","assets/useValueAsRef-BoFfo9R-.js","assets/ToolbarRootContext-B6mBfOj0.js","assets/isElementDisabled-CuK_3Aso.js","assets/store-BTg3TzdU.js","assets/collapsible-C7X1O8X9.js","assets/tabs-DFnnXw6G.js","assets/use-copy-to-clipboard-CAJ8gQCx.js","assets/plus-U2P-lQY5.js","assets/cli-command-panel-DBkpfo1W.js","assets/ec2-CkEARsnn.js","assets/auth-DA1pq184.js","assets/awsClient-pD_9VmT7.js","assets/index.browser-BEm-79SP.js","assets/elbv2-h6KlGbUo.js","assets/tags-section-DcIDRNnq.js","assets/select-DY3fKHla.js","assets/clamp-D0kAXd-u.js","assets/useMutation-CutM3PJn.js","assets/field-B2ItYtQp.js","assets/tags-B-poldSB.js","assets/switch-CBUz4s9f.js","assets/tags-editor-Cpldzu_C.js","assets/index.esm-ClOaByEg.js","assets/zod-DmrlGH9Y.js","assets/tags-field-array-HYS96MMF.js","assets/trash-REoRRV_c.js","assets/create-panel-DMDXu4FP.js","assets/page-heading-75JYUGDy.js","assets/resource-view-BXETZc8v.js","assets/selection-bar-MuN8z7Qc.js","assets/use-row-selection-CZtmDppg.js","assets/form-lock-C6qPAte1.js","assets/bulk-delete-B7E-9nKZ.js","assets/circle-x-DHQkYhPI.js","assets/alert-dialog-DKUcMJKb.js","assets/DialogTitle-BC_80-Qv.js","assets/confirm-phrase-Bvuzco3W.js","assets/target-group-form-BJYeBOHj.js","assets/target-group-details-CQWQ-gcy.js","assets/sliders-horizontal-DZ-kpCNX.js","assets/maximize-2-bpbTyiq2.js","assets/delete-confirmation-dialog-DRnnnG6z.js","assets/state-badge-DuJRo9eW.js","assets/form-panel-rAQfOvhU.js","assets/switch-list-1VM_9QuR.js","assets/resource-delete-dialog-DgcEsN9Y.js"])))=>i.map(i=>d[i]);
import{s as e}from"./schemas-BGv4hnp9.js";import{n as t,t as n}from"./lazyRouteComponent-BnnVgQVQ.js";import{t as r}from"./searchMiddleware-VYy8PwPp.js";import{h as i,m as a}from"./detail-panel-BBYLO5I7.js";import{Mo as o}from"./awsClient-pD_9VmT7.js";import{u as s}from"./elbv2-h6KlGbUo.js";var c=()=>o(()=>import(`./describe-target-groups-ByyacAKF.js`),__vite__mapDeps([0,1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64,65,66,67,68,69])),l=e(i),u=t(`/_auth/ec2/(target-groups)/describe-target-groups/`)({validateSearch:l,search:{middlewares:[r(a)]},loader:async({context:e})=>{await e.queryClient.query({...s,staleTime:`static`})},head:()=>({meta:[{title:`Target Groups | EC2 | Mulga`}]}),component:n(c,`component`)});export{u as t};
@@ -1 +1 @@
import{r as e}from"./rolldown-runtime-XKyaHtl9.js";import{Ht as t,Rt as n,Ut as r,c as i,n as a,r as o,s,t as c}from"./schemas-BGv4hnp9.js";import"./select-DY3fKHla.js";import"./data-table-CqrUk0mI.js";import"./error-banner-DYkSEx5d.js";import"./detail-panel-BBYLO5I7.js";import"./cli-command-panel-DBkpfo1W.js";import"./useValueAsRef-BoFfo9R-.js";import"./radio-group-CusDfuD4.js";import"./field-B2ItYtQp.js";import"./dialog-CggbdV0m.js";import"./command-DP3btIki.js";import"./textarea-CUIsQWob.js";import"./tabs-DFnnXw6G.js";import"./create-panel-DMDXu4FP.js";import"./table-BEe-3So1.js";import"./confirm-phrase-Bvuzco3W.js";import"./delete-confirmation-dialog-DRnnnG6z.js";import"./page-heading-75JYUGDy.js";import"./selection-bar-MuN8z7Qc.js";import"./state-badge-DuJRo9eW.js";import"./console-output-O6RaIKNf.js";import"./dropdown-menu-C1DuyMfR.js";import"./form-lock-C6qPAte1.js";import"./form-panel-rAQfOvhU.js";import"./switch-CBUz4s9f.js";import"./switch-list-1VM_9QuR.js";import"./badge-BTkZUPtC.js";import"./back-link-CTxPMT04.js";import"./form-fields-C1aPNl6R.js";import"./tags-field-array-HYS96MMF.js";import"./card-D1V6WGKR.js";import"./wizard-failure-banner-DsBzs9M_.js";n(),t();var l=e(r(),1);typeof window<`u`?l.useLayoutEffect:l.useEffect,(0,l.createContext)(null);var u=class{#e={};addListener(e,t){let n=this.#e[e];return n===void 0?this.#e[e]=[t]:n.includes(t)||n.push(t),()=>{this.removeListener(e,t)}}emit(e,t){let n=this.#e[e];if(n!==void 0){if(n.length===1){n[0].call(null,t);return}let e,r=Array.from(n);for(let n=0;n<r.length;n++){let i=r[n];try{i.call(null,t)}catch(t){e??={error:t}}}if(e)throw e.error}}removeAllListeners(){this.#e={}}removeListener(e,t){let n=this.#e[e];if(n!==void 0){let e=n.indexOf(t);e>=0&&n.splice(e,1)}}};new u,new u,(0,l.createContext)(null),(0,l.createContext)(null),(0,l.createContext)(null),(0,l.createContext)(null);var d=[`running`,`pending`,`stopped`,`running`,`terminated`];function f(e){return Array.from({length:e},(e,t)=>({id:`i-0${(2712847316+t).toString(16)}e5f6789`,name:`web-${String(t+1).padStart(2,`0`)}`,state:d[t%d.length]??`running`,type:t%3==0?`t3.xlarge`:`t3.micro`,address:`10.200.${Math.floor(t/250)}.${t%250+4}`}))}f(5),f(60),s({name:i().min(1,`Enter a name`),scheme:c([`internet-facing`,`internal`]),deletionProtection:o(),subnetIds:a(i()).min(1,`Pick at least one subnet`),tags:a(s({Key:i(),Value:i()}))}),Object.assign(Error(`The subnet CIDR overlaps 10.0.1.0/24.`),{name:`InvalidSubnet.Conflict`});var p=()=>null;export{p as component};
import{r as e}from"./rolldown-runtime-XKyaHtl9.js";import{Ht as t,Rt as n,Ut as r,c as i,n as a,r as o,s,t as c}from"./schemas-BGv4hnp9.js";import"./select-DY3fKHla.js";import"./data-table-CqrUk0mI.js";import"./error-banner-DYkSEx5d.js";import"./detail-panel-BBYLO5I7.js";import"./cli-command-panel-DBkpfo1W.js";import"./useValueAsRef-BoFfo9R-.js";import"./radio-group-CusDfuD4.js";import"./field-B2ItYtQp.js";import"./dialog-CggbdV0m.js";import"./command-nxbhA2b-.js";import"./textarea-CUIsQWob.js";import"./tabs-DFnnXw6G.js";import"./create-panel-DMDXu4FP.js";import"./table-BEe-3So1.js";import"./confirm-phrase-Bvuzco3W.js";import"./delete-confirmation-dialog-DRnnnG6z.js";import"./page-heading-75JYUGDy.js";import"./selection-bar-MuN8z7Qc.js";import"./state-badge-DuJRo9eW.js";import"./console-output-O6RaIKNf.js";import"./dropdown-menu-C1DuyMfR.js";import"./form-lock-C6qPAte1.js";import"./form-panel-rAQfOvhU.js";import"./switch-CBUz4s9f.js";import"./switch-list-1VM_9QuR.js";import"./badge-BTkZUPtC.js";import"./back-link-CTxPMT04.js";import"./form-fields-C1aPNl6R.js";import"./tags-field-array-HYS96MMF.js";import"./card-D1V6WGKR.js";import"./wizard-failure-banner-DsBzs9M_.js";n(),t();var l=e(r(),1);typeof window<`u`?l.useLayoutEffect:l.useEffect,(0,l.createContext)(null);var u=class{#e={};addListener(e,t){let n=this.#e[e];return n===void 0?this.#e[e]=[t]:n.includes(t)||n.push(t),()=>{this.removeListener(e,t)}}emit(e,t){let n=this.#e[e];if(n!==void 0){if(n.length===1){n[0].call(null,t);return}let e,r=Array.from(n);for(let n=0;n<r.length;n++){let i=r[n];try{i.call(null,t)}catch(t){e??={error:t}}}if(e)throw e.error}}removeAllListeners(){this.#e={}}removeListener(e,t){let n=this.#e[e];if(n!==void 0){let e=n.indexOf(t);e>=0&&n.splice(e,1)}}};new u,new u,(0,l.createContext)(null),(0,l.createContext)(null),(0,l.createContext)(null),(0,l.createContext)(null);var d=[`running`,`pending`,`stopped`,`running`,`terminated`];function f(e){return Array.from({length:e},(e,t)=>({id:`i-0${(2712847316+t).toString(16)}e5f6789`,name:`web-${String(t+1).padStart(2,`0`)}`,state:d[t%d.length]??`running`,type:t%3==0?`t3.xlarge`:`t3.micro`,address:`10.200.${Math.floor(t/250)}.${t%250+4}`}))}f(5),f(60),s({name:i().min(1,`Enter a name`),scheme:c([`internet-facing`,`internal`]),deletionProtection:o(),subnetIds:a(i()).min(1,`Pick at least one subnet`),tags:a(s({Key:i(),Value:i()}))}),Object.assign(Error(`The subnet CIDR overlaps 10.0.1.0/24.`),{name:`InvalidSubnet.Conflict`});var p=()=>null;export{p as component};
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1,2 +1,2 @@
const __vite__mapDeps=(i,m=__vite__mapDeps,d=(m.f||(m.f=["assets/list-hosted-zones-Da2dGlA2.js","assets/rolldown-runtime-XKyaHtl9.js","assets/schemas-BGv4hnp9.js","assets/QueryClientProvider-CTm_NoFP.js","assets/useQuery-CZvSQig-.js","assets/queryOptions-twzzLLsx.js","assets/useSuspenseQuery-C45dM0fa.js","assets/link-D5tcioHY.js","assets/matchContext-DBnoSZp3.js","assets/data-table-CqrUk0mI.js","assets/useSelector-Cvbg8b7C.js","assets/visuallyHidden-D4_nmHIB.js","assets/check-B7aLYzUb.js","assets/useRegisterFieldControl-2x4pEgaC.js","assets/useAriaLabelledBy-BMp0PcVw.js","assets/useControlled-BP9HwLGp.js","assets/table-BEe-3So1.js","assets/circle-x-DHQkYhPI.js","assets/detail-panel-BBYLO5I7.js","assets/x-8n_Gyi12.js","assets/error-banner-DYkSEx5d.js","assets/chevron-down-By1UhB9P.js","assets/copy-BpriwVcd.js","assets/loader-circle-CiggOyRj.js","assets/useScrollLock-FIlKWKju.js","assets/useValueAsRef-BoFfo9R-.js","assets/ToolbarRootContext-B6mBfOj0.js","assets/isElementDisabled-CuK_3Aso.js","assets/store-BTg3TzdU.js","assets/collapsible-C7X1O8X9.js","assets/tabs-DFnnXw6G.js","assets/use-copy-to-clipboard-CAJ8gQCx.js","assets/plus-U2P-lQY5.js","assets/cli-command-panel-DBkpfo1W.js","assets/route53-65U4kfxE.js","assets/awsClient-pD_9VmT7.js","assets/auth-DA1pq184.js","assets/index.browser-BEm-79SP.js","assets/signed-fetch-C7xQR7ap.js","assets/tslib.es6-CT3DuY1l.js","assets/hosted-zone-details-CvkpBeh7.js","assets/useMutation-CutM3PJn.js","assets/sliders-horizontal-DZ-kpCNX.js","assets/maximize-2-bpbTyiq2.js","assets/pencil-CObOlpZY.js","assets/refresh-cw-Da2brmKx.js","assets/trash-REoRRV_c.js","assets/field-B2ItYtQp.js","assets/textarea-CUIsQWob.js","assets/delete-confirmation-dialog-DRnnnG6z.js","assets/alert-dialog-DKUcMJKb.js","assets/DialogTitle-BC_80-Qv.js","assets/confirm-phrase-Bvuzco3W.js","assets/form-lock-C6qPAte1.js","assets/form-panel-rAQfOvhU.js","assets/index.esm-ClOaByEg.js","assets/zod-DmrlGH9Y.js","assets/form-fields-C1aPNl6R.js","assets/select-DY3fKHla.js","assets/clamp-D0kAXd-u.js","assets/create-panel-DMDXu4FP.js","assets/page-heading-75JYUGDy.js","assets/resource-view-BXETZc8v.js","assets/selection-bar-MuN8z7Qc.js","assets/use-row-selection-CZtmDppg.js","assets/bulk-delete-B7E-9nKZ.js"])))=>i.map(i=>d[i]);
import{s as e}from"./schemas-BGv4hnp9.js";import{n as t,t as n}from"./lazyRouteComponent-BnnVgQVQ.js";import{t as r}from"./searchMiddleware-VYy8PwPp.js";import{h as i,m as a}from"./detail-panel-BBYLO5I7.js";import{Mo as o}from"./awsClient-pD_9VmT7.js";import{i as s}from"./route53-65U4kfxE.js";var c=t(`/_auth/route53/(hosted-zones)/list-hosted-zones/`)({validateSearch:e(i),search:{middlewares:[r(a)]},loader:async({context:e})=>{await e.queryClient.query({...s,staleTime:`static`})},head:()=>({meta:[{title:`Hosted zones | DNS | Mulga`}]}),component:n(()=>o(()=>import(`./list-hosted-zones-Da2dGlA2.js`),__vite__mapDeps([0,1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64,65])),`component`)});export{c as t};
const __vite__mapDeps=(i,m=__vite__mapDeps,d=(m.f||(m.f=["assets/list-hosted-zones-KIP-I96G.js","assets/rolldown-runtime-XKyaHtl9.js","assets/schemas-BGv4hnp9.js","assets/QueryClientProvider-CTm_NoFP.js","assets/useQuery-CZvSQig-.js","assets/queryOptions-twzzLLsx.js","assets/useSuspenseQuery-C45dM0fa.js","assets/link-D5tcioHY.js","assets/matchContext-DBnoSZp3.js","assets/data-table-CqrUk0mI.js","assets/useSelector-Cvbg8b7C.js","assets/visuallyHidden-D4_nmHIB.js","assets/check-B7aLYzUb.js","assets/useRegisterFieldControl-2x4pEgaC.js","assets/useAriaLabelledBy-BMp0PcVw.js","assets/useControlled-BP9HwLGp.js","assets/table-BEe-3So1.js","assets/circle-x-DHQkYhPI.js","assets/detail-panel-BBYLO5I7.js","assets/x-8n_Gyi12.js","assets/error-banner-DYkSEx5d.js","assets/chevron-down-By1UhB9P.js","assets/copy-BpriwVcd.js","assets/loader-circle-CiggOyRj.js","assets/useScrollLock-FIlKWKju.js","assets/useValueAsRef-BoFfo9R-.js","assets/ToolbarRootContext-B6mBfOj0.js","assets/isElementDisabled-CuK_3Aso.js","assets/store-BTg3TzdU.js","assets/collapsible-C7X1O8X9.js","assets/tabs-DFnnXw6G.js","assets/use-copy-to-clipboard-CAJ8gQCx.js","assets/plus-U2P-lQY5.js","assets/cli-command-panel-DBkpfo1W.js","assets/route53-65U4kfxE.js","assets/awsClient-pD_9VmT7.js","assets/auth-DA1pq184.js","assets/index.browser-BEm-79SP.js","assets/signed-fetch-C7xQR7ap.js","assets/tslib.es6-CT3DuY1l.js","assets/hosted-zone-details-CvkpBeh7.js","assets/useMutation-CutM3PJn.js","assets/sliders-horizontal-DZ-kpCNX.js","assets/maximize-2-bpbTyiq2.js","assets/pencil-CObOlpZY.js","assets/refresh-cw-Da2brmKx.js","assets/trash-REoRRV_c.js","assets/field-B2ItYtQp.js","assets/textarea-CUIsQWob.js","assets/delete-confirmation-dialog-DRnnnG6z.js","assets/alert-dialog-DKUcMJKb.js","assets/DialogTitle-BC_80-Qv.js","assets/confirm-phrase-Bvuzco3W.js","assets/form-lock-C6qPAte1.js","assets/form-panel-rAQfOvhU.js","assets/index.esm-ClOaByEg.js","assets/zod-DmrlGH9Y.js","assets/form-fields-C1aPNl6R.js","assets/select-DY3fKHla.js","assets/clamp-D0kAXd-u.js","assets/create-panel-DMDXu4FP.js","assets/page-heading-75JYUGDy.js","assets/resource-view-BXETZc8v.js","assets/selection-bar-MuN8z7Qc.js","assets/use-row-selection-CZtmDppg.js","assets/bulk-delete-B7E-9nKZ.js"])))=>i.map(i=>d[i]);
import{s as e}from"./schemas-BGv4hnp9.js";import{n as t,t as n}from"./lazyRouteComponent-BnnVgQVQ.js";import{t as r}from"./searchMiddleware-VYy8PwPp.js";import{h as i,m as a}from"./detail-panel-BBYLO5I7.js";import{Mo as o}from"./awsClient-pD_9VmT7.js";import{i as s}from"./route53-65U4kfxE.js";var c=t(`/_auth/route53/(hosted-zones)/list-hosted-zones/`)({validateSearch:e(i),search:{middlewares:[r(a)]},loader:async({context:e})=>{await e.queryClient.query({...s,staleTime:`static`})},head:()=>({meta:[{title:`Hosted zones | DNS | Mulga`}]}),component:n(()=>o(()=>import(`./list-hosted-zones-KIP-I96G.js`),__vite__mapDeps([0,1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64,65])),`component`)});export{c as t};
@@ -1,2 +1,2 @@
const __vite__mapDeps=(i,m=__vite__mapDeps,d=(m.f||(m.f=["assets/list-instance-profiles-Fx4biqoX.js","assets/rolldown-runtime-XKyaHtl9.js","assets/schemas-BGv4hnp9.js","assets/useSuspenseQuery-C45dM0fa.js","assets/queryOptions-twzzLLsx.js","assets/QueryClientProvider-CTm_NoFP.js","assets/link-D5tcioHY.js","assets/matchContext-DBnoSZp3.js","assets/data-table-CqrUk0mI.js","assets/useSelector-Cvbg8b7C.js","assets/visuallyHidden-D4_nmHIB.js","assets/check-B7aLYzUb.js","assets/useRegisterFieldControl-2x4pEgaC.js","assets/useAriaLabelledBy-BMp0PcVw.js","assets/useControlled-BP9HwLGp.js","assets/table-BEe-3So1.js","assets/detail-panel-BBYLO5I7.js","assets/x-8n_Gyi12.js","assets/error-banner-DYkSEx5d.js","assets/chevron-down-By1UhB9P.js","assets/copy-BpriwVcd.js","assets/loader-circle-CiggOyRj.js","assets/useScrollLock-FIlKWKju.js","assets/useValueAsRef-BoFfo9R-.js","assets/ToolbarRootContext-B6mBfOj0.js","assets/isElementDisabled-CuK_3Aso.js","assets/store-BTg3TzdU.js","assets/collapsible-C7X1O8X9.js","assets/tabs-DFnnXw6G.js","assets/use-copy-to-clipboard-CAJ8gQCx.js","assets/plus-U2P-lQY5.js","assets/cli-command-panel-DBkpfo1W.js","assets/field-B2ItYtQp.js","assets/iam-Uvcozs2Q.js","assets/awsClient-pD_9VmT7.js","assets/auth-DA1pq184.js","assets/index.browser-BEm-79SP.js","assets/json-CS_V3sox.js","assets/iam-Zz-m4LmC.js","assets/useMutation-CutM3PJn.js","assets/create-panel-DMDXu4FP.js","assets/page-heading-75JYUGDy.js","assets/resource-view-BXETZc8v.js","assets/selection-bar-MuN8z7Qc.js","assets/use-row-selection-CZtmDppg.js","assets/form-lock-C6qPAte1.js","assets/index.esm-ClOaByEg.js","assets/zod-DmrlGH9Y.js","assets/bulk-delete-B7E-9nKZ.js","assets/circle-x-DHQkYhPI.js","assets/trash-REoRRV_c.js","assets/alert-dialog-DKUcMJKb.js","assets/DialogTitle-BC_80-Qv.js","assets/confirm-phrase-Bvuzco3W.js","assets/delete-prerequisites-BjEJirR1.js","assets/iam-DiBEUW10.js","assets/instance-profile-details-D8nTpk6E.js","assets/sliders-horizontal-DZ-kpCNX.js","assets/maximize-2-bpbTyiq2.js","assets/delete-confirmation-dialog-DRnnnG6z.js","assets/resource-delete-dialog-DgcEsN9Y.js","assets/iam-items-Bk74IWlO.js"])))=>i.map(i=>d[i]);
import{s as e}from"./schemas-BGv4hnp9.js";import{n as t,t as n}from"./lazyRouteComponent-BnnVgQVQ.js";import{t as r}from"./searchMiddleware-VYy8PwPp.js";import{h as i,m as a}from"./detail-panel-BBYLO5I7.js";import{Mo as o}from"./awsClient-pD_9VmT7.js";import{f as s}from"./iam-Uvcozs2Q.js";var c=t(`/_auth/iam/(instance-profiles)/list-instance-profiles/`)({validateSearch:e(i),search:{middlewares:[r(a)]},loader:async({context:e})=>{await e.queryClient.query({...s,staleTime:`static`})},head:()=>({meta:[{title:`Instance profiles | IAM | Mulga`}]}),component:n(()=>o(()=>import(`./list-instance-profiles-Fx4biqoX.js`),__vite__mapDeps([0,1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61])),`component`)});export{c as t};
const __vite__mapDeps=(i,m=__vite__mapDeps,d=(m.f||(m.f=["assets/list-instance-profiles-C3d71i1j.js","assets/rolldown-runtime-XKyaHtl9.js","assets/schemas-BGv4hnp9.js","assets/useSuspenseQuery-C45dM0fa.js","assets/queryOptions-twzzLLsx.js","assets/QueryClientProvider-CTm_NoFP.js","assets/link-D5tcioHY.js","assets/matchContext-DBnoSZp3.js","assets/data-table-CqrUk0mI.js","assets/useSelector-Cvbg8b7C.js","assets/visuallyHidden-D4_nmHIB.js","assets/check-B7aLYzUb.js","assets/useRegisterFieldControl-2x4pEgaC.js","assets/useAriaLabelledBy-BMp0PcVw.js","assets/useControlled-BP9HwLGp.js","assets/table-BEe-3So1.js","assets/detail-panel-BBYLO5I7.js","assets/x-8n_Gyi12.js","assets/error-banner-DYkSEx5d.js","assets/chevron-down-By1UhB9P.js","assets/copy-BpriwVcd.js","assets/loader-circle-CiggOyRj.js","assets/useScrollLock-FIlKWKju.js","assets/useValueAsRef-BoFfo9R-.js","assets/ToolbarRootContext-B6mBfOj0.js","assets/isElementDisabled-CuK_3Aso.js","assets/store-BTg3TzdU.js","assets/collapsible-C7X1O8X9.js","assets/tabs-DFnnXw6G.js","assets/use-copy-to-clipboard-CAJ8gQCx.js","assets/plus-U2P-lQY5.js","assets/cli-command-panel-DBkpfo1W.js","assets/field-B2ItYtQp.js","assets/iam-Uvcozs2Q.js","assets/awsClient-pD_9VmT7.js","assets/auth-DA1pq184.js","assets/index.browser-BEm-79SP.js","assets/json-CS_V3sox.js","assets/iam-Zz-m4LmC.js","assets/useMutation-CutM3PJn.js","assets/create-panel-DMDXu4FP.js","assets/page-heading-75JYUGDy.js","assets/resource-view-BXETZc8v.js","assets/selection-bar-MuN8z7Qc.js","assets/use-row-selection-CZtmDppg.js","assets/form-lock-C6qPAte1.js","assets/index.esm-ClOaByEg.js","assets/zod-DmrlGH9Y.js","assets/bulk-delete-B7E-9nKZ.js","assets/circle-x-DHQkYhPI.js","assets/trash-REoRRV_c.js","assets/alert-dialog-DKUcMJKb.js","assets/DialogTitle-BC_80-Qv.js","assets/confirm-phrase-Bvuzco3W.js","assets/delete-prerequisites-BjEJirR1.js","assets/iam-DiBEUW10.js","assets/instance-profile-details-D8nTpk6E.js","assets/sliders-horizontal-DZ-kpCNX.js","assets/maximize-2-bpbTyiq2.js","assets/delete-confirmation-dialog-DRnnnG6z.js","assets/resource-delete-dialog-DgcEsN9Y.js","assets/iam-items-Bk74IWlO.js"])))=>i.map(i=>d[i]);
import{s as e}from"./schemas-BGv4hnp9.js";import{n as t,t as n}from"./lazyRouteComponent-BnnVgQVQ.js";import{t as r}from"./searchMiddleware-VYy8PwPp.js";import{h as i,m as a}from"./detail-panel-BBYLO5I7.js";import{Mo as o}from"./awsClient-pD_9VmT7.js";import{f as s}from"./iam-Uvcozs2Q.js";var c=t(`/_auth/iam/(instance-profiles)/list-instance-profiles/`)({validateSearch:e(i),search:{middlewares:[r(a)]},loader:async({context:e})=>{await e.queryClient.query({...s,staleTime:`static`})},head:()=>({meta:[{title:`Instance profiles | IAM | Mulga`}]}),component:n(()=>o(()=>import(`./list-instance-profiles-C3d71i1j.js`),__vite__mapDeps([0,1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61])),`component`)});export{c as t};
@@ -1,2 +1,2 @@
const __vite__mapDeps=(i,m=__vite__mapDeps,d=(m.f||(m.f=["assets/list-knowledge-bases-EA3QM0o8.js","assets/rolldown-runtime-XKyaHtl9.js","assets/schemas-BGv4hnp9.js","assets/select-DY3fKHla.js","assets/visuallyHidden-D4_nmHIB.js","assets/check-B7aLYzUb.js","assets/chevron-down-By1UhB9P.js","assets/useScrollLock-FIlKWKju.js","assets/useValueAsRef-BoFfo9R-.js","assets/useRegisterFieldControl-2x4pEgaC.js","assets/ToolbarRootContext-B6mBfOj0.js","assets/useControlled-BP9HwLGp.js","assets/isElementDisabled-CuK_3Aso.js","assets/clamp-D0kAXd-u.js","assets/useSuspenseQuery-C45dM0fa.js","assets/queryOptions-twzzLLsx.js","assets/QueryClientProvider-CTm_NoFP.js","assets/link-D5tcioHY.js","assets/matchContext-DBnoSZp3.js","assets/data-table-CqrUk0mI.js","assets/useSelector-Cvbg8b7C.js","assets/useAriaLabelledBy-BMp0PcVw.js","assets/table-BEe-3So1.js","assets/detail-panel-BBYLO5I7.js","assets/x-8n_Gyi12.js","assets/error-banner-DYkSEx5d.js","assets/copy-BpriwVcd.js","assets/loader-circle-CiggOyRj.js","assets/store-BTg3TzdU.js","assets/collapsible-C7X1O8X9.js","assets/tabs-DFnnXw6G.js","assets/use-copy-to-clipboard-CAJ8gQCx.js","assets/plus-U2P-lQY5.js","assets/cli-command-panel-DBkpfo1W.js","assets/field-B2ItYtQp.js","assets/bedrock-DN0hxPDJ.js","assets/awsClient-pD_9VmT7.js","assets/auth-DA1pq184.js","assets/index.browser-BEm-79SP.js","assets/bedrockAgent-BVuyuFg4.js","assets/knowledge-base-details-Dd6wW1Xv.js","assets/useMutation-CutM3PJn.js","assets/useQuery-CZvSQig-.js","assets/sliders-horizontal-DZ-kpCNX.js","assets/maximize-2-bpbTyiq2.js","assets/trash-REoRRV_c.js","assets/textarea-CUIsQWob.js","assets/delete-confirmation-dialog-DRnnnG6z.js","assets/alert-dialog-DKUcMJKb.js","assets/DialogTitle-BC_80-Qv.js","assets/confirm-phrase-Bvuzco3W.js","assets/state-badge-DuJRo9eW.js","assets/index.esm-ClOaByEg.js","assets/zod-DmrlGH9Y.js","assets/switch-CBUz4s9f.js","assets/resource-delete-dialog-DgcEsN9Y.js","assets/create-panel-DMDXu4FP.js","assets/page-heading-75JYUGDy.js","assets/resource-view-BXETZc8v.js","assets/selection-bar-MuN8z7Qc.js","assets/use-row-selection-CZtmDppg.js","assets/form-lock-C6qPAte1.js","assets/bulk-delete-B7E-9nKZ.js","assets/circle-x-DHQkYhPI.js"])))=>i.map(i=>d[i]);
import{s as e}from"./schemas-BGv4hnp9.js";import{n as t,t as n}from"./lazyRouteComponent-BnnVgQVQ.js";import{t as r}from"./searchMiddleware-VYy8PwPp.js";import{h as i,m as a}from"./detail-panel-BBYLO5I7.js";import{Mo as o}from"./awsClient-pD_9VmT7.js";import{i as s}from"./bedrockAgent-BVuyuFg4.js";var c=t(`/_auth/bedrock/(knowledge-bases)/list-knowledge-bases/`)({validateSearch:e(i),search:{middlewares:[r(a)]},loader:async({context:e})=>{await e.queryClient.query({...s,staleTime:`static`})},head:()=>({meta:[{title:`Knowledge Bases | Ochre | Mulga`}]}),component:n(()=>o(()=>import(`./list-knowledge-bases-EA3QM0o8.js`),__vite__mapDeps([0,1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63])),`component`)});export{c as t};
const __vite__mapDeps=(i,m=__vite__mapDeps,d=(m.f||(m.f=["assets/list-knowledge-bases-D1cidt9y.js","assets/rolldown-runtime-XKyaHtl9.js","assets/schemas-BGv4hnp9.js","assets/select-DY3fKHla.js","assets/visuallyHidden-D4_nmHIB.js","assets/check-B7aLYzUb.js","assets/chevron-down-By1UhB9P.js","assets/useScrollLock-FIlKWKju.js","assets/useValueAsRef-BoFfo9R-.js","assets/useRegisterFieldControl-2x4pEgaC.js","assets/ToolbarRootContext-B6mBfOj0.js","assets/useControlled-BP9HwLGp.js","assets/isElementDisabled-CuK_3Aso.js","assets/clamp-D0kAXd-u.js","assets/useSuspenseQuery-C45dM0fa.js","assets/queryOptions-twzzLLsx.js","assets/QueryClientProvider-CTm_NoFP.js","assets/link-D5tcioHY.js","assets/matchContext-DBnoSZp3.js","assets/data-table-CqrUk0mI.js","assets/useSelector-Cvbg8b7C.js","assets/useAriaLabelledBy-BMp0PcVw.js","assets/table-BEe-3So1.js","assets/detail-panel-BBYLO5I7.js","assets/x-8n_Gyi12.js","assets/error-banner-DYkSEx5d.js","assets/copy-BpriwVcd.js","assets/loader-circle-CiggOyRj.js","assets/store-BTg3TzdU.js","assets/collapsible-C7X1O8X9.js","assets/tabs-DFnnXw6G.js","assets/use-copy-to-clipboard-CAJ8gQCx.js","assets/plus-U2P-lQY5.js","assets/cli-command-panel-DBkpfo1W.js","assets/field-B2ItYtQp.js","assets/bedrock-DN0hxPDJ.js","assets/awsClient-pD_9VmT7.js","assets/auth-DA1pq184.js","assets/index.browser-BEm-79SP.js","assets/bedrockAgent-BVuyuFg4.js","assets/knowledge-base-details-Dd6wW1Xv.js","assets/useMutation-CutM3PJn.js","assets/useQuery-CZvSQig-.js","assets/sliders-horizontal-DZ-kpCNX.js","assets/maximize-2-bpbTyiq2.js","assets/trash-REoRRV_c.js","assets/textarea-CUIsQWob.js","assets/delete-confirmation-dialog-DRnnnG6z.js","assets/alert-dialog-DKUcMJKb.js","assets/DialogTitle-BC_80-Qv.js","assets/confirm-phrase-Bvuzco3W.js","assets/state-badge-DuJRo9eW.js","assets/index.esm-ClOaByEg.js","assets/zod-DmrlGH9Y.js","assets/switch-CBUz4s9f.js","assets/resource-delete-dialog-DgcEsN9Y.js","assets/create-panel-DMDXu4FP.js","assets/page-heading-75JYUGDy.js","assets/resource-view-BXETZc8v.js","assets/selection-bar-MuN8z7Qc.js","assets/use-row-selection-CZtmDppg.js","assets/form-lock-C6qPAte1.js","assets/bulk-delete-B7E-9nKZ.js","assets/circle-x-DHQkYhPI.js"])))=>i.map(i=>d[i]);
import{s as e}from"./schemas-BGv4hnp9.js";import{n as t,t as n}from"./lazyRouteComponent-BnnVgQVQ.js";import{t as r}from"./searchMiddleware-VYy8PwPp.js";import{h as i,m as a}from"./detail-panel-BBYLO5I7.js";import{Mo as o}from"./awsClient-pD_9VmT7.js";import{i as s}from"./bedrockAgent-BVuyuFg4.js";var c=t(`/_auth/bedrock/(knowledge-bases)/list-knowledge-bases/`)({validateSearch:e(i),search:{middlewares:[r(a)]},loader:async({context:e})=>{await e.queryClient.query({...s,staleTime:`static`})},head:()=>({meta:[{title:`Knowledge Bases | Ochre | Mulga`}]}),component:n(()=>o(()=>import(`./list-knowledge-bases-D1cidt9y.js`),__vite__mapDeps([0,1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63])),`component`)});export{c as t};
@@ -1,2 +1,2 @@
const __vite__mapDeps=(i,m=__vite__mapDeps,d=(m.f||(m.f=["assets/task-definitions-BC0I7SdS.js","assets/rolldown-runtime-XKyaHtl9.js","assets/schemas-BGv4hnp9.js","assets/select-DY3fKHla.js","assets/visuallyHidden-D4_nmHIB.js","assets/check-B7aLYzUb.js","assets/chevron-down-By1UhB9P.js","assets/useScrollLock-FIlKWKju.js","assets/useValueAsRef-BoFfo9R-.js","assets/useRegisterFieldControl-2x4pEgaC.js","assets/ToolbarRootContext-B6mBfOj0.js","assets/useControlled-BP9HwLGp.js","assets/isElementDisabled-CuK_3Aso.js","assets/clamp-D0kAXd-u.js","assets/useSuspenseQuery-C45dM0fa.js","assets/queryOptions-twzzLLsx.js","assets/QueryClientProvider-CTm_NoFP.js","assets/link-D5tcioHY.js","assets/matchContext-DBnoSZp3.js","assets/data-table-CqrUk0mI.js","assets/useSelector-Cvbg8b7C.js","assets/useAriaLabelledBy-BMp0PcVw.js","assets/table-BEe-3So1.js","assets/error-banner-DYkSEx5d.js","assets/detail-panel-BBYLO5I7.js","assets/x-8n_Gyi12.js","assets/copy-BpriwVcd.js","assets/loader-circle-CiggOyRj.js","assets/store-BTg3TzdU.js","assets/collapsible-C7X1O8X9.js","assets/tabs-DFnnXw6G.js","assets/use-copy-to-clipboard-CAJ8gQCx.js","assets/plus-U2P-lQY5.js","assets/cli-command-panel-DBkpfo1W.js","assets/trash-REoRRV_c.js","assets/field-B2ItYtQp.js","assets/ecs-cu78172G.js","assets/awsClient-pD_9VmT7.js","assets/auth-DA1pq184.js","assets/index.browser-BEm-79SP.js","assets/ecs-BqWnAtNj.js","assets/useMutation-CutM3PJn.js","assets/signed-fetch-C7xQR7ap.js","assets/tslib.es6-CT3DuY1l.js","assets/create-panel-DMDXu4FP.js","assets/delete-confirmation-dialog-DRnnnG6z.js","assets/alert-dialog-DKUcMJKb.js","assets/DialogTitle-BC_80-Qv.js","assets/confirm-phrase-Bvuzco3W.js","assets/page-heading-75JYUGDy.js","assets/resource-view-BXETZc8v.js","assets/form-lock-C6qPAte1.js","assets/index.esm-ClOaByEg.js","assets/zod-DmrlGH9Y.js","assets/switch-CBUz4s9f.js","assets/ecs-section-nav-gnxELOaC.js"])))=>i.map(i=>d[i]);
import{s as e}from"./schemas-BGv4hnp9.js";import{n as t,t as n}from"./lazyRouteComponent-BnnVgQVQ.js";import{t as r}from"./searchMiddleware-VYy8PwPp.js";import{h as i,m as a}from"./detail-panel-BBYLO5I7.js";import{Mo as o}from"./awsClient-pD_9VmT7.js";import{o as s}from"./ecs-cu78172G.js";var c=t(`/_auth/ecs/(clusters)/task-definitions`)({validateSearch:e(i),search:{middlewares:[r(a)]},loader:async({context:e})=>{await e.queryClient.query({...s,staleTime:`static`})},head:()=>({meta:[{title:`Task Definitions | ECS | Mulga`}]}),component:n(()=>o(()=>import(`./task-definitions-BC0I7SdS.js`),__vite__mapDeps([0,1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55])),`component`)});export{c as t};
const __vite__mapDeps=(i,m=__vite__mapDeps,d=(m.f||(m.f=["assets/task-definitions-Dap0K3f2.js","assets/rolldown-runtime-XKyaHtl9.js","assets/schemas-BGv4hnp9.js","assets/select-DY3fKHla.js","assets/visuallyHidden-D4_nmHIB.js","assets/check-B7aLYzUb.js","assets/chevron-down-By1UhB9P.js","assets/useScrollLock-FIlKWKju.js","assets/useValueAsRef-BoFfo9R-.js","assets/useRegisterFieldControl-2x4pEgaC.js","assets/ToolbarRootContext-B6mBfOj0.js","assets/useControlled-BP9HwLGp.js","assets/isElementDisabled-CuK_3Aso.js","assets/clamp-D0kAXd-u.js","assets/useSuspenseQuery-C45dM0fa.js","assets/queryOptions-twzzLLsx.js","assets/QueryClientProvider-CTm_NoFP.js","assets/link-D5tcioHY.js","assets/matchContext-DBnoSZp3.js","assets/data-table-CqrUk0mI.js","assets/useSelector-Cvbg8b7C.js","assets/useAriaLabelledBy-BMp0PcVw.js","assets/table-BEe-3So1.js","assets/error-banner-DYkSEx5d.js","assets/detail-panel-BBYLO5I7.js","assets/x-8n_Gyi12.js","assets/copy-BpriwVcd.js","assets/loader-circle-CiggOyRj.js","assets/store-BTg3TzdU.js","assets/collapsible-C7X1O8X9.js","assets/tabs-DFnnXw6G.js","assets/use-copy-to-clipboard-CAJ8gQCx.js","assets/plus-U2P-lQY5.js","assets/cli-command-panel-DBkpfo1W.js","assets/trash-REoRRV_c.js","assets/field-B2ItYtQp.js","assets/ecs-cu78172G.js","assets/awsClient-pD_9VmT7.js","assets/auth-DA1pq184.js","assets/index.browser-BEm-79SP.js","assets/ecs-BqWnAtNj.js","assets/useMutation-CutM3PJn.js","assets/signed-fetch-C7xQR7ap.js","assets/tslib.es6-CT3DuY1l.js","assets/create-panel-DMDXu4FP.js","assets/delete-confirmation-dialog-DRnnnG6z.js","assets/alert-dialog-DKUcMJKb.js","assets/DialogTitle-BC_80-Qv.js","assets/confirm-phrase-Bvuzco3W.js","assets/page-heading-75JYUGDy.js","assets/resource-view-BXETZc8v.js","assets/form-lock-C6qPAte1.js","assets/index.esm-ClOaByEg.js","assets/zod-DmrlGH9Y.js","assets/switch-CBUz4s9f.js","assets/ecs-section-nav-gnxELOaC.js"])))=>i.map(i=>d[i]);
import{s as e}from"./schemas-BGv4hnp9.js";import{n as t,t as n}from"./lazyRouteComponent-BnnVgQVQ.js";import{t as r}from"./searchMiddleware-VYy8PwPp.js";import{h as i,m as a}from"./detail-panel-BBYLO5I7.js";import{Mo as o}from"./awsClient-pD_9VmT7.js";import{o as s}from"./ecs-cu78172G.js";var c=t(`/_auth/ecs/(clusters)/task-definitions`)({validateSearch:e(i),search:{middlewares:[r(a)]},loader:async({context:e})=>{await e.queryClient.query({...s,staleTime:`static`})},head:()=>({meta:[{title:`Task Definitions | ECS | Mulga`}]}),component:n(()=>o(()=>import(`./task-definitions-Dap0K3f2.js`),__vite__mapDeps([0,1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55])),`component`)});export{c as t};
+16 -16
View File
@@ -9,7 +9,7 @@
<meta name="description" content="Spinifex Control Panel" />
<meta name="color-scheme" content="dark light" />
<script src="/preboot.js"></script>
<script type="module" crossorigin src="/assets/index-BWEGDpq2.js"></script>
<script type="module" crossorigin src="/assets/index-FP1YH-GV.js"></script>
<link rel="modulepreload" crossorigin href="/assets/rolldown-runtime-XKyaHtl9.js">
<link rel="modulepreload" crossorigin href="/assets/schemas-BGv4hnp9.js">
<link rel="modulepreload" crossorigin href="/assets/visuallyHidden-D4_nmHIB.js">
@@ -81,7 +81,7 @@
<link rel="modulepreload" crossorigin href="/assets/waypoints-D7e51Jvw.js">
<link rel="modulepreload" crossorigin href="/assets/dialog-CggbdV0m.js">
<link rel="modulepreload" crossorigin href="/assets/tslib.es6-CT3DuY1l.js">
<link rel="modulepreload" crossorigin href="/assets/command-DP3btIki.js">
<link rel="modulepreload" crossorigin href="/assets/command-nxbhA2b-.js">
<link rel="modulepreload" crossorigin href="/assets/uploads-4BEEb2qM.js">
<link rel="modulepreload" crossorigin href="/assets/bedrock-DN0hxPDJ.js">
<link rel="modulepreload" crossorigin href="/assets/sts-CNfMTCfB.js">
@@ -90,10 +90,10 @@
<link rel="modulepreload" crossorigin href="/assets/use-admin-CMdKtYKg.js">
<link rel="modulepreload" crossorigin href="/assets/login-joVHr0NQ.js">
<link rel="modulepreload" crossorigin href="/assets/ecs-cu78172G.js">
<link rel="modulepreload" crossorigin href="/assets/task-definitions-D1aGsanT.js">
<link rel="modulepreload" crossorigin href="/assets/task-definitions-Dd_0StNz.js">
<link rel="modulepreload" crossorigin href="/assets/list-guardrails-COTmrFKQ.js">
<link rel="modulepreload" crossorigin href="/assets/bedrockAgent-BVuyuFg4.js">
<link rel="modulepreload" crossorigin href="/assets/list-knowledge-bases-Ca6B5Dvb.js">
<link rel="modulepreload" crossorigin href="/assets/list-knowledge-bases-CCxHrfdD.js">
<link rel="modulepreload" crossorigin href="/assets/_knowledgeBaseId-BG6K_dMA.js">
<link rel="modulepreload" crossorigin href="/assets/describe-addresses-DJi8d3S0.js">
<link rel="modulepreload" crossorigin href="/assets/_id-fz2ssum0.js">
@@ -127,33 +127,33 @@
<link rel="modulepreload" crossorigin href="/assets/instances-page-Do1_Q3lr.js">
<link rel="modulepreload" crossorigin href="/assets/describe-instances-BbRd--rS.js">
<link rel="modulepreload" crossorigin href="/assets/_id-C92auM83.js">
<link rel="modulepreload" crossorigin href="/assets/describe-internet-gateways-DxvQneDq.js">
<link rel="modulepreload" crossorigin href="/assets/describe-internet-gateways-nMZomPT3.js">
<link rel="modulepreload" crossorigin href="/assets/_id-DNGECHb8.js">
<link rel="modulepreload" crossorigin href="/assets/upload-C5uIcpy7.js">
<link rel="modulepreload" crossorigin href="/assets/triangle-alert-Cs2Y1_T5.js">
<link rel="modulepreload" crossorigin href="/assets/circle-x-DHQkYhPI.js">
<link rel="modulepreload" crossorigin href="/assets/bulk-delete-B7E-9nKZ.js">
<link rel="modulepreload" crossorigin href="/assets/key-pair-details-BqFPWlhO.js">
<link rel="modulepreload" crossorigin href="/assets/describe-key-pairs-B9Q4WsiA.js">
<link rel="modulepreload" crossorigin href="/assets/describe-key-pairs-DatvSSUV.js">
<link rel="modulepreload" crossorigin href="/assets/_id-DKmSmSpQ.js">
<link rel="modulepreload" crossorigin href="/assets/describe-launch-templates-B51DQpNO.js">
<link rel="modulepreload" crossorigin href="/assets/describe-launch-templates-PwXwgU82.js">
<link rel="modulepreload" crossorigin href="/assets/_id-CbZ2KQzg.js">
<link rel="modulepreload" crossorigin href="/assets/elbv2-h6KlGbUo.js">
<link rel="modulepreload" crossorigin href="/assets/describe-load-balancers-DfLXunz4.js">
<link rel="modulepreload" crossorigin href="/assets/describe-load-balancers-DDsbGbVO.js">
<link rel="modulepreload" crossorigin href="/assets/_id-DChPoBhR.js">
<link rel="modulepreload" crossorigin href="/assets/describe-nat-gateways-BVd6DoTM.js">
<link rel="modulepreload" crossorigin href="/assets/describe-nat-gateways-BjIVKrwe.js">
<link rel="modulepreload" crossorigin href="/assets/_id-5vM_sGla.js">
<link rel="modulepreload" crossorigin href="/assets/describe-placement-groups-DqxmWA_w.js">
<link rel="modulepreload" crossorigin href="/assets/describe-placement-groups-db4OW-IE.js">
<link rel="modulepreload" crossorigin href="/assets/_id-BxrlpI0F.js">
<link rel="modulepreload" crossorigin href="/assets/describe-route-tables-ChCFnUsY.js">
<link rel="modulepreload" crossorigin href="/assets/describe-route-tables-Cl96qDiQ.js">
<link rel="modulepreload" crossorigin href="/assets/_id-JYpWp_hI.js">
<link rel="modulepreload" crossorigin href="/assets/describe-security-groups-BHD89ybN.js">
<link rel="modulepreload" crossorigin href="/assets/describe-security-groups-D0zCCxrp.js">
<link rel="modulepreload" crossorigin href="/assets/_id-CY4gz6Jh.js">
<link rel="modulepreload" crossorigin href="/assets/describe-snapshots-BcpkwC5X.js">
<link rel="modulepreload" crossorigin href="/assets/_id-DSlPs8t6.js">
<link rel="modulepreload" crossorigin href="/assets/describe-subnets-C76gFCIA.js">
<link rel="modulepreload" crossorigin href="/assets/_id-B5YrAnTC.js">
<link rel="modulepreload" crossorigin href="/assets/describe-target-groups-BAd6KXgh.js">
<link rel="modulepreload" crossorigin href="/assets/describe-target-groups-DONDUb7w.js">
<link rel="modulepreload" crossorigin href="/assets/_id-bbfu0_oD.js">
<link rel="modulepreload" crossorigin href="/assets/describe-volumes-CEPsX8Og.js">
<link rel="modulepreload" crossorigin href="/assets/_id-D-_H2F43.js">
@@ -169,7 +169,7 @@
<link rel="modulepreload" crossorigin href="/assets/_clusterName-jUCshxW1.js">
<link rel="modulepreload" crossorigin href="/assets/list-groups-Cs51mAGP.js">
<link rel="modulepreload" crossorigin href="/assets/_groupName-DSY1piiZ.js">
<link rel="modulepreload" crossorigin href="/assets/list-instance-profiles-BWnuCZmp.js">
<link rel="modulepreload" crossorigin href="/assets/list-instance-profiles-BMT2I3Nn.js">
<link rel="modulepreload" crossorigin href="/assets/_instanceProfileName-ukjDz_Lr.js">
<link rel="modulepreload" crossorigin href="/assets/list-policies-DgmgiGlQ.js">
<link rel="modulepreload" crossorigin href="/assets/_policyArn-SOKbQb4-.js">
@@ -188,7 +188,7 @@
<link rel="modulepreload" crossorigin href="/assets/describe-db-subnet-groups-DAS74YpD.js">
<link rel="modulepreload" crossorigin href="/assets/_name-QJNlosig.js">
<link rel="modulepreload" crossorigin href="/assets/route53-65U4kfxE.js">
<link rel="modulepreload" crossorigin href="/assets/list-hosted-zones-CBDKkhQK.js">
<link rel="modulepreload" crossorigin href="/assets/list-hosted-zones-Ca4gUDPb.js">
<link rel="modulepreload" crossorigin href="/assets/_id-C5D3ih03.js">
<link rel="modulepreload" crossorigin href="/assets/_bucket-BIwJQdV1.js">
<link rel="modulepreload" crossorigin href="/assets/_-JEkaz5i0.js">
@@ -197,7 +197,7 @@
<link rel="modulepreload" crossorigin href="/assets/_serviceName-CtpfguX9.js">
<link rel="modulepreload" crossorigin href="/assets/_taskId-Ckyo6h4E.js">
<link rel="stylesheet" crossorigin href="/assets/instances-page-CnsZ1jie.css">
<link rel="stylesheet" crossorigin href="/assets/index-DGiYKW8R.css">
<link rel="stylesheet" crossorigin href="/assets/index-RrWhwyj8.css">
</head>
<body>
<div id="app"></div>
@@ -45,10 +45,7 @@ function CommandDialog({
return (
<Dialog {...props}>
<DialogContent
className={cn(
"top-1/3 translate-y-0 overflow-hidden rounded-xl! p-0",
className,
)}
className={cn("overflow-hidden rounded-xl! p-0", className)}
showCloseButton={showCloseButton}
>
{/* Inside the popup, so the dialog is named by its title. */}
@@ -13,7 +13,7 @@ export function DesignCataloguePage() {
<>
<PageHeading
subtitle="Every token, primitive and pattern the console is built from. Switch theme from the header to check both."
title="Design system"
title="Design System"
/>
<div className="flex flex-col gap-10">
<RulesSection />
@@ -146,7 +146,7 @@ export function KnowledgeBasesPage({
Create knowledge base
</Link>
}
title="Knowledge bases"
title="Knowledge Bases"
/>
{selection.ids.length > 0 && (
<SelectionBar count={selection.ids.length} onClear={selection.clear}>
@@ -179,7 +179,7 @@ export function InternetGatewaysPage({
</Link>
}
subtitle="Connect a VPC to the outside world."
title="Internet gateways"
title="Internet Gateways"
/>
{selection.ids.length > 0 && (
<SelectionBar count={selection.ids.length} onClear={selection.clear}>
@@ -192,7 +192,7 @@ export function KeyPairsPage({
</div>
}
subtitle="SSH keys that instances accept at launch."
title="Key pairs"
title="Key Pairs"
/>
{selection.ids.length > 0 && (
<SelectionBar count={selection.ids.length} onClear={selection.clear}>

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