mirror of
https://github.com/mulgadc/spinifex.git
synced 2026-11-02 01:09:48 +00:00
Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 96e62ac213 | |||
| 02bba41669 | |||
| 8fa33f07ce | |||
| 3c192b3558 | |||
| 8cded67776 |
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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).
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 \
|
||||
|
||||
@@ -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"
|
||||
@@ -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 "" ;;
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
-1
File diff suppressed because one or more lines are too long
+2
-2
@@ -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};
|
||||
+2
-2
@@ -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
@@ -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};
|
||||
+2
-2
File diff suppressed because one or more lines are too long
+2
-2
@@ -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};
|
||||
+2
-2
@@ -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};
|
||||
+2
-2
@@ -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};
|
||||
+2
-2
File diff suppressed because one or more lines are too long
+2
-2
File diff suppressed because one or more lines are too long
+2
-2
@@ -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};
|
||||
+2
-2
@@ -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};
|
||||
+2
-2
@@ -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};
|
||||
+2
-2
@@ -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};
|
||||
+2
-2
@@ -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};
|
||||
+2
-2
@@ -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};
|
||||
+2
-2
@@ -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};
|
||||
+2
-2
File diff suppressed because one or more lines are too long
+2
-2
@@ -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
@@ -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
+3
-3
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+2
-2
@@ -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
-1
File diff suppressed because one or more lines are too long
+2
-2
@@ -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
-1
File diff suppressed because one or more lines are too long
+2
-2
@@ -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
-1
File diff suppressed because one or more lines are too long
+2
-2
File diff suppressed because one or more lines are too long
+2
-2
@@ -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
@@ -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. */}
|
||||
|
||||
+1
-1
@@ -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 />
|
||||
|
||||
+1
-1
@@ -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}>
|
||||
|
||||
+1
-1
@@ -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}>
|
||||
|
||||
+1
-1
@@ -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
Reference in New Issue
Block a user