mirror of
https://github.com/mulgadc/spinifex.git
synced 2026-11-02 17:23:32 +00:00
Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f0ce510f8b | |||
| 9e00165374 | |||
| e8db8e7280 | |||
| 9e2589d5d2 | |||
| 0180da70f3 |
@@ -126,8 +126,8 @@ type Assign struct {
|
||||
ENIPrivateIP string `json:"eniPrivateIp,omitempty"`
|
||||
ENISubnetID string `json:"eniSubnetId,omitempty"`
|
||||
// TaskRoleARN is the task's IAM role (from the taskdef); when set, the agent
|
||||
// serves its credentials at 169.254.170.2 under CredID, a scheduler-minted
|
||||
// secret. An assign without a CredID gets no credentials.
|
||||
// serves its credentials at 169.254.170.2 under CredID, defaulting CredID to
|
||||
// the taskID when the scheduler leaves it empty.
|
||||
CredID string `json:"credId,omitempty"`
|
||||
TaskRoleARN string `json:"taskRoleArn,omitempty"`
|
||||
// ExecutionRoleARN is the task's execution role (from the taskdef); when set,
|
||||
|
||||
+3
-3
@@ -152,12 +152,12 @@ Operational commands for inspecting cluster state. These fan out NATS requests t
|
||||
| `spx admin account create` | `--name` (required) | Connects to NATS → refuses if an account already holds the name (case- and whitespace-insensitive) → reserves the name in `spinifex-account-names` KV → CAS loop on `spinifex-account-counter:next_id` for sequential 12-digit ID → creates Account record in `spinifex-accounts` KV → creates default VPC → creates `admin` user, AdministratorAccess policy (Action:*, Resource:*) and one access key → prints credentials and writes a `spinifex-<name>` AWS CLI profile |
|
||||
| `spx admin account create --remote` | `--name` (required), `--endpoint` (default: this node's AWS gateway), `--region` (default: this node's region), `--ca-bundle` (default: this node's CA), `--client-token` (default: generated), `--source` (default: `spx-cli`) | Same account, created through `POST /admin/CreateAccount` over SigV4 instead of NATS — the path the public signup form uses, so a shell can exercise it. Credentials come from the standard AWS chain (env vars or `AWS_PROFILE`), never from the cluster master key; the caller must be an IAM user in the super-admin account holding `spinifex:CreateAccount`. Reuse the printed `--client-token` to retry: a fresh token would create a second account. |
|
||||
| `spx admin account list` | — | Connects to NATS → IAMService.ListAccounts() → prints table with Account ID, Name, Status, Created |
|
||||
| `spx admin account list --remote` | `--endpoint` (default: this node's AWS gateway), `--region` (default: this node's region), `--ca-bundle` (default: this node's CA) | Same listing read through `POST /admin/ListAccounts` over SigV4 instead of NATS, so an off-cluster operator or the load-test harness sees its tenants without SSH access to a node. Credentials come from the standard AWS credential chain: on a cluster node that means `AWS_PROFILE=spinifex` (there is no default profile, so without it the command fails to find credentials), or equivalent credentials; the caller must be an IAM user in the super-admin account holding `spinifex:ListAccounts`. |
|
||||
| `spx admin account list --remote` | `--endpoint` (default: this node's AWS gateway), `--region` (default: this node's region), `--ca-bundle` (default: this node's CA) | Same listing read through `POST /admin/ListAccounts` over SigV4 instead of NATS, so an off-cluster operator or the load-test harness sees its tenants without SSH access to a node. Credentials come from the standard AWS chain; the caller must be an IAM user in the super-admin account holding `spinifex:ListAccounts`. |
|
||||
| `spx admin account quota get <account-id>` | `--endpoint` (default: this node's AWS gateway), `--region` (default: this node's region), `--ca-bundle` (default: this node's CA) | Reads the limits in force for one account through `POST /admin/GetAccountQuota` over SigV4, and reports for each dimension whether it came from the `[quota]` block in `awsgw.toml` (`config`) or from an override stored against the account (`override`). Credentials come from the standard AWS chain; the caller must be an IAM user in the super-admin account holding `spinifex:GetAccountQuota`. |
|
||||
| `spx admin account quota set <account-id>` | `--vcpus`, `--vpcs`, `--subnets`, `--eips`, `--volumes`, `--volumes-gib`, `--rds-instances`, `--load-balancers`, `--clear`, plus `--endpoint`, `--region`, `--ca-bundle` | Overrides individual limits for one account through `POST /admin/PutAccountQuota`, leaving every dimension not named inheriting the configured baseline. `-1` uncaps a dimension; `0` is a real limit that denies every request on it. `--clear` removes the whole override record and cannot be combined with a limit flag. The account must already exist. Prints the resulting limits, so a caller never has to guess what it applied. The caller must hold `spinifex:PutAccountQuota`. |
|
||||
| `spx admin account delete <account-id>` | `--dry-run`, `--force`, `--yes` (requires `--name`), `--name` (account name, must match the stored record), `--endpoint`, `--region`, `--ca-bundle`, `--client-token` | Always runs through `POST /admin/DeleteAccount` over SigV4 and follows `DescribeAccountDeletion` to completion; the CLI never tears an account down in its own process, so a CLI delete and an API delete cannot race. Credentials come from the standard AWS chain; the caller must be an IAM user in the super-admin account holding `spinifex:DeleteAccount` and `spinifex:DescribeAccountDeletion`. Prints the inventory it is about to remove → confirms the account name (prompted unless `--name` is given) → marks the account `TERMINATING`, which blocks its credentials cluster-wide → deletes its resources in dependency order (compute, attachments, storage, network, platform, identity, account), waiting for each stage to actually drain before starting the next → seals the account's quota override record so no later write can recreate it, which needs every gateway node the cluster configures to answer a read-only probe first (otherwise nothing is sealed and the account stays `TERMINATING`) → releases the name reservation and quota counter → deletes the account record. Anything that will not delete is reported as **stuck** with the reason, and the account stays `TERMINATING` so the residue keeps an owner. `000000000000` and `000000000001` are refused. `--dry-run` lists and changes nothing. The compute stage reaps ECS clusters, EKS nodegroups and clusters, and RDS instances before it reaps plain instances, because their backing VMs belong to the system account and a tenant-scoped instance listing cannot see them. The storage stage reaps the account's S3 buckets: uploads in flight are aborted, then the objects are removed a page at a time, then the bucket. Only buckets the account owns are ever listed, so the shared platform buckets are out of reach rather than filtered out. Platform-managed serving endpoints are reaped ahead of instances, because only their own delete releases the ENI and attached data volume alongside the VM, and spot instance requests are cancelled first so a live request cannot launch a replacement for something already terminated. The network stage reaps load balancers, then target groups, then NAT gateways and egress-only internet gateways before the subnets, security groups and addresses they hold. The platform stage reaps ACM certificates. |
|
||||
| `spx admin account delete <account-id>` | `--dry-run`, `--force`, `--yes` (requires `--name`), `--name` (account name, must match the stored record) | Prints the inventory it is about to remove → confirms the account name (prompted unless `--name` is given) → marks the account `TERMINATING`, which blocks its credentials cluster-wide → deletes its resources in dependency order (compute, attachments, storage, network, platform, identity, account), waiting for each stage to actually drain before starting the next → seals the account's quota override record so no later write can recreate it, which needs every gateway node the cluster configures to answer a read-only probe first (otherwise nothing is sealed and the account stays `TERMINATING`) → releases the name reservation and quota counter → deletes the account record. Anything that will not delete is reported as **stuck** with the reason, and the account stays `TERMINATING` so the residue keeps an owner. `000000000000` and `000000000001` are refused. `--dry-run` lists and changes nothing. The compute stage reaps ECS clusters, EKS nodegroups and clusters, and RDS instances before it reaps plain instances, because their backing VMs belong to the system account and a tenant-scoped instance listing cannot see them. The storage stage reaps the account's S3 buckets: uploads in flight are aborted, then the objects are removed a page at a time, then the bucket. Only buckets the account owns are ever listed, so the shared platform buckets are out of reach rather than filtered out. Platform-managed serving endpoints are reaped ahead of instances, because only their own delete releases the ENI and attached data volume alongside the VM, and spot instance requests are cancelled first so a live request cannot launch a replacement for something already terminated. The network stage reaps load balancers, then target groups, then NAT gateways and egress-only internet gateways before the subnets, security groups and addresses they hold. The platform stage reaps ACM certificates. |
|
||||
| `spx admin account delete --force` | as above | Escalates only for resources the ordinary API refuses: it clears a volume attachment in the control plane without the guest's cooperation (`ec2.ForceDetachVolume`), hard-destroys after a graceful attempt times out, and treats an already-missing resource as deleted. It never reorders the stages and never widens scope beyond the account. Use it once the guest is being destroyed — this is the escape from a volume stuck attached to an instance that will not terminate, where neither can otherwise be deleted. |
|
||||
| `spx admin account delete --remote` | as above | Accepted for existing scripts and ignored: every delete already goes through the admin API. |
|
||||
| `spx admin account delete --remote` | as above, plus `--endpoint`, `--region`, `--ca-bundle`, `--client-token` | Same teardown driven through `POST /admin/DeleteAccount` over SigV4 instead of NATS, then follows `DescribeAccountDeletion` to completion, printing each stage as it lands. Credentials come from the standard AWS chain; the caller must be an IAM user in the super-admin account holding `spinifex:DeleteAccount` and `spinifex:DescribeAccountDeletion`. |
|
||||
| `spx admin principal create <name>` | `--grant` (repeatable or comma-separated; default: every admin method — `CreateAccount`, `DeleteAccount`, `DescribeAccountDeletion`, `GetAccountQuota`, `ListAccounts`, `PutAccountQuota`) | Creates an IAM user in the super-admin account with an inline `spx-admin-methods` policy allowing the requested methods, revokes any access key left from a previous run, and mints one new key. Each method is named rather than granted as `spinifex:*`, so a later addition to the admin surface is not authorised by an existing key. The principal's grants are exactly that one policy: re-running with fewer `--grant` values narrows it, and any other inline policy on the user is removed, so a rename cannot leave a wider grant behind. The secret is printed once; store it in a secret manager or an AWS profile, never in a config file. `admin` is refused as a name — it is the account's own administrator and rotating its key would revoke the bootstrap credential. |
|
||||
| `spx admin principal list` | — | Lists every principal in the super-admin account with its access-key count and what it may call. An attached policy is reported by name (`AdministratorAccess (attached)`) rather than expanded, which is how an unscoped principal is spotted. |
|
||||
| `spx admin principal revoke <name>` | — | Deletes every access key the named principal holds, leaving the user and its policy in place. Immediate and cluster-wide; re-issue with `principal create`. |
|
||||
|
||||
@@ -236,12 +236,12 @@ Each stage runs to empty before the next begins, so nothing is deleted while som
|
||||
A large account takes minutes to tear down. The API does not hold the connection open for it: `DeleteAccount` starts the job, returns a `deletionId` immediately, and progress is read from `DescribeAccountDeletion`.
|
||||
|
||||
```bash
|
||||
AWS_PROFILE=spinifex-operator spx admin account delete 000000000042 \
|
||||
AWS_PROFILE=spinifex-operator spx admin account delete 000000000042 --remote \
|
||||
--name customer@example.com --yes \
|
||||
--endpoint https://node1.example.com:9999 --region us-west-1
|
||||
```
|
||||
|
||||
`spx admin account delete` always goes through the admin API, so it signs with the standard AWS credential chain. On a cluster node that means `AWS_PROFILE=spinifex` (there is no default profile, so without it the command cannot find credentials), or equivalent credentials for an IAM user holding `spinifex:DeleteAccount` and `spinifex:DescribeAccountDeletion`, and a reachable gateway. The older `--remote` flag is accepted and ignored. The command starts the job and then polls, printing each stage as it lands. Your own client can do the same, or start the job and check back later — the deletion record outlives the account, so `DescribeAccountDeletion` stays answerable after the account is gone.
|
||||
`spx --remote` starts the job and then polls, printing each stage as it lands. Your own client can do the same, or start the job and check back later — the deletion record outlives the account, so `DescribeAccountDeletion` stays answerable after the account is gone.
|
||||
|
||||
One job runs per account. A second `DeleteAccount` with a different token while one is running returns `OperationInProgress`; with the *same* token it returns the running job, which is what makes a retry safe.
|
||||
|
||||
|
||||
@@ -58,7 +58,7 @@ Authentication uses **EKS access entries** (the API authentication mode) — IAM
|
||||
**Spinifex specifics**
|
||||
|
||||
- **Access entries are the only auth path.** `authentication_mode` accepts `API` (the default when unset) and `API_AND_CONFIG_MAP`; bare `CONFIG_MAP` is rejected. Spinifex has no `aws-auth` ConfigMap path, so `API_AND_CONFIG_MAP` behaves as `API`: grant access with access entries.
|
||||
- **The cluster security group is auto-managed.** As on EKS, Spinifex creates one `eks-cluster-sg-<name>-<id>` in your VPC, tagged `kubernetes.io/cluster/<name>=owned` and `aws:eks:cluster-name`, allowing all traffic from itself. It is attached to the workers and the private-endpoint network interface, and `DescribeCluster` reports it as `clusterSecurityGroupId`. A launch template that names security groups replaces it on the workers, as on AWS. Clusters created before this change keep their `-control-plane-sg` and `-nodegroup-sg` until you recreate them. Deleting a cluster deletes the cluster security group and removes only the rules on your other security groups that reference it. `vpc_config.security_group_ids` is ignored.
|
||||
- **Security groups are auto-managed.** Spinifex creates the cluster and node-group security groups deterministically (e.g. `eks-cluster-<name>-nodegroup-sg`); `vpc_config.security_group_ids` is ignored. To expose a workload you add an ingress rule to the auto-managed node-group SG.
|
||||
- **The worker image is fixed.** Node groups always boot Spinifex's `eks-node` image. `ami_type` is recorded but does not select the image, and the image must be registered on the cluster before you create a node group.
|
||||
- **Workers launch with IMDSv2 required and an IMDS hop limit of 2.**
|
||||
This matches AWS managed node groups and lets a pod without IRSA reach the node role through the SDK default credential chain.
|
||||
@@ -143,25 +143,6 @@ No user-facing operation replaces a control plane, and automatic replacement doe
|
||||
Recreate the cluster to adopt it.
|
||||
User-workload injection through a cluster created with an older image is not measured.
|
||||
|
||||
### Add-on versions
|
||||
|
||||
Add-on versions use the AWS form `v<x.y.z>-eksbuild.<n>`, for example `v1.40.1-eksbuild.1`.
|
||||
`DescribeAddonVersions` returns versions in this form, newest first, and every other add-on response reports the version the same way.
|
||||
Spinifex ships one EKS build of each upstream version, so `<n>` is `1` today.
|
||||
|
||||
`DescribeAddonVersions` honours `addonName`, `kubernetesVersion`, `types`, `publishers`, `owners`, `maxResults` and `nextToken`.
|
||||
Each version carries `compatibilities` with the `clusterVersion`, the `platformVersions`, and a `defaultVersion` flag that is true for exactly one version per add-on and cluster version.
|
||||
A `kubernetesVersion` that Spinifex does not run returns no add-ons.
|
||||
|
||||
`CreateAddon` and `UpdateAddon` accept the AWS form of a version in the catalogue.
|
||||
They also accept the bare upstream form, such as `1.40.1`, and Spinifex logs each use at Info.
|
||||
This is a Spinifex leniency that AWS does not offer, and it exists so that older scripts keep working.
|
||||
Any other value is refused with a message that names `addonVersion` and lists the supported versions, for example `v1.40.1-eksbuild.2`, `1.40` or `v1.40.1`.
|
||||
|
||||
In Terraform, pass the AWS form to `aws_eks_addon.addon_version`, either as a literal or from `data.aws_eks_addon_version`.
|
||||
The AWS provider validates the format before it calls the API, so it rejects the bare form.
|
||||
Leaving `addon_version` unset still installs the catalogue default.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
> [!IMPORTANT]
|
||||
@@ -244,11 +225,12 @@ The list shows the node group workers only. The control-plane VMs never appear a
|
||||
|
||||
### 4. Expose a workload
|
||||
|
||||
Workers join the cluster security group, which only allows intra-cluster traffic. To reach a NodePort service, add one ingress rule to it (`DescribeCluster` reports its id):
|
||||
Workers join an auto-managed security group that only allows intra-cluster traffic. To reach a NodePort service, add one ingress rule to that SG (look it up by its deterministic name):
|
||||
|
||||
```bash
|
||||
SG=$(aws eks describe-cluster --name demo \
|
||||
--query 'cluster.resourcesVpcConfig.clusterSecurityGroupId' --output text)
|
||||
SG=$(aws ec2 describe-security-groups \
|
||||
--filters Name=group-name,Values=eks-cluster-demo-nodegroup-sg \
|
||||
--query 'SecurityGroups[0].GroupId' --output text)
|
||||
|
||||
aws ec2 authorize-security-group-ingress \
|
||||
--group-id "$SG" --protocol tcp --port 30080 --cidr 0.0.0.0/0
|
||||
@@ -290,7 +272,7 @@ The form notes that the authentication mode is API and that the legacy aws-auth
|
||||
|
||||
- The **Access** tab lists IAM principals and their cluster-access policies — add an access entry to let another user or role reach the cluster.
|
||||
- The **Addons** tab installs and shows the health of managed addons.
|
||||
- The **Networking** tab shows the cluster's VPC, subnets, endpoint access, and the auto-managed cluster security group.
|
||||
- The **Networking** tab shows the cluster's VPC, subnets, endpoint access, and the auto-managed security groups.
|
||||
|
||||
To run `kubectl` against the cluster, follow the AWS CLI tab's `update-kubeconfig` step.
|
||||
|
||||
@@ -373,11 +355,11 @@ Control plane readiness and kubectl authentication above lists which clusters re
|
||||
- Confirm the workers can pull images — a default route to an Internet Gateway (public) or NAT Gateway (private).
|
||||
- Confirm the node role has `AmazonEKSWorkerNodePolicy`, `AmazonEKS_CNI_Policy`, and `AmazonEC2ContainerRegistryReadOnly`.
|
||||
|
||||
**Workload unreachable from outside the cluster.** The cluster security group only allows intra-cluster traffic by default. Add an ingress rule for your NodePort to it, as shown in the CLI tab. An Ingress served by the AWS Load Balancer Controller needs no rule: the controller adds it to the cluster security group.
|
||||
**Workload unreachable from outside the cluster.** The node-group security group only allows intra-cluster traffic by default. Add an ingress rule for your NodePort to `eks-cluster-<name>-nodegroup-sg`, as shown in the CLI tab.
|
||||
|
||||
**`create-cluster` rejected with an authentication-mode error.** `CONFIG_MAP` is the one mode Spinifex rejects, because it has no `aws-auth` ConfigMap path. Set `authentication_mode` to `API` or `API_AND_CONFIG_MAP`, or leave it unset, and grant access with access entries.
|
||||
|
||||
**Addon install fails.** Only addons bundled into the registered `eks-node` image install successfully. If you pin an addon version in Terraform, use the AWS form from `DescribeAddonVersions` (see Add-on versions above), or leave `addon_version` unset so Spinifex selects the catalog default.
|
||||
**Addon install fails.** Only addons bundled into the registered `eks-node` image install successfully. If you pin an addon version in Terraform, leave `addon_version` unset so Spinifex selects the catalog default.
|
||||
|
||||
## Reference Architectures
|
||||
|
||||
|
||||
@@ -17,7 +17,7 @@ tags:
|
||||
|
||||
## Overview
|
||||
|
||||
The platform serves **406 operations** across the AWS APIs below. Every page names the operations its service implements from the pinned model, generated from the dispatch tables on each build rather than written by hand.
|
||||
The platform serves **405 operations** across the AWS APIs below. Every page names the operations its service implements from the pinned model, generated from the dispatch tables on each build rather than written by hand.
|
||||
|
||||
| Service | Operations |
|
||||
|---|---:|
|
||||
@@ -27,9 +27,9 @@ The platform serves **406 operations** across the AWS APIs below. Every page nam
|
||||
| [ECS](/coverage/ecs) | 31 |
|
||||
| [EKS](/coverage/eks) | 27 |
|
||||
| [ELBv2](/coverage/elbv2) | 36 |
|
||||
| [IAM](/coverage/iam) | 82 |
|
||||
| [IAM](/coverage/iam) | 81 |
|
||||
| [RDS](/coverage/rds) | 27 |
|
||||
| [Route 53](/coverage/route53) | 13 |
|
||||
| [S3](/coverage/s3) | 26 |
|
||||
| [STS](/coverage/sts) | 5 |
|
||||
| **Total** | **406** |
|
||||
| **Total** | **405** |
|
||||
|
||||
@@ -2938,8 +2938,7 @@
|
||||
"UpdateAccessKey",
|
||||
"UpdateAssumeRolePolicy",
|
||||
"UpdateRole",
|
||||
"UpdateRoleDescription",
|
||||
"UpdateUser"
|
||||
"UpdateRoleDescription"
|
||||
],
|
||||
"implemented": [
|
||||
"AddRoleToInstanceProfile",
|
||||
@@ -3022,8 +3021,7 @@
|
||||
"UpdateAccessKey",
|
||||
"UpdateAssumeRolePolicy",
|
||||
"UpdateRole",
|
||||
"UpdateRoleDescription",
|
||||
"UpdateUser"
|
||||
"UpdateRoleDescription"
|
||||
],
|
||||
"stubbed": [],
|
||||
"unsupported": [],
|
||||
@@ -3123,6 +3121,7 @@
|
||||
"UpdateServerCertificate",
|
||||
"UpdateServiceSpecificCredential",
|
||||
"UpdateSigningCertificate",
|
||||
"UpdateUser",
|
||||
"UploadSSHPublicKey",
|
||||
"UploadServerCertificate",
|
||||
"UploadSigningCertificate"
|
||||
|
||||
@@ -18,7 +18,7 @@ tags:
|
||||
|
||||
## Overview
|
||||
|
||||
Spinifex implements **82 operations** in the IAM `2010-05-08` API model.
|
||||
Spinifex implements **81 operations** in the IAM `2010-05-08` API model.
|
||||
|
||||
### Scope
|
||||
|
||||
@@ -109,4 +109,3 @@ All IAM operations are account-scoped. The root user of an account bypasses poli
|
||||
| `UpdateAssumeRolePolicy` |
|
||||
| `UpdateRole` |
|
||||
| `UpdateRoleDescription` |
|
||||
| `UpdateUser` |
|
||||
|
||||
@@ -987,7 +987,7 @@ spot-price: On owned hardware there is no spot-to-on-demand price differential,
|
||||
|
||||
## iam
|
||||
|
||||
`2010-05-08` — 82 of 180 modelled operations implemented (45.6%), 0 stubbed, 19 not applicable, 0 registered outside the pinned model.
|
||||
`2010-05-08` — 81 of 180 modelled operations implemented (45.0%), 0 stubbed, 19 not applicable, 0 registered outside the pinned model.
|
||||
|
||||
### Not implemented
|
||||
|
||||
@@ -1069,6 +1069,7 @@ spot-price: On owned hardware there is no spot-to-on-demand price differential,
|
||||
- `UpdateOpenIDConnectProviderThumbprint`
|
||||
- `UpdateSAMLProvider`
|
||||
- `UpdateServerCertificate`
|
||||
- `UpdateUser`
|
||||
- `UploadServerCertificate`
|
||||
|
||||
### Not applicable — do not build
|
||||
|
||||
@@ -20,7 +20,7 @@ There is no generation, so the record mixes desired fields with the latest obser
|
||||
| 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 (`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. Versions are stored and used internally in the bare upstream form, and API responses show the AWS form `v<x.y.z>-eksbuild.<n>` (`Spec.AWSVersion`). Create and update accept the AWS form or the exact bare form and reject unknown names and other version strings with `InvalidParameterValue`, which the gateway returns as `InvalidParameterException`. | `domains/eks/addon/catalog_test.go`, `handlers/eks/addons_test.go` `TestDescribeAddonVersions_ReturnsCatalog`, `TestCreateAddon_UnknownAddonRejected`, `TestCreateAddon_UnknownVersionRejected`, `TestUpdateAddon_UnknownVersionRejected` |
|
||||
| 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` |
|
||||
| Observed state | `status` and `health` are overwritten by guest reports on the same record as the desired fields. `health` is the latest failure message, projected as one `AddonIssue` with only `message`. Statuses held: `CREATING`, `ACTIVE`, `UPDATING`, `DEGRADED`, `CREATE_FAILED`; `DELETING` is defined but never stored. | `TestAddonRecord_DecodesHandWrittenJSON`, `handlers/eks/addon_status_reconciler_test.go` `TestApplyAddonStatusReport` |
|
||||
| Generation | None. Reports carry `version` and `ts`, and neither is read: any report for the add-on name applies to whatever is currently desired. | `TestAddonStatusReport_MismatchedVersionIsAccepted` |
|
||||
|
||||
@@ -79,8 +79,7 @@ Terraform manages the cluster, the addons, the ACM cert, and the HTTPS Ingress;
|
||||
- The `argocd` and `aws-ebs-csi-driver` bundles must be baked into the `eks-node` AMI — `describe-addon` returns `no baked bundle` otherwise.
|
||||
- The EBS-CSI default StorageClass uses `provisioner: ebs.csi.aws.com`; a PVC against it provisions a **Viperblock-backed EBS volume**.
|
||||
- If the PVC stays `Pending`, the app still serves (the counter falls back to in-memory) — see [Troubleshooting](#troubleshooting).
|
||||
- Addon versions are unset, so Spinifex installs each catalog default.
|
||||
To pin one, use the AWS form that `DescribeAddonVersions` returns, such as `v3.0.23-eksbuild.1`.
|
||||
- Leave addon versions unset (the AWS provider and the catalog disagree on the version-string format).
|
||||
- The Argo CD `Application` is a `kubernetes_manifest`, so the `argoproj.io` CRDs must exist before `apply` — apply the parent and let the addon reach `ACTIVE` first.
|
||||
|
||||
**Prerequisites:**
|
||||
|
||||
@@ -471,7 +471,8 @@ resource "aws_eks_cluster" "this" {
|
||||
# expects: access_config reads as absent (and its ForceNew
|
||||
# bootstrap_cluster_creator_admin_permissions would replace the live cluster on
|
||||
# re-add), and vpc_config.security_group_ids reads back as drift. Ignore both so
|
||||
# later applies neither replace the cluster nor detach its security groups.
|
||||
# later applies (e.g. adding the Argo CD NodePort rule) neither replace the
|
||||
# cluster nor detach its security groups.
|
||||
lifecycle {
|
||||
ignore_changes = [access_config, vpc_config[0].security_group_ids]
|
||||
}
|
||||
@@ -519,8 +520,8 @@ resource "aws_eks_node_group" "workers" {
|
||||
#
|
||||
# Spinifex wires the controller's AWS credentials and ELB-eligible subnets at the
|
||||
# node level, so no IRSA role or subnet tagging is needed here. addon_version is
|
||||
# omitted so Spinifex installs its catalog default. To pin one, use the AWS form
|
||||
# from DescribeAddonVersions, for example v2.11.0-eksbuild.1.
|
||||
# omitted: the AWS provider demands a v-prefixed version that the catalog rejects,
|
||||
# so let Spinifex default to its catalog version.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
resource "aws_eks_addon" "lbc" {
|
||||
@@ -541,7 +542,7 @@ resource "aws_eks_addon" "lbc" {
|
||||
# Installed through the EKS API; Spinifex stages the bundle host-side and the
|
||||
# worker renders it into the K3s auto-deploy dir. The workloads module registers
|
||||
# the git repo and creates the Argo CD Application that syncs the demo app.
|
||||
# addon_version omitted: the catalog default is installed (see the LBC note).
|
||||
# addon_version omitted (see the LBC note).
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
resource "aws_eks_addon" "argocd" {
|
||||
@@ -561,7 +562,7 @@ resource "aws_eks_addon" "argocd" {
|
||||
#
|
||||
# Ships a default gp3 StorageClass (provisioner ebs.csi.aws.com). A PVC in the
|
||||
# demo app's git manifests dynamically provisions a Viperblock-backed EBS volume
|
||||
# so the app's state survives pod restarts. addon_version omitted: catalog default.
|
||||
# so the app's state survives pod restarts. addon_version omitted (see LBC note).
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
resource "aws_eks_addon" "ebs_csi" {
|
||||
@@ -641,6 +642,53 @@ resource "aws_acm_certificate" "ingress" {
|
||||
}
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Let the ALB reach the workers' NodePort
|
||||
#
|
||||
# Spinifex auto-manages the nodegroup SG and admits only intra-cluster traffic.
|
||||
# The LBC-provisioned ALB lives in this VPC, so admit the VPC CIDR on the
|
||||
# NodePort. Look the SG up by its deterministic name and add one rule.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
data "aws_security_group" "nodegroup" {
|
||||
filter {
|
||||
name = "group-name"
|
||||
values = ["eks-cluster-${var.cluster_name}-nodegroup-sg"]
|
||||
}
|
||||
|
||||
filter {
|
||||
name = "vpc-id"
|
||||
values = [aws_vpc.main.id]
|
||||
}
|
||||
|
||||
depends_on = [aws_eks_node_group.workers]
|
||||
}
|
||||
|
||||
resource "aws_vpc_security_group_ingress_rule" "nodeport_from_vpc" {
|
||||
security_group_id = data.aws_security_group.nodegroup.id
|
||||
cidr_ipv4 = aws_vpc.main.cidr_block
|
||||
from_port = var.node_port
|
||||
to_port = var.node_port
|
||||
ip_protocol = "tcp"
|
||||
|
||||
tags = {
|
||||
Name = "${var.cluster_name}-alb-nodeport"
|
||||
}
|
||||
}
|
||||
|
||||
# Same, for the Argo CD UI NodePort its ALB forwards to.
|
||||
resource "aws_vpc_security_group_ingress_rule" "argocd_nodeport_from_vpc" {
|
||||
security_group_id = data.aws_security_group.nodegroup.id
|
||||
cidr_ipv4 = aws_vpc.main.cidr_block
|
||||
from_port = var.argocd_node_port
|
||||
to_port = var.argocd_node_port
|
||||
ip_protocol = "tcp"
|
||||
|
||||
tags = {
|
||||
Name = "${var.cluster_name}-argocd-nodeport"
|
||||
}
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Outputs
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -64,6 +64,7 @@ The LBC is installed as a managed **addon** (`aws_eks_addon`). Spinifex wires th
|
||||
| Node Group | `workers` | `node_desired_size` `t3.large` worker(s) — 1 or 3 |
|
||||
| Addon | `aws-load-balancer-controller` | Provisions the ALB from the Ingress |
|
||||
| ACM Certificate | `eks-https-ingress` | Self-signed, imported; attached to the HTTPS listener |
|
||||
| SG Ingress Rule | `eks-https-alb-nodeport` | Admits the VPC CIDR to the workers' NodePort |
|
||||
| K8s Deployment + Service | `spinifex-demo` | Themed demo image, 2 replicas, NodePort |
|
||||
| K8s Ingress | `spinifex-demo` | `ingressClassName: alb`, HTTPS via the ACM cert |
|
||||
|
||||
@@ -71,10 +72,9 @@ The LBC is installed as a managed **addon** (`aws_eks_addon`). Spinifex wires th
|
||||
|
||||
- **The LBC needs no IRSA or subnet tagging here.** Credentials and ELB-eligible subnets are injected by Spinifex at the node level and into the `alb` `IngressClassParams`. The Ingress just sets `ingressClassName: alb` plus the `alb.ingress.kubernetes.io/*` annotations.
|
||||
- **`spinifex.io/managed-ingress = "false"`** disables the K3s built-in traefik/servicelb so the LBC owns ingress, matching AWS parity. Omit the tag (or set it `true`) and the built-in ingress stays on.
|
||||
- The ALB targets the workers' **NodePort** (`target-type: instance`). The AWS Load Balancer Controller adds the ALB-to-NodePort rule to the cluster security group itself, so the workbook creates no security group rule.
|
||||
- The ALB targets the workers' **NodePort** (`target-type: instance`). The one SG rule admits the VPC CIDR on that port so the in-VPC ALB can reach the workers; the worker SG is otherwise auto-managed.
|
||||
- Spinifex ACM supports **ImportCertificate only** — the cert is generated by the `tls` provider and imported, not requested.
|
||||
- **`addon_version` is unset**, so Spinifex installs its catalog default.
|
||||
To pin a version, use the AWS form that `DescribeAddonVersions` returns, such as `v2.11.0-eksbuild.1`, or read it from `data.aws_eks_addon_version`.
|
||||
- **Leave `addon_version` unset** (see the [quickstart/addons notes](/docs/eks-quickstart)) — the AWS provider and the Spinifex catalog disagree on the version string format, so let the server choose.
|
||||
|
||||
**Prerequisites:**
|
||||
|
||||
@@ -179,17 +179,16 @@ Confirm the addon is `ACTIVE` and the cluster carries `spinifex.io/managed-ingre
|
||||
|
||||
### HTTPS Loads but Returns 502 / 504
|
||||
|
||||
The ALB reached the workers but the backend didn't answer. Check the demo pods and the rule the controller added to the cluster security group:
|
||||
The ALB reached the workers but the backend didn't answer. Check the demo pods and the NodePort SG rule:
|
||||
|
||||
```bash
|
||||
kubectl get pods -o wide
|
||||
kubectl get svc spinifex-demo
|
||||
SG=$(aws eks describe-cluster --name eks-https --query 'cluster.resourcesVpcConfig.clusterSecurityGroupId' --output text)
|
||||
aws ec2 describe-security-groups --group-ids "$SG" \
|
||||
aws ec2 describe-security-groups --filters "Name=group-name,Values=eks-cluster-eks-https-nodegroup-sg" \
|
||||
--query 'SecurityGroups[0].IpPermissions'
|
||||
```
|
||||
|
||||
The cluster security group must admit the load balancer's security group on the NodePort, and the target group health check (`/healthz`) must pass.
|
||||
The `eks-https-alb-nodeport` rule must admit the VPC CIDR on the NodePort, and the target group health check (`/healthz`) must pass.
|
||||
|
||||
### Addon Fails With "no baked bundle"
|
||||
|
||||
|
||||
@@ -16,8 +16,7 @@
|
||||
# The workloads/ module then creates a Deployment, a NodePort Service, and an
|
||||
# Ingress (ingressClassName: alb). The LBC reconciles that Ingress into an
|
||||
# internet-facing ALB that terminates TLS with the ACM cert and forwards to the
|
||||
# workers' NodePort, adding the ALB-to-NodePort rule to the cluster security group
|
||||
# itself. The cluster's ELB-eligible subnets are injected by Spinifex
|
||||
# workers' NodePort. The cluster's ELB-eligible subnets are injected by Spinifex
|
||||
# into the alb IngressClassParams, so the Ingress needs no subnet annotations.
|
||||
#
|
||||
# Usage:
|
||||
@@ -447,8 +446,8 @@ resource "aws_eks_node_group" "workers" {
|
||||
#
|
||||
# Spinifex wires the controller's AWS credentials and ELB-eligible subnets at the
|
||||
# node level, so no IRSA role or subnet tagging is needed here. addon_version is
|
||||
# omitted so Spinifex installs its catalog default. To pin one, use the AWS form
|
||||
# from DescribeAddonVersions, for example v2.11.0-eksbuild.1.
|
||||
# omitted: the AWS provider demands a v-prefixed version that the catalog rejects,
|
||||
# so let Spinifex default to its catalog version.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
resource "aws_eks_addon" "lbc" {
|
||||
@@ -504,6 +503,40 @@ resource "aws_acm_certificate" "ingress" {
|
||||
}
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Let the ALB reach the workers' NodePort
|
||||
#
|
||||
# Spinifex auto-manages the nodegroup SG and admits only intra-cluster traffic.
|
||||
# The LBC-provisioned ALB lives in this VPC, so admit the VPC CIDR on the
|
||||
# NodePort. Look the SG up by its deterministic name and add one rule.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
data "aws_security_group" "nodegroup" {
|
||||
filter {
|
||||
name = "group-name"
|
||||
values = ["eks-cluster-${var.cluster_name}-nodegroup-sg"]
|
||||
}
|
||||
|
||||
filter {
|
||||
name = "vpc-id"
|
||||
values = [aws_vpc.main.id]
|
||||
}
|
||||
|
||||
depends_on = [aws_eks_node_group.workers]
|
||||
}
|
||||
|
||||
resource "aws_vpc_security_group_ingress_rule" "nodeport_from_vpc" {
|
||||
security_group_id = data.aws_security_group.nodegroup.id
|
||||
cidr_ipv4 = aws_vpc.main.cidr_block
|
||||
from_port = var.node_port
|
||||
to_port = var.node_port
|
||||
ip_protocol = "tcp"
|
||||
|
||||
tags = {
|
||||
Name = "${var.cluster_name}-alb-nodeport"
|
||||
}
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Outputs
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -45,7 +45,7 @@ Under the hood it keeps things minimal: a VPC with two public subnets, an IAM ro
|
||||
- Creating the EKS cluster and worker IAM roles with faithful managed-policy attachments
|
||||
- Provisioning an `aws_eks_cluster` and a managed `aws_eks_node_group`
|
||||
- Pointing the Kubernetes provider at the new cluster and deploying a workload from the same `apply`
|
||||
- Opening one rule on the cluster security group to expose a NodePort
|
||||
- Opening one rule on the auto-managed worker security group to expose a NodePort
|
||||
|
||||
**What gets created**
|
||||
|
||||
@@ -58,13 +58,13 @@ Under the hood it keeps things minimal: a VPC with two public subnets, an IAM ro
|
||||
| ECR Repository | `spinifex-demo` | Holds the demo image the workers pull |
|
||||
| EKS Cluster | `eks-quickstart` | Public API endpoint, API auth mode, Kubernetes 1.32 |
|
||||
| Node Group | `default` | `node_desired_size` `t3.medium` worker(s) — 1 or 3 |
|
||||
| SG Ingress Rule | `eks-quickstart-demo-nodeport` | Opens the demo NodePort on the cluster security group |
|
||||
| SG Ingress Rule | `eks-quickstart-demo-nodeport` | Opens the demo NodePort on the auto-managed worker SG |
|
||||
| K8s Deployment | `spinifex-demo` | The themed demo image from ECR, 2 replicas |
|
||||
| K8s Service | `spinifex-demo` | NodePort publishing the demo on the worker |
|
||||
|
||||
**Spinifex specifics**
|
||||
|
||||
- The cluster security group is **auto-managed**, as on EKS: Spinifex creates `eks-cluster-sg-<name>-<id>`, attaches it to the workers, and reports it as `vpc_config[0].cluster_security_group_id`. `vpc_config.security_group_ids` is ignored. To reach a NodePort, this workbook adds a single ingress rule to that security group.
|
||||
- The cluster's security groups are **auto-managed** — `vpc_config.security_group_ids` is ignored. To reach a NodePort, this workbook looks the worker SG up by its deterministic name (`eks-cluster-<name>-nodegroup-sg`) and adds a single ingress rule.
|
||||
- The worker AMI is always Spinifex's `eks-node` image; `ami_type` is recorded but does **not** select the image.
|
||||
- `authentication_mode` can be `"API"` or `"API_AND_CONFIG_MAP"`, and bare `"CONFIG_MAP"` is rejected. Access is granted through access entries, as there is no `aws-auth` ConfigMap path.
|
||||
- The workers need outbound internet (here via the IGW) to pull the demo container image.
|
||||
@@ -182,9 +182,8 @@ aws ec2 describe-instances --filters "Name=tag:spinifex:eks-cluster,Values=eks-q
|
||||
# Did the demo pods roll out?
|
||||
kubectl get pods -o wide
|
||||
|
||||
# Is the NodePort rule on the cluster security group?
|
||||
SG=$(tofu output -raw cluster_security_group_id)
|
||||
aws ec2 describe-security-groups --group-ids "$SG" \
|
||||
# Is the NodePort rule on the worker SG?
|
||||
aws ec2 describe-security-groups --filters "Name=group-name,Values=eks-cluster-eks-quickstart-nodegroup-sg" \
|
||||
--query 'SecurityGroups[0].IpPermissions'
|
||||
```
|
||||
|
||||
|
||||
@@ -310,15 +310,29 @@ resource "aws_eks_node_group" "default" {
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Open the NodePort on the cluster security group
|
||||
# Open the NodePort on the auto-managed nodegroup SG
|
||||
#
|
||||
# Spinifex creates the cluster security group itself, as EKS does, and attaches
|
||||
# it to the workers. It admits only intra-cluster traffic. To reach the demo
|
||||
# NodePort from a browser, add one rule to the SG the cluster reports.
|
||||
# Spinifex creates the worker SG itself (vpc_config.security_group_ids is
|
||||
# ignored) and admits only intra-cluster traffic. To reach the demo NodePort
|
||||
# from a browser, look the SG up by its deterministic name and add one rule.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
data "aws_security_group" "nodegroup" {
|
||||
filter {
|
||||
name = "group-name"
|
||||
values = ["eks-cluster-${var.cluster_name}-nodegroup-sg"]
|
||||
}
|
||||
|
||||
filter {
|
||||
name = "vpc-id"
|
||||
values = [aws_vpc.main.id]
|
||||
}
|
||||
|
||||
depends_on = [aws_eks_node_group.default]
|
||||
}
|
||||
|
||||
resource "aws_vpc_security_group_ingress_rule" "nodeport" {
|
||||
security_group_id = aws_eks_cluster.this.vpc_config[0].cluster_security_group_id
|
||||
security_group_id = data.aws_security_group.nodegroup.id
|
||||
cidr_ipv4 = var.browse_cidr
|
||||
from_port = var.node_port
|
||||
to_port = var.node_port
|
||||
@@ -353,10 +367,6 @@ output "region" {
|
||||
value = var.region
|
||||
}
|
||||
|
||||
output "cluster_security_group_id" {
|
||||
value = aws_eks_cluster.this.vpc_config[0].cluster_security_group_id
|
||||
}
|
||||
|
||||
output "node_port" {
|
||||
value = var.node_port
|
||||
}
|
||||
|
||||
@@ -47,13 +47,6 @@ type credResponse struct {
|
||||
|
||||
// credEntry caches one task's assumed-role credentials alongside the role it was
|
||||
// assumed from.
|
||||
// credRole is the task role a credID serves and the task it belongs to; the
|
||||
// task ID names the STS session, since the credID is a secret.
|
||||
type credRole struct {
|
||||
roleARN string
|
||||
taskID string
|
||||
}
|
||||
|
||||
type credEntry struct {
|
||||
roleARN string
|
||||
creds stsauth.Credentials
|
||||
@@ -78,7 +71,7 @@ type credEndpoint struct {
|
||||
assume func(ctx context.Context, roleARN, sessionName string) (stsauth.Credentials, error)
|
||||
|
||||
mu sync.Mutex
|
||||
roles map[string]credRole // credID -> task role
|
||||
roles map[string]string // credID -> taskRoleARN
|
||||
cache map[string]credEntry // credID -> cached creds
|
||||
httpClient *http.Client
|
||||
|
||||
@@ -105,7 +98,7 @@ func newCredEndpoint(provider credentials.CredentialsProvider, region, gatewayUR
|
||||
port: port,
|
||||
proxyPort: defaultCredProxyPort,
|
||||
run: run,
|
||||
roles: map[string]credRole{},
|
||||
roles: map[string]string{},
|
||||
cache: map[string]credEntry{},
|
||||
}
|
||||
c.assume = c.assumeOverGateway
|
||||
@@ -114,12 +107,12 @@ func newCredEndpoint(provider credentials.CredentialsProvider, region, gatewayUR
|
||||
|
||||
// Register associates a credID with the task role it serves; Deregister removes
|
||||
// it and drops any cached credentials. Both are no-ops for an empty credID.
|
||||
func (c *credEndpoint) Register(credID, taskID, roleARN string) {
|
||||
func (c *credEndpoint) Register(credID, roleARN string) {
|
||||
if credID == "" || roleARN == "" {
|
||||
return
|
||||
}
|
||||
c.mu.Lock()
|
||||
c.roles[credID] = credRole{roleARN: roleARN, taskID: taskID}
|
||||
c.roles[credID] = roleARN
|
||||
c.mu.Unlock()
|
||||
}
|
||||
|
||||
@@ -149,25 +142,25 @@ func (c *credEndpoint) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
http.Error(w, "not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
creds, role, err := c.fetch(r.Context(), credID)
|
||||
creds, roleARN, err := c.fetch(r.Context(), credID)
|
||||
if err != nil {
|
||||
if errors.Is(err, errUnknownCredID) {
|
||||
http.Error(w, "not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
slog.Error("ecs-agent: assume task role failed", "task", role.taskID, "role", role.roleARN, "err", err)
|
||||
slog.Error("ecs-agent: assume task role failed", "credId", credID, "err", err)
|
||||
http.Error(w, "credentials unavailable", http.StatusBadGateway)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
if err := json.NewEncoder(w).Encode(credResponse{
|
||||
RoleArn: role.roleARN,
|
||||
RoleArn: roleARN,
|
||||
AccessKeyID: creds.AccessKeyID,
|
||||
SecretAccessKey: creds.SecretAccessKey,
|
||||
Token: creds.SessionToken,
|
||||
Expiration: creds.Expiration.UTC().Format(time.RFC3339),
|
||||
}); err != nil {
|
||||
slog.Warn("ecs-agent: encode credential response", "task", role.taskID, "err", err)
|
||||
slog.Warn("ecs-agent: encode credential response", "credId", credID, "err", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -176,28 +169,28 @@ var errUnknownCredID = fmt.Errorf("unknown credId")
|
||||
|
||||
// fetch returns valid credentials for credID, reusing the cache until within the
|
||||
// refresh margin and otherwise assuming the task role over the gateway.
|
||||
func (c *credEndpoint) fetch(ctx context.Context, credID string) (stsauth.Credentials, credRole, error) {
|
||||
func (c *credEndpoint) fetch(ctx context.Context, credID string) (stsauth.Credentials, string, error) {
|
||||
c.mu.Lock()
|
||||
role, known := c.roles[credID]
|
||||
roleARN, known := c.roles[credID]
|
||||
if !known {
|
||||
c.mu.Unlock()
|
||||
return stsauth.Credentials{}, credRole{}, errUnknownCredID
|
||||
return stsauth.Credentials{}, "", errUnknownCredID
|
||||
}
|
||||
if e, ok := c.cache[credID]; ok && e.roleARN == role.roleARN && credValid(e.creds, credRefreshMargin) {
|
||||
if e, ok := c.cache[credID]; ok && e.roleARN == roleARN && credValid(e.creds, credRefreshMargin) {
|
||||
c.mu.Unlock()
|
||||
return e.creds, role, nil
|
||||
return e.creds, roleARN, nil
|
||||
}
|
||||
c.mu.Unlock()
|
||||
|
||||
creds, err := c.assume(ctx, role.roleARN, sessionName(role.taskID))
|
||||
creds, err := c.assume(ctx, roleARN, sessionName(credID))
|
||||
if err != nil {
|
||||
return stsauth.Credentials{}, role, err
|
||||
return stsauth.Credentials{}, "", err
|
||||
}
|
||||
|
||||
c.mu.Lock()
|
||||
c.cache[credID] = credEntry{roleARN: role.roleARN, creds: creds}
|
||||
c.cache[credID] = credEntry{roleARN: roleARN, creds: creds}
|
||||
c.mu.Unlock()
|
||||
return creds, role, nil
|
||||
return creds, roleARN, nil
|
||||
}
|
||||
|
||||
// AssumeProvider returns a CredentialsProvider that mints credentials by assuming
|
||||
@@ -397,10 +390,10 @@ func credValid(c stsauth.Credentials, margin time.Duration) bool {
|
||||
return time.Now().Add(margin).Before(c.Expiration)
|
||||
}
|
||||
|
||||
// sessionName derives an AssumeRole RoleSessionName from the task ID, satisfying the
|
||||
// sessionName derives an AssumeRole RoleSessionName from credID, satisfying the
|
||||
// STS [\w+=,.@-]{2,64} constraint.
|
||||
func sessionName(taskID string) string {
|
||||
name := "ecs-" + taskID
|
||||
func sessionName(credID string) string {
|
||||
name := "ecs-" + credID
|
||||
if len(name) > 64 {
|
||||
name = name[:64]
|
||||
}
|
||||
|
||||
@@ -29,7 +29,7 @@ func TestCredEndpoint_ServesRegisteredCredID(t *testing.T) {
|
||||
c, _ := newTestCredEndpoint(stsauth.Credentials{
|
||||
AccessKeyID: "AKIA", SecretAccessKey: "secret", SessionToken: "tok", Expiration: exp,
|
||||
})
|
||||
c.Register("cred-1", "task-1", "arn:aws:iam::111122223333:role/task")
|
||||
c.Register("cred-1", "arn:aws:iam::111122223333:role/task")
|
||||
|
||||
rr := httptest.NewRecorder()
|
||||
c.ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/v2/credentials/cred-1", nil))
|
||||
@@ -51,26 +51,6 @@ func TestCredEndpoint_ServesRegisteredCredID(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// The STS session name lands in the assumed-role ARN and in STS logs, so it is
|
||||
// built from the public task ID and never from the secret credID.
|
||||
func TestCredEndpoint_SessionNameIsTaskIDNotCredID(t *testing.T) {
|
||||
c, _ := newTestCredEndpoint(stsauth.Credentials{})
|
||||
var gotSession string
|
||||
c.assume = func(ctx context.Context, roleARN, session string) (stsauth.Credentials, error) {
|
||||
gotSession = session
|
||||
return stsauth.Credentials{AccessKeyID: "AKIA", SecretAccessKey: "secret"}, nil
|
||||
}
|
||||
c.Register("secret-cred", "task-1", "arn:role")
|
||||
rr := httptest.NewRecorder()
|
||||
c.ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/v2/credentials/secret-cred", nil))
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200", rr.Code)
|
||||
}
|
||||
if gotSession != "ecs-task-1" {
|
||||
t.Errorf("session name = %q, want ecs-task-1", gotSession)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCredEndpoint_UnknownCredIDIs404(t *testing.T) {
|
||||
c, calls := newTestCredEndpoint(stsauth.Credentials{AccessKeyID: "x", SecretAccessKey: "y"})
|
||||
rr := httptest.NewRecorder()
|
||||
@@ -85,7 +65,7 @@ func TestCredEndpoint_UnknownCredIDIs404(t *testing.T) {
|
||||
|
||||
func TestCredEndpoint_WrongMethodIs405(t *testing.T) {
|
||||
c, _ := newTestCredEndpoint(stsauth.Credentials{AccessKeyID: "x", SecretAccessKey: "y"})
|
||||
c.Register("cred-1", "task-1", "arn:role")
|
||||
c.Register("cred-1", "arn:role")
|
||||
rr := httptest.NewRecorder()
|
||||
c.ServeHTTP(rr, httptest.NewRequest(http.MethodPost, "/v2/credentials/cred-1", nil))
|
||||
if rr.Code != http.StatusMethodNotAllowed {
|
||||
@@ -97,7 +77,7 @@ func TestCredEndpoint_CachesUntilExpiry(t *testing.T) {
|
||||
c, calls := newTestCredEndpoint(stsauth.Credentials{
|
||||
AccessKeyID: "AKIA", SecretAccessKey: "secret", Expiration: time.Now().Add(time.Hour),
|
||||
})
|
||||
c.Register("cred-1", "task-1", "arn:role")
|
||||
c.Register("cred-1", "arn:role")
|
||||
for hit := range 3 {
|
||||
rr := httptest.NewRecorder()
|
||||
c.ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/v2/credentials/cred-1", nil))
|
||||
@@ -115,7 +95,7 @@ func TestCredEndpoint_RefreshesNearExpiry(t *testing.T) {
|
||||
c, calls := newTestCredEndpoint(stsauth.Credentials{
|
||||
AccessKeyID: "AKIA", SecretAccessKey: "secret", Expiration: time.Now().Add(time.Minute),
|
||||
})
|
||||
c.Register("cred-1", "task-1", "arn:role")
|
||||
c.Register("cred-1", "arn:role")
|
||||
for range 2 {
|
||||
rr := httptest.NewRecorder()
|
||||
c.ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/v2/credentials/cred-1", nil))
|
||||
@@ -127,7 +107,7 @@ func TestCredEndpoint_RefreshesNearExpiry(t *testing.T) {
|
||||
|
||||
func TestCredEndpoint_DeregisterDropsCredID(t *testing.T) {
|
||||
c, _ := newTestCredEndpoint(stsauth.Credentials{AccessKeyID: "x", SecretAccessKey: "y"})
|
||||
c.Register("cred-1", "task-1", "arn:role")
|
||||
c.Register("cred-1", "arn:role")
|
||||
c.Deregister("cred-1")
|
||||
rr := httptest.NewRecorder()
|
||||
c.ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/v2/credentials/cred-1", nil))
|
||||
@@ -141,7 +121,7 @@ func TestCredEndpoint_AssumeFailureIs502(t *testing.T) {
|
||||
c.assume = func(ctx context.Context, roleARN, sessionName string) (stsauth.Credentials, error) {
|
||||
return stsauth.Credentials{}, context.DeadlineExceeded
|
||||
}
|
||||
c.Register("cred-1", "task-1", "arn:role")
|
||||
c.Register("cred-1", "arn:role")
|
||||
rr := httptest.NewRecorder()
|
||||
c.ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/v2/credentials/cred-1", nil))
|
||||
if rr.Code != http.StatusBadGateway {
|
||||
@@ -153,7 +133,7 @@ func TestCredEndpoint_StartServesOverLoopback(t *testing.T) {
|
||||
c, _ := newTestCredEndpoint(stsauth.Credentials{
|
||||
AccessKeyID: "AKIA", SecretAccessKey: "secret", Expiration: time.Now().Add(time.Hour),
|
||||
})
|
||||
c.Register("cred-1", "task-1", "arn:role")
|
||||
c.Register("cred-1", "arn:role")
|
||||
c.port = 0 // bind an ephemeral port instead of privileged 80
|
||||
if err := c.Start(); err != nil {
|
||||
t.Fatalf("start: %v", err)
|
||||
|
||||
@@ -1,12 +1,8 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/mulgadc/spinifex/internal/stsauth"
|
||||
|
||||
ecsv1 "github.com/mulgadc/spinifex/contracts/ecs/v1"
|
||||
)
|
||||
|
||||
@@ -17,7 +13,7 @@ func TestTaskCredID(t *testing.T) {
|
||||
want string
|
||||
}{
|
||||
{"no role", ecsv1.Assign{TaskID: "t1"}, ""},
|
||||
{"role, no credID -> none", ecsv1.Assign{TaskID: "t1", TaskRoleARN: "arn:role"}, ""},
|
||||
{"role, no credID -> taskID", ecsv1.Assign{TaskID: "t1", TaskRoleARN: "arn:role"}, "t1"},
|
||||
{"role + credID", ecsv1.Assign{TaskID: "t1", CredID: "c9", TaskRoleARN: "arn:role"}, "c9"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
@@ -27,37 +23,6 @@ func TestTaskCredID(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// The task ID is public (it is the task ARN suffix), so naming another task's ID
|
||||
// at the credential endpoint must not yield that task's role.
|
||||
func TestRegisterTaskCreds_TaskIDDoesNotServeCredentials(t *testing.T) {
|
||||
c, _ := newTestCredEndpoint(stsauth.Credentials{AccessKeyID: "AKIA", SecretAccessKey: "secret"})
|
||||
a := Agent{cred: c}
|
||||
get := func(id string) int {
|
||||
rr := httptest.NewRecorder()
|
||||
c.ServeHTTP(rr, httptest.NewRequest(http.MethodGet, credRelativeURI(id), nil))
|
||||
return rr.Code
|
||||
}
|
||||
|
||||
legacy := ecsv1.Assign{TaskID: "victim-task", TaskRoleARN: "arn:aws:iam::000000000010:role/high"}
|
||||
if got := a.registerTaskCreds(&legacy); got != "" {
|
||||
t.Errorf("assign without CredID registered credID %q", got)
|
||||
}
|
||||
if code := get("victim-task"); code != http.StatusNotFound {
|
||||
t.Errorf("GET by task ID of CredID-less assign = %d, want 404", code)
|
||||
}
|
||||
|
||||
minted := ecsv1.Assign{TaskID: "victim-2", CredID: "secret-cred", TaskRoleARN: "arn:aws:iam::000000000010:role/high"}
|
||||
if got := a.registerTaskCreds(&minted); got != "secret-cred" {
|
||||
t.Fatalf("registerTaskCreds = %q, want secret-cred", got)
|
||||
}
|
||||
if code := get("victim-2"); code != http.StatusNotFound {
|
||||
t.Errorf("GET by task ID = %d, want 404", code)
|
||||
}
|
||||
if code := get("secret-cred"); code != http.StatusOK {
|
||||
t.Errorf("GET by CredID = %d, want 200", code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWithCredEnv(t *testing.T) {
|
||||
base := map[string]string{"FOO": "bar"}
|
||||
|
||||
|
||||
@@ -63,7 +63,7 @@ func TestReconcile_AdoptsRunningLabeledContainer(t *testing.T) {
|
||||
if !adopted["t-001"] {
|
||||
t.Fatalf("t-001 not adopted; got %v", adopted)
|
||||
}
|
||||
if got := a.cred.roles["t-001"].roleARN; got != testTaskRole {
|
||||
if got := a.cred.roles["t-001"]; got != testTaskRole {
|
||||
t.Errorf("cred not re-registered: roles[t-001] = %q, want %q", got, testTaskRole)
|
||||
}
|
||||
if n := countStatus(cp.taskStates(), "t-001", ecsv1.TaskStatusRunning); n != 1 {
|
||||
|
||||
@@ -306,24 +306,24 @@ func (a *Agent) pullResolver(as *ecsv1.Assign) ctrruntime.Resolver {
|
||||
// credential endpoint and returns the credID (empty when the task has no role).
|
||||
func (a *Agent) registerTaskCreds(as *ecsv1.Assign) string {
|
||||
credID := taskCredID(as)
|
||||
if credID == "" && as.TaskRoleARN != "" {
|
||||
slog.Warn("ecs-agent: task has a role but no credential ID, starting it without credentials", "task", as.TaskID, "role", as.TaskRoleARN)
|
||||
}
|
||||
if credID == "" || a.cred == nil {
|
||||
return credID
|
||||
}
|
||||
a.cred.Register(credID, as.TaskID, as.TaskRoleARN)
|
||||
a.cred.Register(credID, as.TaskRoleARN)
|
||||
return credID
|
||||
}
|
||||
|
||||
// taskCredID is the credential ID a task's containers fetch credentials under:
|
||||
// the scheduler-minted secret CredID. Empty when the task carries no IAM role or
|
||||
// no CredID; the public task ID is never used, since any API caller can learn it.
|
||||
// the scheduler-assigned CredID, falling back to the taskID. Empty when the task
|
||||
// carries no IAM role.
|
||||
func taskCredID(as *ecsv1.Assign) string {
|
||||
if as.TaskRoleARN == "" {
|
||||
return ""
|
||||
}
|
||||
return as.CredID
|
||||
if as.CredID != "" {
|
||||
return as.CredID
|
||||
}
|
||||
return as.TaskID
|
||||
}
|
||||
|
||||
// withCredEnv returns env with AWS_CONTAINER_CREDENTIALS_RELATIVE_URI set for
|
||||
|
||||
@@ -5,6 +5,7 @@ package config
|
||||
import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"maps"
|
||||
"net/netip"
|
||||
"os"
|
||||
"path/filepath"
|
||||
@@ -355,6 +356,11 @@ type EBSConfig struct {
|
||||
CacheSizeMB *int `json:"CacheSizeMB" mapstructure:"cache_size_mb"`
|
||||
}
|
||||
|
||||
// SupportedEBSProvider reports whether name is a provider a node may run.
|
||||
func SupportedEBSProvider(name string) bool {
|
||||
return name == EBSProviderViperblockd || name == EBSProviderQEMUNBD
|
||||
}
|
||||
|
||||
// ResolvedProvider normalizes an empty Provider to EBSProviderViperblockd,
|
||||
// the default provider.
|
||||
func (c EBSConfig) ResolvedProvider() string {
|
||||
@@ -801,6 +807,27 @@ func validateVultrPool(cc *ClusterConfig, p ExternalPool) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// validateSingleEBSProvider refuses nodes whose explicit [nodes.<name>.ebs]
|
||||
// providers differ. A peer stanza without one is unknown, not the default: the
|
||||
// generated config omits it, and the cluster's KV lock is the authoritative check.
|
||||
func validateSingleEBSProvider(cc *ClusterConfig) error {
|
||||
var first, want string
|
||||
for _, name := range slices.Sorted(maps.Keys(cc.Nodes)) {
|
||||
if !viper.IsSet("nodes." + name + ".ebs.provider") {
|
||||
continue
|
||||
}
|
||||
got := cc.Nodes[name].EBS.ResolvedProvider()
|
||||
if first == "" {
|
||||
first, want = name, got
|
||||
continue
|
||||
}
|
||||
if got != want {
|
||||
return fmt.Errorf("config: [nodes.%s.ebs] provider resolves to %q but [nodes.%s.ebs] resolves to %q; every node in a cluster must use the same EBS provider", first, want, name, got)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateClusterConfig(cc *ClusterConfig) error {
|
||||
if viper.IsSet("network.external_dhcp") {
|
||||
return fmt.Errorf("config: [network] external_dhcp is no longer supported; remove the key (static WAN-pool allocation only)")
|
||||
@@ -809,9 +836,9 @@ func validateClusterConfig(cc *ClusterConfig) error {
|
||||
if viper.IsSet("nodes." + nodeName + ".vpcd.dhcp_bind_bridge") {
|
||||
return fmt.Errorf("config: [nodes.%s.vpcd] dhcp_bind_bridge is no longer supported; remove the key (vpcd no longer runs a DHCP client)", nodeName)
|
||||
}
|
||||
switch nodeCfg.EBS.Provider {
|
||||
case "", EBSProviderViperblockd, EBSProviderQEMUNBD:
|
||||
case EBSProviderEmbedded:
|
||||
switch {
|
||||
case nodeCfg.EBS.Provider == "", SupportedEBSProvider(nodeCfg.EBS.Provider):
|
||||
case nodeCfg.EBS.Provider == EBSProviderEmbedded:
|
||||
// Refuse rather than silently upgrade: the embedded engine is gone,
|
||||
// and starting anyway would migrate this node's volumes to
|
||||
// ebsmetadata one-way without the operator ever asking for it.
|
||||
@@ -829,6 +856,9 @@ func validateClusterConfig(cc *ClusterConfig) error {
|
||||
return fmt.Errorf("config: [nodes.%s.ebs] cache_size_mb=%d must not be negative; use 0 to disable the cache, or remove the key for the default of %d", nodeName, *c, DefaultCacheSizeMB)
|
||||
}
|
||||
}
|
||||
if err := validateSingleEBSProvider(cc); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := validateNorthstarCluster(cc); err != nil {
|
||||
return err
|
||||
|
||||
@@ -1108,6 +1108,45 @@ provider = "bogus"
|
||||
assert.Contains(t, err.Error(), `provider="bogus"`)
|
||||
}
|
||||
|
||||
func TestLoadConfig_EBSProvider_SingleProviderPerCluster(t *testing.T) {
|
||||
stanza := func(node, provider string) string {
|
||||
return fmt.Sprintf("[nodes.%s.ebs]\nprovider = %q\n", node, provider)
|
||||
}
|
||||
cases := []struct {
|
||||
name string
|
||||
ebs []string
|
||||
wantErr string
|
||||
}{
|
||||
{"all unset", []string{"", "", ""}, ""},
|
||||
{"explicit viperblockd beside unset peers", []string{"", stanza("n2", EBSProviderViperblockd), ""}, ""},
|
||||
{"explicit qemunbd beside unset peers", []string{stanza("n1", EBSProviderQEMUNBD), "", ""}, ""},
|
||||
{"all explicit qemunbd", []string{stanza("n1", EBSProviderQEMUNBD), stanza("n2", EBSProviderQEMUNBD), stanza("n3", EBSProviderQEMUNBD)}, ""},
|
||||
{"two explicit providers differ", []string{stanza("n1", EBSProviderQEMUNBD), stanza("n2", EBSProviderViperblockd), ""},
|
||||
`[nodes.n1.ebs] provider resolves to "qemunbd" but [nodes.n2.ebs] resolves to "viperblockd"`},
|
||||
{"only the third node differs", []string{stanza("n1", EBSProviderViperblockd), stanza("n2", EBSProviderViperblockd), stanza("n3", EBSProviderQEMUNBD)},
|
||||
`[nodes.n1.ebs] provider resolves to "viperblockd" but [nodes.n3.ebs] resolves to "qemunbd"`},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
body := "node = \"n1\"\n"
|
||||
for i, ebs := range tc.ebs {
|
||||
body += fmt.Sprintf("\n[nodes.n%d]\nregion = \"us-east-1\"\n%s", i+1, ebs)
|
||||
}
|
||||
resetViper(t)
|
||||
path := filepath.Join(t.TempDir(), "spinifex.toml")
|
||||
require.NoError(t, os.WriteFile(path, []byte(body), 0600))
|
||||
|
||||
_, err := LoadConfig(path)
|
||||
if tc.wantErr == "" {
|
||||
require.NoError(t, err)
|
||||
return
|
||||
}
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), tc.wantErr)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestEBSConfig_ResolvedTunablesDefault(t *testing.T) {
|
||||
var c EBSConfig
|
||||
assert.Equal(t, DefaultNBDKitThreads, c.ResolvedThreads())
|
||||
|
||||
@@ -152,30 +152,3 @@ func TestImportCertificate_SerialZeroRejected(t *testing.T) {
|
||||
assert.Equal(t, awserrors.ErrorValidationException, code)
|
||||
assert.Equal(t, "The serial number in the certificate is not supported by ACM.", msg)
|
||||
}
|
||||
|
||||
func TestImportCertificate_ExpiredRejected(t *testing.T) {
|
||||
t.Parallel()
|
||||
svc := setupACMService(t)
|
||||
key, err := rsa.GenerateKey(rand.Reader, 1024)
|
||||
require.NoError(t, err)
|
||||
tmpl := &x509.Certificate{
|
||||
SerialNumber: big.NewInt(time.Now().UnixNano()),
|
||||
Subject: pkix.Name{CommonName: "expired.example.com"},
|
||||
NotBefore: time.Now().Add(-48 * time.Hour),
|
||||
NotAfter: time.Now().Add(-24 * time.Hour),
|
||||
}
|
||||
der, err := x509.CreateCertificate(rand.Reader, tmpl, tmpl, &key.PublicKey, key)
|
||||
require.NoError(t, err)
|
||||
_, err = svc.ImportCertificate(context.Background(), &acm.ImportCertificateInput{
|
||||
Certificate: pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der}),
|
||||
PrivateKey: pem.EncodeToMemory(&pem.Block{Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(key)}),
|
||||
}, testAccountID)
|
||||
code, msg, ok := awserrors.ResolveErrorDetail(err)
|
||||
require.True(t, ok)
|
||||
assert.Equal(t, awserrors.ErrorValidationException, code)
|
||||
assert.Equal(t, "The certificate is expired. You can Import a certificate only during its validity period.", msg)
|
||||
|
||||
list, err := svc.ListCertificates(context.Background(), &acm.ListCertificatesInput{}, testAccountID)
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, list.CertificateSummaryList)
|
||||
}
|
||||
|
||||
@@ -223,13 +223,10 @@ func (s *ACMServiceImpl) ImportCertificate(ctx context.Context, input *acm.Impor
|
||||
if leaf.SerialNumber.Sign() == 0 {
|
||||
return nil, awserrors.Errorf(awserrors.ErrorValidationException, "The serial number in the certificate is not supported by ACM.")
|
||||
}
|
||||
certArn := aws.StringValue(input.CertificateArn)
|
||||
now := time.Now().UTC()
|
||||
if certArn == "" && now.After(leaf.NotAfter) {
|
||||
return nil, awserrors.Errorf(awserrors.ErrorValidationException, "The certificate is expired. You can Import a certificate only during its validity period.")
|
||||
}
|
||||
|
||||
certArn := aws.StringValue(input.CertificateArn)
|
||||
var inUseBy []string
|
||||
now := time.Now().UTC()
|
||||
createdAt := now
|
||||
tags := tagsToMap(input.Tags)
|
||||
reimport := certArn != ""
|
||||
|
||||
@@ -134,9 +134,6 @@ func marshalEC2Response(action string, output any) ([]byte, error) {
|
||||
|
||||
var ec2Actions = map[string]ec2Action{
|
||||
"DescribeInstances": ec2Handler(func(ctx context.Context, input *ec2.DescribeInstancesInput, c *call, accountID string) (any, error) {
|
||||
if err := ec2instanceapi.ValidateDescribeInstancesInput(input); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out, err := ec2instanceapi.DescribeInstancesChecked(ctx, input, c.NATS, 0, c.NodeIDs, accountID)
|
||||
if err != nil {
|
||||
return out, err
|
||||
@@ -260,9 +257,6 @@ var ec2Actions = map[string]ec2Action{
|
||||
}
|
||||
}),
|
||||
"DescribeImages": ec2Handler(func(ctx context.Context, input *ec2.DescribeImagesInput, c *call, accountID string) (any, error) {
|
||||
if err := ec2imageapi.ValidateDescribeImagesInput(input); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return ec2imageapi.DescribeImages(ctx, input, c.NATS, accountID)
|
||||
}),
|
||||
"CreateImage": ec2Handler(func(ctx context.Context, input *ec2.CreateImageInput, c *call, accountID string) (any, error) {
|
||||
@@ -293,9 +287,6 @@ var ec2Actions = map[string]ec2Action{
|
||||
return ec2zoneapi.DescribeAvailabilityZones(input, c.Region, c.AZ)
|
||||
}),
|
||||
"DescribeVolumes": ec2Handler(func(ctx context.Context, input *ec2.DescribeVolumesInput, c *call, accountID string) (any, error) {
|
||||
if err := ec2volumeapi.ValidateDescribeVolumesInput(input); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return ec2volumeapi.DescribeVolumes(ctx, input, c.NATS, accountID)
|
||||
}),
|
||||
"ModifyVolume": ec2Handler(func(ctx context.Context, input *ec2.ModifyVolumeInput, c *call, accountID string) (any, error) {
|
||||
|
||||
@@ -6,7 +6,6 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/mulgadc/spinifex/spinifex/foundation/aws/errors"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
@@ -31,27 +30,3 @@ func TestImportKeyPair_Base64PaddingWorkaround(t *testing.T) {
|
||||
assert.NotContains(t, q["PublicKeyMaterial"], "%3D",
|
||||
"Expected no URL-encoded padding remaining")
|
||||
}
|
||||
|
||||
// A well-prefixed ID of the wrong shape is refused before any service is
|
||||
// asked, so it can never come back as NotFound.
|
||||
func TestDescribe_MalformedIDRejectedBeforeLookup(t *testing.T) {
|
||||
tests := []struct {
|
||||
action, param, id, code, msg string
|
||||
}{
|
||||
{"DescribeInstances", "InstanceId.1", "i-zzz", awserrors.ErrorInvalidInstanceIDMalformed, `Invalid id: "i-zzz"`},
|
||||
{"DescribeImages", "ImageId.1", "ami-zzz", awserrors.ErrorInvalidAMIIDMalformed, `Invalid id: "ami-zzz"`},
|
||||
{"DescribeVolumes", "VolumeId.1", "vol-zzz", awserrors.ErrorInvalidParameterValue, "Value (vol-zzz) for parameter volumes is invalid. Expected: 'vol-...'."},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.action, func(t *testing.T) {
|
||||
h := ec2Actions[tt.action]
|
||||
input, err := h.parse(map[string]string{tt.param: tt.id})
|
||||
require.NoError(t, err)
|
||||
_, err = h.dispatch(t.Context(), tt.action, input, &call{Handler: NewHandler(Deps{})}, "123456789012")
|
||||
code, msg, ok := awserrors.ResolveErrorDetail(err)
|
||||
require.True(t, ok)
|
||||
assert.Equal(t, tt.code, code)
|
||||
assert.Equal(t, tt.msg, msg)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,7 +5,6 @@ package image
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"github.com/aws/aws-sdk-go/service/ec2"
|
||||
@@ -14,12 +13,8 @@ import (
|
||||
"github.com/nats-io/nats.go"
|
||||
)
|
||||
|
||||
// imageIDSuffixPattern is the AWS AMI ID shape after ami-: 8 (legacy) or 17
|
||||
// lowercase hex characters.
|
||||
var imageIDSuffixPattern = regexp.MustCompile(`^([0-9a-f]{8}|[0-9a-f]{17})$`)
|
||||
|
||||
// ValidateDescribeImagesInput accepts a nil input and rejects any ImageIds entry that is not
|
||||
// ami- followed by 8 or 17 lowercase hex characters with InvalidAMIID.Malformed.
|
||||
// ValidateDescribeImagesInput accepts a nil input and rejects any ImageIds entry without the ami-
|
||||
// prefix with InvalidAMIID.Malformed.
|
||||
func ValidateDescribeImagesInput(input *ec2.DescribeImagesInput) (err error) {
|
||||
if input == nil {
|
||||
return nil
|
||||
@@ -27,16 +22,9 @@ func ValidateDescribeImagesInput(input *ec2.DescribeImagesInput) (err error) {
|
||||
|
||||
if input.ImageIds != nil {
|
||||
for _, imageId := range input.ImageIds {
|
||||
if imageId == nil {
|
||||
continue
|
||||
}
|
||||
suffix, ok := strings.CutPrefix(*imageId, "ami-")
|
||||
if !ok {
|
||||
if imageId != nil && !strings.HasPrefix(*imageId, "ami-") {
|
||||
return errors.New(awserrors.ErrorInvalidAMIIDMalformed)
|
||||
}
|
||||
if !imageIDSuffixPattern.MatchString(suffix) {
|
||||
return awserrors.Errorf(awserrors.ErrorInvalidAMIIDMalformed, "Invalid id: %q", *imageId)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -46,6 +34,11 @@ func ValidateDescribeImagesInput(input *ec2.DescribeImagesInput) (err error) {
|
||||
// DescribeImages implements the EC2 DescribeImages action, returning the AMIs visible to
|
||||
// accountID from the NATS image service.
|
||||
func DescribeImages(ctx context.Context, input *ec2.DescribeImagesInput, natsConn *nats.Conn, accountID string) (output ec2.DescribeImagesOutput, err error) {
|
||||
err = ValidateDescribeImagesInput(input)
|
||||
if err != nil {
|
||||
return output, err
|
||||
}
|
||||
|
||||
imageService := ec2image.NewNATSImageService(natsConn, 0)
|
||||
result, err := imageService.DescribeImages(ctx, input, accountID)
|
||||
if err != nil {
|
||||
|
||||
@@ -53,8 +53,8 @@ func TestValidateDescribeImagesInput(t *testing.T) {
|
||||
name: "MultipleValidImageIds",
|
||||
input: &ec2.DescribeImagesInput{
|
||||
ImageIds: []*string{
|
||||
aws.String("ami-1a2b3c4d"),
|
||||
aws.String("ami-0123456789abcdef1"),
|
||||
aws.String("ami-111"),
|
||||
aws.String("ami-222"),
|
||||
},
|
||||
},
|
||||
wantErr: false,
|
||||
@@ -63,7 +63,7 @@ func TestValidateDescribeImagesInput(t *testing.T) {
|
||||
name: "MixedValidAndInvalidImageIds",
|
||||
input: &ec2.DescribeImagesInput{
|
||||
ImageIds: []*string{
|
||||
aws.String("ami-0123456789abcdef0"),
|
||||
aws.String("ami-valid"),
|
||||
aws.String("invalid-id"),
|
||||
},
|
||||
},
|
||||
@@ -85,24 +85,11 @@ func TestValidateDescribeImagesInput(t *testing.T) {
|
||||
err := ValidateDescribeImagesInput(tt.input)
|
||||
|
||||
if tt.wantErr {
|
||||
code, ok := awserrors.ResolveErrorCode(err)
|
||||
assert.True(t, ok)
|
||||
assert.Equal(t, tt.errMsg, code)
|
||||
assert.Error(t, err)
|
||||
assert.Equal(t, tt.errMsg, err.Error())
|
||||
} else {
|
||||
assert.NoError(t, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// AWS answers a well-prefixed ID whose suffix is not 8 or 17 lowercase hex
|
||||
// characters with Malformed, not NotFound.
|
||||
func TestValidateDescribeImagesInput_MalformedSuffix(t *testing.T) {
|
||||
for _, id := range []string{"ami-zzz", "ami-0123456789ABCDEF0", "ami-0123456789abcdef", "ami-"} {
|
||||
err := ValidateDescribeImagesInput(&ec2.DescribeImagesInput{ImageIds: []*string{aws.String(id)}})
|
||||
code, msg, ok := awserrors.ResolveErrorDetail(err)
|
||||
assert.True(t, ok, id)
|
||||
assert.Equal(t, awserrors.ErrorInvalidAMIIDMalformed, code, id)
|
||||
assert.Equal(t, `Invalid id: "`+id+`"`, msg, id)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -39,6 +39,13 @@ func TestCreateImage_NilNATS(t *testing.T) {
|
||||
assert.Error(t, err)
|
||||
}
|
||||
|
||||
func TestDescribeImages_ValidationErrors(t *testing.T) {
|
||||
_, err := DescribeImages(context.Background(), &ec2.DescribeImagesInput{
|
||||
ImageIds: []*string{aws.String("bad-id")},
|
||||
}, nil, "")
|
||||
assert.EqualError(t, err, awserrors.ErrorInvalidAMIIDMalformed)
|
||||
}
|
||||
|
||||
func TestDescribeImages_NilNATS(t *testing.T) {
|
||||
_, err := DescribeImages(context.Background(), nil, nil, "acct-123")
|
||||
assert.Error(t, err)
|
||||
|
||||
@@ -89,12 +89,6 @@ func DescribeInstances(ctx context.Context, input *ec2.DescribeInstancesInput, n
|
||||
return &ec2.DescribeInstancesOutput{Reservations: reservations}, nil
|
||||
}
|
||||
|
||||
// ValidateDescribeInstancesInput rejects any InstanceIds entry that is not a
|
||||
// well-formed instance ID with InvalidInstanceID.Malformed.
|
||||
func ValidateDescribeInstancesInput(input *ec2.DescribeInstancesInput) error {
|
||||
return validateInstanceIDFormat(input.InstanceIds)
|
||||
}
|
||||
|
||||
// DescribeInstancesChecked is the customer-facing variant served by the
|
||||
// gateway's DescribeInstances action. On top of DescribeInstances' aggregation,
|
||||
// it asserts InvalidInstanceID.NotFound for any explicitly named instance ID
|
||||
|
||||
@@ -35,7 +35,7 @@ func resolveAndAuthorizeProfile(spec *ec2.IamInstanceProfileSpecification, iamSv
|
||||
}
|
||||
profile, err := iamSvc.ResolveInstanceProfile(accountID, nameOrARN)
|
||||
if err != nil {
|
||||
if code, _ := awserrors.ResolveErrorCode(err); code == awserrors.ErrorIAMNoSuchEntity {
|
||||
if err.Error() == awserrors.ErrorIAMNoSuchEntity {
|
||||
return nil, errors.New(awserrors.ErrorInvalidIamInstanceProfileNotFound)
|
||||
}
|
||||
return nil, err
|
||||
|
||||
@@ -45,7 +45,7 @@ func (f *fakeIAMService) ResolveInstanceProfile(accountID, nameOrARN string) (*i
|
||||
if f.resolveFn != nil {
|
||||
return f.resolveFn(accountID, nameOrARN)
|
||||
}
|
||||
return nil, awserrors.Errorf(awserrors.ErrorIAMNoSuchEntity, "Instance Profile %s cannot be found.", nameOrARN)
|
||||
return nil, errors.New(awserrors.ErrorIAMNoSuchEntity)
|
||||
}
|
||||
|
||||
func (f *fakeIAMService) CanonicalResourceARN(accountID string, kind arn.IAMResourceType, name string) (string, error) {
|
||||
|
||||
@@ -25,7 +25,7 @@ var instanceIDPattern = regexp.MustCompile(`^i-([0-9a-f]{8}|[0-9a-f]{17})$`)
|
||||
func validateInstanceIDFormat(ids []*string) error {
|
||||
for _, id := range ids {
|
||||
if id != nil && !instanceIDPattern.MatchString(*id) {
|
||||
return awserrors.Errorf(awserrors.ErrorInvalidInstanceIDMalformed, "Invalid id: %q", *id)
|
||||
return errors.New(awserrors.ErrorInvalidInstanceIDMalformed)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
|
||||
@@ -57,10 +57,8 @@ func TestValidateInstanceIDFormat(t *testing.T) {
|
||||
}
|
||||
for _, id := range malformed {
|
||||
err := validateInstanceIDFormat([]*string{aws.String(id)})
|
||||
code, msg, ok := awserrors.ResolveErrorDetail(err)
|
||||
require.True(t, ok, id)
|
||||
assert.Equal(t, awserrors.ErrorInvalidInstanceIDMalformed, code, id)
|
||||
assert.Equal(t, `Invalid id: "`+id+`"`, msg, id)
|
||||
require.Error(t, err, id)
|
||||
assert.Equal(t, awserrors.ErrorInvalidInstanceIDMalformed, err.Error(), id)
|
||||
}
|
||||
err := validateInstanceIDFormat([]*string{aws.String("i-0123456789abcdef0"), aws.String("i-abc")})
|
||||
require.Error(t, err, "one malformed id fails the whole batch")
|
||||
@@ -102,9 +100,8 @@ func TestLifecycleVerbs_MalformedIDRejectedBeforeNATS(t *testing.T) {
|
||||
}
|
||||
for name, call := range calls {
|
||||
err := call()
|
||||
code, ok := awserrors.ResolveErrorCode(err)
|
||||
require.True(t, ok, name)
|
||||
assert.Equal(t, awserrors.ErrorInvalidInstanceIDMalformed, code, name)
|
||||
require.Error(t, err, name)
|
||||
assert.Equal(t, awserrors.ErrorInvalidInstanceIDMalformed, err.Error(), name)
|
||||
}
|
||||
require.NoError(t, nc.Flush())
|
||||
assert.Zero(t, requests.Load(), "no NATS request may be sent for a batch holding a malformed id")
|
||||
|
||||
@@ -5,7 +5,6 @@ package volume
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"github.com/aws/aws-sdk-go/service/ec2"
|
||||
@@ -14,13 +13,8 @@ import (
|
||||
"github.com/nats-io/nats.go"
|
||||
)
|
||||
|
||||
// volumeIDSuffixPattern is the AWS volume ID shape after vol-: 8 (legacy) or
|
||||
// 17 lowercase hex characters.
|
||||
var volumeIDSuffixPattern = regexp.MustCompile(`^([0-9a-f]{8}|[0-9a-f]{17})$`)
|
||||
|
||||
// ValidateDescribeVolumesInput accepts a nil input, rejects any VolumeIds entry without the
|
||||
// vol- prefix with InvalidVolumeID.Malformed, and any with a malformed suffix with
|
||||
// InvalidParameterValue, as AWS does.
|
||||
// ValidateDescribeVolumesInput accepts a nil input and rejects any VolumeIds entry without the
|
||||
// vol- prefix with InvalidVolumeID.Malformed.
|
||||
func ValidateDescribeVolumesInput(input *ec2.DescribeVolumesInput) error {
|
||||
if input == nil {
|
||||
return nil
|
||||
@@ -28,17 +22,9 @@ func ValidateDescribeVolumesInput(input *ec2.DescribeVolumesInput) error {
|
||||
|
||||
if input.VolumeIds != nil {
|
||||
for _, volumeId := range input.VolumeIds {
|
||||
if volumeId == nil {
|
||||
continue
|
||||
}
|
||||
suffix, ok := strings.CutPrefix(*volumeId, "vol-")
|
||||
if !ok {
|
||||
if volumeId != nil && !strings.HasPrefix(*volumeId, "vol-") {
|
||||
return errors.New(awserrors.ErrorInvalidVolumeIDMalformed)
|
||||
}
|
||||
if !volumeIDSuffixPattern.MatchString(suffix) {
|
||||
return awserrors.Errorf(awserrors.ErrorInvalidParameterValue,
|
||||
"Value (%s) for parameter volumes is invalid. Expected: 'vol-...'.", *volumeId)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -49,6 +35,11 @@ func ValidateDescribeVolumesInput(input *ec2.DescribeVolumesInput) error {
|
||||
func DescribeVolumes(ctx context.Context, input *ec2.DescribeVolumesInput, natsConn *nats.Conn, accountID string) (ec2.DescribeVolumesOutput, error) {
|
||||
var output ec2.DescribeVolumesOutput
|
||||
|
||||
err := ValidateDescribeVolumesInput(input)
|
||||
if err != nil {
|
||||
return output, err
|
||||
}
|
||||
|
||||
volumeService := ec2volume.NewNATSVolumeService(natsConn)
|
||||
result, err := volumeService.DescribeVolumes(ctx, input, accountID)
|
||||
if err != nil {
|
||||
|
||||
@@ -5,7 +5,6 @@ import (
|
||||
|
||||
"github.com/aws/aws-sdk-go/aws"
|
||||
"github.com/aws/aws-sdk-go/service/ec2"
|
||||
"github.com/mulgadc/spinifex/spinifex/foundation/aws/errors"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
@@ -37,9 +36,9 @@ func TestValidateDescribeVolumesInput(t *testing.T) {
|
||||
name: "ValidMultipleVolumeIds",
|
||||
input: &ec2.DescribeVolumesInput{
|
||||
VolumeIds: []*string{
|
||||
aws.String("vol-0123456789abcdef1"),
|
||||
aws.String("vol-1a2b3c4d"),
|
||||
aws.String("vol-0123456789abcdef2"),
|
||||
aws.String("vol-abc123"),
|
||||
aws.String("vol-def456"),
|
||||
aws.String("vol-ghi789"),
|
||||
},
|
||||
},
|
||||
wantErr: false,
|
||||
@@ -72,7 +71,7 @@ func TestValidateDescribeVolumesInput(t *testing.T) {
|
||||
name: "MixedValidAndInvalid",
|
||||
input: &ec2.DescribeVolumesInput{
|
||||
VolumeIds: []*string{
|
||||
aws.String("vol-0123456789abcdef3"),
|
||||
aws.String("vol-valid123"),
|
||||
aws.String("invalid-id"),
|
||||
},
|
||||
},
|
||||
@@ -82,7 +81,7 @@ func TestValidateDescribeVolumesInput(t *testing.T) {
|
||||
{
|
||||
name: "NilVolumeIdInList",
|
||||
input: &ec2.DescribeVolumesInput{
|
||||
VolumeIds: []*string{nil, aws.String("vol-0123456789abcdef1")},
|
||||
VolumeIds: []*string{nil, aws.String("vol-abc123")},
|
||||
},
|
||||
wantErr: false,
|
||||
},
|
||||
@@ -101,15 +100,3 @@ func TestValidateDescribeVolumesInput(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// AWS answers a well-prefixed ID whose suffix is not 8 or 17 lowercase hex
|
||||
// characters with InvalidParameterValue, not NotFound.
|
||||
func TestValidateDescribeVolumesInput_MalformedSuffix(t *testing.T) {
|
||||
for _, id := range []string{"vol-zzz", "vol-0123456789ABCDEF0", "vol-0123456789abcdef", "vol-"} {
|
||||
err := ValidateDescribeVolumesInput(&ec2.DescribeVolumesInput{VolumeIds: []*string{aws.String(id)}})
|
||||
code, msg, ok := awserrors.ResolveErrorDetail(err)
|
||||
assert.True(t, ok, id)
|
||||
assert.Equal(t, awserrors.ErrorInvalidParameterValue, code, id)
|
||||
assert.Equal(t, "Value ("+id+") for parameter volumes is invalid. Expected: 'vol-...'.", msg, id)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -96,6 +96,13 @@ func TestModifyVolume_NilNATS(t *testing.T) {
|
||||
assert.Error(t, err)
|
||||
}
|
||||
|
||||
func TestDescribeVolumes_ValidationErrors(t *testing.T) {
|
||||
_, err := DescribeVolumes(context.Background(), &ec2.DescribeVolumesInput{
|
||||
VolumeIds: []*string{aws.String("bad-id")},
|
||||
}, nil, "")
|
||||
assert.EqualError(t, err, awserrors.ErrorInvalidVolumeIDMalformed)
|
||||
}
|
||||
|
||||
func TestDescribeVolumes_NilNATS(t *testing.T) {
|
||||
_, err := DescribeVolumes(context.Background(), nil, nil, "acct-123")
|
||||
assert.Error(t, err)
|
||||
|
||||
@@ -738,7 +738,7 @@ func (s *IMDSServiceImpl) profileFor(ctx context.Context, eni *eniFacts) (*resol
|
||||
}
|
||||
profile, err := s.iam.ResolveInstanceProfile(ctx, eni.iamAccountID(), inst.iamInstanceProfileArn)
|
||||
if err != nil {
|
||||
if code, _ := awserrors.ResolveErrorCode(err); code == awserrors.ErrorIAMNoSuchEntity {
|
||||
if err.Error() == awserrors.ErrorIAMNoSuchEntity {
|
||||
return nil, roleMissProfileDeleted, nil // profile deleted; treat as no role
|
||||
}
|
||||
slog.ErrorContext(ctx, "IMDS: resolve instance profile failed", "account_id", eni.iamAccountID(), "arn", inst.iamInstanceProfileArn, "err", err)
|
||||
|
||||
@@ -1152,7 +1152,7 @@ func TestHTTP_SecurityCredentialsListBackendError500(t *testing.T) {
|
||||
// genuine backend error (TestHTTP_*BackendError500) is a 500.
|
||||
func TestHTTP_IAMInfoDeletedProfile404(t *testing.T) {
|
||||
res := &fakeResolver{eni: testENI(), inst: &instanceFacts{iamInstanceProfileArn: "arn:aws:iam::111122223333:instance-profile/gone"}}
|
||||
svc, _ := newTestService(res, &fakeIAM{profileErr: awserrors.Errorf(awserrors.ErrorIAMNoSuchEntity, "Instance Profile gone cannot be found.")}, &fakeAssumer{})
|
||||
svc, _ := newTestService(res, &fakeIAM{profileErr: errors.New(awserrors.ErrorIAMNoSuchEntity)}, &fakeAssumer{})
|
||||
h := withTapENI(svc.httpHandler(), testENI())
|
||||
token := issueToken(t, h)
|
||||
rec := get(t, h, prefixMetaData+"iam/info", token)
|
||||
|
||||
@@ -224,7 +224,7 @@ func (s *VPCServiceImpl) CreateNetworkInterface(ctx context.Context, input *ec2.
|
||||
groupNames := make(map[string]string)
|
||||
s.fillSGNames(ctx, accountID, record.SecurityGroupIds, groupNames)
|
||||
return &ec2.CreateNetworkInterfaceOutput{
|
||||
NetworkInterface: s.eniRecordToEC2(&record, accountID, groupNames, s.vpcDNSHostnames(ctx, accountID, record.VpcId)),
|
||||
NetworkInterface: s.eniRecordToEC2(&record, accountID, groupNames),
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -595,7 +595,7 @@ func (s *VPCServiceImpl) DescribeNetworkInterfaces(ctx context.Context, input *e
|
||||
}
|
||||
|
||||
s.fillSGNames(ctx, accountID, record.SecurityGroupIds, groupNames)
|
||||
enis = append(enis, s.eniRecordToEC2(&record, accountID, groupNames, s.vpcDNSHostnames(ctx, accountID, record.VpcId)))
|
||||
enis = append(enis, s.eniRecordToEC2(&record, accountID, groupNames))
|
||||
}
|
||||
|
||||
// If specific ENI IDs were requested but not found, return error
|
||||
@@ -1076,26 +1076,9 @@ func (s *VPCServiceImpl) fillSGNames(ctx context.Context, accountID string, ids
|
||||
}
|
||||
}
|
||||
|
||||
// vpcDNSHostnames reports whether the VPC has enableDnsHostnames on. An
|
||||
// unreadable VPC reads as off, so its interfaces go unnamed.
|
||||
func (s *VPCServiceImpl) vpcDNSHostnames(ctx context.Context, accountID, vpcID string) bool {
|
||||
entry, err := s.vpcKV.Get(ctx, kvutil.AccountKey(accountID, vpcID))
|
||||
if err != nil {
|
||||
slog.WarnContext(ctx, "vpcDNSHostnames: VPC read failed", "vpcId", vpcID, "err", err)
|
||||
return false
|
||||
}
|
||||
var rec VPCRecord
|
||||
if err := json.Unmarshal(entry.Value(), &rec); err != nil {
|
||||
slog.WarnContext(ctx, "vpcDNSHostnames: VPC unmarshal failed", "vpcId", vpcID, "err", err)
|
||||
return false
|
||||
}
|
||||
return rec.EnableDnsHostnames
|
||||
}
|
||||
|
||||
// eniRecordToEC2 converts an ENI record to an EC2 NetworkInterface, naming
|
||||
// each security group from groupNames. AWS names the interface only while its
|
||||
// VPC has enableDnsHostnames on.
|
||||
func (s *VPCServiceImpl) eniRecordToEC2(record *ENIRecord, accountID string, groupNames map[string]string, dnsHostnames bool) *ec2.NetworkInterface {
|
||||
// each security group from groupNames.
|
||||
func (s *VPCServiceImpl) eniRecordToEC2(record *ENIRecord, accountID string, groupNames map[string]string) *ec2.NetworkInterface {
|
||||
// ENIs with spinifex:managed-by tag are system-managed (e.g. by ELBv2)
|
||||
requesterManaged := record.Tags["spinifex:managed-by"] != ""
|
||||
|
||||
@@ -1133,7 +1116,7 @@ func (s *VPCServiceImpl) eniRecordToEC2(record *ENIRecord, accountID string, gro
|
||||
eni.Groups = groups
|
||||
}
|
||||
|
||||
if dnsHostnames && s.privateDNSName != nil && record.PrivateIpAddress != "" {
|
||||
if s.privateDNSName != nil && record.PrivateIpAddress != "" {
|
||||
if name := s.privateDNSName(record.PrivateIpAddress); name != "" {
|
||||
eni.PrivateDnsName = aws.String(name)
|
||||
eni.PrivateIpAddresses[0].PrivateDnsName = aws.String(name)
|
||||
|
||||
@@ -55,11 +55,15 @@ func TestNetworkInterface_NamesGroupsAndPrivateAddress(t *testing.T) {
|
||||
vpcId := createTestVPC(t, svc, "10.0.0.0/16")
|
||||
subnetId := createTestSubnet(t, svc, vpcId, "10.0.1.0/24")
|
||||
sgId := createTestSG(t, svc, vpcId, "web")
|
||||
setHostnames(t, svc, vpcId, true)
|
||||
|
||||
// Without a namer an interface carries no private name.
|
||||
unnamed := createTestENI(t, svc, subnetId)
|
||||
assert.Nil(t, describeTestENI(t, svc, unnamed).PrivateDnsName)
|
||||
desc, err := svc.DescribeNetworkInterfaces(context.Background(), &ec2.DescribeNetworkInterfacesInput{
|
||||
NetworkInterfaceIds: []*string{aws.String(unnamed)},
|
||||
}, testAccountID)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, desc.NetworkInterfaces, 1)
|
||||
assert.Nil(t, desc.NetworkInterfaces[0].PrivateDnsName)
|
||||
|
||||
svc.SetPrivateDNSNamer(func(ip string) string { return "name-of-" + ip })
|
||||
out, err := svc.CreateNetworkInterface(context.Background(), &ec2.CreateNetworkInterfaceInput{
|
||||
@@ -68,8 +72,13 @@ func TestNetworkInterface_NamesGroupsAndPrivateAddress(t *testing.T) {
|
||||
}, testAccountID)
|
||||
require.NoError(t, err)
|
||||
|
||||
desc := describeTestENI(t, svc, *out.NetworkInterface.NetworkInterfaceId)
|
||||
for name, eni := range map[string]*ec2.NetworkInterface{"create": out.NetworkInterface, "describe": desc} {
|
||||
desc, err = svc.DescribeNetworkInterfaces(context.Background(), &ec2.DescribeNetworkInterfacesInput{
|
||||
NetworkInterfaceIds: []*string{out.NetworkInterface.NetworkInterfaceId},
|
||||
}, testAccountID)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, desc.NetworkInterfaces, 1)
|
||||
|
||||
for name, eni := range map[string]*ec2.NetworkInterface{"create": out.NetworkInterface, "describe": desc.NetworkInterfaces[0]} {
|
||||
want := "name-of-" + aws.StringValue(eni.PrivateIpAddress)
|
||||
assert.Equal(t, want, aws.StringValue(eni.PrivateDnsName), name)
|
||||
require.Len(t, eni.PrivateIpAddresses, 1, name)
|
||||
@@ -80,58 +89,6 @@ func TestNetworkInterface_NamesGroupsAndPrivateAddress(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestNetworkInterface_PrivateDnsNameFollowsVpcDnsHostnames(t *testing.T) {
|
||||
t.Parallel()
|
||||
svc := setupTestVPCService(t)
|
||||
svc.SetPrivateDNSNamer(func(ip string) string { return "name-of-" + ip })
|
||||
vpcId := createTestVPC(t, svc, "10.0.0.0/16")
|
||||
subnetId := createTestSubnet(t, svc, vpcId, "10.0.1.0/24")
|
||||
|
||||
// A new VPC has enableDnsHostnames off, so its interfaces go unnamed.
|
||||
out, err := svc.CreateNetworkInterface(context.Background(), &ec2.CreateNetworkInterfaceInput{
|
||||
SubnetId: aws.String(subnetId),
|
||||
}, testAccountID)
|
||||
require.NoError(t, err)
|
||||
eniId := *out.NetworkInterface.NetworkInterfaceId
|
||||
for name, eni := range map[string]*ec2.NetworkInterface{"create": out.NetworkInterface, "describe": describeTestENI(t, svc, eniId)} {
|
||||
assert.Nil(t, eni.PrivateDnsName, name)
|
||||
require.Len(t, eni.PrivateIpAddresses, 1, name)
|
||||
assert.Nil(t, eni.PrivateIpAddresses[0].PrivateDnsName, name)
|
||||
}
|
||||
|
||||
// Turning the flag on names the existing interface.
|
||||
setHostnames(t, svc, vpcId, true)
|
||||
eni := describeTestENI(t, svc, eniId)
|
||||
want := "name-of-" + aws.StringValue(eni.PrivateIpAddress)
|
||||
assert.Equal(t, want, aws.StringValue(eni.PrivateDnsName))
|
||||
assert.Equal(t, want, aws.StringValue(eni.PrivateIpAddresses[0].PrivateDnsName))
|
||||
|
||||
// And turning it off again drops the name.
|
||||
setHostnames(t, svc, vpcId, false)
|
||||
eni = describeTestENI(t, svc, eniId)
|
||||
assert.Nil(t, eni.PrivateDnsName)
|
||||
assert.Nil(t, eni.PrivateIpAddresses[0].PrivateDnsName)
|
||||
}
|
||||
|
||||
func setHostnames(t *testing.T, svc *VPCServiceImpl, vpcId string, on bool) {
|
||||
t.Helper()
|
||||
_, err := svc.ModifyVpcAttribute(context.Background(), &ec2.ModifyVpcAttributeInput{
|
||||
VpcId: aws.String(vpcId),
|
||||
EnableDnsHostnames: &ec2.AttributeBooleanValue{Value: aws.Bool(on)},
|
||||
}, testAccountID)
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
func describeTestENI(t *testing.T, svc *VPCServiceImpl, eniId string) *ec2.NetworkInterface {
|
||||
t.Helper()
|
||||
desc, err := svc.DescribeNetworkInterfaces(context.Background(), &ec2.DescribeNetworkInterfacesInput{
|
||||
NetworkInterfaceIds: []*string{aws.String(eniId)},
|
||||
}, testAccountID)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, desc.NetworkInterfaces, 1)
|
||||
return desc.NetworkInterfaces[0]
|
||||
}
|
||||
|
||||
func TestCreateNetworkInterface_SequentialIPs(t *testing.T) {
|
||||
t.Parallel()
|
||||
svc := setupTestVPCService(t)
|
||||
|
||||
@@ -272,15 +272,11 @@ type TaskRecord struct {
|
||||
// Group / StartedBy mirror the AWS task fields. A service's tasks carry
|
||||
// Group="service:{name}" so the reconciler counts them and the task-state
|
||||
// hook resolves a RUNNING/STOPPED task back to its owning service.
|
||||
Group string `json:"group,omitempty"`
|
||||
StartedBy string `json:"startedBy,omitempty"`
|
||||
TaskDefFamily string `json:"taskDefFamily"`
|
||||
TaskDefRevision int `json:"taskDefRevision"`
|
||||
TaskDefARN string `json:"taskDefArn"`
|
||||
// CredID is the secret under which the task's containers fetch task-role
|
||||
// credentials from the agent. Set only when the task has a role; it must
|
||||
// never be projected into an API response or logged.
|
||||
CredID string `json:"credId,omitempty"`
|
||||
Group string `json:"group,omitempty"`
|
||||
StartedBy string `json:"startedBy,omitempty"`
|
||||
TaskDefFamily string `json:"taskDefFamily"`
|
||||
TaskDefRevision int `json:"taskDefRevision"`
|
||||
TaskDefARN string `json:"taskDefArn"`
|
||||
ContainerInstanceID string `json:"containerInstanceId,omitempty"`
|
||||
ContainerInstanceARN string `json:"containerInstanceArn,omitempty"`
|
||||
DesiredStatus string `json:"desiredStatus"`
|
||||
|
||||
@@ -288,7 +288,7 @@ func (s *Service) DescribeClusters(ctx context.Context, input *ecs.DescribeClust
|
||||
names = []string{defaultCluster}
|
||||
}
|
||||
includeSettings := slices.Contains(awsStringSlice(input.Include), ecs.ClusterFieldSettings)
|
||||
out := &ecs.DescribeClustersOutput{Clusters: []*ecs.Cluster{}, Failures: []*ecs.Failure{}}
|
||||
out := &ecs.DescribeClustersOutput{}
|
||||
for _, name := range names {
|
||||
name = ClusterShortName(name)
|
||||
var rec ClusterRecord
|
||||
@@ -458,7 +458,7 @@ func (s *Service) RegisterTaskDefinition(ctx context.Context, input *ecs.Registe
|
||||
if family == "" {
|
||||
return nil, errors.New(awserrors.ErrorECSInvalidParameter)
|
||||
}
|
||||
if err := validateContainerDefs(input.ContainerDefinitions, aws.StringValue(input.Memory)); err != nil {
|
||||
if err := validateContainerDefs(input.ContainerDefinitions); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
warnUnsupportedLogDrivers(ctx, family, input.ContainerDefinitions)
|
||||
@@ -497,23 +497,12 @@ func (s *Service) RegisterTaskDefinition(ctx context.Context, input *ecs.Registe
|
||||
// The test here is per value, not per field: an empty list or a false/unset
|
||||
// flag requests nothing and is accepted, because there is nothing to enforce
|
||||
// or refuse. Only a value that asks for the restriction is rejected.
|
||||
//
|
||||
// Ahead of those, AWS's own rules: every container needs an image, and without
|
||||
// a task-level memory every container needs memory or memoryReservation.
|
||||
func validateContainerDefs(defs []*ecs.ContainerDefinition, taskMemory string) error {
|
||||
func validateContainerDefs(defs []*ecs.ContainerDefinition) error {
|
||||
for _, c := range defs {
|
||||
if c == nil {
|
||||
continue
|
||||
}
|
||||
name := aws.StringValue(c.Name)
|
||||
if aws.StringValue(c.Image) == "" {
|
||||
return awserrors.Errorf(awserrors.ErrorECSClientException,
|
||||
"Container.image should not be null or empty.")
|
||||
}
|
||||
if taskMemory == "" && c.Memory == nil && c.MemoryReservation == nil {
|
||||
return awserrors.Errorf(awserrors.ErrorECSClientException,
|
||||
"Invalid setting for container '%s'. At least one of 'memory' or 'memoryReservation' must be specified.", name)
|
||||
}
|
||||
if len(c.Secrets) > 0 {
|
||||
return errors.New(awserrors.ErrorECSInvalidParameter)
|
||||
}
|
||||
|
||||
@@ -2,9 +2,7 @@ package ecs
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"github.com/mulgadc/spinifex/spinifex/domains/ecs/taskdefinition"
|
||||
"maps"
|
||||
"strconv"
|
||||
"testing"
|
||||
"time"
|
||||
@@ -304,89 +302,6 @@ func TestService_RunTask_AssignCarriesTaskRole(t *testing.T) {
|
||||
assert.Equal(t, roleARN, poll.Assignments[0].TaskRoleARN)
|
||||
}
|
||||
|
||||
// A role task's credential ID is a scheduler-minted secret: not the public task
|
||||
// ID, distinct per task, stable on re-delivery, persisted on the task record and
|
||||
// absent from every API response. A role-less task gets none.
|
||||
func TestService_TaskCredIDIsMintedSecret(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
svc, _ := newTestService(t)
|
||||
_, err := svc.CreateCluster(ctx, &ecs.CreateClusterInput{ClusterName: aws.String("web")}, testAccountID)
|
||||
require.NoError(t, err)
|
||||
_, err = svc.RegisterTaskDefinition(ctx, &ecs.RegisterTaskDefinitionInput{
|
||||
Family: aws.String("victim"),
|
||||
TaskRoleArn: aws.String("arn:aws:iam::123456789012:role/task-high"),
|
||||
ContainerDefinitions: []*ecs.ContainerDefinition{{
|
||||
Name: aws.String("app"), Image: aws.String("registry/app:1"),
|
||||
Cpu: aws.Int64(128), Memory: aws.Int64(256), Essential: aws.Bool(true),
|
||||
}},
|
||||
}, testAccountID)
|
||||
require.NoError(t, err)
|
||||
registerTaskDef(t, svc, "attacker", 128, 256)
|
||||
registerInstance(t, svc, "web", "i-1", 1024, 2048)
|
||||
|
||||
run, err := svc.RunTask(ctx, &ecs.RunTaskInput{Cluster: aws.String("web"), TaskDefinition: aws.String("victim")}, testAccountID)
|
||||
require.NoError(t, err)
|
||||
start, err := svc.StartTask(ctx, &ecs.StartTaskInput{
|
||||
Cluster: aws.String("web"), TaskDefinition: aws.String("victim"), ContainerInstances: []*string{aws.String("i-1")},
|
||||
}, testAccountID)
|
||||
require.NoError(t, err)
|
||||
_, err = svc.StartTask(ctx, &ecs.StartTaskInput{
|
||||
Cluster: aws.String("web"), TaskDefinition: aws.String("attacker"), ContainerInstances: []*string{aws.String("i-1")},
|
||||
}, testAccountID)
|
||||
require.NoError(t, err)
|
||||
|
||||
poll := func() map[string]ecsv1.Assign {
|
||||
p, perr := svc.PollAssignments(ctx, &PollAssignmentsInput{Cluster: "web", ContainerInstance: "i-1"}, testAccountID)
|
||||
require.NoError(t, perr)
|
||||
byTask := map[string]ecsv1.Assign{}
|
||||
for _, as := range p.Assignments {
|
||||
byTask[as.TaskID] = as
|
||||
}
|
||||
return byTask
|
||||
}
|
||||
first, second := poll(), poll()
|
||||
require.Len(t, first, 3)
|
||||
|
||||
kv, err := svc.bucket(ctx, testAccountID)
|
||||
require.NoError(t, err)
|
||||
var apiJSON []byte
|
||||
for _, out := range []any{run, start} {
|
||||
b, merr := json.Marshal(out)
|
||||
require.NoError(t, merr)
|
||||
apiJSON = append(apiJSON, b...)
|
||||
}
|
||||
var taskARNs []*string
|
||||
for as := range maps.Values(first) {
|
||||
taskARNs = append(taskARNs, aws.String(as.TaskARN))
|
||||
}
|
||||
desc, err := svc.DescribeTasks(ctx, &ecs.DescribeTasksInput{Cluster: aws.String("web"), Tasks: taskARNs}, testAccountID)
|
||||
require.NoError(t, err)
|
||||
b, err := json.Marshal(desc)
|
||||
require.NoError(t, err)
|
||||
apiJSON = append(apiJSON, b...)
|
||||
|
||||
seen := map[string]bool{}
|
||||
for taskID, as := range first {
|
||||
if as.TaskRoleARN == "" {
|
||||
assert.Empty(t, as.CredID, "role-less task got a credential ID")
|
||||
continue
|
||||
}
|
||||
require.NotEmpty(t, as.CredID)
|
||||
assert.NotEqual(t, taskID, as.CredID)
|
||||
assert.False(t, seen[as.CredID], "credential ID reused across tasks")
|
||||
seen[as.CredID] = true
|
||||
assert.Equal(t, as.CredID, second[taskID].CredID, "re-delivered assign changed credential ID")
|
||||
|
||||
var rec TaskRecord
|
||||
found, gerr := getJSON(ctx, kv, TaskKey("web", taskID), &rec)
|
||||
require.NoError(t, gerr)
|
||||
require.True(t, found)
|
||||
assert.Equal(t, as.CredID, rec.CredID)
|
||||
assert.NotContains(t, string(apiJSON), as.CredID, "credential ID leaked in an API response")
|
||||
}
|
||||
assert.Len(t, seen, 2)
|
||||
}
|
||||
|
||||
// PollAssignments is at-least-once: re-poll without ack re-delivers the assign;
|
||||
// acking it (then STOPPED) drains the inbox so it is never re-delivered.
|
||||
func TestService_PollAssignments_AckAndReclaim(t *testing.T) {
|
||||
@@ -897,12 +812,6 @@ func TestService_ListTasks_EmptyIsPresentNotAbsent(t *testing.T) {
|
||||
assert.Empty(t, out.TaskArns)
|
||||
}
|
||||
|
||||
func TestService_ListTasks_ClusterNotFound(t *testing.T) {
|
||||
svc, _ := newTestService(t)
|
||||
_, err := svc.ListTasks(context.Background(), &ecs.ListTasksInput{Cluster: aws.String("ghost")}, testAccountID)
|
||||
require.EqualError(t, err, awserrors.ErrorECSClusterNotFound)
|
||||
}
|
||||
|
||||
func TestService_ListContainerInstances_EmptyIsPresentNotAbsent(t *testing.T) {
|
||||
svc, _ := newTestService(t)
|
||||
_, err := svc.CreateCluster(context.Background(), &ecs.CreateClusterInput{ClusterName: aws.String("web")}, testAccountID)
|
||||
@@ -920,21 +829,3 @@ func TestAccountIDFromBucket(t *testing.T) {
|
||||
_, ok = accountIDFromBucket("some-other-bucket")
|
||||
assert.False(t, ok)
|
||||
}
|
||||
|
||||
func TestService_DescribeClusters_ListsAlwaysPresent(t *testing.T) {
|
||||
svc, _ := newTestService(t)
|
||||
_, err := svc.CreateCluster(context.Background(), &ecs.CreateClusterInput{ClusterName: aws.String("web")}, testAccountID)
|
||||
require.NoError(t, err)
|
||||
|
||||
found, err := svc.DescribeClusters(context.Background(), &ecs.DescribeClustersInput{Clusters: []*string{aws.String("web")}}, testAccountID)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, found.Clusters, 1)
|
||||
assert.NotNil(t, found.Failures)
|
||||
assert.Empty(t, found.Failures)
|
||||
|
||||
miss, err := svc.DescribeClusters(context.Background(), &ecs.DescribeClustersInput{Clusters: []*string{aws.String("nope")}}, testAccountID)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, miss.Failures, 1)
|
||||
assert.NotNil(t, miss.Clusters)
|
||||
assert.Empty(t, miss.Clusters)
|
||||
}
|
||||
|
||||
@@ -313,7 +313,7 @@ func TestService_CreateService_ProjectsNetworkConfigurationAndManagedTags(t *tes
|
||||
Family: aws.String("app"),
|
||||
NetworkMode: aws.String(NetworkModeAwsvpc),
|
||||
ContainerDefinitions: []*ecs.ContainerDefinition{{
|
||||
Name: aws.String("app"), Image: aws.String("registry/app:1"), Essential: aws.Bool(true), Memory: aws.Int64(256),
|
||||
Name: aws.String("app"), Image: aws.String("registry/app:1"), Essential: aws.Bool(true),
|
||||
}},
|
||||
}, testAccountID)
|
||||
require.NoError(t, err)
|
||||
@@ -352,7 +352,7 @@ func TestService_UpdateService_ChangesNetworkConfiguration(t *testing.T) {
|
||||
Family: aws.String("app"),
|
||||
NetworkMode: aws.String(NetworkModeAwsvpc),
|
||||
ContainerDefinitions: []*ecs.ContainerDefinition{{
|
||||
Name: aws.String("app"), Image: aws.String("registry/app:1"), Essential: aws.Bool(true), Memory: aws.Int64(256),
|
||||
Name: aws.String("app"), Image: aws.String("registry/app:1"), Essential: aws.Bool(true),
|
||||
}},
|
||||
}, testAccountID)
|
||||
require.NoError(t, err)
|
||||
|
||||
@@ -16,8 +16,6 @@ import (
|
||||
// agent to reach anything other than PENDING.
|
||||
func seedTasks(t *testing.T, svc *ecsdomain.Service, cluster string, recs ...*ecsdomain.TaskRecord) {
|
||||
t.Helper()
|
||||
_, err := svc.CreateCluster(t.Context(), &ecs.CreateClusterInput{ClusterName: aws.String(cluster)}, ecsdomain.TestAccountID)
|
||||
require.NoError(t, err)
|
||||
kv, err := svc.Bucket(t.Context(), ecsdomain.TestAccountID)
|
||||
require.NoError(t, err)
|
||||
for _, r := range recs {
|
||||
|
||||
@@ -19,7 +19,7 @@ func TestRegisterTaskDefinition_RejectsSecrets(t *testing.T) {
|
||||
_, err := svc.RegisterTaskDefinition(context.Background(), &ecs.RegisterTaskDefinitionInput{
|
||||
Family: aws.String("app"),
|
||||
ContainerDefinitions: []*ecs.ContainerDefinition{{
|
||||
Name: aws.String("app"), Image: aws.String("registry/app:1"), Essential: aws.Bool(true), Memory: aws.Int64(256),
|
||||
Name: aws.String("app"), Image: aws.String("registry/app:1"), Essential: aws.Bool(true),
|
||||
Secrets: []*ecs.Secret{{
|
||||
Name: aws.String("DB_PASSWORD"), ValueFrom: aws.String("arn:aws:ssm:::parameter/db"),
|
||||
}},
|
||||
@@ -37,7 +37,7 @@ func TestRegisterTaskDefinition_AcceptsUnsupportedLogDriver(t *testing.T) {
|
||||
_, err := svc.RegisterTaskDefinition(context.Background(), &ecs.RegisterTaskDefinitionInput{
|
||||
Family: aws.String("app"),
|
||||
ContainerDefinitions: []*ecs.ContainerDefinition{{
|
||||
Name: aws.String("app"), Image: aws.String("registry/app:1"), Essential: aws.Bool(true), Memory: aws.Int64(256),
|
||||
Name: aws.String("app"), Image: aws.String("registry/app:1"), Essential: aws.Bool(true),
|
||||
LogConfiguration: &ecs.LogConfiguration{
|
||||
LogDriver: aws.String("awslogs"),
|
||||
Options: map[string]*string{"awslogs-group": aws.String("/ecs/app")},
|
||||
@@ -67,7 +67,7 @@ func TestRegisterTaskDefinition_EchoesRequiresCompatibilities(t *testing.T) {
|
||||
Family: aws.String("app"),
|
||||
RequiresCompatibilities: aws.StringSlice([]string{"EC2"}),
|
||||
ContainerDefinitions: []*ecs.ContainerDefinition{{
|
||||
Name: aws.String("app"), Image: aws.String("registry/app:1"), Essential: aws.Bool(true), Memory: aws.Int64(256),
|
||||
Name: aws.String("app"), Image: aws.String("registry/app:1"), Essential: aws.Bool(true),
|
||||
}},
|
||||
}, testAccountID)
|
||||
require.NoError(t, err)
|
||||
@@ -91,7 +91,7 @@ func TestRegisterTaskDefinition_EchoesRuntimePlatform(t *testing.T) {
|
||||
OperatingSystemFamily: aws.String("LINUX"),
|
||||
},
|
||||
ContainerDefinitions: []*ecs.ContainerDefinition{{
|
||||
Name: aws.String("app"), Image: aws.String("registry/app:1"), Essential: aws.Bool(true), Memory: aws.Int64(256),
|
||||
Name: aws.String("app"), Image: aws.String("registry/app:1"), Essential: aws.Bool(true),
|
||||
}},
|
||||
}, testAccountID)
|
||||
require.NoError(t, err)
|
||||
@@ -113,7 +113,7 @@ func TestRegisterTaskDefinition_RuntimePlatformOmittedWhenUnset(t *testing.T) {
|
||||
_, err := svc.RegisterTaskDefinition(context.Background(), &ecs.RegisterTaskDefinitionInput{
|
||||
Family: aws.String("app"),
|
||||
ContainerDefinitions: []*ecs.ContainerDefinition{{
|
||||
Name: aws.String("app"), Image: aws.String("registry/app:1"), Essential: aws.Bool(true), Memory: aws.Int64(256),
|
||||
Name: aws.String("app"), Image: aws.String("registry/app:1"), Essential: aws.Bool(true),
|
||||
}},
|
||||
}, testAccountID)
|
||||
require.NoError(t, err)
|
||||
@@ -253,7 +253,7 @@ func TestRegisterTaskDefinition_EchoesRuntimeFields(t *testing.T) {
|
||||
_, err := svc.RegisterTaskDefinition(context.Background(), &ecs.RegisterTaskDefinitionInput{
|
||||
Family: aws.String("app"),
|
||||
ContainerDefinitions: []*ecs.ContainerDefinition{{
|
||||
Name: aws.String("app"), Image: aws.String("registry/app:1"), Essential: aws.Bool(true), Memory: aws.Int64(256),
|
||||
Name: aws.String("app"), Image: aws.String("registry/app:1"), Essential: aws.Bool(true),
|
||||
User: aws.String("1000:1000"),
|
||||
ReadonlyRootFilesystem: aws.Bool(true),
|
||||
Privileged: aws.Bool(false),
|
||||
@@ -310,7 +310,7 @@ func TestRegisterTaskDefinition_RuntimeFieldsOmittedWhenUnset(t *testing.T) {
|
||||
_, err := svc.RegisterTaskDefinition(context.Background(), &ecs.RegisterTaskDefinitionInput{
|
||||
Family: aws.String("app"),
|
||||
ContainerDefinitions: []*ecs.ContainerDefinition{{
|
||||
Name: aws.String("app"), Image: aws.String("registry/app:1"), Essential: aws.Bool(true), Memory: aws.Int64(256),
|
||||
Name: aws.String("app"), Image: aws.String("registry/app:1"), Essential: aws.Bool(true),
|
||||
}},
|
||||
}, testAccountID)
|
||||
require.NoError(t, err)
|
||||
@@ -347,7 +347,7 @@ func TestRegisterTaskDefinition_EchoesEmptyCollections(t *testing.T) {
|
||||
_, err := svc.RegisterTaskDefinition(context.Background(), &ecs.RegisterTaskDefinitionInput{
|
||||
Family: aws.String("app"),
|
||||
ContainerDefinitions: []*ecs.ContainerDefinition{{
|
||||
Name: aws.String("app"), Image: aws.String("registry/app:1"), Essential: aws.Bool(true), Memory: aws.Int64(256),
|
||||
Name: aws.String("app"), Image: aws.String("registry/app:1"), Essential: aws.Bool(true),
|
||||
Environment: []*ecs.KeyValuePair{},
|
||||
MountPoints: []*ecs.MountPoint{},
|
||||
VolumesFrom: []*ecs.VolumeFrom{},
|
||||
@@ -386,7 +386,7 @@ func TestRegisterTaskDefinition_FillsAWSDefaults(t *testing.T) {
|
||||
Family: aws.String("app"),
|
||||
NetworkMode: aws.String("awsvpc"),
|
||||
ContainerDefinitions: []*ecs.ContainerDefinition{{
|
||||
Name: aws.String("app"), Image: aws.String("registry/app:1"), Essential: aws.Bool(true), Memory: aws.Int64(256),
|
||||
Name: aws.String("app"), Image: aws.String("registry/app:1"), Essential: aws.Bool(true),
|
||||
PortMappings: []*ecs.PortMapping{{ContainerPort: aws.Int64(80)}},
|
||||
}},
|
||||
}, testAccountID)
|
||||
@@ -420,7 +420,7 @@ func TestRegisterTaskDefinition_BridgeLeavesHostPortUnset(t *testing.T) {
|
||||
Family: aws.String("app"),
|
||||
NetworkMode: aws.String("bridge"),
|
||||
ContainerDefinitions: []*ecs.ContainerDefinition{{
|
||||
Name: aws.String("app"), Image: aws.String("registry/app:1"), Essential: aws.Bool(true), Memory: aws.Int64(256),
|
||||
Name: aws.String("app"), Image: aws.String("registry/app:1"), Essential: aws.Bool(true),
|
||||
PortMappings: []*ecs.PortMapping{{ContainerPort: aws.Int64(53), Protocol: aws.String("udp")}},
|
||||
}},
|
||||
}, testAccountID)
|
||||
@@ -435,7 +435,7 @@ func TestRegisterTaskDefinition_AcceptsEmptyRuntimeFields(t *testing.T) {
|
||||
_, err := svc.RegisterTaskDefinition(context.Background(), &ecs.RegisterTaskDefinitionInput{
|
||||
Family: aws.String("app"),
|
||||
ContainerDefinitions: []*ecs.ContainerDefinition{{
|
||||
Name: aws.String("app"), Image: aws.String("registry/app:1"), Essential: aws.Bool(true), Memory: aws.Int64(256),
|
||||
Name: aws.String("app"), Image: aws.String("registry/app:1"), Essential: aws.Bool(true),
|
||||
MountPoints: []*ecs.MountPoint{},
|
||||
VolumesFrom: []*ecs.VolumeFrom{},
|
||||
SystemControls: []*ecs.SystemControl{},
|
||||
@@ -455,7 +455,7 @@ func TestRegisterTaskDefinition_RejectsMountPoints(t *testing.T) {
|
||||
_, err := svc.RegisterTaskDefinition(context.Background(), &ecs.RegisterTaskDefinitionInput{
|
||||
Family: aws.String("app"),
|
||||
ContainerDefinitions: []*ecs.ContainerDefinition{{
|
||||
Name: aws.String("app"), Image: aws.String("registry/app:1"), Essential: aws.Bool(true), Memory: aws.Int64(256),
|
||||
Name: aws.String("app"), Image: aws.String("registry/app:1"), Essential: aws.Bool(true),
|
||||
MountPoints: []*ecs.MountPoint{{
|
||||
SourceVolume: aws.String("data"), ContainerPath: aws.String("/data"),
|
||||
}},
|
||||
@@ -476,7 +476,7 @@ func TestRegisterTaskDefinition_RejectsVolumesFrom(t *testing.T) {
|
||||
_, err := svc.RegisterTaskDefinition(context.Background(), &ecs.RegisterTaskDefinitionInput{
|
||||
Family: aws.String("app"),
|
||||
ContainerDefinitions: []*ecs.ContainerDefinition{{
|
||||
Name: aws.String("app"), Image: aws.String("registry/app:1"), Essential: aws.Bool(true), Memory: aws.Int64(256),
|
||||
Name: aws.String("app"), Image: aws.String("registry/app:1"), Essential: aws.Bool(true),
|
||||
VolumesFrom: []*ecs.VolumeFrom{{SourceContainer: aws.String("other")}},
|
||||
}},
|
||||
}, testAccountID)
|
||||
@@ -494,7 +494,7 @@ func TestRegisterTaskDefinition_RejectsLinuxParametersDevices(t *testing.T) {
|
||||
_, err := svc.RegisterTaskDefinition(context.Background(), &ecs.RegisterTaskDefinitionInput{
|
||||
Family: aws.String("app"),
|
||||
ContainerDefinitions: []*ecs.ContainerDefinition{{
|
||||
Name: aws.String("app"), Image: aws.String("registry/app:1"), Essential: aws.Bool(true), Memory: aws.Int64(256),
|
||||
Name: aws.String("app"), Image: aws.String("registry/app:1"), Essential: aws.Bool(true),
|
||||
LinuxParameters: &ecs.LinuxParameters{
|
||||
Devices: []*ecs.Device{{HostPath: aws.String("/dev/fuse")}},
|
||||
},
|
||||
@@ -514,7 +514,7 @@ func TestRegisterTaskDefinition_RejectsInitProcessEnabled(t *testing.T) {
|
||||
_, err := svc.RegisterTaskDefinition(context.Background(), &ecs.RegisterTaskDefinitionInput{
|
||||
Family: aws.String("app"),
|
||||
ContainerDefinitions: []*ecs.ContainerDefinition{{
|
||||
Name: aws.String("app"), Image: aws.String("registry/app:1"), Essential: aws.Bool(true), Memory: aws.Int64(256),
|
||||
Name: aws.String("app"), Image: aws.String("registry/app:1"), Essential: aws.Bool(true),
|
||||
LinuxParameters: &ecs.LinuxParameters{InitProcessEnabled: aws.Bool(true)},
|
||||
}},
|
||||
}, testAccountID)
|
||||
@@ -533,7 +533,7 @@ func TestRegisterTaskDefinition_RejectsSharedMemoryAndTmpfs(t *testing.T) {
|
||||
_, err := svc.RegisterTaskDefinition(context.Background(), &ecs.RegisterTaskDefinitionInput{
|
||||
Family: aws.String("shm"),
|
||||
ContainerDefinitions: []*ecs.ContainerDefinition{{
|
||||
Name: aws.String("app"), Image: aws.String("registry/app:1"), Essential: aws.Bool(true), Memory: aws.Int64(256),
|
||||
Name: aws.String("app"), Image: aws.String("registry/app:1"), Essential: aws.Bool(true),
|
||||
LinuxParameters: &ecs.LinuxParameters{SharedMemorySize: aws.Int64(64)},
|
||||
}},
|
||||
}, testAccountID)
|
||||
@@ -545,7 +545,7 @@ func TestRegisterTaskDefinition_RejectsSharedMemoryAndTmpfs(t *testing.T) {
|
||||
_, err = svc.RegisterTaskDefinition(context.Background(), &ecs.RegisterTaskDefinitionInput{
|
||||
Family: aws.String("tmpfs"),
|
||||
ContainerDefinitions: []*ecs.ContainerDefinition{{
|
||||
Name: aws.String("app"), Image: aws.String("registry/app:1"), Essential: aws.Bool(true), Memory: aws.Int64(256),
|
||||
Name: aws.String("app"), Image: aws.String("registry/app:1"), Essential: aws.Bool(true),
|
||||
LinuxParameters: &ecs.LinuxParameters{
|
||||
Tmpfs: []*ecs.Tmpfs{{ContainerPath: aws.String("/tmp"), Size: aws.Int64(64)}},
|
||||
},
|
||||
@@ -567,7 +567,7 @@ func TestRunTask_AssignCarriesRuntimeFields(t *testing.T) {
|
||||
_, err = svc.RegisterTaskDefinition(context.Background(), &ecs.RegisterTaskDefinitionInput{
|
||||
Family: aws.String("app"),
|
||||
ContainerDefinitions: []*ecs.ContainerDefinition{{
|
||||
Name: aws.String("app"), Image: aws.String("registry/app:1"), Essential: aws.Bool(true), Memory: aws.Int64(256),
|
||||
Name: aws.String("app"), Image: aws.String("registry/app:1"), Essential: aws.Bool(true),
|
||||
User: aws.String("1000"),
|
||||
ReadonlyRootFilesystem: aws.Bool(true),
|
||||
Privileged: aws.Bool(true),
|
||||
@@ -608,42 +608,3 @@ func TestRunTask_AssignCarriesRuntimeFields(t *testing.T) {
|
||||
assert.Equal(t, "net.core.somaxconn", ac.SystemControls[0].Namespace)
|
||||
assert.Equal(t, []string{"SYS_PTRACE"}, ac.CapAdd)
|
||||
}
|
||||
|
||||
// AWS rejects a container with no image before checking its memory, and
|
||||
// without task-level memory requires memory or memoryReservation per container.
|
||||
func TestRegisterTaskDefinition_RequiresImageAndMemory(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
taskMemory *string
|
||||
container *ecs.ContainerDefinition
|
||||
wantErr string
|
||||
}{
|
||||
{"no image or memory", nil, &ecs.ContainerDefinition{Name: aws.String("web")},
|
||||
"Container.image should not be null or empty."},
|
||||
{"empty image", aws.String("512"), &ecs.ContainerDefinition{Name: aws.String("web"), Image: aws.String("")},
|
||||
"Container.image should not be null or empty."},
|
||||
{"no memory", nil, &ecs.ContainerDefinition{Name: aws.String("web"), Image: aws.String("registry/web:1")},
|
||||
"Invalid setting for container 'web'. At least one of 'memory' or 'memoryReservation' must be specified."},
|
||||
{"memoryReservation only", nil, &ecs.ContainerDefinition{
|
||||
Name: aws.String("web"), Image: aws.String("registry/web:1"), MemoryReservation: aws.Int64(128)}, ""},
|
||||
{"task-level memory", aws.String("512"), &ecs.ContainerDefinition{
|
||||
Name: aws.String("web"), Image: aws.String("registry/web:1")}, ""},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
svc, _ := newTestService(t)
|
||||
_, err := svc.RegisterTaskDefinition(context.Background(), &ecs.RegisterTaskDefinitionInput{
|
||||
Family: aws.String("app"), Memory: tc.taskMemory,
|
||||
ContainerDefinitions: []*ecs.ContainerDefinition{tc.container},
|
||||
}, testAccountID)
|
||||
if tc.wantErr == "" {
|
||||
require.NoError(t, err)
|
||||
return
|
||||
}
|
||||
code, msg, ok := awserrors.ResolveErrorDetail(err)
|
||||
require.True(t, ok)
|
||||
assert.Equal(t, awserrors.ErrorECSClientException, code)
|
||||
assert.Equal(t, tc.wantErr, msg)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -237,9 +237,6 @@ func (s *Service) newTaskRecord(accountID, cluster, taskID string, td *taskdefin
|
||||
GPU: gpu,
|
||||
CreatedAt: now,
|
||||
}
|
||||
if td.TaskRoleArn != "" {
|
||||
rec.CredID = uuid.NewV4().String()
|
||||
}
|
||||
for _, c := range td.Containers {
|
||||
rec.Containers = append(rec.Containers, ContainerState{Name: c.Name, Status: TaskStatusPending})
|
||||
}
|
||||
@@ -260,7 +257,6 @@ func (s *Service) publishAssign(ctx context.Context, kv jetstream.KeyValue, acco
|
||||
TaskDefFamily: td.Family,
|
||||
TaskDefRevision: td.Revision,
|
||||
TaskRoleARN: td.TaskRoleArn,
|
||||
CredID: rec.CredID,
|
||||
ExecutionRoleARN: td.ExecutionRoleArn,
|
||||
ENIID: rec.ENIID,
|
||||
ENIMacAddress: rec.ENIMacAddress,
|
||||
@@ -307,14 +303,6 @@ func (s *Service) ListTasks(ctx context.Context, input *ecs.ListTasksInput, acco
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var clusterRec ClusterRecord
|
||||
found, err := getJSON(ctx, kv, ClusterMetaKey(cluster), &clusterRec)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !found {
|
||||
return nil, errors.New(awserrors.ErrorECSClusterNotFound)
|
||||
}
|
||||
keys, err := keysWithPrefix(ctx, kv, TasksPrefix(cluster))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
|
||||
@@ -4,14 +4,8 @@ import (
|
||||
"fmt"
|
||||
"maps"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// firstBuild is the EKS build number of every bundled version; it moves per
|
||||
// version when Spinifex rebuilds a bundle.
|
||||
const firstBuild = 1
|
||||
|
||||
// NvidiaDevicePlugin is staged by a GPU node group's launch (EnsureGPUDevicePlugin),
|
||||
// never user-requested directly.
|
||||
const NvidiaDevicePlugin = "nvidia-device-plugin"
|
||||
@@ -24,11 +18,6 @@ type Spec struct {
|
||||
// Versions lists supported versions newest-first; [0] is the default.
|
||||
Versions []string
|
||||
DefaultVersion string
|
||||
// Builds maps each upstream version to its EKS build number, the n in
|
||||
// v<upstream>-eksbuild.<n>.
|
||||
Builds map[string]int
|
||||
// Type is the AWS add-on type reported by DescribeAddonVersions.
|
||||
Type string
|
||||
// RequiresIRSA indicates the add-on needs an IAM role for service-account binding.
|
||||
RequiresIRSA bool
|
||||
Description string
|
||||
@@ -39,26 +28,26 @@ type Spec struct {
|
||||
|
||||
// catalog is the bundled add-on registry. Keep newest version first per slice.
|
||||
var catalog = buildCatalog(
|
||||
typed("networking", newSpec("aws-load-balancer-controller", true,
|
||||
newSpec("aws-load-balancer-controller", true,
|
||||
"Provisions ELBv2 load balancers for Kubernetes Service/Ingress resources.",
|
||||
"2.11.0")),
|
||||
typed("gitops", newSpec("argocd", false,
|
||||
"2.11.0"),
|
||||
newSpec("argocd", false,
|
||||
"Declarative GitOps continuous delivery for Kubernetes.",
|
||||
"3.0.23")),
|
||||
typed("storage", newSpec("aws-ebs-csi-driver", true,
|
||||
"3.0.23"),
|
||||
newSpec("aws-ebs-csi-driver", true,
|
||||
"Container Storage Interface driver for Amazon EBS (Viperblock) volumes.",
|
||||
"1.40.1")),
|
||||
"1.40.1"),
|
||||
// Staged automatically for GPU node groups, so hidden from the public
|
||||
// catalogue like spinifex-noop but still creatable by name.
|
||||
hidden(typed("accelerator", newSpec(NvidiaDevicePlugin, false,
|
||||
hidden(newSpec(NvidiaDevicePlugin, false,
|
||||
"NVIDIA device plugin exposing nvidia.com/gpu allocatable via CDI on GPU-tainted nodes.",
|
||||
"0.17.4"))),
|
||||
"0.17.4")),
|
||||
// spinifex-noop is the delivery-transport fixture: a trivial bundle
|
||||
// (Namespace + ConfigMap) the add-on e2e uses to prove stage, render,
|
||||
// auto-deploy, ACTIVE and delete round-trip without a real workload.
|
||||
hidden(typed("fixture", newSpec("spinifex-noop", false,
|
||||
hidden(newSpec("spinifex-noop", false,
|
||||
"No-op delivery-transport fixture (Namespace + ConfigMap).",
|
||||
"0.1.0"))),
|
||||
"0.1.0")),
|
||||
)
|
||||
|
||||
// hidden marks a spec as internal: still creatable, but absent from the
|
||||
@@ -68,12 +57,6 @@ func hidden(s Spec) Spec {
|
||||
return s
|
||||
}
|
||||
|
||||
// typed sets the AWS add-on type reported by DescribeAddonVersions.
|
||||
func typed(addonType string, s Spec) Spec {
|
||||
s.Type = addonType
|
||||
return s
|
||||
}
|
||||
|
||||
// newSpec builds a spec with the first version as its default.
|
||||
func newSpec(name string, requiresIRSA bool, description string, versions ...string) Spec {
|
||||
spec := Spec{
|
||||
@@ -85,10 +68,6 @@ func newSpec(name string, requiresIRSA bool, description string, versions ...str
|
||||
if len(versions) > 0 {
|
||||
spec.DefaultVersion = versions[0]
|
||||
}
|
||||
spec.Builds = make(map[string]int, len(versions))
|
||||
for _, v := range versions {
|
||||
spec.Builds[v] = firstBuild
|
||||
}
|
||||
return spec
|
||||
}
|
||||
|
||||
@@ -132,50 +111,6 @@ func (s Spec) SupportsVersion(version string) bool {
|
||||
return slices.Contains(s.Versions, version)
|
||||
}
|
||||
|
||||
// awsForm renders a catalog version as v<upstream>-eksbuild.<build>.
|
||||
func (s Spec) awsForm(upstream string) string {
|
||||
build, ok := s.Builds[upstream]
|
||||
if !ok {
|
||||
build = firstBuild
|
||||
}
|
||||
return "v" + upstream + "-eksbuild." + strconv.Itoa(build)
|
||||
}
|
||||
|
||||
// AWSVersion returns the AWS form of a stored version. A version the catalogue
|
||||
// does not list, or one already in AWS form, is returned unchanged.
|
||||
func (s Spec) AWSVersion(stored string) string {
|
||||
if !slices.Contains(s.Versions, stored) {
|
||||
return stored
|
||||
}
|
||||
return s.awsForm(stored)
|
||||
}
|
||||
|
||||
// AWSVersions returns every supported version in AWS form, newest first.
|
||||
func (s Spec) AWSVersions() []string {
|
||||
out := make([]string, len(s.Versions))
|
||||
for i, v := range s.Versions {
|
||||
out[i] = s.awsForm(v)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// ParseVersion maps a client-supplied version, in AWS form or bare upstream
|
||||
// form, to the upstream version stored on the record. Nothing else is accepted.
|
||||
func (s Spec) ParseVersion(input string) (string, bool) {
|
||||
if slices.Contains(s.Versions, input) {
|
||||
return input, true
|
||||
}
|
||||
if !strings.HasPrefix(input, "v") {
|
||||
return "", false
|
||||
}
|
||||
for _, v := range s.Versions {
|
||||
if input == s.awsForm(v) {
|
||||
return v, true
|
||||
}
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
// Specs returns every catalogue entry, hidden ones included, sorted by name.
|
||||
func Specs() []Spec {
|
||||
names := slices.Sorted(maps.Keys(catalog))
|
||||
|
||||
@@ -100,78 +100,3 @@ func TestValidateCatalog_ChecksTheBundledCatalog(t *testing.T) {
|
||||
t.Cleanup(func() { catalog = previous })
|
||||
require.EqualError(t, ValidateCatalog(), `add-on "broken" has no versions`)
|
||||
}
|
||||
|
||||
// buildSpec is a two-version spec whose newest version is on EKS build 2, so
|
||||
// a test cannot pass by hardcoding build 1.
|
||||
func buildSpec() Spec {
|
||||
return Spec{
|
||||
Name: "fixture",
|
||||
Versions: []string{"2.0.0", "1.0.0"},
|
||||
Builds: map[string]int{"2.0.0": 2, "1.0.0": 1},
|
||||
}
|
||||
}
|
||||
|
||||
func TestSpec_AWSVersionUsesTheCatalogBuildNumber(t *testing.T) {
|
||||
t.Parallel()
|
||||
spec := buildSpec()
|
||||
assert.Equal(t, "v2.0.0-eksbuild.2", spec.AWSVersion("2.0.0"))
|
||||
assert.Equal(t, "v1.0.0-eksbuild.1", spec.AWSVersion("1.0.0"))
|
||||
}
|
||||
|
||||
func TestSpec_AWSVersionIsIdempotent(t *testing.T) {
|
||||
t.Parallel()
|
||||
spec := buildSpec()
|
||||
assert.Equal(t, "v2.0.0-eksbuild.2", spec.AWSVersion("v2.0.0-eksbuild.2"),
|
||||
"a record already holding the AWS form must not be wrapped twice")
|
||||
}
|
||||
|
||||
func TestSpec_AWSVersionsAreNewestFirst(t *testing.T) {
|
||||
t.Parallel()
|
||||
assert.Equal(t, []string{"v2.0.0-eksbuild.2", "v1.0.0-eksbuild.1"}, buildSpec().AWSVersions())
|
||||
}
|
||||
|
||||
func TestSpec_ParseVersionAcceptsOnlyTheExactForms(t *testing.T) {
|
||||
t.Parallel()
|
||||
spec := buildSpec()
|
||||
for input, want := range map[string]string{
|
||||
"v2.0.0-eksbuild.2": "2.0.0",
|
||||
"2.0.0": "2.0.0",
|
||||
"v1.0.0-eksbuild.1": "1.0.0",
|
||||
"1.0.0": "1.0.0",
|
||||
} {
|
||||
got, ok := spec.ParseVersion(input)
|
||||
assert.True(t, ok, "%q must be accepted", input)
|
||||
assert.Equal(t, want, got, "%q", input)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSpec_ParseVersionRefusesEverythingElse(t *testing.T) {
|
||||
t.Parallel()
|
||||
spec := buildSpec()
|
||||
for _, input := range []string{
|
||||
"v2.0.0-eksbuild.1", // wrong build for 2.0.0
|
||||
"v2.0.0-eksbuild.3",
|
||||
"v1.0.0-eksbuild.2", // wrong build for 1.0.0
|
||||
"v2.0.0", // missing build
|
||||
"2.0", // partial
|
||||
"v2.0.0-eksbuild.",
|
||||
"2.0.0-eksbuild.2", // missing v
|
||||
"V2.0.0-eksbuild.2",
|
||||
" 2.0.0",
|
||||
"9.9.9",
|
||||
"",
|
||||
} {
|
||||
_, ok := spec.ParseVersion(input)
|
||||
assert.False(t, ok, "%q must be refused", input)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCatalog_EveryVersionHasAnExplicitBuild(t *testing.T) {
|
||||
t.Parallel()
|
||||
for _, spec := range Specs() {
|
||||
for _, v := range spec.Versions {
|
||||
assert.Equal(t, 1, spec.Builds[v], "%s %s needs an explicit build number", spec.Name, v)
|
||||
assert.Equal(t, "v"+v+"-eksbuild.1", spec.AWSVersion(v), "%s %s", spec.Name, v)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,105 +0,0 @@
|
||||
package awsapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"github.com/aws/aws-sdk-go/aws"
|
||||
"github.com/aws/aws-sdk-go/service/eks"
|
||||
"github.com/mulgadc/spinifex/internal/testkit"
|
||||
"github.com/nats-io/nats.go"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// captureDescribeAddonVersions answers eks.DescribeAddonVersions and returns a
|
||||
// function reading the last input the gateway sent to the service.
|
||||
func captureDescribeAddonVersions(t *testing.T, nc *nats.Conn) func() eks.DescribeAddonVersionsInput {
|
||||
t.Helper()
|
||||
var (
|
||||
mu sync.Mutex
|
||||
last eks.DescribeAddonVersionsInput
|
||||
)
|
||||
sub, err := nc.Subscribe("eks.DescribeAddonVersions", func(m *nats.Msg) {
|
||||
mu.Lock()
|
||||
last = eks.DescribeAddonVersionsInput{}
|
||||
_ = json.Unmarshal(m.Data, &last)
|
||||
mu.Unlock()
|
||||
_ = m.Respond([]byte(`{}`))
|
||||
})
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(func() { _ = sub.Unsubscribe() })
|
||||
return func() eks.DescribeAddonVersionsInput {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
return last
|
||||
}
|
||||
}
|
||||
|
||||
// The gateway folds a GET query string into the body as a JSON object of
|
||||
// string lists; the model binds every field below from the query.
|
||||
func TestDescribeAddonVersions_PassesEveryModelQueryParameter(t *testing.T) {
|
||||
_, nc := testutil.StartTestNATS(t)
|
||||
got := captureDescribeAddonVersions(t, nc)
|
||||
|
||||
query := []byte(`{"addonName":["aws-ebs-csi-driver"],"kubernetesVersion":["1.32"],` +
|
||||
`"maxResults":["2"],"nextToken":["tok"],"types":["storage","networking"],` +
|
||||
`"publishers":["eks"],"owners":["aws"]}`)
|
||||
_, err := DescribeAddonVersions(context.Background(), nc, acct, query)
|
||||
require.NoError(t, err)
|
||||
|
||||
in := got()
|
||||
assert.Equal(t, "aws-ebs-csi-driver", aws.StringValue(in.AddonName))
|
||||
assert.Equal(t, "1.32", aws.StringValue(in.KubernetesVersion))
|
||||
assert.Equal(t, int64(2), aws.Int64Value(in.MaxResults))
|
||||
assert.Equal(t, "tok", aws.StringValue(in.NextToken))
|
||||
assert.Equal(t, []string{"storage", "networking"}, aws.StringValueSlice(in.Types))
|
||||
assert.Equal(t, []string{"eks"}, aws.StringValueSlice(in.Publishers))
|
||||
assert.Equal(t, []string{"aws"}, aws.StringValueSlice(in.Owners))
|
||||
}
|
||||
|
||||
func TestDescribeAddonVersions_NoQueryLeavesEveryFilterUnset(t *testing.T) {
|
||||
_, nc := testutil.StartTestNATS(t)
|
||||
got := captureDescribeAddonVersions(t, nc)
|
||||
|
||||
_, err := DescribeAddonVersions(context.Background(), nc, acct, nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
in := got()
|
||||
assert.Nil(t, in.AddonName)
|
||||
assert.Nil(t, in.KubernetesVersion)
|
||||
assert.Nil(t, in.MaxResults)
|
||||
assert.Nil(t, in.NextToken)
|
||||
assert.Empty(t, in.Types)
|
||||
}
|
||||
|
||||
func TestDescribeAddonVersions_RefusesMalformedQuery(t *testing.T) {
|
||||
_, nc := testutil.StartTestNATS(t)
|
||||
captureDescribeAddonVersions(t, nc)
|
||||
|
||||
_, err := DescribeAddonVersions(context.Background(), nc, acct, []byte(`{"maxResults":["many"]}`))
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "maxResults")
|
||||
|
||||
_, err = DescribeAddonVersions(context.Background(), nc, acct, []byte(`not json`))
|
||||
require.Error(t, err)
|
||||
}
|
||||
|
||||
// The route must hand the folded query to the wrapper, not drop it.
|
||||
func TestDescribeAddonVersionsRoute_PassesTheQuery(t *testing.T) {
|
||||
_, nc := testutil.StartTestNATS(t)
|
||||
got := captureDescribeAddonVersions(t, nc)
|
||||
|
||||
for _, r := range routes {
|
||||
if r.Action != "DescribeAddonVersions" {
|
||||
continue
|
||||
}
|
||||
_, err := r.Handler(context.Background(), nc, acct, "", nil, []byte(`{"addonName":["argocd"]}`))
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "argocd", aws.StringValue(got().AddonName))
|
||||
return
|
||||
}
|
||||
t.Fatal("no DescribeAddonVersions route")
|
||||
}
|
||||
@@ -16,23 +16,8 @@ func ListAddons(ctx context.Context, natsConn *nats.Conn, accountID, cluster str
|
||||
}
|
||||
|
||||
// DescribeAddonVersions — GET /addons/supported-versions.
|
||||
func DescribeAddonVersions(ctx context.Context, natsConn *nats.Conn, accountID string, query []byte) (*eks.DescribeAddonVersionsOutput, error) {
|
||||
q, err := parseListQuery(query)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
input := &eks.DescribeAddonVersionsInput{
|
||||
AddonName: q.str("addonName"),
|
||||
KubernetesVersion: q.str("kubernetesVersion"),
|
||||
NextToken: q.str("nextToken"),
|
||||
Types: q.strs("types"),
|
||||
Publishers: q.strs("publishers"),
|
||||
Owners: q.strs("owners"),
|
||||
}
|
||||
if input.MaxResults, err = q.int64("maxResults"); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return ekscluster.NewNATSEKSService(natsConn).DescribeAddonVersions(ctx, input, accountID)
|
||||
func DescribeAddonVersions(ctx context.Context, natsConn *nats.Conn, accountID string) (*eks.DescribeAddonVersionsOutput, error) {
|
||||
return ekscluster.NewNATSEKSService(natsConn).DescribeAddonVersions(ctx, &eks.DescribeAddonVersionsInput{}, accountID)
|
||||
}
|
||||
|
||||
// CreateAddon — POST /clusters/{name}/addons.
|
||||
|
||||
@@ -1,61 +0,0 @@
|
||||
package awsapi
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strconv"
|
||||
|
||||
"github.com/aws/aws-sdk-go/aws"
|
||||
awserrors "github.com/mulgadc/spinifex/spinifex/foundation/aws/errors"
|
||||
)
|
||||
|
||||
// listQuery holds the query-string inputs of a GET action, which the gateway
|
||||
// folds into the body as a JSON object of string lists.
|
||||
type listQuery map[string][]string
|
||||
|
||||
// parseListQuery decodes the folded query of a GET action. An empty body means
|
||||
// the request carried no query string.
|
||||
func parseListQuery(body []byte) (listQuery, error) {
|
||||
q := listQuery{}
|
||||
if len(body) == 0 {
|
||||
return q, nil
|
||||
}
|
||||
if err := json.Unmarshal(body, (*map[string][]string)(&q)); err != nil {
|
||||
return nil, awserrors.Errorf(awserrors.ErrorEKSInvalidParameter, "malformed query string")
|
||||
}
|
||||
return q, nil
|
||||
}
|
||||
|
||||
// str returns the first value of name, or nil when it is absent or empty.
|
||||
func (q listQuery) str(name string) *string {
|
||||
if v := q[name]; len(v) > 0 && v[0] != "" {
|
||||
return aws.String(v[0])
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// strs returns every non-empty value of name, or nil when there are none.
|
||||
func (q listQuery) strs(name string) []*string {
|
||||
var out []string
|
||||
for _, v := range q[name] {
|
||||
if v != "" {
|
||||
out = append(out, v)
|
||||
}
|
||||
}
|
||||
if len(out) == 0 {
|
||||
return nil
|
||||
}
|
||||
return aws.StringSlice(out)
|
||||
}
|
||||
|
||||
// int64 returns the first value of name as an integer, or nil when absent.
|
||||
func (q listQuery) int64(name string) (*int64, error) {
|
||||
s := q.str(name)
|
||||
if s == nil {
|
||||
return nil, nil
|
||||
}
|
||||
n, err := strconv.ParseInt(*s, 10, 64)
|
||||
if err != nil {
|
||||
return nil, awserrors.Errorf(awserrors.ErrorEKSInvalidParameter, "%s must be an integer", name)
|
||||
}
|
||||
return aws.Int64(n), nil
|
||||
}
|
||||
@@ -82,39 +82,17 @@ func (d Deps) dispatch(w http.ResponseWriter, inv dispatch.Invocation) error {
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
action, _, _, routed := router.Lookup(inv.Request.Method, inv.Request.URL.EscapedPath())
|
||||
if routed && IsInternalAction(action) {
|
||||
if action, _, _, ok := router.Lookup(inv.Request.Method, inv.Request.URL.EscapedPath()); ok && IsInternalAction(action) {
|
||||
return err
|
||||
}
|
||||
code, msg, ok := awserrors.ResolveErrorDetail(err)
|
||||
if !ok || code != awserrors.ErrorInvalidParameterValue {
|
||||
return err
|
||||
}
|
||||
wire := awserrors.ErrorEKSInvalidParameter
|
||||
if override, ok := parameterRefusalCodes[action]; ok {
|
||||
wire = override
|
||||
if msg == "" {
|
||||
msg = "a parameter of the request is not valid"
|
||||
}
|
||||
}
|
||||
if msg == "" {
|
||||
return errors.New(wire)
|
||||
return errors.New(awserrors.ErrorEKSInvalidParameter)
|
||||
}
|
||||
return awserrors.Errorf(wire, "%s", msg)
|
||||
}
|
||||
|
||||
// parameterRefusalCodes holds the operations whose model does not declare
|
||||
// InvalidParameterException, with the declared code a refused parameter uses.
|
||||
var parameterRefusalCodes = map[string]string{
|
||||
"DescribeCluster": awserrors.ErrorEKSClient,
|
||||
"DeleteCluster": awserrors.ErrorEKSClient,
|
||||
"DescribeAccessEntry": awserrors.ErrorEKSInvalidRequest,
|
||||
"DeleteAccessEntry": awserrors.ErrorEKSInvalidRequest,
|
||||
"DisassociateAccessPolicy": awserrors.ErrorEKSInvalidRequest,
|
||||
"ListAssociatedAccessPolicies": awserrors.ErrorEKSInvalidRequest,
|
||||
"TagResource": awserrors.ErrorEKSBadRequest,
|
||||
"UntagResource": awserrors.ErrorEKSBadRequest,
|
||||
"ListTagsForResource": awserrors.ErrorEKSBadRequest,
|
||||
return awserrors.Errorf(awserrors.ErrorEKSInvalidParameter, "%s", msg)
|
||||
}
|
||||
|
||||
// serve resolves the route, authorizes the request, then runs the action.
|
||||
|
||||
@@ -1,98 +0,0 @@
|
||||
//test:in-package — routes is unexported, and the table must cover exactly the
|
||||
// actions the registration serves.
|
||||
|
||||
package awsapi
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/mulgadc/bluebottle/pkg/iampolicy"
|
||||
"github.com/mulgadc/spinifex/internal/awsmodel"
|
||||
"github.com/mulgadc/spinifex/internal/testkit"
|
||||
awserrors "github.com/mulgadc/spinifex/spinifex/foundation/aws/errors"
|
||||
"github.com/mulgadc/spinifex/spinifex/ingress/aws/dispatch"
|
||||
"github.com/nats-io/nats.go"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// routePath fills a route pattern with placeholder segments. The tag routes
|
||||
// take an ARN, which has to be escaped to stay one path segment.
|
||||
func routePath(pattern string) string {
|
||||
arn := url.PathEscape("arn:aws:eks:us-east-1:111122223333:cluster/c")
|
||||
path := strings.ReplaceAll(pattern, "*", arn)
|
||||
for {
|
||||
start := strings.Index(path, "{")
|
||||
if start < 0 {
|
||||
return path
|
||||
}
|
||||
end := strings.Index(path[start:], "}")
|
||||
path = path[:start] + "x" + path[start+end+1:]
|
||||
}
|
||||
}
|
||||
|
||||
// A parameter refusal must reach the wire under a code the operation declares
|
||||
// in the EKS model, or SDK clients cannot match it to a typed error.
|
||||
func TestRegistration_ParameterRefusalCodeIsDeclaredByOperation(t *testing.T) {
|
||||
_, nc := testutil.StartTestNATS(t)
|
||||
sub, err := nc.Subscribe("eks.>", func(m *nats.Msg) {
|
||||
if m.Reply != "" {
|
||||
_ = m.Respond(awserrors.GenerateErrorPayload(awserrors.ErrorInvalidParameterValue))
|
||||
}
|
||||
})
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(func() { _ = sub.Unsubscribe() })
|
||||
|
||||
model, err := awsmodel.Load(awsmodel.EKS)
|
||||
require.NoError(t, err)
|
||||
registration := NewRegistration(Deps{NATS: nc})
|
||||
|
||||
for _, route := range routes {
|
||||
if IsInternalAction(route.Action) {
|
||||
continue
|
||||
}
|
||||
// The handler takes no input and the model declares no client error.
|
||||
if route.Action == "ListAccessPolicies" {
|
||||
continue
|
||||
}
|
||||
t.Run(route.Action, func(t *testing.T) {
|
||||
operation, ok := model.Operation(route.Action)
|
||||
require.True(t, ok, "%s is not an operation in the EKS model", route.Action)
|
||||
declared := make([]string, 0, len(operation.Errors))
|
||||
for _, ref := range operation.Errors {
|
||||
declared = append(declared, ref.Shape)
|
||||
}
|
||||
|
||||
req := httptest.NewRequest(route.Method, routePath(route.Pattern), strings.NewReader(`{}`))
|
||||
inv := dispatch.Invocation{
|
||||
Request: req,
|
||||
AccountID: "111122223333",
|
||||
Region: "us-east-1",
|
||||
Authorize: func(string, string, []string, iampolicy.ConditionKeys) error { return nil },
|
||||
Actor: func() (dispatch.Actor, error) {
|
||||
return dispatch.Actor{AccountID: "111122223333", PrincipalType: "user", UserName: "alice"}, nil
|
||||
},
|
||||
}
|
||||
|
||||
err := registration.Dispatch(httptest.NewRecorder(), inv)
|
||||
|
||||
require.Error(t, err)
|
||||
code, ok := awserrors.ResolveErrorCode(err)
|
||||
require.True(t, ok, "unresolvable error code: %v", err)
|
||||
assert.Contains(t, declared, code,
|
||||
"%s %s refuses a parameter with %s, which the operation does not declare", route.Method, route.Pattern, code)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// The access operations declare InvalidRequestException, not a parameter code.
|
||||
func TestEKSInvalidRequestCodeIsRegistered(t *testing.T) {
|
||||
assert.Equal(t, "InvalidRequestException", awserrors.ErrorEKSInvalidRequest)
|
||||
assert.Equal(t, http.StatusBadRequest, awserrors.HTTPStatusForError(awserrors.Errorf(awserrors.ErrorEKSInvalidRequest, "x")))
|
||||
assert.Equal(t, "BadRequestException", awserrors.ErrorEKSBadRequest)
|
||||
assert.Equal(t, http.StatusBadRequest, awserrors.HTTPStatusForError(awserrors.Errorf(awserrors.ErrorEKSBadRequest, "x")))
|
||||
}
|
||||
@@ -131,7 +131,7 @@ var routes = []route{
|
||||
// Addons
|
||||
{Method: "GET", Pattern: "/addons/supported-versions", Action: "DescribeAddonVersions",
|
||||
Handler: func(ctx context.Context, nc *nats.Conn, acct, callerARN string, p []string, b []byte) (any, error) {
|
||||
return DescribeAddonVersions(ctx, nc, acct, b)
|
||||
return DescribeAddonVersions(ctx, nc, acct)
|
||||
}},
|
||||
{Method: "GET", Pattern: "/clusters/{clusterName}/addons", Action: "ListAddons",
|
||||
Handler: func(ctx context.Context, nc *nats.Conn, acct, callerARN string, p []string, b []byte) (any, error) {
|
||||
|
||||
@@ -176,7 +176,7 @@ func TestGatewayWrappers_Addons(t *testing.T) {
|
||||
require.NoError(t, err)
|
||||
assert.NotNil(t, out1)
|
||||
|
||||
out2, err := DescribeAddonVersions(context.Background(), nc, acct, nil)
|
||||
out2, err := DescribeAddonVersions(context.Background(), nc, acct)
|
||||
require.NoError(t, err)
|
||||
assert.NotNil(t, out2)
|
||||
|
||||
|
||||
@@ -1,171 +0,0 @@
|
||||
package cluster
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/aws/aws-sdk-go/aws"
|
||||
"github.com/aws/aws-sdk-go/service/eks"
|
||||
"github.com/mulgadc/spinifex/spinifex/domains/eks/addon"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func describeVersions(t *testing.T, svc *EKSServiceImpl, in *eks.DescribeAddonVersionsInput) *eks.DescribeAddonVersionsOutput {
|
||||
t.Helper()
|
||||
out, err := svc.DescribeAddonVersions(context.Background(), in, testAccountID)
|
||||
require.NoError(t, err)
|
||||
return out
|
||||
}
|
||||
|
||||
// The terraform aws_eks_addon_version data source asks for one add-on at the
|
||||
// cluster's Kubernetes version, then takes the version whose compatibility for
|
||||
// that cluster version is the default (or, with most_recent, the first one).
|
||||
func TestDescribeAddonVersions_TerraformProviderCall(t *testing.T) {
|
||||
t.Parallel()
|
||||
svc := setupTestService(t)
|
||||
|
||||
out := describeVersions(t, svc, &eks.DescribeAddonVersionsInput{
|
||||
AddonName: aws.String(csiAddon),
|
||||
KubernetesVersion: aws.String(defaultK8sVersion),
|
||||
})
|
||||
require.Len(t, out.Addons, 1)
|
||||
info := out.Addons[0]
|
||||
assert.Equal(t, csiAddon, aws.StringValue(info.AddonName))
|
||||
assert.Nil(t, out.NextToken)
|
||||
|
||||
spec, ok := addon.Lookup(csiAddon)
|
||||
require.True(t, ok)
|
||||
require.Equal(t, spec.AWSVersions(), versionStrings(info), "newest first, AWS form")
|
||||
|
||||
defaults := 0
|
||||
for _, v := range info.AddonVersions {
|
||||
require.Len(t, v.Compatibilities, 1, "one entry, for the requested cluster version")
|
||||
c := v.Compatibilities[0]
|
||||
assert.Equal(t, defaultK8sVersion, aws.StringValue(c.ClusterVersion))
|
||||
assert.Equal(t, []string{"*"}, aws.StringValueSlice(c.PlatformVersions))
|
||||
if aws.BoolValue(c.DefaultVersion) {
|
||||
defaults++
|
||||
assert.Equal(t, spec.AWSVersion(spec.DefaultVersion), aws.StringValue(v.AddonVersion))
|
||||
}
|
||||
}
|
||||
assert.Equal(t, 1, defaults, "exactly one defaultVersion true")
|
||||
}
|
||||
|
||||
// AWS observed (EKS-01): compatibilities holds only the requested version. The
|
||||
// response for a version no add-on supports is not captured; this pins the
|
||||
// shape #61 chose, an empty list with no error.
|
||||
func TestDescribeAddonVersions_UnsupportedKubernetesVersionIsEmpty(t *testing.T) {
|
||||
t.Parallel()
|
||||
svc := setupTestService(t)
|
||||
|
||||
out := describeVersions(t, svc, &eks.DescribeAddonVersionsInput{
|
||||
AddonName: aws.String(csiAddon),
|
||||
KubernetesVersion: aws.String("1.29"),
|
||||
})
|
||||
assert.Empty(t, out.Addons)
|
||||
assert.Nil(t, out.NextToken)
|
||||
}
|
||||
|
||||
func TestDescribeAddonVersions_FiltersByNameAndHidesFixturesUnlessNamed(t *testing.T) {
|
||||
t.Parallel()
|
||||
svc := setupTestService(t)
|
||||
|
||||
for _, name := range []string{csiAddon, "argocd", albController} {
|
||||
out := describeVersions(t, svc, &eks.DescribeAddonVersionsInput{AddonName: aws.String(name)})
|
||||
require.Len(t, out.Addons, 1, name)
|
||||
assert.Equal(t, name, aws.StringValue(out.Addons[0].AddonName))
|
||||
}
|
||||
assert.Empty(t, describeVersions(t, svc, &eks.DescribeAddonVersionsInput{AddonName: aws.String("vpc-cni")}).Addons)
|
||||
}
|
||||
|
||||
// maxResults counts add-ons, not versions; a page boundary never splits an
|
||||
// add-on's version list, and the last page carries no token.
|
||||
func TestDescribeAddonVersions_PagesByAddon(t *testing.T) {
|
||||
t.Parallel()
|
||||
svc := setupTestService(t)
|
||||
|
||||
all := describeVersions(t, svc, &eks.DescribeAddonVersionsInput{})
|
||||
require.Greater(t, len(all.Addons), 1)
|
||||
assert.Nil(t, all.NextToken)
|
||||
|
||||
var names []string
|
||||
in := &eks.DescribeAddonVersionsInput{MaxResults: aws.Int64(1)}
|
||||
for range len(all.Addons) {
|
||||
out := describeVersions(t, svc, in)
|
||||
require.Len(t, out.Addons, 1)
|
||||
names = append(names, aws.StringValue(out.Addons[0].AddonName))
|
||||
assert.Equal(t, versionStrings(all.Addons[len(names)-1]), versionStrings(out.Addons[0]))
|
||||
in.NextToken = out.NextToken
|
||||
}
|
||||
assert.Nil(t, in.NextToken, "last page carries no token")
|
||||
var want []string
|
||||
for _, a := range all.Addons {
|
||||
want = append(want, aws.StringValue(a.AddonName))
|
||||
}
|
||||
assert.Equal(t, want, names)
|
||||
}
|
||||
|
||||
// Every add-on carries the descriptive fields the data source and console read.
|
||||
func TestDescribeAddonVersions_CarriesAddonAndVersionMetadata(t *testing.T) {
|
||||
t.Parallel()
|
||||
svc := setupTestService(t)
|
||||
|
||||
for _, a := range describeVersions(t, svc, &eks.DescribeAddonVersionsInput{}).Addons {
|
||||
name := aws.StringValue(a.AddonName)
|
||||
assert.NotEmpty(t, aws.StringValue(a.Type), name)
|
||||
assert.NotEmpty(t, aws.StringValue(a.Owner), name)
|
||||
assert.NotEmpty(t, aws.StringValue(a.Publisher), name)
|
||||
assert.Nil(t, a.MarketplaceInformation, name)
|
||||
for _, v := range a.AddonVersions {
|
||||
assert.NotEmpty(t, v.Architecture, name)
|
||||
assert.NotNil(t, v.RequiresConfiguration, name)
|
||||
assert.NotNil(t, v.RequiresIamPermissions, name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// types, publishers and owners are model filters; each keeps add-ons whose
|
||||
// field matches one of the given values.
|
||||
func TestDescribeAddonVersions_FiltersByTypePublisherOwner(t *testing.T) {
|
||||
t.Parallel()
|
||||
svc := setupTestService(t)
|
||||
|
||||
first := describeVersions(t, svc, &eks.DescribeAddonVersionsInput{}).Addons[0]
|
||||
cases := []struct {
|
||||
name string
|
||||
match func(*eks.DescribeAddonVersionsInput, []*string)
|
||||
value *string
|
||||
}{
|
||||
{"types", func(in *eks.DescribeAddonVersionsInput, v []*string) { in.Types = v }, first.Type},
|
||||
{"publishers", func(in *eks.DescribeAddonVersionsInput, v []*string) { in.Publishers = v }, first.Publisher},
|
||||
{"owners", func(in *eks.DescribeAddonVersionsInput, v []*string) { in.Owners = v }, first.Owner},
|
||||
}
|
||||
for _, c := range cases {
|
||||
hit := &eks.DescribeAddonVersionsInput{}
|
||||
c.match(hit, aws.StringSlice([]string{"nobody", aws.StringValue(c.value)}))
|
||||
out := describeVersions(t, svc, hit)
|
||||
require.NotEmpty(t, out.Addons, c.name)
|
||||
assert.Contains(t, addonNames(out), aws.StringValue(first.AddonName), c.name)
|
||||
|
||||
miss := &eks.DescribeAddonVersionsInput{}
|
||||
c.match(miss, aws.StringSlice([]string{"nobody"}))
|
||||
assert.Empty(t, describeVersions(t, svc, miss).Addons, c.name)
|
||||
}
|
||||
}
|
||||
|
||||
func versionStrings(a *eks.AddonInfo) []string {
|
||||
out := make([]string, 0, len(a.AddonVersions))
|
||||
for _, v := range a.AddonVersions {
|
||||
out = append(out, aws.StringValue(v.AddonVersion))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func addonNames(o *eks.DescribeAddonVersionsOutput) []string {
|
||||
out := make([]string, 0, len(o.Addons))
|
||||
for _, a := range o.Addons {
|
||||
out = append(out, aws.StringValue(a.AddonName))
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -1,220 +0,0 @@
|
||||
package cluster
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"log/slog"
|
||||
"testing"
|
||||
|
||||
"github.com/aws/aws-sdk-go/aws"
|
||||
"github.com/aws/aws-sdk-go/service/eks"
|
||||
"github.com/mulgadc/spinifex/spinifex/domains/eks/addon"
|
||||
awserrors "github.com/mulgadc/spinifex/spinifex/foundation/aws/errors"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
const (
|
||||
csiAddon = "aws-ebs-csi-driver"
|
||||
csiBare = "1.40.1"
|
||||
csiAWSVersion = "v1.40.1-eksbuild.1"
|
||||
csiWrongBuild = "v1.40.1-eksbuild.2"
|
||||
versionParam = "addonVersion"
|
||||
notBeforeAWSOK = "an AWS-format version must be accepted"
|
||||
)
|
||||
|
||||
func TestDescribeAddonVersions_ReturnsAWSFormNewestFirst(t *testing.T) {
|
||||
t.Parallel()
|
||||
svc := setupTestService(t)
|
||||
|
||||
out, err := svc.DescribeAddonVersions(context.Background(), &eks.DescribeAddonVersionsInput{}, testAccountID)
|
||||
require.NoError(t, err)
|
||||
require.NotEmpty(t, out.Addons)
|
||||
for _, a := range out.Addons {
|
||||
spec, ok := addon.Lookup(aws.StringValue(a.AddonName))
|
||||
require.True(t, ok)
|
||||
require.Len(t, a.AddonVersions, len(spec.Versions))
|
||||
for i, v := range a.AddonVersions {
|
||||
assert.Equal(t, "v"+spec.Versions[i]+"-eksbuild.1", aws.StringValue(v.AddonVersion),
|
||||
"%s version %d", spec.Name, i)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateAddon_AcceptsBothFormsAndStoresTheBareForm(t *testing.T) {
|
||||
t.Parallel()
|
||||
for name, input := range map[string]string{"aws form": csiAWSVersion, "bare form": csiBare} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
svc, fake := setupAddonService(t)
|
||||
|
||||
out, err := svc.CreateAddon(context.Background(), &eks.CreateAddonInput{
|
||||
ClusterName: aws.String("c1"), AddonName: aws.String(csiAddon),
|
||||
AddonVersion: aws.String(input),
|
||||
}, testAccountID)
|
||||
require.NoError(t, err, notBeforeAWSOK)
|
||||
assert.Equal(t, csiAWSVersion, aws.StringValue(out.Addon.AddonVersion), "response is in AWS form")
|
||||
|
||||
rec, err := addon.Get(t.Context(), acctKVForTest(t, svc), "c1", csiAddon)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, csiBare, rec.AddonVersion, "the record keeps the upstream form")
|
||||
require.Len(t, fake.installs, 1)
|
||||
assert.Equal(t, csiBare, fake.installs[0].AddonVersion, "the bundle lookup sees the upstream form")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateAddon_DefaultVersionResponseIsAWSForm(t *testing.T) {
|
||||
t.Parallel()
|
||||
svc, _ := setupAddonService(t)
|
||||
out, err := svc.CreateAddon(context.Background(), &eks.CreateAddonInput{
|
||||
ClusterName: aws.String("c1"), AddonName: aws.String(csiAddon),
|
||||
}, testAccountID)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, csiAWSVersion, aws.StringValue(out.Addon.AddonVersion))
|
||||
}
|
||||
|
||||
func TestUpdateAddon_AcceptsBothFormsAndStoresTheBareForm(t *testing.T) {
|
||||
t.Parallel()
|
||||
for name, input := range map[string]string{"aws form": csiAWSVersion, "bare form": csiBare} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
svc, fake := setupAddonService(t)
|
||||
_, err := svc.CreateAddon(context.Background(), &eks.CreateAddonInput{
|
||||
ClusterName: aws.String("c1"), AddonName: aws.String(csiAddon),
|
||||
}, testAccountID)
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = svc.UpdateAddon(context.Background(), &eks.UpdateAddonInput{
|
||||
ClusterName: aws.String("c1"), AddonName: aws.String(csiAddon),
|
||||
AddonVersion: aws.String(input),
|
||||
}, testAccountID)
|
||||
require.NoError(t, err, notBeforeAWSOK)
|
||||
|
||||
rec, err := addon.Get(t.Context(), acctKVForTest(t, svc), "c1", csiAddon)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, csiBare, rec.AddonVersion)
|
||||
require.Len(t, fake.installs, 2)
|
||||
assert.Equal(t, csiBare, fake.installs[1].AddonVersion)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDescribeAddon_ReturnsAWSFormForAnyStoredForm(t *testing.T) {
|
||||
t.Parallel()
|
||||
for name, stored := range map[string]string{
|
||||
"record stored bare before the change": csiBare,
|
||||
"record already in AWS form": csiAWSVersion,
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
svc, _ := setupAddonService(t)
|
||||
_, err := svc.addons().Create(context.Background(), acctKVForTest(t, svc), testAccountID, addon.Desired{
|
||||
Cluster: "c1", Name: csiAddon, Version: stored,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
desc, err := svc.DescribeAddon(context.Background(), &eks.DescribeAddonInput{
|
||||
ClusterName: aws.String("c1"), AddonName: aws.String(csiAddon),
|
||||
}, testAccountID)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, csiAWSVersion, aws.StringValue(desc.Addon.AddonVersion), "no double wrap")
|
||||
|
||||
list, err := svc.ListAddons(context.Background(), &eks.ListAddonsInput{ClusterName: aws.String("c1")}, testAccountID)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, []string{csiAddon}, aws.StringValueSlice(list.Addons))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateAddon_ResponseAndRecordAfterBareRecord(t *testing.T) {
|
||||
t.Parallel()
|
||||
svc, _ := setupAddonService(t)
|
||||
_, err := svc.addons().Create(context.Background(), acctKVForTest(t, svc), testAccountID, addon.Desired{
|
||||
Cluster: "c1", Name: csiAddon, Version: csiBare,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = svc.UpdateAddon(context.Background(), &eks.UpdateAddonInput{
|
||||
ClusterName: aws.String("c1"), AddonName: aws.String(csiAddon),
|
||||
ConfigurationValues: aws.String(`{"a":1}`),
|
||||
}, testAccountID)
|
||||
require.NoError(t, err)
|
||||
desc, err := svc.DescribeAddon(context.Background(), &eks.DescribeAddonInput{
|
||||
ClusterName: aws.String("c1"), AddonName: aws.String(csiAddon),
|
||||
}, testAccountID)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, csiAWSVersion, aws.StringValue(desc.Addon.AddonVersion))
|
||||
}
|
||||
|
||||
func TestAddonVersionRefusal_NamesTheParameterAndListsSupportedValues(t *testing.T) {
|
||||
t.Parallel()
|
||||
svc, _ := setupAddonService(t)
|
||||
_, err := svc.CreateAddon(context.Background(), &eks.CreateAddonInput{
|
||||
ClusterName: aws.String("c1"), AddonName: aws.String("argocd"),
|
||||
}, testAccountID)
|
||||
require.NoError(t, err)
|
||||
|
||||
refusals := map[string]func() error{
|
||||
"create": func() error {
|
||||
_, e := svc.CreateAddon(context.Background(), &eks.CreateAddonInput{
|
||||
ClusterName: aws.String("c1"), AddonName: aws.String(csiAddon),
|
||||
AddonVersion: aws.String(csiWrongBuild),
|
||||
}, testAccountID)
|
||||
return e
|
||||
},
|
||||
"update": func() error {
|
||||
_, e := svc.UpdateAddon(context.Background(), &eks.UpdateAddonInput{
|
||||
ClusterName: aws.String("c1"), AddonName: aws.String("argocd"),
|
||||
AddonVersion: aws.String("v3.0.23-eksbuild.2"),
|
||||
}, testAccountID)
|
||||
return e
|
||||
},
|
||||
}
|
||||
want := map[string]string{"create": csiAWSVersion, "update": "v3.0.23-eksbuild.1"}
|
||||
for name, refuse := range refusals {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
err := refuse()
|
||||
require.Error(t, err)
|
||||
code, message, ok := awserrors.ResolveErrorDetail(err)
|
||||
require.True(t, ok)
|
||||
assert.Equal(t, awserrors.ErrorInvalidParameterValue, code, "this change leaves the error code alone")
|
||||
assert.Contains(t, message, versionParam)
|
||||
assert.Contains(t, message, want[name], "the supported AWS-form value is listed")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAddonBareVersionInput_LogsItsUseAtInfo(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
restore := slog.Default()
|
||||
slog.SetDefault(slog.New(slog.NewTextHandler(&buf, &slog.HandlerOptions{Level: slog.LevelInfo})))
|
||||
t.Cleanup(func() { slog.SetDefault(restore) })
|
||||
|
||||
svc, _ := setupAddonService(t)
|
||||
_, err := svc.CreateAddon(context.Background(), &eks.CreateAddonInput{
|
||||
ClusterName: aws.String("c1"), AddonName: aws.String(csiAddon),
|
||||
}, testAccountID)
|
||||
require.NoError(t, err)
|
||||
|
||||
buf.Reset()
|
||||
_, err = svc.UpdateAddon(context.Background(), &eks.UpdateAddonInput{
|
||||
ClusterName: aws.String("c1"), AddonName: aws.String(csiAddon),
|
||||
AddonVersion: aws.String(csiBare),
|
||||
}, testAccountID)
|
||||
require.NoError(t, err)
|
||||
logged := buf.String()
|
||||
assert.Contains(t, logged, "level=INFO")
|
||||
assert.Contains(t, logged, "bare")
|
||||
assert.Contains(t, logged, csiAddon)
|
||||
assert.Contains(t, logged, "c1")
|
||||
|
||||
buf.Reset()
|
||||
_, err = svc.UpdateAddon(context.Background(), &eks.UpdateAddonInput{
|
||||
ClusterName: aws.String("c1"), AddonName: aws.String(csiAddon),
|
||||
AddonVersion: aws.String(csiAWSVersion),
|
||||
}, testAccountID)
|
||||
require.NoError(t, err, notBeforeAWSOK)
|
||||
assert.NotContains(t, buf.String(), "bare", "the AWS form is the intended form and logs nothing")
|
||||
}
|
||||
@@ -2,12 +2,8 @@ package cluster
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
"github.com/aws/aws-sdk-go/aws"
|
||||
"github.com/aws/aws-sdk-go/service/eks"
|
||||
@@ -39,20 +35,15 @@ func (s *EKSServiceImpl) ListAddons(ctx context.Context, input *eks.ListAddonsIn
|
||||
return &eks.ListAddonsOutput{Addons: aws.StringSlice(names)}, nil
|
||||
}
|
||||
|
||||
// DescribeAddonVersions returns the add-on catalogue filtered by name, owner,
|
||||
// publisher, type and the Kubernetes version its versions support, one page
|
||||
// of add-ons at a time.
|
||||
func (s *EKSServiceImpl) DescribeAddonVersions(_ context.Context, input *eks.DescribeAddonVersionsInput, _ string) (*eks.DescribeAddonVersionsOutput, error) {
|
||||
if input == nil {
|
||||
input = &eks.DescribeAddonVersionsInput{}
|
||||
// DescribeAddonVersions returns the static add-on catalog, optionally filtered by name.
|
||||
func (s *EKSServiceImpl) DescribeAddonVersions(ctx context.Context, input *eks.DescribeAddonVersionsInput, _ string) (*eks.DescribeAddonVersionsOutput, error) {
|
||||
filter := ""
|
||||
if input != nil {
|
||||
filter = aws.StringValue(input.AddonName)
|
||||
}
|
||||
// Every catalogue version supports only the K3s line the node image ships.
|
||||
if v := aws.StringValue(input.KubernetesVersion); v != "" && v != defaultK8sVersion {
|
||||
return &eks.DescribeAddonVersionsOutput{Addons: []*eks.AddonInfo{}}, nil
|
||||
}
|
||||
filter := aws.StringValue(input.AddonName)
|
||||
var matched []addon.Spec
|
||||
for _, spec := range addon.Specs() {
|
||||
specs := addon.Specs()
|
||||
out := make([]*eks.AddonInfo, 0, len(specs))
|
||||
for _, spec := range specs {
|
||||
if filter != "" && spec.Name != filter {
|
||||
continue
|
||||
}
|
||||
@@ -61,51 +52,9 @@ func (s *EKSServiceImpl) DescribeAddonVersions(_ context.Context, input *eks.Des
|
||||
if spec.Hidden && filter == "" {
|
||||
continue
|
||||
}
|
||||
if !matchesAny(input.Types, spec.Type) || !matchesAny(input.Owners, addonOwner) ||
|
||||
!matchesAny(input.Publishers, addonPublisher) {
|
||||
continue
|
||||
}
|
||||
matched = append(matched, spec)
|
||||
}
|
||||
page, next, err := pageAddons(matched, input.MaxResults, input.NextToken)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make([]*eks.AddonInfo, 0, len(page))
|
||||
for _, spec := range page {
|
||||
out = append(out, addonSpecToAWS(spec))
|
||||
}
|
||||
return &eks.DescribeAddonVersionsOutput{Addons: out, NextToken: next}, nil
|
||||
}
|
||||
|
||||
// matchesAny reports whether the filter is empty or lists value.
|
||||
func matchesAny(filter []*string, value string) bool {
|
||||
return len(filter) == 0 || slices.Contains(aws.StringValueSlice(filter), value)
|
||||
}
|
||||
|
||||
// maxAddonPage bounds maxResults, the model's range being 1 to 100.
|
||||
const maxAddonPage = 100
|
||||
|
||||
// pageAddons returns the add-ons after the token, at most maxResults of them.
|
||||
// The token is the base64 name of the last add-on returned, opaque to clients.
|
||||
func pageAddons(specs []addon.Spec, maxResults *int64, token *string) ([]addon.Spec, *string, error) {
|
||||
if t := aws.StringValue(token); t != "" {
|
||||
raw, err := base64.RawURLEncoding.DecodeString(t)
|
||||
if err != nil {
|
||||
return nil, nil, awserrors.Errorf(awserrors.ErrorEKSInvalidParameter, "nextToken is not valid")
|
||||
}
|
||||
after := string(raw)
|
||||
specs = slices.DeleteFunc(slices.Clone(specs), func(sp addon.Spec) bool { return sp.Name <= after })
|
||||
}
|
||||
limit := len(specs)
|
||||
if maxResults != nil {
|
||||
limit = min(max(int(*maxResults), 1), maxAddonPage)
|
||||
}
|
||||
if limit >= len(specs) {
|
||||
return specs, nil, nil
|
||||
}
|
||||
next := base64.RawURLEncoding.EncodeToString([]byte(specs[limit-1].Name))
|
||||
return specs[:limit], &next, nil
|
||||
return &eks.DescribeAddonVersionsOutput{Addons: out}, nil
|
||||
}
|
||||
|
||||
// validateAddonServiceAccountRoleArn rejects a non-empty value that is not
|
||||
@@ -140,12 +89,11 @@ func (s *EKSServiceImpl) CreateAddon(ctx context.Context, input *eks.CreateAddon
|
||||
if !ok {
|
||||
return nil, errors.New(awserrors.ErrorInvalidParameterValue)
|
||||
}
|
||||
version := spec.DefaultVersion
|
||||
if requested := aws.StringValue(input.AddonVersion); requested != "" {
|
||||
var err error
|
||||
if version, err = resolveAddonVersion(ctx, spec, requested, cluster); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
version := aws.StringValue(input.AddonVersion)
|
||||
if version == "" {
|
||||
version = spec.DefaultVersion
|
||||
} else if !spec.SupportsVersion(version) {
|
||||
return nil, errors.New(awserrors.ErrorInvalidParameterValue)
|
||||
}
|
||||
acctKV, err := s.acctKVForNewDependant(ctx, accountID, cluster)
|
||||
if err != nil {
|
||||
@@ -249,13 +197,9 @@ func (s *EKSServiceImpl) UpdateAddon(ctx context.Context, input *eks.UpdateAddon
|
||||
version := aws.StringValue(input.AddonVersion)
|
||||
if version != "" {
|
||||
spec, ok := addon.Lookup(addonName)
|
||||
if !ok {
|
||||
if !ok || !spec.SupportsVersion(version) {
|
||||
return nil, errors.New(awserrors.ErrorInvalidParameterValue)
|
||||
}
|
||||
var err error
|
||||
if version, err = resolveAddonVersion(ctx, spec, version, cluster); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
rec, err := s.addons().Update(ctx, acctKV, accountID, cluster, addonName, addon.Change{
|
||||
Version: version,
|
||||
@@ -302,7 +246,7 @@ func addonRecordToAWS(cluster string, rec *addon.Record) *eks.Addon {
|
||||
out := &eks.Addon{
|
||||
AddonArn: aws.String(rec.Arn),
|
||||
AddonName: aws.String(rec.AddonName),
|
||||
AddonVersion: aws.String(awsAddonVersion(rec)),
|
||||
AddonVersion: aws.String(rec.AddonVersion),
|
||||
ClusterName: aws.String(cluster),
|
||||
Status: aws.String(string(rec.Status)),
|
||||
CreatedAt: aws.Time(rec.CreatedAt),
|
||||
@@ -325,36 +269,17 @@ func addonRecordToAWS(cluster string, rec *addon.Record) *eks.Addon {
|
||||
return out
|
||||
}
|
||||
|
||||
// The owner and publisher Spinifex reports for every catalogue add-on, as AWS
|
||||
// does for the add-ons it publishes.
|
||||
const (
|
||||
addonOwner = "aws"
|
||||
addonPublisher = "eks"
|
||||
)
|
||||
|
||||
// addonSpecToAWS converts a catalog spec to the SDK AddonInfo shape. Every
|
||||
// version supports the one cluster version the node image ships, and exactly
|
||||
// one of them is that cluster version's default.
|
||||
// addonSpecToAWS converts a catalog spec to the SDK AddonInfo shape.
|
||||
func addonSpecToAWS(spec addon.Spec) *eks.AddonInfo {
|
||||
versions := make([]*eks.AddonVersionInfo, 0, len(spec.Versions))
|
||||
for _, v := range spec.AWSVersions() {
|
||||
for _, v := range spec.Versions {
|
||||
versions = append(versions, &eks.AddonVersionInfo{
|
||||
AddonVersion: aws.String(v),
|
||||
Architecture: aws.StringSlice([]string{"amd64"}),
|
||||
RequiresConfiguration: aws.Bool(false),
|
||||
RequiresIamPermissions: aws.Bool(spec.RequiresIRSA),
|
||||
Compatibilities: []*eks.Compatibility{{
|
||||
ClusterVersion: aws.String(defaultK8sVersion),
|
||||
PlatformVersions: aws.StringSlice([]string{"*"}),
|
||||
DefaultVersion: aws.Bool(v == spec.AWSVersion(spec.DefaultVersion)),
|
||||
}},
|
||||
})
|
||||
}
|
||||
return &eks.AddonInfo{
|
||||
AddonName: aws.String(spec.Name),
|
||||
Type: aws.String(spec.Type),
|
||||
Owner: aws.String(addonOwner),
|
||||
Publisher: aws.String(addonPublisher),
|
||||
AddonVersions: versions,
|
||||
}
|
||||
}
|
||||
@@ -383,30 +308,3 @@ func (s *EKSServiceImpl) addonBucket(ctx context.Context, accountID string) (jet
|
||||
}
|
||||
return GetOrCreateAccountBucket(ctx, js, accountID)
|
||||
}
|
||||
|
||||
// resolveAddonVersion maps a client version, in AWS form or bare upstream form,
|
||||
// to the upstream form the record and the bundle lookup use. The bare form is
|
||||
// more lenient than AWS, so each use is logged.
|
||||
func resolveAddonVersion(ctx context.Context, spec addon.Spec, requested, cluster string) (string, error) {
|
||||
upstream, ok := spec.ParseVersion(requested)
|
||||
if !ok {
|
||||
return "", awserrors.Errorf(awserrors.ErrorInvalidParameterValue,
|
||||
"addonVersion %q is not supported for add-on %s; supported versions: %s",
|
||||
requested, spec.Name, strings.Join(spec.AWSVersions(), ", "))
|
||||
}
|
||||
if requested == upstream {
|
||||
slog.InfoContext(ctx, "EKS add-on version given in bare upstream form",
|
||||
"addon", spec.Name, "cluster", cluster, "version", requested)
|
||||
}
|
||||
return upstream, nil
|
||||
}
|
||||
|
||||
// awsAddonVersion returns a stored record's version in AWS form; an add-on the
|
||||
// catalogue no longer lists keeps its stored string.
|
||||
func awsAddonVersion(rec *addon.Record) string {
|
||||
spec, ok := addon.Lookup(rec.AddonName)
|
||||
if !ok {
|
||||
return rec.AddonVersion
|
||||
}
|
||||
return spec.AWSVersion(rec.AddonVersion)
|
||||
}
|
||||
|
||||
@@ -98,9 +98,7 @@ func TestCreateAddon_UnknownVersionRejected(t *testing.T) {
|
||||
ClusterName: aws.String("c1"), AddonName: aws.String(albController),
|
||||
AddonVersion: aws.String("9.9.9-nope"),
|
||||
}, testAccountID)
|
||||
code, ok := awserrors.ResolveErrorCode(err)
|
||||
require.True(t, ok)
|
||||
assert.Equal(t, awserrors.ErrorInvalidParameterValue, code)
|
||||
require.EqualError(t, err, awserrors.ErrorInvalidParameterValue)
|
||||
}
|
||||
|
||||
func TestCreateAddon_CreatesStagingAndDefaultsVersion(t *testing.T) {
|
||||
@@ -115,7 +113,7 @@ func TestCreateAddon_CreatesStagingAndDefaultsVersion(t *testing.T) {
|
||||
require.NotNil(t, out.Addon)
|
||||
// Version defaults to catalog default; status starts CREATING (honest until
|
||||
// VM-side delivery confirms ACTIVE).
|
||||
assert.Equal(t, spec.AWSVersion(spec.DefaultVersion), aws.StringValue(out.Addon.AddonVersion))
|
||||
assert.Equal(t, spec.DefaultVersion, aws.StringValue(out.Addon.AddonVersion))
|
||||
assert.Equal(t, string(addon.StatusCreating), aws.StringValue(out.Addon.Status))
|
||||
assert.Contains(t, aws.StringValue(out.Addon.AddonArn), ":addon/c1/"+albController)
|
||||
|
||||
@@ -216,7 +214,7 @@ func TestUpdateAddon_ChangesVersionAndReinstalls(t *testing.T) {
|
||||
}, testAccountID)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, `{"replicaCount":2}`, aws.StringValue(desc.Addon.ConfigurationValues))
|
||||
assert.Equal(t, spec.AWSVersion(spec.DefaultVersion), aws.StringValue(desc.Addon.AddonVersion))
|
||||
assert.Equal(t, spec.DefaultVersion, aws.StringValue(desc.Addon.AddonVersion))
|
||||
assert.Equal(t, string(addon.StatusUpdating), aws.StringValue(desc.Addon.Status))
|
||||
}
|
||||
|
||||
@@ -232,9 +230,7 @@ func TestUpdateAddon_UnknownVersionRejected(t *testing.T) {
|
||||
ClusterName: aws.String("c1"), AddonName: aws.String("argocd"),
|
||||
AddonVersion: aws.String("9.9.9-nope"),
|
||||
}, testAccountID)
|
||||
code, ok := awserrors.ResolveErrorCode(err)
|
||||
require.True(t, ok)
|
||||
assert.Equal(t, awserrors.ErrorInvalidParameterValue, code)
|
||||
require.EqualError(t, err, awserrors.ErrorInvalidParameterValue)
|
||||
}
|
||||
|
||||
func TestUpdateAddon_MissingIsNotFound(t *testing.T) {
|
||||
|
||||
@@ -1,421 +0,0 @@
|
||||
package cluster
|
||||
|
||||
import (
|
||||
"context"
|
||||
"maps"
|
||||
"strings"
|
||||
"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"
|
||||
"github.com/mulgadc/spinifex/spinifex/foundation/aws/tags"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// sgCreatesIn returns the CreateSecurityGroup calls made against vpc whose name matches.
|
||||
func sgCreatesIn(f *fakeSGProvisioner, vpc string, match func(name string) bool) []*ec2.CreateSecurityGroupInput {
|
||||
var out []*ec2.CreateSecurityGroupInput
|
||||
for _, c := range f.createCalls {
|
||||
if aws.StringValue(c.VpcId) == vpc && match(aws.StringValue(c.GroupName)) {
|
||||
out = append(out, c)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func sgCreateTags(c *ec2.CreateSecurityGroupInput) map[string]string {
|
||||
m := map[string]string{}
|
||||
for _, spec := range c.TagSpecifications {
|
||||
maps.Copy(m, sgTagMap(spec.Tags))
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
func authorizedOn(f *fakeSGProvisioner, sgID string) []*ec2.IpPermission {
|
||||
var out []*ec2.IpPermission
|
||||
for _, c := range f.authorizeCalls {
|
||||
if aws.StringValue(c.GroupId) == sgID {
|
||||
out = append(out, c.IpPermissions...)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func deletedSGIDs(f *fakeSGProvisioner) []string {
|
||||
var out []string
|
||||
for _, c := range f.deleteCalls {
|
||||
out = append(out, aws.StringValue(c.GroupId))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// seedClusterSG registers an AWS-style cluster SG the way a created one looks.
|
||||
func seedClusterSG(f *fakeSGProvisioner, cluster, vpc, id string) {
|
||||
f.existing[clusterSGNamePrefix+cluster+"-0123456789abcdef|"+vpc] = id
|
||||
if f.tagsByID == nil {
|
||||
f.tagsByID = map[string][]*ec2.Tag{}
|
||||
}
|
||||
f.tagsByID[id] = []*ec2.Tag{
|
||||
{Key: aws.String("kubernetes.io/cluster/" + cluster), Value: aws.String("owned")},
|
||||
{Key: aws.String("aws:eks:cluster-name"), Value: aws.String(cluster)},
|
||||
{Key: aws.String(clusterEKSClusterTagKey), Value: aws.String(cluster)},
|
||||
{Key: aws.String(clusterEKSRoleTagKey), Value: aws.String("cluster")},
|
||||
{Key: aws.String(tags.ManagedByKey), Value: aws.String(tags.ManagedByEKS)},
|
||||
}
|
||||
}
|
||||
|
||||
// seedCustomerSG registers a customer-made SG, optionally tagged to imitate ours.
|
||||
func seedCustomerSG(f *fakeSGProvisioner, name, vpc, id string, tg []*ec2.Tag) {
|
||||
f.existing[name+"|"+vpc] = id
|
||||
if f.tagsByID == nil {
|
||||
f.tagsByID = map[string][]*ec2.Tag{}
|
||||
}
|
||||
f.tagsByID[id] = tg
|
||||
}
|
||||
|
||||
// spoofTags are every tag a customer can set that the service also stamps.
|
||||
func spoofTags(cluster string) []*ec2.Tag {
|
||||
return []*ec2.Tag{
|
||||
{Key: aws.String(clusterEKSClusterTagKey), Value: aws.String(cluster)},
|
||||
{Key: aws.String(clusterEKSRoleTagKey), Value: aws.String("cluster")},
|
||||
{Key: aws.String(tags.ManagedByKey), Value: aws.String(tags.ManagedByEKS)},
|
||||
{Key: aws.String("kubernetes.io/cluster/" + cluster), Value: aws.String("owned")},
|
||||
}
|
||||
}
|
||||
|
||||
func setStoredClusterSG(t *testing.T, f *deleteClusterFixture, cluster, id string) {
|
||||
t.Helper()
|
||||
meta, err := GetClusterMeta(t.Context(), f.kv, cluster)
|
||||
require.NoError(t, err)
|
||||
meta.ResourcesVpcConfig.ClusterSecurityGroupId = id
|
||||
require.NoError(t, PutClusterMeta(t.Context(), f.kv, meta))
|
||||
}
|
||||
|
||||
func createAlpha(t *testing.T, mutate func(*eks.CreateClusterInput)) (*eksServiceFixture, *ClusterMeta) {
|
||||
t.Helper()
|
||||
f := newEKSServiceFixture(t)
|
||||
f.sg.uniqueIDs = true
|
||||
in := createInput("alpha")
|
||||
if mutate != nil {
|
||||
mutate(in)
|
||||
}
|
||||
_, err := f.svc.CreateCluster(context.Background(), in, testAccountID, "")
|
||||
require.NoError(t, err)
|
||||
f.svc.WaitLaunches()
|
||||
meta, err := GetClusterMeta(t.Context(), f.kv, "alpha")
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, meta.ResourcesVpcConfig)
|
||||
return f, meta
|
||||
}
|
||||
|
||||
func withPrivateAccess(in *eks.CreateClusterInput) {
|
||||
in.ResourcesVpcConfig.EndpointPrivateAccess = aws.Bool(true)
|
||||
}
|
||||
|
||||
// CreateCluster makes the one AWS-style cluster SG in the caller's VPC and
|
||||
// reports it as clusterSecurityGroupId.
|
||||
func TestCreateCluster_ClusterSGHasAWSNameTagsAndRules(t *testing.T) {
|
||||
t.Parallel()
|
||||
f, meta := createAlpha(t, nil)
|
||||
|
||||
created := sgCreatesIn(f.sg, "vpc-aaa", func(n string) bool { return strings.HasPrefix(n, clusterSGNamePrefix+"alpha-") })
|
||||
require.Len(t, created, 1, "exactly one eks-cluster-sg-<name>-<id> SG in the customer VPC")
|
||||
name := aws.StringValue(created[0].GroupName)
|
||||
assert.NotEmpty(t, strings.TrimPrefix(name, clusterSGNamePrefix+"alpha-"), "name carries a unique id suffix")
|
||||
|
||||
tg := sgCreateTags(created[0])
|
||||
assert.Equal(t, "owned", tg["kubernetes.io/cluster/alpha"])
|
||||
assert.Equal(t, "alpha", tg["aws:eks:cluster-name"])
|
||||
assert.NotEmpty(t, tg["Name"])
|
||||
assert.Equal(t, "alpha", tg[clusterEKSClusterTagKey])
|
||||
assert.Equal(t, tags.ManagedByEKS, tg[tags.ManagedByKey])
|
||||
|
||||
id := f.sg.existing[name+"|vpc-aaa"]
|
||||
require.NotEmpty(t, id)
|
||||
assert.Equal(t, id, meta.ResourcesVpcConfig.ClusterSecurityGroupId)
|
||||
|
||||
perms := authorizedOn(f.sg, id)
|
||||
require.Len(t, perms, 1, "inbound all from itself, nothing else")
|
||||
assert.Equal(t, "-1", aws.StringValue(perms[0].IpProtocol))
|
||||
require.Len(t, perms[0].UserIdGroupPairs, 1)
|
||||
assert.Equal(t, id, aws.StringValue(perms[0].UserIdGroupPairs[0].GroupId))
|
||||
for _, c := range f.sg.revokeEgressCall {
|
||||
assert.NotEqual(t, id, aws.StringValue(c.GroupId), "egress stays allow-all")
|
||||
}
|
||||
|
||||
out, err := f.svc.DescribeCluster(context.Background(), &eks.DescribeClusterInput{Name: aws.String("alpha")}, testAccountID)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, id, aws.StringValue(out.Cluster.ResourcesVpcConfig.ClusterSecurityGroupId))
|
||||
}
|
||||
|
||||
// A new cluster has no customer-VPC control-plane or nodegroup SG, and exactly
|
||||
// one SG per cluster carries kubernetes.io/cluster/<name>.
|
||||
func TestCreateCluster_NoSeparateCustomerVPCControlPlaneOrNodegroupSG(t *testing.T) {
|
||||
t.Parallel()
|
||||
f, _ := createAlpha(t, withPrivateAccess)
|
||||
|
||||
retired := sgCreatesIn(f.sg, "vpc-aaa", func(n string) bool {
|
||||
return n == ClusterControlPlaneSGName("alpha") || n == ClusterNodegroupSGName("alpha")
|
||||
})
|
||||
assert.Empty(t, retired, "customer VPC must not get control-plane-sg or nodegroup-sg")
|
||||
|
||||
tagged := 0
|
||||
for _, c := range f.sg.createCalls {
|
||||
if _, ok := sgCreateTags(c)["kubernetes.io/cluster/alpha"]; ok {
|
||||
tagged++
|
||||
}
|
||||
}
|
||||
assert.Equal(t, 1, tagged, "exactly one SG tagged kubernetes.io/cluster/alpha")
|
||||
}
|
||||
|
||||
// The private-endpoint ENI carries its own SG and the cluster SG.
|
||||
func TestCreateCluster_PrivateEndpointENICarriesBothSGs(t *testing.T) {
|
||||
t.Parallel()
|
||||
f, meta := createAlpha(t, withPrivateAccess)
|
||||
|
||||
var eni *ec2.CreateNetworkInterfaceInput
|
||||
for _, c := range f.vpc.createCalls {
|
||||
if strings.Contains(aws.StringValue(c.Description), "private-endpoint") {
|
||||
eni = c
|
||||
}
|
||||
}
|
||||
require.NotNil(t, eni, "private-endpoint ENI created")
|
||||
peSG := f.sg.existing[ClusterPrivateEndpointSGName("alpha")+"|vpc-aaa"]
|
||||
require.NotEmpty(t, peSG)
|
||||
assert.ElementsMatch(t,
|
||||
[]string{peSG, meta.ResourcesVpcConfig.ClusterSecurityGroupId},
|
||||
aws.StringValueSlice(eni.Groups))
|
||||
assert.NotContains(t, sgTagMap(f.sg.tagsByID[peSG]), "kubernetes.io/cluster/alpha",
|
||||
"only the cluster SG carries the cluster tag")
|
||||
}
|
||||
|
||||
// The system control-plane VPC keeps its control-plane SG and rules.
|
||||
func TestCreateCluster_SystemCPVPCSecurityGroupUnchanged(t *testing.T) {
|
||||
t.Parallel()
|
||||
f, meta := createAlpha(t, nil)
|
||||
require.NotNil(t, meta.ManagedCPVPC)
|
||||
cpVPC := meta.ManagedCPVPC.VpcId
|
||||
|
||||
cp := sgCreatesIn(f.sg, cpVPC, func(n string) bool { return n == ClusterControlPlaneSGName("alpha") })
|
||||
require.Len(t, cp, 1)
|
||||
cpID := f.sg.existing[ClusterControlPlaneSGName("alpha")+"|"+cpVPC]
|
||||
require.NotEmpty(t, cpID)
|
||||
|
||||
var ports []int64
|
||||
selfRef := false
|
||||
for _, p := range authorizedOn(f.sg, cpID) {
|
||||
ports = append(ports, aws.Int64Value(p.FromPort))
|
||||
for _, pair := range p.UserIdGroupPairs {
|
||||
if aws.StringValue(pair.GroupId) == cpID {
|
||||
selfRef = true
|
||||
}
|
||||
}
|
||||
}
|
||||
assert.Subset(t, ports, []int64{k3sAPIServerPort, konnectivityAgentPort, webhookReadyPort, 2379, 10250})
|
||||
assert.True(t, selfRef, "HA self-referencing rules stay")
|
||||
assert.Empty(t, sgCreatesIn(f.sg, cpVPC, func(n string) bool { return strings.HasPrefix(n, clusterSGNamePrefix) }),
|
||||
"the cluster SG is a customer-VPC resource only")
|
||||
}
|
||||
|
||||
// Workers launch with exactly the cluster SG and no extra SG or cross rules.
|
||||
func TestCreateNodegroup_WorkersGetExactlyTheClusterSG(t *testing.T) {
|
||||
t.Parallel()
|
||||
f := newEKSServiceFixture(t)
|
||||
f.sg.uniqueIDs = true
|
||||
seedActiveClusterWithToken(t, f, "c1")
|
||||
meta, err := GetClusterMeta(t.Context(), f.kv, "c1")
|
||||
require.NoError(t, err)
|
||||
meta.ResourcesVpcConfig.ClusterSecurityGroupId = "sg-cluster"
|
||||
require.NoError(t, PutClusterMeta(t.Context(), f.kv, meta))
|
||||
seedClusterSG(f.sg, "c1", "vpc-aaa", "sg-cluster")
|
||||
markWorkersReady(t, f, "c1", "ng1", 2)
|
||||
|
||||
_, err = f.svc.CreateNodegroup(context.Background(), createNGInput("c1", "ng1", 2), testAccountID)
|
||||
require.NoError(t, err)
|
||||
f.svc.WaitLaunches()
|
||||
|
||||
require.Len(t, f.worker.runCalls, 2)
|
||||
for _, c := range f.worker.runCalls {
|
||||
assert.Equal(t, []string{"sg-cluster"}, aws.StringValueSlice(c.SecurityGroupIds))
|
||||
}
|
||||
assert.Empty(t, f.sg.createCalls, "no nodegroup or control-plane SG is created for a worker")
|
||||
assert.Empty(t, f.sg.authorizeCalls, "no CP<->nodegroup cross rules")
|
||||
}
|
||||
|
||||
// A launch template that names security groups gets exactly those.
|
||||
func TestLaunchOneWorker_TemplateSecurityGroupsReplaceClusterSG(t *testing.T) {
|
||||
t.Parallel()
|
||||
reader := &fakeLaunchTemplates{out: &ec2.DescribeLaunchTemplateVersionsOutput{
|
||||
LaunchTemplateVersions: []*ec2.LaunchTemplateVersion{{
|
||||
LaunchTemplateData: &ec2.ResponseLaunchTemplateData{
|
||||
SecurityGroupIds: aws.StringSlice([]string{"sg-lt-1", "sg-lt-2"}),
|
||||
},
|
||||
}},
|
||||
}}
|
||||
f, err := launchWorkerWith(t, &NodegroupLaunchTemplate{ID: "lt-0123", Version: "1"}, reader)
|
||||
require.NoError(t, err)
|
||||
|
||||
require.Len(t, f.worker.runCalls, 1)
|
||||
assert.Equal(t, []string{"sg-lt-1", "sg-lt-2"}, aws.StringValueSlice(f.worker.runCalls[0].SecurityGroupIds))
|
||||
}
|
||||
|
||||
// A template naming no security groups falls back to the cluster SG alone.
|
||||
func TestLaunchOneWorker_TemplateWithoutSecurityGroupsUsesClusterSG(t *testing.T) {
|
||||
t.Parallel()
|
||||
f, err := launchWorkerWith(t, &NodegroupLaunchTemplate{ID: "lt-0123", Version: "1"},
|
||||
&fakeLaunchTemplates{out: templateVersionWith(nil)})
|
||||
require.NoError(t, err)
|
||||
|
||||
require.Len(t, f.worker.runCalls, 1)
|
||||
assert.Equal(t, []string{"sg-ng"}, aws.StringValueSlice(f.worker.runCalls[0].SecurityGroupIds))
|
||||
}
|
||||
|
||||
// Delete removes the SG recorded on the cluster, whatever its name, and leaves
|
||||
// another cluster's SG in the same VPC alone.
|
||||
func TestDeleteCluster_RemovesClusterSGFoundByTag(t *testing.T) {
|
||||
t.Parallel()
|
||||
f := newDeleteClusterFixture(t, "alpha")
|
||||
seedClusterSG(f.sg, "alpha", "vpc-aaa", "sg-new")
|
||||
seedClusterSG(f.sg, "beta", "vpc-aaa", "sg-beta")
|
||||
setStoredClusterSG(t, f, "alpha", "sg-new")
|
||||
f.sg.perms = map[string][]*ec2.IpPermission{"sg-new": {{
|
||||
IpProtocol: aws.String("-1"),
|
||||
UserIdGroupPairs: []*ec2.UserIdGroupPair{{GroupId: aws.String("sg-new")}},
|
||||
}}}
|
||||
f.sg.enforceDepViolation = true
|
||||
|
||||
deleteAndSettle(t, f.svc, "alpha")
|
||||
|
||||
deleted := deletedSGIDs(f.sg)
|
||||
assert.Contains(t, deleted, "sg-new")
|
||||
assert.NotContains(t, deleted, "sg-beta")
|
||||
_, err := GetClusterMeta(t.Context(), f.kv, "alpha")
|
||||
assert.Error(t, err, "teardown completed and removed the record")
|
||||
}
|
||||
|
||||
// An old-layout cluster (control-plane-sg + nodegroup-sg, no cluster SG) still
|
||||
// has both SGs removed.
|
||||
func TestDeleteCluster_OldLayoutStillRemovesBothNamedSGs(t *testing.T) {
|
||||
t.Parallel()
|
||||
f := newDeleteClusterFixture(t, "alpha")
|
||||
f.sg.existing[ClusterControlPlaneSGName("alpha")+"|vpc-aaa"] = "sg-old-cp"
|
||||
f.sg.existing[ClusterNodegroupSGName("alpha")+"|vpc-aaa"] = "sg-old-ng"
|
||||
|
||||
deleteAndSettle(t, f.svc, "alpha")
|
||||
|
||||
deleted := deletedSGIDs(f.sg)
|
||||
assert.Contains(t, deleted, "sg-old-cp")
|
||||
assert.Contains(t, deleted, "sg-old-ng")
|
||||
}
|
||||
|
||||
// A cluster SG that cannot be deleted yet keeps the cluster DELETING; the
|
||||
// deleting reaper removes it once the dependency clears, not the request.
|
||||
func TestDeleteCluster_ClusterSGConvergesInTheReaper(t *testing.T) {
|
||||
origBudget, origInterval := sgDeleteWaitBudget, sgDeleteWaitInterval
|
||||
sgDeleteWaitBudget, sgDeleteWaitInterval = 10*time.Millisecond, time.Millisecond
|
||||
t.Cleanup(func() { sgDeleteWaitBudget, sgDeleteWaitInterval = origBudget, origInterval })
|
||||
|
||||
f := newDeleteClusterFixture(t, "alpha")
|
||||
seedClusterSG(f.sg, "alpha", "vpc-aaa", "sg-new")
|
||||
setStoredClusterSG(t, f, "alpha", "sg-new")
|
||||
f.sg.deleteFailsBefore = map[string]int{"sg-new": 1000}
|
||||
|
||||
deleteAndSettle(t, f.svc, "alpha")
|
||||
meta := requireTeardownRetained(t, f.kv, "alpha")
|
||||
assert.Contains(t, meta.DeleteFailedStep, stepSecurityGroups)
|
||||
|
||||
f.sg.mu.Lock()
|
||||
f.sg.deleteFailsBefore["sg-new"] = 0
|
||||
f.sg.mu.Unlock()
|
||||
redrive(t, f.svc)
|
||||
|
||||
_, err := GetClusterMeta(t.Context(), f.kv, "alpha")
|
||||
assert.Error(t, err, "the reaper finished the teardown")
|
||||
}
|
||||
|
||||
// A customer-made SG carrying the tags the service stamps is neither adopted by
|
||||
// a create retry nor deleted with the cluster.
|
||||
func TestEnsureClusterSG_DoesNotAdoptACustomerTaggedSG(t *testing.T) {
|
||||
t.Parallel()
|
||||
sg := newFakeSGProvisioner()
|
||||
sg.uniqueIDs = true
|
||||
seedCustomerSG(sg, "customer-sg", "vpc-aaa", "sg-spoof", spoofTags("c1"))
|
||||
|
||||
id, err := EnsureClusterSG(context.Background(), sg, testAccountID, "c1", "vpc-aaa")
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.NotEqual(t, "sg-spoof", id)
|
||||
assert.Len(t, sg.createCalls, 1)
|
||||
for _, p := range sg.authorizeCalls {
|
||||
assert.NotEqual(t, "sg-spoof", aws.StringValue(p.GroupId))
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeleteCluster_DoesNotDeleteACustomerTaggedSG(t *testing.T) {
|
||||
t.Parallel()
|
||||
f := newDeleteClusterFixture(t, "alpha")
|
||||
seedClusterSG(f.sg, "alpha", "vpc-aaa", "sg-new")
|
||||
setStoredClusterSG(t, f, "alpha", "sg-new")
|
||||
seedCustomerSG(f.sg, "customer-sg", "vpc-aaa", "sg-spoof", spoofTags("alpha"))
|
||||
f.sg.perms = map[string][]*ec2.IpPermission{"sg-spoof": {{
|
||||
IpProtocol: aws.String("tcp"), FromPort: aws.Int64(22), ToPort: aws.Int64(22),
|
||||
IpRanges: []*ec2.IpRange{{CidrIp: aws.String("10.0.0.0/8")}},
|
||||
}}}
|
||||
|
||||
deleteAndSettle(t, f.svc, "alpha")
|
||||
|
||||
assert.Contains(t, deletedSGIDs(f.sg), "sg-new")
|
||||
assert.NotContains(t, deletedSGIDs(f.sg), "sg-spoof")
|
||||
for _, r := range f.sg.revokeCalls {
|
||||
assert.NotEqual(t, "sg-spoof", aws.StringValue(r.GroupId), "no rule is revoked on it")
|
||||
}
|
||||
}
|
||||
|
||||
// Deleting a cluster revokes, on an unrelated SG in the VPC, only the rules that
|
||||
// reference a cluster-owned SG.
|
||||
func TestDeleteClusterSGs_UnrelatedSGKeepsItsOtherRules(t *testing.T) {
|
||||
t.Parallel()
|
||||
sgp := newFakeSGProvisioner()
|
||||
seedClusterSG(sgp, "alpha", "vpc-aaa", "sg-new")
|
||||
seedCustomerSG(sgp, "customer-sg", "vpc-aaa", "sg-other", nil)
|
||||
ssh := &ec2.IpPermission{
|
||||
IpProtocol: aws.String("tcp"), FromPort: aws.Int64(22), ToPort: aws.Int64(22),
|
||||
IpRanges: []*ec2.IpRange{{CidrIp: aws.String("10.0.0.0/8")}},
|
||||
}
|
||||
mixed := &ec2.IpPermission{
|
||||
IpProtocol: aws.String("tcp"), FromPort: aws.Int64(443), ToPort: aws.Int64(443),
|
||||
IpRanges: []*ec2.IpRange{{CidrIp: aws.String("192.168.0.0/16")}},
|
||||
UserIdGroupPairs: []*ec2.UserIdGroupPair{
|
||||
{GroupId: aws.String("sg-new")},
|
||||
{GroupId: aws.String("sg-peer")},
|
||||
},
|
||||
}
|
||||
sgp.perms = map[string][]*ec2.IpPermission{"sg-other": {ssh, mixed}}
|
||||
sgp.permsEgress = map[string][]*ec2.IpPermission{"sg-other": {groupRefPerm("tcp", 6443, "sg-new"), groupRefPerm("tcp", 80, "sg-peer")}}
|
||||
|
||||
require.NoError(t, DeleteClusterSGs(context.Background(), sgp, testAccountID, "alpha", "vpc-aaa", "sg-new"))
|
||||
|
||||
var revoked []*ec2.IpPermission
|
||||
for _, r := range sgp.revokeCalls {
|
||||
if aws.StringValue(r.GroupId) == "sg-other" {
|
||||
revoked = append(revoked, r.IpPermissions...)
|
||||
}
|
||||
}
|
||||
require.Len(t, revoked, 1, "only the rule referencing the cluster SG goes")
|
||||
require.Len(t, revoked[0].UserIdGroupPairs, 1)
|
||||
assert.Equal(t, "sg-new", aws.StringValue(revoked[0].UserIdGroupPairs[0].GroupId))
|
||||
assert.Empty(t, revoked[0].IpRanges, "its CIDR sources are not part of the revoke")
|
||||
|
||||
var revokedEgress []*ec2.IpPermission
|
||||
for _, r := range sgp.revokeEgressCall {
|
||||
if aws.StringValue(r.GroupId) == "sg-other" {
|
||||
revokedEgress = append(revokedEgress, r.IpPermissions...)
|
||||
}
|
||||
}
|
||||
require.Len(t, revokedEgress, 1)
|
||||
assert.Equal(t, "sg-new", aws.StringValue(revokedEgress[0].UserIdGroupPairs[0].GroupId))
|
||||
assert.NotContains(t, deletedSGIDs(sgp), "sg-other")
|
||||
}
|
||||
@@ -419,6 +419,37 @@ func TestCreateCluster_BuildsManagedCPVPCUnderSystemAccount(t *testing.T) {
|
||||
assert.True(t, tagged, "NAT gateway EIP must carry the ManagedBy=eks tag")
|
||||
}
|
||||
|
||||
// The primary cluster SG (clusterSecurityGroupId) must be created in the
|
||||
// caller's own VPC, not the system-managed CP VPC the control-plane VM
|
||||
// actually lives in — otherwise the tenant's own DescribeSecurityGroups can
|
||||
// never resolve it. fakeSubnetResolver resolves every subnet to "vpc-aaa".
|
||||
func TestCreateCluster_ClusterSecurityGroupCreatedInCallerVPC(t *testing.T) {
|
||||
t.Parallel()
|
||||
f := newEKSServiceFixture(t)
|
||||
|
||||
_, err := f.svc.CreateCluster(context.Background(), createInput("alpha"), testAccountID, "")
|
||||
require.NoError(t, err)
|
||||
f.svc.WaitLaunches()
|
||||
|
||||
meta, err := GetClusterMeta(t.Context(), f.kv, "alpha")
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, meta.ResourcesVpcConfig)
|
||||
assert.NotEmpty(t, meta.ResourcesVpcConfig.ClusterSecurityGroupId)
|
||||
|
||||
found := false
|
||||
for _, call := range f.sg.createCalls {
|
||||
if aws.StringValue(call.GroupName) == ClusterControlPlaneSGName("alpha") && aws.StringValue(call.VpcId) == "vpc-aaa" {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
assert.True(t, found, "control-plane SG must be created in the caller's VPC (vpc-aaa)")
|
||||
|
||||
out, err := f.svc.DescribeCluster(context.Background(), &eks.DescribeClusterInput{Name: aws.String("alpha")}, testAccountID)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, out.Cluster.ResourcesVpcConfig)
|
||||
assert.Equal(t, meta.ResourcesVpcConfig.ClusterSecurityGroupId, aws.StringValue(out.Cluster.ResourcesVpcConfig.ClusterSecurityGroupId))
|
||||
}
|
||||
|
||||
// Security groups the caller explicitly passes on CreateCluster must come back
|
||||
// unchanged from DescribeCluster — never substituted with platform-created ids.
|
||||
func TestCreateCluster_CallerSecurityGroupIdsEchoedNotOverwritten(t *testing.T) {
|
||||
|
||||
@@ -472,9 +472,13 @@ type nodegroupLaunchCtx struct {
|
||||
func (s *EKSServiceImpl) launchNodegroupInfra(ctx context.Context, lc nodegroupLaunchCtx) {
|
||||
acctKV, accountID, cluster, ng, meta, rec := lc.acctKV, lc.accountID, lc.cluster, lc.ng, lc.meta, lc.rec
|
||||
|
||||
ngSGID, err := EnsureWorkerSG(ctx, s.deps.VPCSG, accountID, cluster, meta.ResourcesVpcConfig.VpcId, meta.ResourcesVpcConfig.ClusterSecurityGroupId)
|
||||
cpSGID, ngSGID, err := EnsureClusterSGs(ctx, s.deps.VPCSG, accountID, cluster, meta.ResourcesVpcConfig.VpcId)
|
||||
if err != nil {
|
||||
s.markNodegroupFailed(ctx, acctKV, cluster, ng, "ensure cluster SG: "+err.Error())
|
||||
s.markNodegroupFailed(ctx, acctKV, cluster, ng, "ensure cluster SGs: "+err.Error())
|
||||
return
|
||||
}
|
||||
if err := EnsureNodegroupSGRules(ctx, s.deps.VPCSG, accountID, cluster, cpSGID, ngSGID); err != nil {
|
||||
s.markNodegroupFailed(ctx, acctKV, cluster, ng, "ensure nodegroup SG rules: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
@@ -703,15 +707,11 @@ func (s *EKSServiceImpl) launchOneWorker(ctx context.Context, rec *NodegroupReco
|
||||
}}
|
||||
}
|
||||
|
||||
metadataOptions, templateSGs, err := s.workerTemplateSettings(ctx, rec, accountID)
|
||||
metadataOptions, err := s.workerMetadataOptions(ctx, rec, accountID)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
runInput.MetadataOptions = metadataOptions
|
||||
// As on AWS, security groups named by the launch template replace the cluster SG.
|
||||
if len(templateSGs) > 0 {
|
||||
runInput.SecurityGroupIds = aws.StringSlice(templateSGs)
|
||||
}
|
||||
|
||||
// Attach the node role's instance profile so IMDS serves the role to the ECR
|
||||
// credential provider; without it worker pulls from the internal ECR get 401.
|
||||
@@ -743,23 +743,21 @@ func (s *EKSServiceImpl) launchOneWorker(ctx context.Context, rec *NodegroupReco
|
||||
return "", errors.New("run worker returned no instance id")
|
||||
}
|
||||
|
||||
// workerTemplateSettings chooses a worker's IMDS options and security groups.
|
||||
// Metadata fields a launch template version sets win as given, and every unset
|
||||
// field gets the managed nodegroup default (IMDSv2 required, hop limit 2) so pods
|
||||
// behind the node can reach IMDS. The template's security group IDs, if any, are
|
||||
// returned for the caller to use in place of the cluster SG.
|
||||
func (s *EKSServiceImpl) workerTemplateSettings(ctx context.Context, rec *NodegroupRecord, accountID string) (*ec2.InstanceMetadataOptionsRequest, []string, error) {
|
||||
// workerMetadataOptions chooses a worker's IMDS options: fields a launch template
|
||||
// version sets win as given, and every unset field gets the managed nodegroup
|
||||
// default (IMDSv2 required, hop limit 2) so pods behind the node can reach IMDS.
|
||||
func (s *EKSServiceImpl) workerMetadataOptions(ctx context.Context, rec *NodegroupRecord, accountID string) (*ec2.InstanceMetadataOptionsRequest, error) {
|
||||
managedDefault := &ec2.InstanceMetadataOptionsRequest{
|
||||
HttpTokens: aws.String(ec2.HttpTokensStateRequired),
|
||||
HttpPutResponseHopLimit: aws.Int64(workerMetadataHopLimit),
|
||||
}
|
||||
if rec.LaunchTemplate == nil {
|
||||
return managedDefault, nil, nil
|
||||
return managedDefault, nil
|
||||
}
|
||||
if s.deps.LaunchTemplates == nil {
|
||||
slog.ErrorContext(ctx, "EKS nodegroup: launch template named but no launch template service wired",
|
||||
"nodegroup", rec.Name, "launchTemplate", rec.LaunchTemplate.ID+rec.LaunchTemplate.Name)
|
||||
return nil, nil, errors.New(awserrors.ErrorServerInternal)
|
||||
return nil, errors.New(awserrors.ErrorServerInternal)
|
||||
}
|
||||
|
||||
version := rec.LaunchTemplate.Version
|
||||
@@ -774,16 +772,14 @@ func (s *EKSServiceImpl) workerTemplateSettings(ctx context.Context, rec *Nodegr
|
||||
}
|
||||
out, err := s.deps.LaunchTemplates.DescribeLaunchTemplateVersions(ctx, in, accountID)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
return nil, err
|
||||
}
|
||||
if len(out.LaunchTemplateVersions) == 0 || out.LaunchTemplateVersions[0].LaunchTemplateData == nil {
|
||||
return nil, nil, errors.New(awserrors.ErrorInvalidLaunchTemplateIdVersionNotFound)
|
||||
return nil, errors.New(awserrors.ErrorInvalidLaunchTemplateIdVersionNotFound)
|
||||
}
|
||||
data := out.LaunchTemplateVersions[0].LaunchTemplateData
|
||||
sgIDs := aws.StringValueSlice(data.SecurityGroupIds)
|
||||
opts := data.MetadataOptions
|
||||
opts := out.LaunchTemplateVersions[0].LaunchTemplateData.MetadataOptions
|
||||
if opts == nil {
|
||||
return managedDefault, sgIDs, nil
|
||||
return managedDefault, nil
|
||||
}
|
||||
req := &ec2.InstanceMetadataOptionsRequest{
|
||||
HttpTokens: opts.HttpTokens,
|
||||
@@ -800,7 +796,7 @@ func (s *EKSServiceImpl) workerTemplateSettings(ctx context.Context, rec *Nodegr
|
||||
if req.HttpPutResponseHopLimit == nil {
|
||||
req.HttpPutResponseHopLimit = managedDefault.HttpPutResponseHopLimit
|
||||
}
|
||||
return req, sgIDs, nil
|
||||
return req, nil
|
||||
}
|
||||
|
||||
// selectWorkerHost picks the host for the next worker: the eligible host (one
|
||||
@@ -890,7 +886,7 @@ func (s *EKSServiceImpl) ensureNodeInstanceProfile(accountID, nodeRoleARN string
|
||||
return s.attachRoleToProfile(accountID, profileName, roleName,
|
||||
aws.StringValue(out.InstanceProfile.Arn), len(out.InstanceProfile.Roles) > 0)
|
||||
}
|
||||
if code, _ := awserrors.ResolveErrorCode(err); code != awserrors.ErrorIAMNoSuchEntity {
|
||||
if err.Error() != awserrors.ErrorIAMNoSuchEntity {
|
||||
return "", fmt.Errorf("get instance profile %q: %w", profileName, err)
|
||||
}
|
||||
|
||||
@@ -899,7 +895,7 @@ func (s *EKSServiceImpl) ensureNodeInstanceProfile(accountID, nodeRoleARN string
|
||||
})
|
||||
if err != nil {
|
||||
// A racing worker launch may have created it first; re-read and converge.
|
||||
if code, _ := awserrors.ResolveErrorCode(err); code != awserrors.ErrorIAMEntityAlreadyExists {
|
||||
if err.Error() != awserrors.ErrorIAMEntityAlreadyExists {
|
||||
return "", fmt.Errorf("create instance profile %q: %w", profileName, err)
|
||||
}
|
||||
got, gerr := s.iamEnsurer().GetInstanceProfile(accountID, &iam.GetInstanceProfileInput{
|
||||
@@ -926,7 +922,7 @@ func (s *EKSServiceImpl) attachRoleToProfile(accountID, profileName, roleName, p
|
||||
InstanceProfileName: aws.String(profileName),
|
||||
RoleName: aws.String(roleName),
|
||||
})
|
||||
if code, _ := awserrors.ResolveErrorCode(err); err != nil && code != awserrors.ErrorIAMLimitExceeded {
|
||||
if err != nil && err.Error() != awserrors.ErrorIAMLimitExceeded {
|
||||
return "", fmt.Errorf("add role %q to instance profile %q: %w", roleName, profileName, err)
|
||||
}
|
||||
return profileARN, nil
|
||||
@@ -1096,9 +1092,9 @@ func (s *EKSServiceImpl) launchWorkersCAS(ctx context.Context, acctKV jetstream.
|
||||
}
|
||||
return nil, fmt.Errorf("resolve eks-node AMI: %w", err)
|
||||
}
|
||||
ngSGID, err := EnsureWorkerSG(ctx, s.deps.VPCSG, accountID, cluster, meta.ResourcesVpcConfig.VpcId, meta.ResourcesVpcConfig.ClusterSecurityGroupId)
|
||||
_, ngSGID, err := EnsureClusterSGs(ctx, s.deps.VPCSG, accountID, cluster, meta.ResourcesVpcConfig.VpcId)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("ensure cluster SG: %w", err)
|
||||
return nil, fmt.Errorf("ensure cluster SGs: %w", err)
|
||||
}
|
||||
|
||||
// Each iteration records at most one worker, so bound by the gap plus CAS slack.
|
||||
|
||||
@@ -338,25 +338,6 @@ func TestEnsureNodegroupSGRules_DuplicateRuleTolerated(t *testing.T) {
|
||||
"duplicate-rule error must be treated as success")
|
||||
}
|
||||
|
||||
// A cluster created before the cluster SG keeps launching workers into its
|
||||
// nodegroup SG with the control-plane cross rules.
|
||||
func TestCreateNodegroup_OldLayoutClusterKeepsNodegroupSG(t *testing.T) {
|
||||
t.Parallel()
|
||||
f := newEKSServiceFixture(t)
|
||||
f.sg.createIDs = []string{"sg-old-cp", "sg-old-ng"}
|
||||
seedActiveClusterWithToken(t, f, "c1")
|
||||
f.sg.existing[ClusterNodegroupSGName("c1")+"|vpc-aaa"] = "sg-old-ng"
|
||||
markWorkersReady(t, f, "c1", "ng1", 1)
|
||||
|
||||
_, err := f.svc.CreateNodegroup(context.Background(), createNGInput("c1", "ng1", 1), testAccountID)
|
||||
require.NoError(t, err)
|
||||
f.svc.WaitLaunches()
|
||||
|
||||
require.Len(t, f.worker.runCalls, 1)
|
||||
assert.Equal(t, []string{"sg-old-ng"}, aws.StringValueSlice(f.worker.runCalls[0].SecurityGroupIds))
|
||||
assert.Len(t, f.sg.authorizeCalls, 6, "the cp<->ng cross rules are still ensured")
|
||||
}
|
||||
|
||||
func TestCreateNodegroup_HappyPath(t *testing.T) {
|
||||
t.Parallel()
|
||||
f := newEKSServiceFixture(t)
|
||||
@@ -401,14 +382,8 @@ func TestCreateNodegroup_HappyPath(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// A cluster with no SG yet gets the cluster SG, created once with its self
|
||||
// rule, and every worker carries it.
|
||||
require.Len(t, f.sg.createCalls, 1)
|
||||
clusterSG := f.sg.existing[aws.StringValue(f.sg.createCalls[0].GroupName)+"|vpc-aaa"]
|
||||
// SG ingress rules were authorized.
|
||||
assert.NotEmpty(t, f.sg.authorizeCalls)
|
||||
for _, call := range f.worker.runCalls {
|
||||
assert.Equal(t, []string{clusterSG}, aws.StringValueSlice(call.SecurityGroupIds))
|
||||
}
|
||||
|
||||
// Duplicate create → ResourceInUseException.
|
||||
_, err = f.svc.CreateNodegroup(context.Background(), createNGInput("c1", "ng1", 2), testAccountID)
|
||||
|
||||
@@ -30,7 +30,7 @@ func EnsurePrivateEndpointENI(ctx context.Context,
|
||||
vpcSvc k3sVPCProvisioner,
|
||||
sgp sgProvisioner,
|
||||
resolver SubnetVPCResolver,
|
||||
accountID, clusterName, subnetID, vpcID, clusterSGID string,
|
||||
accountID, clusterName, subnetID, vpcID string,
|
||||
) (*PrivateEndpointENI, error) {
|
||||
if accountID == "" {
|
||||
return nil, errors.New("eks: EnsurePrivateEndpointENI empty account id")
|
||||
@@ -41,9 +41,6 @@ func EnsurePrivateEndpointENI(ctx context.Context,
|
||||
if vpcID == "" {
|
||||
return nil, errors.New("eks: EnsurePrivateEndpointENI empty vpc id")
|
||||
}
|
||||
if clusterSGID == "" {
|
||||
return nil, errors.New("eks: EnsurePrivateEndpointENI empty cluster SG id")
|
||||
}
|
||||
|
||||
vpcCIDR, err := resolver.GetVPCCIDR(ctx, accountID, vpcID)
|
||||
if err != nil {
|
||||
@@ -58,7 +55,7 @@ func EnsurePrivateEndpointENI(ctx context.Context,
|
||||
eniOut, err := vpcSvc.CreateNetworkInterface(ctx, &ec2.CreateNetworkInterfaceInput{
|
||||
SubnetId: aws.String(subnetID),
|
||||
Description: aws.String("EKS private-endpoint ENI for " + clusterName),
|
||||
Groups: aws.StringSlice([]string{sgID, clusterSGID}),
|
||||
Groups: aws.StringSlice([]string{sgID}),
|
||||
TagSpecifications: []*ec2.TagSpecification{{
|
||||
ResourceType: aws.String("network-interface"),
|
||||
Tags: []*ec2.Tag{
|
||||
|
||||
@@ -132,32 +132,30 @@ func TestEnsurePrivateEndpointENI_HappyPath(t *testing.T) {
|
||||
sgp := newFakeSGProvisioner()
|
||||
sgp.createIDs = []string{"sg-pe-001"}
|
||||
|
||||
pe, err := EnsurePrivateEndpointENI(context.Background(), vpcSvc, sgp, fakeSubnetResolver{}, "111122223333", "alpha", "subnet-aaa", "vpc-aaa", "sg-cluster")
|
||||
pe, err := EnsurePrivateEndpointENI(context.Background(), vpcSvc, sgp, fakeSubnetResolver{}, "111122223333", "alpha", "subnet-aaa", "vpc-aaa")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "eni-pe-001", pe.ENIID)
|
||||
assert.Equal(t, "10.0.1.50", pe.ENIIP)
|
||||
assert.Equal(t, "02:0a:01:23:45:67", pe.ENIMac)
|
||||
assert.Equal(t, "sg-pe-001", pe.SGID)
|
||||
|
||||
// ENI must be created in the customer subnet, bound to the private-endpoint SG
|
||||
// and the cluster SG.
|
||||
// ENI must be created in the customer subnet, bound to the private-endpoint SG.
|
||||
require.Len(t, vpcSvc.createCalls, 1)
|
||||
createIn := vpcSvc.createCalls[0]
|
||||
assert.Equal(t, "subnet-aaa", aws.StringValue(createIn.SubnetId))
|
||||
assert.Equal(t, []string{"sg-pe-001", "sg-cluster"}, aws.StringValueSlice(createIn.Groups))
|
||||
require.Len(t, createIn.Groups, 1)
|
||||
assert.Equal(t, "sg-pe-001", aws.StringValue(createIn.Groups[0]))
|
||||
}
|
||||
|
||||
func TestEnsurePrivateEndpointENI_EmptyInputsRejected(t *testing.T) {
|
||||
t.Parallel()
|
||||
vpcSvc := &fakeK3sVPC{}
|
||||
sgp := newFakeSGProvisioner()
|
||||
_, err := EnsurePrivateEndpointENI(context.Background(), vpcSvc, sgp, fakeSubnetResolver{}, "", "alpha", "subnet-aaa", "vpc-aaa", "sg-cluster")
|
||||
_, err := EnsurePrivateEndpointENI(context.Background(), vpcSvc, sgp, fakeSubnetResolver{}, "", "alpha", "subnet-aaa", "vpc-aaa")
|
||||
require.Error(t, err)
|
||||
_, err = EnsurePrivateEndpointENI(context.Background(), vpcSvc, sgp, fakeSubnetResolver{}, "111122223333", "alpha", "", "vpc-aaa", "sg-cluster")
|
||||
_, err = EnsurePrivateEndpointENI(context.Background(), vpcSvc, sgp, fakeSubnetResolver{}, "111122223333", "alpha", "", "vpc-aaa")
|
||||
require.Error(t, err)
|
||||
_, err = EnsurePrivateEndpointENI(context.Background(), vpcSvc, sgp, fakeSubnetResolver{}, "111122223333", "alpha", "subnet-aaa", "", "sg-cluster")
|
||||
require.Error(t, err)
|
||||
_, err = EnsurePrivateEndpointENI(context.Background(), vpcSvc, sgp, fakeSubnetResolver{}, "111122223333", "alpha", "subnet-aaa", "vpc-aaa", "")
|
||||
_, err = EnsurePrivateEndpointENI(context.Background(), vpcSvc, sgp, fakeSubnetResolver{}, "111122223333", "alpha", "subnet-aaa", "")
|
||||
require.Error(t, err)
|
||||
assert.Empty(t, vpcSvc.createCalls)
|
||||
}
|
||||
|
||||
@@ -2,8 +2,6 @@ package cluster
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
@@ -42,7 +40,6 @@ const clusterEKSAccountTagKey = "spinifex:eks-cluster-account"
|
||||
const clusterEKSNodegroupTagKey = "spinifex:eks-nodegroup"
|
||||
|
||||
const (
|
||||
clusterEKSRoleCluster = "cluster"
|
||||
clusterEKSRoleControlPlane = "control-plane"
|
||||
clusterEKSRoleNodegroup = "nodegroup"
|
||||
clusterEKSRolePrivateEndpoint = "private-endpoint"
|
||||
@@ -65,101 +62,7 @@ func ClusterNodegroupSGName(clusterName string) string {
|
||||
return fmt.Sprintf("eks-cluster-%s-nodegroup-sg", clusterName)
|
||||
}
|
||||
|
||||
// clusterSGNamePrefix starts the name of the AWS-style cluster security group,
|
||||
// eks-cluster-sg-<cluster>-<id>.
|
||||
const clusterSGNamePrefix = "eks-cluster-sg-"
|
||||
|
||||
// EnsureClusterSG creates or reuses the cluster security group in the customer
|
||||
// VPC: tagged as EKS tags its primary SG, inbound all from itself, outbound all
|
||||
// (the VPC service's default egress). It is found by tag, so the random name
|
||||
// suffix never has to be stored. Workers and the private-endpoint ENI carry it.
|
||||
func EnsureClusterSG(ctx context.Context, sgp sgProvisioner, accountID, clusterName, vpcID string) (string, error) {
|
||||
if clusterName == "" {
|
||||
return "", errors.New("eks: EnsureClusterSG empty cluster name")
|
||||
}
|
||||
if vpcID == "" {
|
||||
return "", errors.New("eks: EnsureClusterSG empty vpc id")
|
||||
}
|
||||
id, err := lookupClusterSG(ctx, sgp, accountID, vpcID, clusterName)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if id == "" {
|
||||
suffix := make([]byte, 8)
|
||||
if _, err := rand.Read(suffix); err != nil {
|
||||
return "", fmt.Errorf("generate cluster SG name: %w", err)
|
||||
}
|
||||
name := clusterSGNamePrefix + clusterName + "-" + hex.EncodeToString(suffix)
|
||||
out, err := sgp.CreateSecurityGroup(ctx, &ec2.CreateSecurityGroupInput{
|
||||
GroupName: aws.String(name),
|
||||
Description: aws.String(fmt.Sprintf("EKS created security group applied to ENI that is attached to EKS Control Plane master nodes, as well as any managed workloads (cluster %s)", clusterName)),
|
||||
VpcId: aws.String(vpcID),
|
||||
TagSpecifications: []*ec2.TagSpecification{{
|
||||
ResourceType: aws.String("security-group"),
|
||||
Tags: []*ec2.Tag{
|
||||
{Key: aws.String("Name"), Value: aws.String(name)},
|
||||
{Key: aws.String("kubernetes.io/cluster/" + clusterName), Value: aws.String("owned")},
|
||||
{Key: aws.String("aws:eks:cluster-name"), Value: aws.String(clusterName)},
|
||||
{Key: aws.String(tags.ManagedByKey), Value: aws.String(tags.ManagedByEKS)},
|
||||
{Key: aws.String(clusterEKSClusterTagKey), Value: aws.String(clusterName)},
|
||||
{Key: aws.String(clusterEKSRoleTagKey), Value: aws.String(clusterEKSRoleCluster)},
|
||||
},
|
||||
}},
|
||||
}, accountID)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("create cluster SG %s: %w", name, err)
|
||||
}
|
||||
if out == nil || aws.StringValue(out.GroupId) == "" {
|
||||
return "", fmt.Errorf("eks: CreateSecurityGroup returned empty GroupId for %s", name)
|
||||
}
|
||||
id = aws.StringValue(out.GroupId)
|
||||
}
|
||||
_, err = sgp.AuthorizeSecurityGroupIngress(ctx, &ec2.AuthorizeSecurityGroupIngressInput{
|
||||
GroupId: aws.String(id),
|
||||
IpPermissions: []*ec2.IpPermission{{
|
||||
IpProtocol: aws.String("-1"),
|
||||
UserIdGroupPairs: []*ec2.UserIdGroupPair{{
|
||||
GroupId: aws.String(id),
|
||||
Description: aws.String("EKS cluster security group, all traffic from itself"),
|
||||
}},
|
||||
}},
|
||||
}, accountID)
|
||||
if err != nil && !awserrors.IsErrorCode(err, awserrors.ErrorInvalidPermissionDuplicate) {
|
||||
return "", fmt.Errorf("authorize self ingress on cluster SG %s: %w", id, err)
|
||||
}
|
||||
return id, nil
|
||||
}
|
||||
|
||||
// EnsureWorkerSG returns the SG nodegroup workers launch with. A cluster created
|
||||
// before the cluster SG existed keeps its nodegroup SG and control-plane rules
|
||||
// until it is recreated; every other cluster gets the SG recorded on it,
|
||||
// clusterSGID, or a freshly ensured one when none is recorded yet.
|
||||
func EnsureWorkerSG(ctx context.Context, sgp sgProvisioner, accountID, clusterName, vpcID, clusterSGID string) (string, error) {
|
||||
if vpcID == "" {
|
||||
return "", errors.New("eks: EnsureWorkerSG empty vpc id")
|
||||
}
|
||||
legacy, err := lookupSGByName(ctx, sgp, accountID, vpcID, ClusterNodegroupSGName(clusterName))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if legacy == "" {
|
||||
if clusterSGID != "" {
|
||||
return clusterSGID, nil
|
||||
}
|
||||
return EnsureClusterSG(ctx, sgp, accountID, clusterName, vpcID)
|
||||
}
|
||||
cpSGID, ngSGID, err := EnsureClusterSGs(ctx, sgp, accountID, clusterName, vpcID)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if err := EnsureNodegroupSGRules(ctx, sgp, accountID, clusterName, cpSGID, ngSGID); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return ngSGID, nil
|
||||
}
|
||||
|
||||
// EnsureClusterSGs creates or reuses the control-plane and nodegroup SGs: the
|
||||
// control-plane SG in the system CP VPC, and both in a pre-cluster-SG customer VPC.
|
||||
// EnsureClusterSGs creates or reuses the control-plane and nodegroup SGs in the customer VPC.
|
||||
// Idempotent; the two creates are independent (DeleteCluster handles partial teardown).
|
||||
func EnsureClusterSGs(ctx context.Context, sgp sgProvisioner, accountID, clusterName, vpcID string) (controlPlaneSGID, nodegroupSGID string, err error) {
|
||||
if clusterName == "" {
|
||||
@@ -188,16 +91,16 @@ func EnsureClusterSGs(ctx context.Context, sgp sgProvisioner, accountID, cluster
|
||||
return controlPlaneSGID, nodegroupSGID, nil
|
||||
}
|
||||
|
||||
// DeleteClusterSGs removes the cluster's SGs: the stored cluster SG, the
|
||||
// control-plane and nodegroup SGs of a cluster created before the cluster SG, and
|
||||
// the LBC-orphaned SGs. AWS refuses to delete an SG still referenced by another
|
||||
// SG's rule, in either direction, so before any delete every rule on a
|
||||
// cluster-owned SG is revoked and, on any other SG in the VPC, only the rules that
|
||||
// reference a cluster-owned SG. Unrelated customer SGs keep everything else.
|
||||
// clusterSGID is the id recorded on the cluster; ownership never comes from a
|
||||
// customer-settable tag. Missing SGs are no-ops; the first error is returned
|
||||
// after the full sweep.
|
||||
func DeleteClusterSGs(ctx context.Context, sgp sgProvisioner, accountID, clusterName, vpcID, clusterSGID string) error {
|
||||
// DeleteClusterSGs removes the cluster control-plane and nodegroup SGs. AWS
|
||||
// refuses to delete a security group still referenced by ANY other SG's rule —
|
||||
// not just the cp<->ng cross-reference (EnsureNodegroupSGRules) but the VPC's
|
||||
// LBC/ALB SG, the private-endpoint SG, and user/TF SGs, in either direction
|
||||
// (ingress or egress). To break every cycle order-independently, all ingress and
|
||||
// egress rules are revoked on every non-default SG in the VPC first, then the two
|
||||
// cluster-owned SGs are deleted. Referrer SGs are owned and deleted by their own
|
||||
// teardown — only the cluster SGs are deleted here. Missing SGs are no-ops; the
|
||||
// first error is returned after the full sweep.
|
||||
func DeleteClusterSGs(ctx context.Context, sgp sgProvisioner, accountID, clusterName, vpcID string) error {
|
||||
if clusterName == "" {
|
||||
return errors.New("eks: DeleteClusterSGs empty cluster name")
|
||||
}
|
||||
@@ -212,6 +115,9 @@ func DeleteClusterSGs(ctx context.Context, sgp sgProvisioner, accountID, cluster
|
||||
}
|
||||
}
|
||||
|
||||
// Break every cross-reference in the VPC before deleting any SG. Revoking
|
||||
// both directions on every non-default SG guarantees no rule still points at
|
||||
// a cluster SG, so the deletes below succeed regardless of order.
|
||||
out, err := sgp.DescribeSecurityGroups(ctx, &ec2.DescribeSecurityGroupsInput{
|
||||
Filters: []*ec2.Filter{
|
||||
{Name: aws.String("vpc-id"), Values: aws.StringSlice([]string{vpcID})},
|
||||
@@ -221,49 +127,12 @@ func DeleteClusterSGs(ctx context.Context, sgp sgProvisioner, accountID, cluster
|
||||
slog.WarnContext(ctx, "DeleteClusterSGs: describe SGs failed", "vpc", vpcID, "err", err)
|
||||
record(fmt.Errorf("describe SGs in vpc %s: %w", vpcID, err))
|
||||
}
|
||||
// A failed describe returns a nil out; fall back to an empty set so the
|
||||
// sweeps below become no-ops while the targeted deletes still run.
|
||||
// A failed describe returns a nil out; fall back to an empty set so the two
|
||||
// cross-reference sweeps below become no-ops while the targeted deletes still run.
|
||||
var sgs []*ec2.SecurityGroup
|
||||
if out != nil {
|
||||
sgs = out.SecurityGroups
|
||||
}
|
||||
|
||||
// owned is every SG this cluster owns, in delete order: LBC-orphaned SGs
|
||||
// first (they reference the cluster SG), then the stored cluster SG, then the
|
||||
// pre-cluster-SG named ones.
|
||||
var owned []string
|
||||
isOwned := map[string]bool{}
|
||||
own := func(id string) {
|
||||
if id != "" && !isOwned[id] {
|
||||
isOwned[id] = true
|
||||
owned = append(owned, id)
|
||||
}
|
||||
}
|
||||
for _, g := range sgs {
|
||||
if g != nil && g.GroupId != nil && aws.StringValue(g.GroupName) != "default" && sgHasLBCClusterTag(g, clusterName) {
|
||||
own(*g.GroupId)
|
||||
}
|
||||
}
|
||||
own(clusterSGID)
|
||||
// With no stored id (a create that died before recording it) the SG is still
|
||||
// ours when it carries the tags only the service can set.
|
||||
if clusterSGID == "" {
|
||||
for _, g := range sgs {
|
||||
if g != nil && g.GroupId != nil && sgIsClusterSG(g, clusterName) {
|
||||
own(*g.GroupId)
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, name := range []string{ClusterControlPlaneSGName(clusterName), ClusterNodegroupSGName(clusterName)} {
|
||||
id, lookupErr := lookupSGByName(ctx, sgp, accountID, vpcID, name)
|
||||
if lookupErr != nil {
|
||||
slog.WarnContext(ctx, "DeleteClusterSGs: SG lookup failed", "sg", name, "err", lookupErr)
|
||||
record(lookupErr)
|
||||
continue
|
||||
}
|
||||
own(id)
|
||||
}
|
||||
|
||||
for _, g := range sgs {
|
||||
if g == nil || g.GroupId == nil {
|
||||
continue
|
||||
@@ -273,19 +142,41 @@ func DeleteClusterSGs(ctx context.Context, sgp sgProvisioner, accountID, cluster
|
||||
if aws.StringValue(g.GroupName) == "default" {
|
||||
continue
|
||||
}
|
||||
var revokeErr error
|
||||
if isOwned[*g.GroupId] {
|
||||
revokeErr = revokeAllSGRules(ctx, sgp, accountID, g)
|
||||
} else {
|
||||
revokeErr = revokeRulesReferencing(ctx, sgp, accountID, g, isOwned)
|
||||
}
|
||||
if revokeErr != nil {
|
||||
if revokeErr := revokeAllSGRules(ctx, sgp, accountID, g); revokeErr != nil {
|
||||
slog.WarnContext(ctx, "DeleteClusterSGs: revoke rules failed", "sg", aws.StringValue(g.GroupId), "err", revokeErr)
|
||||
record(revokeErr)
|
||||
}
|
||||
}
|
||||
|
||||
for _, id := range owned {
|
||||
// Reap LBC-orphaned SGs (k8s-traffic-toc-*): the in-cluster AWS LoadBalancer
|
||||
// Controller creates these in the customer VPC and spinifex never tracks them,
|
||||
// so nothing else deletes them and they pin the VPC on DependencyViolation.
|
||||
// Match by the LBC ownership tag; rules were already revoked above. Runs after
|
||||
// the matching ALB reap, so the LB no longer references the SG.
|
||||
for _, g := range sgs {
|
||||
if g == nil || g.GroupId == nil || aws.StringValue(g.GroupName) == "default" {
|
||||
continue
|
||||
}
|
||||
if !sgHasLBCClusterTag(g, clusterName) {
|
||||
continue
|
||||
}
|
||||
if delErr := deleteSGAwaitingDetach(ctx, sgp, accountID, aws.StringValue(g.GroupId)); delErr != nil {
|
||||
slog.WarnContext(ctx, "DeleteClusterSGs: LBC SG delete failed", "sg", aws.StringValue(g.GroupId), "err", delErr)
|
||||
record(fmt.Errorf("delete LBC SG %s: %w", aws.StringValue(g.GroupId), delErr))
|
||||
}
|
||||
}
|
||||
|
||||
// Delete only the cluster-owned SGs.
|
||||
for _, name := range []string{ClusterControlPlaneSGName(clusterName), ClusterNodegroupSGName(clusterName)} {
|
||||
id, lookupErr := lookupSGByName(ctx, sgp, accountID, vpcID, name)
|
||||
if lookupErr != nil {
|
||||
slog.WarnContext(ctx, "DeleteClusterSGs: SG lookup failed", "sg", name, "err", lookupErr)
|
||||
record(lookupErr)
|
||||
continue
|
||||
}
|
||||
if id == "" {
|
||||
continue
|
||||
}
|
||||
if delErr := deleteSGAwaitingDetach(ctx, sgp, accountID, id); delErr != nil {
|
||||
slog.WarnContext(ctx, "DeleteClusterSGs: SG delete failed", "sg", id, "err", delErr)
|
||||
record(fmt.Errorf("delete SG %s: %w", id, delErr))
|
||||
@@ -357,57 +248,6 @@ func revokeAllSGRules(ctx context.Context, sgp sgProvisioner, accountID string,
|
||||
return nil
|
||||
}
|
||||
|
||||
// revokeRulesReferencing revokes, on a SG the cluster does not own, only the
|
||||
// rules that reference an owned SG, and only those references. Every other rule
|
||||
// on the SG is left in place.
|
||||
func revokeRulesReferencing(ctx context.Context, sgp sgProvisioner, accountID string, g *ec2.SecurityGroup, owned map[string]bool) error {
|
||||
sgID := aws.StringValue(g.GroupId)
|
||||
if perms := referencingPerms(g.IpPermissions, owned); len(perms) > 0 {
|
||||
if _, err := sgp.RevokeSecurityGroupIngress(ctx, &ec2.RevokeSecurityGroupIngressInput{
|
||||
GroupId: g.GroupId,
|
||||
IpPermissions: perms,
|
||||
}, accountID); err != nil && !awserrors.IsNotFound(err) {
|
||||
return fmt.Errorf("revoke ingress on %s: %w", sgID, err)
|
||||
}
|
||||
}
|
||||
if perms := referencingPerms(g.IpPermissionsEgress, owned); len(perms) > 0 {
|
||||
if _, err := sgp.RevokeSecurityGroupEgress(ctx, &ec2.RevokeSecurityGroupEgressInput{
|
||||
GroupId: g.GroupId,
|
||||
IpPermissions: perms,
|
||||
}, accountID); err != nil && !awserrors.IsNotFound(err) {
|
||||
return fmt.Errorf("revoke egress on %s: %w", sgID, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// referencingPerms returns, for each permission that names an owned SG, a copy
|
||||
// carrying only those group references.
|
||||
func referencingPerms(perms []*ec2.IpPermission, owned map[string]bool) []*ec2.IpPermission {
|
||||
var out []*ec2.IpPermission
|
||||
for _, p := range perms {
|
||||
if p == nil {
|
||||
continue
|
||||
}
|
||||
var pairs []*ec2.UserIdGroupPair
|
||||
for _, pair := range p.UserIdGroupPairs {
|
||||
if pair != nil && owned[aws.StringValue(pair.GroupId)] {
|
||||
pairs = append(pairs, pair)
|
||||
}
|
||||
}
|
||||
if len(pairs) == 0 {
|
||||
continue
|
||||
}
|
||||
out = append(out, &ec2.IpPermission{
|
||||
IpProtocol: p.IpProtocol,
|
||||
FromPort: p.FromPort,
|
||||
ToPort: p.ToPort,
|
||||
UserIdGroupPairs: pairs,
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// EnsurePrivateEndpointSG creates (or reuses) the customer-VPC SG for the
|
||||
// private-endpoint ENI and admits the customer VPC CIDR on :443 (kubectl/SDK) and
|
||||
// :6443, so in-VPC workers + kubectl reach the apiserver via the Set A NIC. :6443
|
||||
@@ -646,42 +486,6 @@ func sgHasLBCClusterTag(g *ec2.SecurityGroup, clusterName string) bool {
|
||||
})
|
||||
}
|
||||
|
||||
// sgIsClusterSG reports whether g carries every tag the service stamps on this
|
||||
// cluster's SG. aws:eks:cluster-name is reserved, so a customer cannot set it.
|
||||
func sgIsClusterSG(g *ec2.SecurityGroup, clusterName string) bool {
|
||||
have := map[string]string{}
|
||||
for _, tag := range g.Tags {
|
||||
have[aws.StringValue(tag.Key)] = aws.StringValue(tag.Value)
|
||||
}
|
||||
return have[tags.ManagedByKey] == tags.ManagedByEKS &&
|
||||
have["aws:eks:cluster-name"] == clusterName &&
|
||||
have[clusterEKSClusterTagKey] == clusterName &&
|
||||
have[clusterEKSRoleTagKey] == clusterEKSRoleCluster
|
||||
}
|
||||
|
||||
// lookupClusterSG finds a cluster SG that an earlier, interrupted create made
|
||||
// before its id was recorded. Only the tags the service alone can set count.
|
||||
func lookupClusterSG(ctx context.Context, sgp sgProvisioner, accountID, vpcID, clusterName string) (string, error) {
|
||||
out, err := sgp.DescribeSecurityGroups(ctx, &ec2.DescribeSecurityGroupsInput{
|
||||
Filters: []*ec2.Filter{
|
||||
{Name: aws.String("vpc-id"), Values: aws.StringSlice([]string{vpcID})},
|
||||
{Name: aws.String("tag:" + tags.ManagedByKey), Values: aws.StringSlice([]string{tags.ManagedByEKS})},
|
||||
{Name: aws.String("tag:aws:eks:cluster-name"), Values: aws.StringSlice([]string{clusterName})},
|
||||
{Name: aws.String("tag:" + clusterEKSClusterTagKey), Values: aws.StringSlice([]string{clusterName})},
|
||||
{Name: aws.String("tag:" + clusterEKSRoleTagKey), Values: aws.StringSlice([]string{clusterEKSRoleCluster})},
|
||||
},
|
||||
}, accountID)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("describe cluster SG for %s: %w", clusterName, err)
|
||||
}
|
||||
for _, g := range out.SecurityGroups {
|
||||
if g != nil && g.GroupId != nil && sgIsClusterSG(g, clusterName) {
|
||||
return *g.GroupId, nil
|
||||
}
|
||||
}
|
||||
return "", nil
|
||||
}
|
||||
|
||||
func lookupSGByName(ctx context.Context, sgp sgProvisioner, accountID, vpcID, name string) (string, error) {
|
||||
out, err := sgp.DescribeSecurityGroups(ctx, &ec2.DescribeSecurityGroupsInput{
|
||||
Filters: []*ec2.Filter{
|
||||
|
||||
@@ -3,7 +3,6 @@ package cluster
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
@@ -53,11 +52,6 @@ type fakeSGProvisioner struct {
|
||||
// nodegroup SG IDs.
|
||||
createIDs []string
|
||||
|
||||
// uniqueIDs makes CreateSecurityGroup hand out sg-1, sg-2, ... once createIDs
|
||||
// is exhausted, so a flow that creates several SGs can tell them apart.
|
||||
uniqueIDs bool
|
||||
nextSeq int
|
||||
|
||||
createErr error
|
||||
describeErr error
|
||||
deleteErr error
|
||||
@@ -78,23 +72,13 @@ func (f *fakeSGProvisioner) CreateSecurityGroup(_ context.Context, input *ec2.Cr
|
||||
return nil, f.createErr
|
||||
}
|
||||
id := "sg-default"
|
||||
switch {
|
||||
case len(f.createIDs) > 0:
|
||||
if len(f.createIDs) > 0 {
|
||||
id = f.createIDs[0]
|
||||
f.createIDs = f.createIDs[1:]
|
||||
case f.uniqueIDs:
|
||||
f.nextSeq++
|
||||
id = "sg-" + strconv.Itoa(f.nextSeq)
|
||||
}
|
||||
if input.GroupName != nil && input.VpcId != nil {
|
||||
f.existing[*input.GroupName+"|"+*input.VpcId] = id
|
||||
}
|
||||
for _, spec := range input.TagSpecifications {
|
||||
if f.tagsByID == nil {
|
||||
f.tagsByID = map[string][]*ec2.Tag{}
|
||||
}
|
||||
f.tagsByID[id] = append(f.tagsByID[id], spec.Tags...)
|
||||
}
|
||||
return &ec2.CreateSecurityGroupOutput{GroupId: aws.String(id)}, nil
|
||||
}
|
||||
|
||||
@@ -120,18 +104,15 @@ func (f *fakeSGProvisioner) DescribeSecurityGroups(_ context.Context, input *ec2
|
||||
return out, nil
|
||||
}
|
||||
var name, vpc string
|
||||
tagFilters := map[string]string{}
|
||||
for _, filt := range input.Filters {
|
||||
if filt == nil || filt.Name == nil || len(filt.Values) == 0 || filt.Values[0] == nil {
|
||||
continue
|
||||
}
|
||||
switch {
|
||||
case *filt.Name == "group-name":
|
||||
switch *filt.Name {
|
||||
case "group-name":
|
||||
name = *filt.Values[0]
|
||||
case *filt.Name == "vpc-id":
|
||||
case "vpc-id":
|
||||
vpc = *filt.Values[0]
|
||||
case strings.HasPrefix(*filt.Name, "tag:"):
|
||||
tagFilters[strings.TrimPrefix(*filt.Name, "tag:")] = *filt.Values[0]
|
||||
}
|
||||
}
|
||||
out := &ec2.DescribeSecurityGroupsOutput{}
|
||||
@@ -142,9 +123,6 @@ func (f *fakeSGProvisioner) DescribeSecurityGroups(_ context.Context, input *ec2
|
||||
if len(parts) != 2 || parts[1] != vpc {
|
||||
continue
|
||||
}
|
||||
if !sgTagsMatch(f.tagsByID[id], tagFilters) {
|
||||
continue
|
||||
}
|
||||
out.SecurityGroups = append(out.SecurityGroups, &ec2.SecurityGroup{
|
||||
GroupId: aws.String(id),
|
||||
GroupName: aws.String(parts[0]),
|
||||
@@ -289,7 +267,7 @@ func TestDeleteClusterSGs_DeletesBothExisting(t *testing.T) {
|
||||
sgp.existing["eks-cluster-alpha-control-plane-sg|vpc-aaa"] = "sg-existing-cp"
|
||||
sgp.existing["eks-cluster-alpha-nodegroup-sg|vpc-aaa"] = "sg-existing-ng"
|
||||
|
||||
err := DeleteClusterSGs(context.Background(), sgp, "111122223333", "alpha", "vpc-aaa", "")
|
||||
err := DeleteClusterSGs(context.Background(), sgp, "111122223333", "alpha", "vpc-aaa")
|
||||
require.NoError(t, err)
|
||||
|
||||
require.Len(t, sgp.deleteCalls, 2)
|
||||
@@ -313,7 +291,7 @@ func TestDeleteClusterSGs_ReapsLBCTaggedSGs(t *testing.T) {
|
||||
"sg-lbc-other": {{Key: aws.String(lbcClusterOwnershipTagKey), Value: aws.String("beta")}},
|
||||
}
|
||||
|
||||
err := DeleteClusterSGs(context.Background(), sgp, "111122223333", "alpha", "vpc-aaa", "")
|
||||
err := DeleteClusterSGs(context.Background(), sgp, "111122223333", "alpha", "vpc-aaa")
|
||||
require.NoError(t, err)
|
||||
|
||||
deleted := map[string]bool{}
|
||||
@@ -331,7 +309,7 @@ func TestDeleteClusterSGs_MissingSGsNoOp(t *testing.T) {
|
||||
t.Parallel()
|
||||
sgp := newFakeSGProvisioner()
|
||||
|
||||
err := DeleteClusterSGs(context.Background(), sgp, "111122223333", "alpha", "vpc-aaa", "")
|
||||
err := DeleteClusterSGs(context.Background(), sgp, "111122223333", "alpha", "vpc-aaa")
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, sgp.deleteCalls, "no delete calls expected when SGs already absent")
|
||||
}
|
||||
@@ -345,7 +323,7 @@ func TestDeleteClusterSGs_FirstErrorSurfacedSweepContinues(t *testing.T) {
|
||||
// after a single attempt per SG (the retry path is covered separately).
|
||||
sgp.deleteErr = errors.New("vpcd unavailable")
|
||||
|
||||
err := DeleteClusterSGs(context.Background(), sgp, "111122223333", "alpha", "vpc-aaa", "")
|
||||
err := DeleteClusterSGs(context.Background(), sgp, "111122223333", "alpha", "vpc-aaa")
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "sg-existing-cp")
|
||||
assert.Len(t, sgp.deleteCalls, 2, "both delete calls should be attempted despite first error")
|
||||
@@ -370,7 +348,7 @@ func TestDeleteClusterSGs_RetriesDependencyViolation(t *testing.T) {
|
||||
sgp.existing["eks-cluster-alpha-nodegroup-sg|vpc-aaa"] = "sg-ng"
|
||||
sgp.deleteFailsBefore = map[string]int{"sg-ng": 2} // 2 DepViolations, then ok
|
||||
|
||||
err := DeleteClusterSGs(context.Background(), sgp, "111122223333", "alpha", "vpc-aaa", "")
|
||||
err := DeleteClusterSGs(context.Background(), sgp, "111122223333", "alpha", "vpc-aaa")
|
||||
require.NoError(t, err, "delete must succeed once the ENI detaches")
|
||||
assert.GreaterOrEqual(t, len(sgp.deleteCalls), 3, "retried past the transient violations")
|
||||
})
|
||||
@@ -381,7 +359,7 @@ func TestDeleteClusterSGs_RetriesDependencyViolation(t *testing.T) {
|
||||
sgp.existing["eks-cluster-alpha-nodegroup-sg|vpc-aaa"] = "sg-ng"
|
||||
sgp.deleteErr = errors.New("DependencyViolation: still referenced")
|
||||
|
||||
err := DeleteClusterSGs(context.Background(), sgp, "111122223333", "alpha", "vpc-aaa", "")
|
||||
err := DeleteClusterSGs(context.Background(), sgp, "111122223333", "alpha", "vpc-aaa")
|
||||
require.Error(t, err, "a persistent DependencyViolation must surface, not leak the SG")
|
||||
assert.Contains(t, err.Error(), "sg-ng")
|
||||
})
|
||||
@@ -404,7 +382,7 @@ func TestDeleteClusterSGs_RevokesCrossRefsBeforeDelete(t *testing.T) {
|
||||
// AWS refuses to delete an SG still referenced by a sibling's rule.
|
||||
sgp.enforceDepViolation = true
|
||||
|
||||
err := DeleteClusterSGs(context.Background(), sgp, "111122223333", "alpha", "vpc-aaa", "")
|
||||
err := DeleteClusterSGs(context.Background(), sgp, "111122223333", "alpha", "vpc-aaa")
|
||||
require.NoError(t, err, "revoke-then-delete must clear the cp<->ng cycle so both SGs delete")
|
||||
|
||||
require.Len(t, sgp.revokeCalls, 2, "ingress on both SGs must be revoked before delete")
|
||||
@@ -440,7 +418,7 @@ func TestDeleteClusterSGs_RevokesEgressAndNonClusterReferrers(t *testing.T) {
|
||||
}
|
||||
sgp.enforceDepViolation = true
|
||||
|
||||
err := DeleteClusterSGs(context.Background(), sgp, "111122223333", "alpha", "vpc-aaa", "")
|
||||
err := DeleteClusterSGs(context.Background(), sgp, "111122223333", "alpha", "vpc-aaa")
|
||||
require.NoError(t, err, "revoking both directions on all non-default SGs must clear every cycle")
|
||||
|
||||
revokedEgress := map[string]bool{}
|
||||
@@ -613,64 +591,3 @@ func assertSGCreateTagged(t *testing.T, in *ec2.CreateSecurityGroupInput, name,
|
||||
assert.Equal(t, clusterName, got[clusterEKSClusterTagKey])
|
||||
assert.Equal(t, role, got[clusterEKSRoleTagKey])
|
||||
}
|
||||
|
||||
// sgTagsMatch reports whether tags carry every key=value in want.
|
||||
func sgTagsMatch(tags []*ec2.Tag, want map[string]string) bool {
|
||||
have := sgTagMap(tags)
|
||||
for k, v := range want {
|
||||
if have[k] != v {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func sgTagMap(tags []*ec2.Tag) map[string]string {
|
||||
m := map[string]string{}
|
||||
for _, tg := range tags {
|
||||
m[aws.StringValue(tg.Key)] = aws.StringValue(tg.Value)
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
func TestEnsureClusterSG_EmptyInputsRejected(t *testing.T) {
|
||||
t.Parallel()
|
||||
sg := newFakeSGProvisioner()
|
||||
_, err := EnsureClusterSG(context.Background(), sg, testAccountID, "", "vpc-aaa")
|
||||
require.Error(t, err)
|
||||
_, err = EnsureClusterSG(context.Background(), sg, testAccountID, "c1", "")
|
||||
require.Error(t, err)
|
||||
assert.Empty(t, sg.createCalls)
|
||||
}
|
||||
|
||||
func TestEnsureClusterSG_SecondCallReusesTheTaggedSG(t *testing.T) {
|
||||
t.Parallel()
|
||||
sg := newFakeSGProvisioner()
|
||||
sg.uniqueIDs = true
|
||||
|
||||
first, err := EnsureClusterSG(context.Background(), sg, testAccountID, "c1", "vpc-aaa")
|
||||
require.NoError(t, err)
|
||||
second, err := EnsureClusterSG(context.Background(), sg, testAccountID, "c1", "vpc-aaa")
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, first, second)
|
||||
assert.Len(t, sg.createCalls, 1)
|
||||
}
|
||||
|
||||
func TestEnsureClusterSG_DuplicateSelfRuleTolerated(t *testing.T) {
|
||||
t.Parallel()
|
||||
sg := newFakeSGProvisioner()
|
||||
sg.authorizeErr = errors.New(awserrors.ErrorInvalidPermissionDuplicate)
|
||||
|
||||
_, err := EnsureClusterSG(context.Background(), sg, testAccountID, "c1", "vpc-aaa")
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
func TestEnsureClusterSG_AuthorizeErrorSurfaced(t *testing.T) {
|
||||
t.Parallel()
|
||||
sg := newFakeSGProvisioner()
|
||||
sg.authorizeErr = errors.New("boom")
|
||||
|
||||
_, err := EnsureClusterSG(context.Background(), sg, testAccountID, "c1", "vpc-aaa")
|
||||
require.Error(t, err)
|
||||
}
|
||||
|
||||
@@ -977,13 +977,13 @@ func (s *EKSServiceImpl) launchClusterInfra(ctx context.Context, lc clusterLaunc
|
||||
return
|
||||
}
|
||||
|
||||
// The customer-facing cluster security group lives in the caller's own VPC,
|
||||
// not the system-managed CP VPC above, so the tenant's own
|
||||
// DescribeSecurityGroups resolves it. Workers and the private-endpoint ENI
|
||||
// carry it. Idempotent with the nodegroup launch, which finds it by tag.
|
||||
custClusterSG, err := EnsureClusterSG(ctx, s.deps.VPCSG, accountID, name, lc.vpcID)
|
||||
// The customer-facing "cluster security group" AWS auto-creates lives in the
|
||||
// caller's own VPC, not the system-managed CP VPC above — otherwise the
|
||||
// tenant's own DescribeSecurityGroups can never resolve it. Idempotent with
|
||||
// launchNodegroupInfra's own EnsureClusterSGs call, which reuses this same id.
|
||||
custClusterSG, _, err := EnsureClusterSGs(ctx, s.deps.VPCSG, accountID, name, lc.vpcID)
|
||||
if err != nil {
|
||||
s.failClusterLaunch(ctx, acctKV, name, accountID, meta, "ensure customer-vpc cluster SG", err)
|
||||
s.failClusterLaunch(ctx, acctKV, name, accountID, meta, "ensure customer-vpc cluster SGs", err)
|
||||
return
|
||||
}
|
||||
meta.ResourcesVpcConfig.ClusterSecurityGroupId = custClusterSG
|
||||
@@ -1002,7 +1002,7 @@ func (s *EKSServiceImpl) launchClusterInfra(ctx context.Context, lc clusterLaunc
|
||||
// NIC and proxies to the CP target group with no data-plane change.
|
||||
var crossAccountENIs []systeminstance.ExtraENIInput
|
||||
if privateAccess {
|
||||
pe, perr := EnsurePrivateEndpointENI(ctx, s.deps.VPCK3s, s.deps.VPCSG, s.deps.VPCSubnet, accountID, name, lc.subnetIDs[0], lc.vpcID, custClusterSG)
|
||||
pe, perr := EnsurePrivateEndpointENI(ctx, s.deps.VPCK3s, s.deps.VPCSG, s.deps.VPCSubnet, accountID, name, lc.subnetIDs[0], lc.vpcID)
|
||||
if perr != nil {
|
||||
s.failClusterLaunch(ctx, acctKV, name, accountID, meta, "ensure private endpoint ENI", perr)
|
||||
return
|
||||
@@ -1621,7 +1621,7 @@ func (s *EKSServiceImpl) teardownClusterInfra(ctx context.Context, accountID, na
|
||||
// New topology: SGs + IGW + subnets + route tables + NAT GW + VPC all live
|
||||
// in the managed CP VPC under the system account. DeleteClusterCPVPC removes
|
||||
// the IGW + VPC after the SGs, so SG cleanup must precede it.
|
||||
cpSGErr := DeleteClusterSGs(ctx, s.deps.VPCSG, infraAcct, name, meta.ManagedCPVPC.VpcId, "")
|
||||
cpSGErr := DeleteClusterSGs(ctx, s.deps.VPCSG, infraAcct, name, meta.ManagedCPVPC.VpcId)
|
||||
if cpSGErr != nil {
|
||||
fail(stepSecurityGroups, fmt.Errorf("delete cluster SGs: %w", cpSGErr))
|
||||
}
|
||||
@@ -1629,7 +1629,7 @@ func (s *EKSServiceImpl) teardownClusterInfra(ctx context.Context, accountID, na
|
||||
// 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, meta.ResourcesVpcConfig.ClusterSecurityGroupId); err != nil {
|
||||
if err := DeleteClusterSGs(ctx, s.deps.VPCSG, accountID, name, meta.ResourcesVpcConfig.VpcId); err != nil {
|
||||
fail(stepSecurityGroups, fmt.Errorf("delete customer-VPC cluster SGs: %w", err))
|
||||
}
|
||||
}
|
||||
@@ -1653,7 +1653,7 @@ func (s *EKSServiceImpl) teardownClusterInfra(ctx context.Context, accountID, na
|
||||
// Legacy topology: CP lived in the customer VPC; reclaim its SGs + the
|
||||
// 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, meta.ResourcesVpcConfig.ClusterSecurityGroupId); err != nil {
|
||||
if err := DeleteClusterSGs(ctx, s.deps.VPCSG, accountID, name, meta.ResourcesVpcConfig.VpcId); err != nil {
|
||||
fail(stepSecurityGroups, fmt.Errorf("delete cluster SGs: %w", err))
|
||||
}
|
||||
if s.deps.IGW != nil {
|
||||
|
||||
@@ -46,7 +46,7 @@ backend {{.Name}}
|
||||
{{- if .Redirect}}
|
||||
http-request redirect {{if .Redirect.Scheme}}scheme {{.Redirect.Scheme}}{{else}}location {{.Redirect.Location}}{{end}} code {{.Redirect.Code}}
|
||||
{{- else if .FixedResponse}}
|
||||
http-request return status {{.FixedResponse.StatusCode}}{{if .FixedResponse.ContentType}} content-type {{squote .FixedResponse.ContentType}}{{end}}{{if .FixedResponse.Body}} string {{squote .FixedResponse.Body}}{{end}}
|
||||
http-request return status {{.FixedResponse.StatusCode}}{{if .FixedResponse.ContentType}} content-type "{{.FixedResponse.ContentType}}"{{end}}{{if .FixedResponse.Body}} string "{{.FixedResponse.Body}}"{{end}}
|
||||
{{- else}}
|
||||
balance roundrobin{{if .HealthCheckPath}}
|
||||
option httpchk
|
||||
@@ -65,14 +65,7 @@ backend {{.Name}}
|
||||
{{end}}
|
||||
`
|
||||
|
||||
var haproxyHTTPTmpl = template.Must(template.New("haproxy").Funcs(template.FuncMap{"squote": haproxySingleQuote}).Parse(haproxyConfigTemplate))
|
||||
|
||||
// haproxySingleQuote renders s as one literal HAProxy word. HAProxy expands
|
||||
// environment variables inside double quotes but not single quotes, so tenant
|
||||
// text is single-quoted with each embedded quote closed, escaped and reopened.
|
||||
func haproxySingleQuote(s string) string {
|
||||
return "'" + strings.ReplaceAll(s, "'", `'\''`) + "'"
|
||||
}
|
||||
var haproxyHTTPTmpl = template.Must(template.New("haproxy").Parse(haproxyConfigTemplate))
|
||||
|
||||
// HAProxyConfig holds the full configuration for rendering the HAProxy template.
|
||||
type HAProxyConfig struct {
|
||||
@@ -573,10 +566,17 @@ func validHTTPStatusCode(s string) bool {
|
||||
return true
|
||||
}
|
||||
|
||||
// validFixedResponseBody rejects bytes that would end the single-line HAProxy
|
||||
// directive the single-quoted body is rendered into.
|
||||
// validFixedResponseBody rejects bytes that would terminate or inject into the HAProxy directive.
|
||||
func validFixedResponseBody(s string) bool {
|
||||
return s != "" && !strings.ContainsAny(s, "\n\r\x00")
|
||||
if s == "" {
|
||||
return false
|
||||
}
|
||||
for _, r := range s {
|
||||
if r == '\n' || r == '\r' || r == '"' || r == '\\' || r == '#' {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// buildRedirect translates a RedirectAction into an HAProxy redirect directive.
|
||||
|
||||
@@ -279,7 +279,7 @@ func TestGenerateHAProxyConfig_FixedResponseDefault(t *testing.T) {
|
||||
// Synthetic default backend named off the listener ARN, returning the fixed response.
|
||||
assert.Contains(t, config, "default_backend bkdefault_lst-616760baf6a3031c3")
|
||||
assert.Contains(t, config, "backend bkdefault_lst-616760baf6a3031c3")
|
||||
assert.Contains(t, config, `http-request return status 404 content-type 'text/plain' string 'no route'`)
|
||||
assert.Contains(t, config, `http-request return status 404 content-type "text/plain" string "no route"`)
|
||||
// Host-header rule still routes to the app backend.
|
||||
assert.Contains(t, config, "use_backend bk_tg-app1 if")
|
||||
assert.Contains(t, config, "10.42.0.9:8080")
|
||||
@@ -309,59 +309,7 @@ func TestGenerateHAProxyConfig_FixedResponseRejectsUnsafeBody(t *testing.T) {
|
||||
|
||||
assert.Contains(t, config, "http-request return status 200")
|
||||
assert.NotContains(t, config, "always_true")
|
||||
assert.NotContains(t, config, `string 'evil`)
|
||||
}
|
||||
|
||||
// TestGenerateHAProxyConfig_FixedResponseIsLiteral renders the content type and
|
||||
// body in single quotes, where HAProxy performs no environment expansion, so a
|
||||
// body referencing the lb-agent's environment is served verbatim.
|
||||
func TestGenerateHAProxyConfig_FixedResponseIsLiteral(t *testing.T) {
|
||||
t.Parallel()
|
||||
lb := &LoadBalancerRecord{LoadBalancerID: "lb-env"}
|
||||
listeners := []*ListenerRecord{
|
||||
{
|
||||
ListenerArn: "arn:lst-env",
|
||||
Port: 80,
|
||||
DefaultActions: []ListenerAction{
|
||||
{Type: ActionTypeFixedResponse, FixedResponse: &FixedResponseAction{
|
||||
StatusCode: "200",
|
||||
ContentType: "text/plain",
|
||||
MessageBody: `sk=${LB_SECRET_KEY} ak=$LB_ACCESS_KEY it's "q" \ #x`,
|
||||
}},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
config, _, err := GenerateHAProxyConfigWithCerts(lb, listeners, nil, nil, "0.0.0.0", nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Contains(t, config, `http-request return status 200 content-type 'text/plain' string 'sk=${LB_SECRET_KEY} ak=$LB_ACCESS_KEY it'\''s "q" \ #x'`+"\n")
|
||||
assert.NotContains(t, config, `"${LB_SECRET_KEY}`)
|
||||
}
|
||||
|
||||
// TestGenerateHAProxyConfig_FixedResponseKeepsStoredTab keeps a stored body with
|
||||
// a tab, which is literal inside single quotes and rendered before API checks existed.
|
||||
func TestGenerateHAProxyConfig_FixedResponseKeepsStoredTab(t *testing.T) {
|
||||
t.Parallel()
|
||||
lb := &LoadBalancerRecord{LoadBalancerID: "lb-tab"}
|
||||
listeners := []*ListenerRecord{
|
||||
{
|
||||
ListenerArn: "arn:lst-tab",
|
||||
Port: 80,
|
||||
DefaultActions: []ListenerAction{
|
||||
{Type: ActionTypeFixedResponse, FixedResponse: &FixedResponseAction{
|
||||
StatusCode: "503",
|
||||
ContentType: "text/html",
|
||||
MessageBody: "<pre>\tdown</pre>",
|
||||
}},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
config, _, err := GenerateHAProxyConfigWithCerts(lb, listeners, nil, nil, "0.0.0.0", nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Contains(t, config, "http-request return status 503 content-type 'text/html' string '<pre>\tdown</pre>'\n")
|
||||
assert.NotContains(t, config, `string "evil`)
|
||||
}
|
||||
|
||||
// TestGenerateHAProxyConfig_RedirectSchemeDefault renders the canonical
|
||||
|
||||
@@ -17,8 +17,6 @@ import (
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
"unicode"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/aws/aws-sdk-go/aws"
|
||||
"github.com/aws/aws-sdk-go/service/ec2"
|
||||
@@ -2703,9 +2701,8 @@ func listenerActionFromSDK(a *elbv2.Action) ListenerAction {
|
||||
return action
|
||||
}
|
||||
|
||||
// validateListenerAction enforces the per-type action contract. Redirect and
|
||||
// fixed-response fields are validated here so invalid input fails at the API,
|
||||
// not at render time.
|
||||
// validateListenerAction enforces the per-type action contract. Redirect fields
|
||||
// are validated here so invalid input fails at the API, not at render time.
|
||||
func validateListenerAction(a ListenerAction) error {
|
||||
if a.Type == ActionTypeRedirect {
|
||||
if a.Redirect == nil {
|
||||
@@ -2713,39 +2710,6 @@ func validateListenerAction(a ListenerAction) error {
|
||||
}
|
||||
return validateRedirectAction(a.Redirect)
|
||||
}
|
||||
if a.Type == ActionTypeFixedResponse {
|
||||
return validateFixedResponseAction(a.FixedResponse)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// fixedResponseContentTypes are the only ContentType values ELBv2 accepts.
|
||||
var fixedResponseContentTypes = map[string]bool{
|
||||
"text/plain": true,
|
||||
"text/css": true,
|
||||
"text/html": true,
|
||||
"application/javascript": true,
|
||||
"application/json": true,
|
||||
}
|
||||
|
||||
// maxFixedResponseBodyLen is ELBv2's MessageBody length limit in characters.
|
||||
const maxFixedResponseBodyLen = 1024
|
||||
|
||||
// validateFixedResponseAction rejects a ContentType ELBv2 does not allow, or a
|
||||
// MessageBody that is too long or holds control characters.
|
||||
func validateFixedResponseAction(fr *FixedResponseAction) error {
|
||||
if fr == nil {
|
||||
return errors.New(awserrors.ErrorMissingParameter)
|
||||
}
|
||||
if fr.ContentType != "" && !fixedResponseContentTypes[fr.ContentType] {
|
||||
return errors.New(awserrors.ErrorInvalidParameterValue)
|
||||
}
|
||||
if utf8.RuneCountInString(fr.MessageBody) > maxFixedResponseBodyLen {
|
||||
return errors.New(awserrors.ErrorInvalidParameterValue)
|
||||
}
|
||||
if strings.ContainsFunc(fr.MessageBody, unicode.IsControl) {
|
||||
return errors.New(awserrors.ErrorInvalidParameterValue)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -3539,18 +3503,14 @@ func (s *ELBv2ServiceImpl) tgRecordToSDK(r *TargetGroupRecord, lbArns []string)
|
||||
HealthCheckEnabled: aws.Bool(r.HealthCheck.Enabled),
|
||||
HealthCheckProtocol: aws.String(r.HealthCheck.Protocol),
|
||||
HealthCheckPort: aws.String(r.HealthCheck.Port),
|
||||
HealthCheckPath: aws.String(r.HealthCheck.Path),
|
||||
HealthCheckIntervalSeconds: aws.Int64(r.HealthCheck.IntervalSeconds),
|
||||
HealthCheckTimeoutSeconds: aws.Int64(r.HealthCheck.TimeoutSeconds),
|
||||
HealthyThresholdCount: aws.Int64(r.HealthCheck.HealthyThreshold),
|
||||
UnhealthyThresholdCount: aws.Int64(r.HealthCheck.UnhealthyThreshold),
|
||||
}
|
||||
|
||||
// AWS omits both keys on TCP target groups, which have no path or matcher.
|
||||
if r.HealthCheck.Path != "" {
|
||||
tg.HealthCheckPath = aws.String(r.HealthCheck.Path)
|
||||
}
|
||||
if r.HealthCheck.Matcher != "" {
|
||||
tg.Matcher = &elbv2.Matcher{HttpCode: aws.String(r.HealthCheck.Matcher)}
|
||||
Matcher: &elbv2.Matcher{
|
||||
HttpCode: aws.String(r.HealthCheck.Matcher),
|
||||
},
|
||||
}
|
||||
|
||||
// Omitted for NLB (TCP/UDP/TLS) target groups, matching AWS, which returns
|
||||
|
||||
@@ -591,8 +591,8 @@ func (s *ELBv2ServiceImpl) validateAndConvertRuleActions(ctx context.Context, in
|
||||
return nil, err
|
||||
}
|
||||
case ActionTypeFixedResponse:
|
||||
if err := validateFixedResponseAction(action.FixedResponse); err != nil {
|
||||
return nil, err
|
||||
if action.FixedResponse == nil {
|
||||
return nil, errors.New(awserrors.ErrorMissingParameter)
|
||||
}
|
||||
default:
|
||||
return nil, errors.New(awserrors.ErrorELBv2InvalidConfigurationRequest)
|
||||
|
||||
@@ -7,7 +7,6 @@ import (
|
||||
|
||||
"github.com/aws/aws-sdk-go/aws"
|
||||
"github.com/aws/aws-sdk-go/service/elbv2"
|
||||
awserrors "github.com/mulgadc/spinifex/spinifex/foundation/aws/errors"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
@@ -661,53 +660,3 @@ func sortByPriority(rs []*RuleRecord) []*RuleRecord {
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func TestValidateListenerAction_FixedResponse(t *testing.T) {
|
||||
t.Parallel()
|
||||
fixed := func(ct, body string) ListenerAction {
|
||||
return ListenerAction{Type: ActionTypeFixedResponse, FixedResponse: &FixedResponseAction{
|
||||
StatusCode: "200", ContentType: ct, MessageBody: body,
|
||||
}}
|
||||
}
|
||||
|
||||
for _, ct := range []string{"", "text/plain", "text/css", "text/html", "application/javascript", "application/json"} {
|
||||
assert.NoError(t, validateListenerAction(fixed(ct, "ok")), ct)
|
||||
}
|
||||
assert.NoError(t, validateListenerAction(fixed("text/plain", "")))
|
||||
assert.NoError(t, validateListenerAction(fixed("text/plain", "sk=${LB_SECRET_KEY} it's \"q\" #x")))
|
||||
assert.NoError(t, validateListenerAction(fixed("text/plain", strings.Repeat("é", 1024))))
|
||||
|
||||
bad := []ListenerAction{
|
||||
fixed("text/$LB_SECRET_KEY", "ok"),
|
||||
fixed("text/xml", "ok"),
|
||||
fixed("TEXT/PLAIN", "ok"),
|
||||
fixed("text/plain", strings.Repeat("a", 1025)),
|
||||
fixed("text/plain", "line\nacl x always_true"),
|
||||
fixed("text/plain", "cr\r"),
|
||||
fixed("text/plain", "nul\x00"),
|
||||
}
|
||||
for _, a := range bad {
|
||||
assert.EqualError(t, validateListenerAction(a), awserrors.ErrorInvalidParameterValue, "%+v", *a.FixedResponse)
|
||||
}
|
||||
assert.EqualError(t, validateListenerAction(ListenerAction{Type: ActionTypeFixedResponse}), awserrors.ErrorMissingParameter)
|
||||
}
|
||||
|
||||
func TestCreateRule_RejectsFixedResponseContentType(t *testing.T) {
|
||||
t.Parallel()
|
||||
env := newRuleTestEnv(t, "cr-badfixed")
|
||||
|
||||
_, err := env.svc.CreateRule(context.Background(), &elbv2.CreateRuleInput{
|
||||
ListenerArn: aws.String(env.listenerArn),
|
||||
Priority: aws.Int64(1),
|
||||
Conditions: []*elbv2.RuleCondition{{Field: aws.String("path-pattern"), Values: aws.StringSlice([]string{"/x"})}},
|
||||
Actions: []*elbv2.Action{{
|
||||
Type: aws.String("fixed-response"),
|
||||
FixedResponseConfig: &elbv2.FixedResponseActionConfig{
|
||||
StatusCode: aws.String("200"),
|
||||
ContentType: aws.String("text/$LB_SECRET_KEY"),
|
||||
MessageBody: aws.String("ok"),
|
||||
},
|
||||
}},
|
||||
}, testAccountID)
|
||||
require.EqualError(t, err, awserrors.ErrorInvalidParameterValue)
|
||||
}
|
||||
|
||||
@@ -459,12 +459,11 @@ func TestCreateTargetGroup_TCPProtocol(t *testing.T) {
|
||||
assert.Equal(t, int64(5432), *tg.Port)
|
||||
// NLB health check defaults: TCP protocol, no path, no matcher.
|
||||
assert.Equal(t, "TCP", *tg.HealthCheckProtocol)
|
||||
assert.Nil(t, tg.HealthCheckPath)
|
||||
assert.Nil(t, tg.Matcher)
|
||||
assert.Equal(t, int64(30), *tg.HealthCheckIntervalSeconds)
|
||||
assert.Empty(t, *tg.HealthCheckPath)
|
||||
assert.Empty(t, *tg.Matcher.HttpCode)
|
||||
assert.Equal(t, int64(10), *tg.HealthCheckTimeoutSeconds)
|
||||
assert.Equal(t, int64(5), *tg.HealthyThresholdCount)
|
||||
assert.Equal(t, int64(2), *tg.UnhealthyThresholdCount)
|
||||
assert.Equal(t, int64(3), *tg.HealthyThresholdCount)
|
||||
assert.Equal(t, int64(3), *tg.UnhealthyThresholdCount)
|
||||
}
|
||||
|
||||
func TestCreateTargetGroup_NLBProtocols(t *testing.T) {
|
||||
|
||||
@@ -99,8 +99,8 @@ const (
|
||||
// Default health check values (NLB).
|
||||
DefaultNLBHealthCheckInterval = 30
|
||||
DefaultNLBHealthCheckTimeout = 10
|
||||
DefaultNLBHealthyThreshold = 5
|
||||
DefaultNLBUnhealthyThreshold = 2
|
||||
DefaultNLBHealthyThreshold = 3
|
||||
DefaultNLBUnhealthyThreshold = 3
|
||||
DefaultNLBHealthCheckProtocol = ProtocolTCP
|
||||
DefaultNLBHealthCheckPort = "traffic-port"
|
||||
|
||||
|
||||
@@ -33,15 +33,9 @@ func TestSetAccountStatusCountsOnlyChanges(t *testing.T) {
|
||||
{AccountStatusTerminating, 3},
|
||||
}
|
||||
for _, step := range steps {
|
||||
if step.status == AccountStatusTerminating {
|
||||
generation, err := svc.QuiesceAccount(account.AccountID, "op-1", 1)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, step.want, generation, "status %s", step.status)
|
||||
} else {
|
||||
got, err := svc.SetAccountStatus(account.AccountID, step.status)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, step.want, got.Generation, "status %s", step.status)
|
||||
}
|
||||
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)
|
||||
|
||||
@@ -9,7 +9,7 @@ import (
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func newLifecycleService(t *testing.T) *iamdomain.IAMServiceImpl {
|
||||
func newLifecycleService(t *testing.T) iamdomain.IAMService {
|
||||
t.Helper()
|
||||
_, nc, _ := testutil.StartTestJetStream(t)
|
||||
|
||||
@@ -56,7 +56,7 @@ func TestTerminatingIsAOneWayDoor(t *testing.T) {
|
||||
account, err := svc.CreateAccount("tenant@example.com")
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = svc.QuiesceAccount(account.AccountID, "op-1", 1)
|
||||
_, err = svc.SetAccountStatus(account.AccountID, iamdomain.AccountStatusTerminating)
|
||||
require.NoError(t, err)
|
||||
|
||||
for _, status := range []string{iamdomain.AccountStatusActive, iamdomain.AccountStatusSuspended} {
|
||||
@@ -92,11 +92,11 @@ func TestDeleteAccountRequiresTerminating(t *testing.T) {
|
||||
account, err := svc.CreateAccount("tenant@example.com")
|
||||
require.NoError(t, err)
|
||||
|
||||
require.Error(t, svc.DeleteAccount(account.AccountID, "op-1", 1))
|
||||
require.Error(t, svc.DeleteAccount(account.AccountID))
|
||||
|
||||
_, err = svc.QuiesceAccount(account.AccountID, "op-1", 1)
|
||||
_, err = svc.SetAccountStatus(account.AccountID, iamdomain.AccountStatusTerminating)
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, svc.DeleteAccount(account.AccountID, "op-1", 1))
|
||||
require.NoError(t, svc.DeleteAccount(account.AccountID))
|
||||
|
||||
_, err = svc.GetAccount(account.AccountID)
|
||||
assert.Error(t, err)
|
||||
@@ -112,9 +112,9 @@ func TestProtectedAccountsCanNeverBeDeleted(t *testing.T) {
|
||||
t.Run(accountID, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
// Even a status that would otherwise permit deletion must not.
|
||||
_, _ = svc.QuiesceAccount(accountID, "op-1", 1)
|
||||
_, _ = svc.SetAccountStatus(accountID, iamdomain.AccountStatusTerminating)
|
||||
|
||||
err := svc.DeleteAccount(accountID, "op-1", 1)
|
||||
err := svc.DeleteAccount(accountID)
|
||||
|
||||
require.Error(t, err)
|
||||
assert.ErrorIs(t, err, iamdomain.ErrAccountUndeletable)
|
||||
|
||||
@@ -1,334 +0,0 @@
|
||||
package iam
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
|
||||
"github.com/nats-io/nats.go/jetstream"
|
||||
|
||||
awsidentifiers "github.com/mulgadc/spinifex/spinifex/foundation/aws/identifiers"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// newQuiesceAccount returns a service and a tenant account. The first account a
|
||||
// fresh service creates takes the protected admin ID, so it is burned first.
|
||||
func newQuiesceAccount(t *testing.T) (*IAMServiceImpl, string) {
|
||||
t.Helper()
|
||||
svc := setupTestIAMService(t)
|
||||
_, err := svc.CreateAccount("reserved-admin")
|
||||
require.NoError(t, err)
|
||||
account, err := svc.CreateAccount("quiesce-target")
|
||||
require.NoError(t, err)
|
||||
return svc, account.AccountID
|
||||
}
|
||||
|
||||
// writeAccountRecord rewrites the stored record directly, to produce states the
|
||||
// service itself would not, such as a record written by an older binary.
|
||||
func writeAccountRecord(t *testing.T, svc *IAMServiceImpl, id string, mutate func(*Account)) {
|
||||
t.Helper()
|
||||
account, err := svc.GetAccount(id)
|
||||
require.NoError(t, err)
|
||||
mutate(account)
|
||||
raw, err := json.Marshal(account)
|
||||
require.NoError(t, err)
|
||||
_, err = svc.accountsBucket.Put(context.Background(), id, raw)
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
func TestQuiesceAccountRuleTable(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
type op struct {
|
||||
id string
|
||||
fence uint64
|
||||
}
|
||||
cases := []struct {
|
||||
name string
|
||||
// start puts the account in its starting state.
|
||||
start func(t *testing.T, svc *IAMServiceImpl, id string)
|
||||
call op
|
||||
// wantErr is nil when the call must succeed.
|
||||
wantErr error
|
||||
// wantGeneration is the generation returned and stored.
|
||||
wantGeneration uint64
|
||||
wantStatus string
|
||||
wantOp *TerminationOp
|
||||
}{
|
||||
{
|
||||
name: "active becomes terminating and records the operation",
|
||||
start: func(*testing.T, *IAMServiceImpl, string) {},
|
||||
call: op{"op-1", 1},
|
||||
wantGeneration: 1,
|
||||
wantStatus: AccountStatusTerminating,
|
||||
wantOp: &TerminationOp{OperationID: "op-1", Fence: 1},
|
||||
},
|
||||
{
|
||||
name: "suspended becomes terminating and records the operation",
|
||||
start: func(t *testing.T, svc *IAMServiceImpl, id string) {
|
||||
_, err := svc.SetAccountStatus(id, AccountStatusSuspended)
|
||||
require.NoError(t, err)
|
||||
},
|
||||
call: op{"op-1", 1},
|
||||
wantGeneration: 2,
|
||||
wantStatus: AccountStatusTerminating,
|
||||
wantOp: &TerminationOp{OperationID: "op-1", Fence: 1},
|
||||
},
|
||||
{
|
||||
name: "same operation and fence is idempotent",
|
||||
start: func(t *testing.T, svc *IAMServiceImpl, id string) {
|
||||
_, err := svc.QuiesceAccount(id, "op-1", 1)
|
||||
require.NoError(t, err)
|
||||
},
|
||||
call: op{"op-1", 1},
|
||||
wantGeneration: 1,
|
||||
wantStatus: AccountStatusTerminating,
|
||||
wantOp: &TerminationOp{OperationID: "op-1", Fence: 1},
|
||||
},
|
||||
{
|
||||
name: "same operation at a higher fence records the fence only",
|
||||
start: func(t *testing.T, svc *IAMServiceImpl, id string) {
|
||||
_, err := svc.QuiesceAccount(id, "op-1", 1)
|
||||
require.NoError(t, err)
|
||||
},
|
||||
call: op{"op-1", 2},
|
||||
wantGeneration: 1,
|
||||
wantStatus: AccountStatusTerminating,
|
||||
wantOp: &TerminationOp{OperationID: "op-1", Fence: 2},
|
||||
},
|
||||
{
|
||||
name: "same operation at a lower fence is refused",
|
||||
start: func(t *testing.T, svc *IAMServiceImpl, id string) {
|
||||
_, err := svc.QuiesceAccount(id, "op-1", 3)
|
||||
require.NoError(t, err)
|
||||
},
|
||||
call: op{"op-1", 2},
|
||||
wantErr: ErrQuiesceSuperseded,
|
||||
wantStatus: AccountStatusTerminating,
|
||||
wantOp: &TerminationOp{OperationID: "op-1", Fence: 3},
|
||||
},
|
||||
{
|
||||
name: "a different operation is refused even at a higher fence",
|
||||
start: func(t *testing.T, svc *IAMServiceImpl, id string) {
|
||||
_, err := svc.QuiesceAccount(id, "op-1", 1)
|
||||
require.NoError(t, err)
|
||||
},
|
||||
call: op{"op-2", 9},
|
||||
wantErr: ErrQuiesceSuperseded,
|
||||
wantStatus: AccountStatusTerminating,
|
||||
wantOp: &TerminationOp{OperationID: "op-1", Fence: 1},
|
||||
},
|
||||
{
|
||||
name: "terminating without an operation, written by an older binary, adopts it",
|
||||
start: func(t *testing.T, svc *IAMServiceImpl, id string) {
|
||||
writeAccountRecord(t, svc, id, func(a *Account) {
|
||||
a.Status = AccountStatusTerminating
|
||||
a.Generation = 4
|
||||
})
|
||||
},
|
||||
call: op{"op-1", 1},
|
||||
wantGeneration: 4,
|
||||
wantStatus: AccountStatusTerminating,
|
||||
wantOp: &TerminationOp{OperationID: "op-1", Fence: 1},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
svc, id := newQuiesceAccount(t)
|
||||
tc.start(t, svc, id)
|
||||
|
||||
generation, err := svc.QuiesceAccount(id, tc.call.id, tc.call.fence)
|
||||
if tc.wantErr != nil {
|
||||
require.ErrorIs(t, err, tc.wantErr)
|
||||
} else {
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, tc.wantGeneration, generation)
|
||||
}
|
||||
|
||||
stored, err := svc.GetAccount(id)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, tc.wantStatus, stored.Status)
|
||||
assert.Equal(t, tc.wantOp, stored.TerminationOp)
|
||||
if tc.wantErr == nil {
|
||||
assert.Equal(t, tc.wantGeneration, stored.Generation)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestQuiesceAccountRefusesTheProtectedAccounts(t *testing.T) {
|
||||
t.Parallel()
|
||||
svc := setupTestIAMService(t)
|
||||
systemSecret, err := svc.key.EncryptBase64("system-secret")
|
||||
require.NoError(t, err)
|
||||
adminSecret, err := svc.key.EncryptBase64("admin-secret")
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, svc.SeedBootstrap(&BootstrapData{
|
||||
AccessKeyID: "AKIASYSTEM1234567890",
|
||||
EncryptedSecret: systemSecret,
|
||||
AccountID: awsidentifiers.GlobalAccountID,
|
||||
Admin: &AdminBootstrapData{
|
||||
AccountID: "000000000001",
|
||||
AccountName: "spinifex",
|
||||
UserName: "admin",
|
||||
AccessKeyID: "AKIAADMIN12345678901",
|
||||
EncryptedSecret: adminSecret,
|
||||
},
|
||||
}))
|
||||
|
||||
for _, id := range []string{awsidentifiers.GlobalAccountID, "000000000001"} {
|
||||
generation, err := svc.QuiesceAccount(id, "op-1", 1)
|
||||
require.ErrorIs(t, err, ErrAccountUndeletable, id)
|
||||
assert.Zero(t, generation)
|
||||
}
|
||||
|
||||
stored, err := svc.GetAccount(awsidentifiers.GlobalAccountID)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, AccountStatusActive, stored.Status)
|
||||
assert.Nil(t, stored.TerminationOp)
|
||||
}
|
||||
|
||||
func TestQuiesceAccountNeedsAnOperationAndAFence(t *testing.T) {
|
||||
t.Parallel()
|
||||
svc, id := newQuiesceAccount(t)
|
||||
|
||||
for _, call := range []struct {
|
||||
op string
|
||||
fence uint64
|
||||
}{{"", 1}, {"op-1", 0}} {
|
||||
_, err := svc.QuiesceAccount(id, call.op, call.fence)
|
||||
require.Error(t, err)
|
||||
}
|
||||
|
||||
stored, err := svc.GetAccount(id)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, AccountStatusActive, stored.Status)
|
||||
}
|
||||
|
||||
func TestQuiesceAccountOfAMissingAccountIsNoSuchEntity(t *testing.T) {
|
||||
t.Parallel()
|
||||
svc := setupTestIAMService(t)
|
||||
_, err := svc.QuiesceAccount("000000000077", "op-1", 1)
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "NoSuchEntity")
|
||||
}
|
||||
|
||||
// Entering TERMINATING without an owning operation would leave an account that
|
||||
// no job can be fenced against, so SetAccountStatus refuses it.
|
||||
func TestSetAccountStatusRefusesTerminating(t *testing.T) {
|
||||
t.Parallel()
|
||||
svc, id := newQuiesceAccount(t)
|
||||
|
||||
got, err := svc.SetAccountStatus(id, AccountStatusTerminating)
|
||||
require.ErrorIs(t, err, ErrUseQuiesceAccount)
|
||||
assert.Nil(t, got)
|
||||
|
||||
stored, err := svc.GetAccount(id)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, AccountStatusActive, stored.Status)
|
||||
assert.Zero(t, stored.Generation)
|
||||
}
|
||||
|
||||
// A status write by SetAccountStatus must not drop the operation record.
|
||||
func TestSetAccountStatusKeepsTheTerminationOp(t *testing.T) {
|
||||
t.Parallel()
|
||||
svc, id := newQuiesceAccount(t)
|
||||
_, err := svc.QuiesceAccount(id, "op-1", 2)
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = svc.SetAccountStatus(id, AccountStatusSuspended)
|
||||
require.ErrorIs(t, err, ErrAccountUndeletable)
|
||||
|
||||
stored, err := svc.GetAccount(id)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, &TerminationOp{OperationID: "op-1", Fence: 2}, stored.TerminationOp)
|
||||
}
|
||||
|
||||
// racingDeleteBucket lets a takeover land between the delete's read and its
|
||||
// conditional delete.
|
||||
type racingDeleteBucket struct {
|
||||
jetstream.KeyValue
|
||||
|
||||
raced atomic.Bool
|
||||
compete func()
|
||||
}
|
||||
|
||||
func (b *racingDeleteBucket) Delete(ctx context.Context, key string, opts ...jetstream.KVDeleteOpt) error {
|
||||
if b.raced.CompareAndSwap(false, true) {
|
||||
b.compete()
|
||||
}
|
||||
return b.KeyValue.Delete(ctx, key, opts...)
|
||||
}
|
||||
|
||||
// The final removal is fenced: only the holder whose operation and fence are
|
||||
// on the record may remove the account.
|
||||
func TestDeleteAccountIsFenced(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
op string
|
||||
fence uint64
|
||||
wantErr error
|
||||
}{
|
||||
{"the matching operation and fence removes the account", "op-1", 2, nil},
|
||||
{"a stale fence cannot remove the account", "op-1", 1, ErrQuiesceSuperseded},
|
||||
{"a newer fence than the record cannot remove the account", "op-1", 3, ErrQuiesceSuperseded},
|
||||
{"a different operation cannot remove the account", "op-2", 2, ErrQuiesceSuperseded},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
svc, id := newQuiesceAccount(t)
|
||||
_, err := svc.QuiesceAccount(id, "op-1", 2)
|
||||
require.NoError(t, err)
|
||||
|
||||
err = svc.DeleteAccount(id, tc.op, tc.fence)
|
||||
|
||||
_, getErr := svc.GetAccount(id)
|
||||
if tc.wantErr == nil {
|
||||
require.NoError(t, err)
|
||||
require.Error(t, getErr, "the account must be gone")
|
||||
return
|
||||
}
|
||||
require.ErrorIs(t, err, tc.wantErr)
|
||||
require.NoError(t, getErr, "a refused delete must remove nothing")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeleteAccountOfARecordWithoutAnOperationIsRefused(t *testing.T) {
|
||||
t.Parallel()
|
||||
svc, id := newQuiesceAccount(t)
|
||||
writeAccountRecord(t, svc, id, func(a *Account) { a.Status = AccountStatusTerminating })
|
||||
|
||||
require.ErrorIs(t, svc.DeleteAccount(id, "op-1", 1), ErrQuiesceSuperseded)
|
||||
_, err := svc.GetAccount(id)
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
// A record that changes between the read and the delete is not removed on the
|
||||
// strength of the earlier read: the conditional delete refuses and the retry
|
||||
// re-reads, so a takeover in that window wins.
|
||||
func TestDeleteAccountDoesNotRemoveARecordTakenOverMidDelete(t *testing.T) {
|
||||
t.Parallel()
|
||||
svc, id := newQuiesceAccount(t)
|
||||
_, err := svc.QuiesceAccount(id, "op-1", 1)
|
||||
require.NoError(t, err)
|
||||
|
||||
underlying := svc.accountsBucket
|
||||
svc.accountsBucket = &racingDeleteBucket{KeyValue: underlying, compete: func() {
|
||||
_, takeoverErr := svc.QuiesceAccount(id, "op-1", 2)
|
||||
require.NoError(t, takeoverErr)
|
||||
}}
|
||||
|
||||
require.ErrorIs(t, svc.DeleteAccount(id, "op-1", 1), ErrQuiesceSuperseded)
|
||||
svc.accountsBucket = underlying
|
||||
stored, err := svc.GetAccount(id)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, &TerminationOp{OperationID: "op-1", Fence: 2}, stored.TerminationOp)
|
||||
}
|
||||
@@ -12,7 +12,7 @@ import (
|
||||
|
||||
// newStatusReaderFixture starts a real IAM service (which creates and
|
||||
// migrates spinifex-accounts) and a reader attached to the same JetStream.
|
||||
func newStatusReaderFixture(t *testing.T) (*iamdomain.IAMServiceImpl, *iamdomain.AccountStatusReader, jetstream.JetStream) {
|
||||
func newStatusReaderFixture(t *testing.T) (iamdomain.IAMService, *iamdomain.AccountStatusReader, jetstream.JetStream) {
|
||||
t.Helper()
|
||||
_, nc, js := testutil.StartTestJetStream(t)
|
||||
|
||||
@@ -32,7 +32,7 @@ func TestAccountStatusReader_TerminatingIsTrue(t *testing.T) {
|
||||
require.NoError(t, err)
|
||||
account, err := svc.CreateAccount("tenant@example.com")
|
||||
require.NoError(t, err)
|
||||
_, err = svc.QuiesceAccount(account.AccountID, "op-1", 1)
|
||||
_, err = svc.SetAccountStatus(account.AccountID, iamdomain.AccountStatusTerminating)
|
||||
require.NoError(t, err)
|
||||
|
||||
terminating, err := reader.AccountTerminating(t.Context(), account.AccountID)
|
||||
|
||||
@@ -48,7 +48,6 @@ var iamScopes = map[string]resourceScope{
|
||||
"CreateUser": createScope(arn.IAMUser, "UserName"),
|
||||
"GetUser": existingScope(arn.IAMUser, "UserName"),
|
||||
"DeleteUser": existingScope(arn.IAMUser, "UserName"),
|
||||
"UpdateUser": existingScope(arn.IAMUser, "UserName"),
|
||||
"CreateAccessKey": existingScope(arn.IAMUser, "UserName"),
|
||||
"ListAccessKeys": existingScope(arn.IAMUser, "UserName"),
|
||||
"DeleteAccessKey": existingScope(arn.IAMUser, "UserName"),
|
||||
@@ -285,7 +284,7 @@ func lookupResult(t lookupTarget, resource string, err error) (string, error) {
|
||||
}
|
||||
return resource, nil
|
||||
}
|
||||
if code, _ := awserrors.ResolveErrorCode(err); code == awserrors.ErrorIAMNoSuchEntity {
|
||||
if err.Error() == awserrors.ErrorIAMNoSuchEntity {
|
||||
// Authorization widens to "*", so a Deny scoped to the real target
|
||||
// will not fire. Without this line that widening leaves no trace.
|
||||
slog.Warn("IAM authz: target not found, authorizing account-wide",
|
||||
|
||||
@@ -33,7 +33,7 @@ func (s *resolverService) CanonicalResourceARN(_ string, kind arn.IAMResourceTyp
|
||||
return resource, nil
|
||||
}
|
||||
}
|
||||
return "", awserrors.Errorf(awserrors.ErrorIAMNoSuchEntity, "The %s with name %s cannot be found.", kind, name)
|
||||
return "", errors.New(awserrors.ErrorIAMNoSuchEntity)
|
||||
}
|
||||
|
||||
func (s *resolverService) LookupAccessKey(_ string) (*iamdomain.AccessKey, error) {
|
||||
|
||||
@@ -96,9 +96,6 @@ var actions = map[string]action{
|
||||
"DeleteUser": handler(func(accountID string, input *iam.DeleteUserInput, d Deps) (any, error) {
|
||||
return DeleteUser(accountID, input, d.IAM)
|
||||
}),
|
||||
"UpdateUser": handler(func(accountID string, input *iam.UpdateUserInput, d Deps) (any, error) {
|
||||
return UpdateUser(accountID, input, d.IAM)
|
||||
}),
|
||||
"CreateAccessKey": handler(func(accountID string, input *iam.CreateAccessKeyInput, d Deps) (any, error) {
|
||||
return CreateAccessKey(accountID, input, d.IAM)
|
||||
}),
|
||||
|
||||
@@ -26,7 +26,6 @@ type regService struct {
|
||||
|
||||
getUserName string
|
||||
getUserCalls int
|
||||
updateUser *iam.UpdateUserInput
|
||||
addRoleCalls int
|
||||
canonicalARN string
|
||||
canonicalErr error
|
||||
@@ -42,11 +41,6 @@ func (s *regService) GetUser(_ string, in *iam.GetUserInput) (*iam.GetUserOutput
|
||||
return &iam.GetUserOutput{}, nil
|
||||
}
|
||||
|
||||
func (s *regService) UpdateUser(_ string, in *iam.UpdateUserInput) (*iam.UpdateUserOutput, error) {
|
||||
s.updateUser = in
|
||||
return &iam.UpdateUserOutput{}, nil
|
||||
}
|
||||
|
||||
func (s *regService) AddRoleToInstanceProfile(_ string, _ *iam.AddRoleToInstanceProfileInput) (*iam.AddRoleToInstanceProfileOutput, error) {
|
||||
s.addRoleCalls++
|
||||
return &iam.AddRoleToInstanceProfileOutput{}, nil
|
||||
@@ -274,23 +268,6 @@ func TestRegistration_AddRoleToInstanceProfilePassRole(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
func TestRegistration_UpdateUser(t *testing.T) {
|
||||
svc := ®Service{}
|
||||
w, calls, err := invoke(t, Deps{IAM: svc}, testAccountID, dispatch.Actor{}, nil, url.Values{
|
||||
"Action": {"UpdateUser"}, "UserName": {"alice"}, "NewUserName": {"alicia"}, "NewPath": {"/eng/"},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.Len(t, calls, 1)
|
||||
assert.Equal(t, []string{"arn:aws:iam::" + testAccountID + ":user/alice"}, calls[0].resources)
|
||||
assert.Contains(t, w.Body.String(), "UpdateUserResponse")
|
||||
require.NotNil(t, svc.updateUser)
|
||||
assert.Equal(t, "alicia", *svc.updateUser.NewUserName)
|
||||
assert.Equal(t, "/eng/", *svc.updateUser.NewPath)
|
||||
|
||||
_, _, err = invoke(t, Deps{IAM: svc}, testAccountID, dispatch.Actor{}, nil, url.Values{"Action": {"UpdateUser"}})
|
||||
assert.True(t, awserrors.IsErrorCode(err, awserrors.ErrorMissingParameter), "got %v", err)
|
||||
}
|
||||
|
||||
func TestActionNames_SortedAndInventory(t *testing.T) {
|
||||
names := ActionNames()
|
||||
require.NotEmpty(t, names)
|
||||
|
||||
@@ -78,15 +78,6 @@ func DeleteUser(accountID string, input *iam.DeleteUserInput, svc iamdomain.IAMS
|
||||
return svc.DeleteUser(accountID, input)
|
||||
}
|
||||
|
||||
// UpdateUser implements the IAM UpdateUser action, renaming a user or changing its path. It
|
||||
// returns MissingParameter when UserName is absent, then calls the IAM service for accountID.
|
||||
func UpdateUser(accountID string, input *iam.UpdateUserInput, svc iamdomain.IAMService) (*iam.UpdateUserOutput, error) {
|
||||
if input.UserName == nil || *input.UserName == "" {
|
||||
return nil, errors.New(awserrors.ErrorMissingParameter)
|
||||
}
|
||||
return svc.UpdateUser(accountID, input)
|
||||
}
|
||||
|
||||
// PutUserPolicy implements the IAM PutUserPolicy action, adding or replacing a user inline
|
||||
// policy. It returns MissingParameter when UserName, PolicyName or PolicyDocument is absent, then
|
||||
// calls the IAM service for accountID.
|
||||
|
||||
@@ -1,186 +0,0 @@
|
||||
package iam
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/aws/aws-sdk-go/aws"
|
||||
"github.com/aws/aws-sdk-go/service/iam"
|
||||
"github.com/mulgadc/spinifex/spinifex/foundation/aws/errors"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// TestEntityErrorMessages pins the per-case text AWS returns for NoSuchEntity,
|
||||
// EntityAlreadyExists, DeleteConflict and LimitExceeded, as recorded against
|
||||
// real IAM, so a client sees which entity is missing or in conflict.
|
||||
func TestEntityErrorMessages(t *testing.T) {
|
||||
svc := setupTestIAMService(t)
|
||||
acct := testAccountID
|
||||
|
||||
createTestUser(t, svc, "u1")
|
||||
createTestRole(t, svc, "r1")
|
||||
createTestRole(t, svc, "r2")
|
||||
createTestGroup(t, svc, "g1")
|
||||
createTestInstanceProfile(t, svc, "ip1")
|
||||
policy := createTestPolicy(t, svc, "p1")
|
||||
policyARN := aws.StringValue(policy.Arn)
|
||||
missingPolicyARN := "arn:aws:iam::" + acct + ":policy/p1-nope"
|
||||
_, err := svc.CreateOpenIDConnectProvider(acct, &iam.CreateOpenIDConnectProviderInput{
|
||||
Url: aws.String(testIssuer), ClientIDList: aws.StringSlice([]string{"sts.amazonaws.com"}),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
missingOIDCARN := "arn:aws:iam::" + acct + ":oidc-provider/oidc.nope.example.com"
|
||||
|
||||
// Conflict fixtures: a user holding a key, a role with a managed policy, a
|
||||
// role and profile linked to each other, and a group with a member.
|
||||
createTestUser(t, svc, "u-key")
|
||||
_, err = svc.CreateAccessKey(acct, &iam.CreateAccessKeyInput{UserName: aws.String("u-key")})
|
||||
require.NoError(t, err)
|
||||
createTestRole(t, svc, "r-pol")
|
||||
_, err = svc.AttachRolePolicy(acct, &iam.AttachRolePolicyInput{RoleName: aws.String("r-pol"), PolicyArn: aws.String(policyARN)})
|
||||
require.NoError(t, err)
|
||||
createTestRole(t, svc, "r-ip")
|
||||
createTestInstanceProfile(t, svc, "ip-r")
|
||||
_, err = svc.AddRoleToInstanceProfile(acct, &iam.AddRoleToInstanceProfileInput{InstanceProfileName: aws.String("ip-r"), RoleName: aws.String("r-ip")})
|
||||
require.NoError(t, err)
|
||||
createTestGroup(t, svc, "g-mem")
|
||||
_, err = svc.AddUserToGroup(acct, &iam.AddUserToGroupInput{GroupName: aws.String("g-mem"), UserName: aws.String("u1")})
|
||||
require.NoError(t, err)
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
call func() error
|
||||
wantCode string
|
||||
wantMsg string
|
||||
}{
|
||||
{"GetUser missing", func() error {
|
||||
_, err := svc.GetUser(acct, &iam.GetUserInput{UserName: aws.String("nope")})
|
||||
return err
|
||||
}, awserrors.ErrorIAMNoSuchEntity, "The user with name nope cannot be found."},
|
||||
{"TagUser missing", func() error {
|
||||
_, err := svc.TagUser(acct, &iam.TagUserInput{UserName: aws.String("nope"), Tags: []*iam.Tag{{Key: aws.String("k"), Value: aws.String("v")}}})
|
||||
return err
|
||||
}, awserrors.ErrorIAMNoSuchEntity, "The user with name nope cannot be found."},
|
||||
{"AddUserToGroup missing user", func() error {
|
||||
_, err := svc.AddUserToGroup(acct, &iam.AddUserToGroupInput{GroupName: aws.String("g1"), UserName: aws.String("nope")})
|
||||
return err
|
||||
}, awserrors.ErrorIAMNoSuchEntity, "The user with name nope cannot be found."},
|
||||
{"GetRole missing", func() error {
|
||||
_, err := svc.GetRole(acct, &iam.GetRoleInput{RoleName: aws.String("nope")})
|
||||
return err
|
||||
}, awserrors.ErrorIAMNoSuchEntity, "The role with name nope cannot be found."},
|
||||
{"TagRole missing", func() error {
|
||||
_, err := svc.TagRole(acct, &iam.TagRoleInput{RoleName: aws.String("nope"), Tags: []*iam.Tag{{Key: aws.String("k"), Value: aws.String("v")}}})
|
||||
return err
|
||||
}, awserrors.ErrorIAMNoSuchEntity, "The role with name nope cannot be found."},
|
||||
{"DeleteGroup missing", func() error {
|
||||
_, err := svc.DeleteGroup(acct, &iam.DeleteGroupInput{GroupName: aws.String("nope")})
|
||||
return err
|
||||
}, awserrors.ErrorIAMNoSuchEntity, "The group with name nope cannot be found."},
|
||||
{"GetPolicy missing", func() error {
|
||||
_, err := svc.GetPolicy(acct, &iam.GetPolicyInput{PolicyArn: aws.String(missingPolicyARN)})
|
||||
return err
|
||||
}, awserrors.ErrorIAMNoSuchEntity, "Policy " + missingPolicyARN + " was not found."},
|
||||
{"DeletePolicy missing", func() error {
|
||||
_, err := svc.DeletePolicy(acct, &iam.DeletePolicyInput{PolicyArn: aws.String(missingPolicyARN)})
|
||||
return err
|
||||
}, awserrors.ErrorIAMNoSuchEntity, "Policy " + missingPolicyARN + " was not found."},
|
||||
{"DetachUserPolicy not attached", func() error {
|
||||
_, err := svc.DetachUserPolicy(acct, &iam.DetachUserPolicyInput{UserName: aws.String("u1"), PolicyArn: aws.String(policyARN)})
|
||||
return err
|
||||
}, awserrors.ErrorIAMNoSuchEntity, "Policy " + policyARN + " was not found."},
|
||||
{"AttachRolePolicy missing policy", func() error {
|
||||
_, err := svc.AttachRolePolicy(acct, &iam.AttachRolePolicyInput{RoleName: aws.String("r1"), PolicyArn: aws.String(missingPolicyARN)})
|
||||
return err
|
||||
}, awserrors.ErrorIAMNoSuchEntity, "Policy " + missingPolicyARN + " does not exist or is not attachable."},
|
||||
{"GetInstanceProfile missing", func() error {
|
||||
_, err := svc.GetInstanceProfile(acct, &iam.GetInstanceProfileInput{InstanceProfileName: aws.String("nope")})
|
||||
return err
|
||||
}, awserrors.ErrorIAMNoSuchEntity, "Instance Profile nope cannot be found."},
|
||||
{"RemoveRoleFromInstanceProfile not in profile", func() error {
|
||||
_, err := svc.RemoveRoleFromInstanceProfile(acct, &iam.RemoveRoleFromInstanceProfileInput{InstanceProfileName: aws.String("ip1"), RoleName: aws.String("r2")})
|
||||
return err
|
||||
}, awserrors.ErrorIAMNoSuchEntity, "Role r2 in Instance Profile ip1 cannot be found."},
|
||||
{"GetOpenIDConnectProvider missing", func() error {
|
||||
_, err := svc.GetOpenIDConnectProvider(acct, &iam.GetOpenIDConnectProviderInput{OpenIDConnectProviderArn: aws.String(missingOIDCARN)})
|
||||
return err
|
||||
}, awserrors.ErrorIAMNoSuchEntity, "OpenIDConnect Provider not found for arn " + missingOIDCARN},
|
||||
{"GetUserPolicy missing", func() error {
|
||||
_, err := svc.GetUserPolicy(acct, &iam.GetUserPolicyInput{UserName: aws.String("u1"), PolicyName: aws.String("inl")})
|
||||
return err
|
||||
}, awserrors.ErrorIAMNoSuchEntity, "The user policy with name inl cannot be found."},
|
||||
{"DeleteRolePolicy missing", func() error {
|
||||
_, err := svc.DeleteRolePolicy(acct, &iam.DeleteRolePolicyInput{RoleName: aws.String("r1"), PolicyName: aws.String("rinl")})
|
||||
return err
|
||||
}, awserrors.ErrorIAMNoSuchEntity, "The role policy with name rinl cannot be found."},
|
||||
{"DeleteAccessKey missing", func() error {
|
||||
_, err := svc.DeleteAccessKey(acct, &iam.DeleteAccessKeyInput{UserName: aws.String("u1"), AccessKeyId: aws.String("AKIANOPE")})
|
||||
return err
|
||||
}, awserrors.ErrorIAMNoSuchEntity, "The Access Key with id AKIANOPE cannot be found."},
|
||||
|
||||
{"CreateUser duplicate", func() error {
|
||||
_, err := svc.CreateUser(acct, &iam.CreateUserInput{UserName: aws.String("u1")})
|
||||
return err
|
||||
}, awserrors.ErrorIAMEntityAlreadyExists, "User with name u1 already exists."},
|
||||
{"CreateRole duplicate", func() error {
|
||||
_, err := svc.CreateRole(acct, &iam.CreateRoleInput{RoleName: aws.String("r1"), AssumeRolePolicyDocument: aws.String(validTrustPolicy())})
|
||||
return err
|
||||
}, awserrors.ErrorIAMEntityAlreadyExists, "Role with name r1 already exists."},
|
||||
{"CreateGroup duplicate", func() error {
|
||||
_, err := svc.CreateGroup(acct, &iam.CreateGroupInput{GroupName: aws.String("g1")})
|
||||
return err
|
||||
}, awserrors.ErrorIAMEntityAlreadyExists, "Group with name g1 already exists."},
|
||||
{"CreatePolicy duplicate", func() error {
|
||||
_, err := svc.CreatePolicy(acct, &iam.CreatePolicyInput{PolicyName: aws.String("p1"), PolicyDocument: aws.String(validPolicyDocument())})
|
||||
return err
|
||||
}, awserrors.ErrorIAMEntityAlreadyExists, "A policy called p1 already exists. Duplicate names are not allowed."},
|
||||
{"CreateInstanceProfile duplicate", func() error {
|
||||
_, err := svc.CreateInstanceProfile(acct, &iam.CreateInstanceProfileInput{InstanceProfileName: aws.String("ip1")})
|
||||
return err
|
||||
}, awserrors.ErrorIAMEntityAlreadyExists, "Instance Profile ip1 already exists."},
|
||||
{"CreateOpenIDConnectProvider duplicate", func() error {
|
||||
_, err := svc.CreateOpenIDConnectProvider(acct, &iam.CreateOpenIDConnectProviderInput{
|
||||
Url: aws.String(testIssuer), ClientIDList: aws.StringSlice([]string{"sts.amazonaws.com"}),
|
||||
})
|
||||
return err
|
||||
}, awserrors.ErrorIAMEntityAlreadyExists, "Provider with url " + testIssuer + " already exists."},
|
||||
|
||||
{"DeleteUser with access key", func() error {
|
||||
_, err := svc.DeleteUser(acct, &iam.DeleteUserInput{UserName: aws.String("u-key")})
|
||||
return err
|
||||
}, awserrors.ErrorIAMDeleteConflict, "Cannot delete entity, must delete access keys first."},
|
||||
{"DeleteRole with attached policy", func() error {
|
||||
_, err := svc.DeleteRole(acct, &iam.DeleteRoleInput{RoleName: aws.String("r-pol")})
|
||||
return err
|
||||
}, awserrors.ErrorIAMDeleteConflict, "Cannot delete entity, must detach all policies first."},
|
||||
{"DeleteRole in instance profile", func() error {
|
||||
_, err := svc.DeleteRole(acct, &iam.DeleteRoleInput{RoleName: aws.String("r-ip")})
|
||||
return err
|
||||
}, awserrors.ErrorIAMDeleteConflict, "Cannot delete entity, must remove roles from instance profile first."},
|
||||
{"DeleteInstanceProfile with role", func() error {
|
||||
_, err := svc.DeleteInstanceProfile(acct, &iam.DeleteInstanceProfileInput{InstanceProfileName: aws.String("ip-r")})
|
||||
return err
|
||||
}, awserrors.ErrorIAMDeleteConflict, "Cannot delete entity, must remove roles from instance profile first."},
|
||||
{"DeleteGroup with member", func() error {
|
||||
_, err := svc.DeleteGroup(acct, &iam.DeleteGroupInput{GroupName: aws.String("g-mem")})
|
||||
return err
|
||||
}, awserrors.ErrorIAMDeleteConflict, "Cannot delete entity, must remove users from group first."},
|
||||
{"DeletePolicy attached", func() error {
|
||||
_, err := svc.DeletePolicy(acct, &iam.DeletePolicyInput{PolicyArn: aws.String(policyARN)})
|
||||
return err
|
||||
}, awserrors.ErrorIAMDeleteConflict, "Cannot delete a policy attached to entities."},
|
||||
|
||||
{"AddRoleToInstanceProfile second role", func() error {
|
||||
_, err := svc.AddRoleToInstanceProfile(acct, &iam.AddRoleToInstanceProfileInput{InstanceProfileName: aws.String("ip-r"), RoleName: aws.String("r2")})
|
||||
return err
|
||||
}, awserrors.ErrorIAMLimitExceeded, "Cannot exceed quota for InstanceSessionsPerInstanceProfile: 1"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
code, msg, ok := awserrors.ResolveErrorDetail(tc.call())
|
||||
require.True(t, ok, "error carries no AWS code")
|
||||
assert.Equal(t, tc.wantCode, code)
|
||||
assert.Equal(t, tc.wantMsg, msg)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -65,7 +65,7 @@ func (s *IAMServiceImpl) CreateGroup(accountID string, input *iam.CreateGroupInp
|
||||
kvKey := accountID + "." + groupName
|
||||
if _, err := s.groupsBucket.Create(ctx, kvKey, data); err != nil {
|
||||
if errors.Is(err, jetstream.ErrKeyExists) {
|
||||
return nil, awserrors.Errorf(awserrors.ErrorIAMEntityAlreadyExists, "Group with name %s already exists.", groupName)
|
||||
return nil, errors.New(awserrors.ErrorIAMEntityAlreadyExists)
|
||||
}
|
||||
return nil, fmt.Errorf("store group: %w", err)
|
||||
}
|
||||
@@ -175,11 +175,11 @@ func (s *IAMServiceImpl) DeleteGroup(accountID string, input *iam.DeleteGroupInp
|
||||
}
|
||||
|
||||
if len(group.AttachedPolicies) > 0 {
|
||||
return nil, errDetachPoliciesFirst
|
||||
return nil, errors.New(awserrors.ErrorIAMDeleteConflict)
|
||||
}
|
||||
|
||||
if len(group.InlinePolicies) > 0 {
|
||||
return nil, errDeletePoliciesFirst
|
||||
return nil, errors.New(awserrors.ErrorIAMDeleteConflict)
|
||||
}
|
||||
|
||||
members, err := s.findGroupMembers(ctx, accountID, groupName)
|
||||
@@ -187,7 +187,7 @@ func (s *IAMServiceImpl) DeleteGroup(accountID string, input *iam.DeleteGroupInp
|
||||
return nil, fmt.Errorf("check group members: %w", err)
|
||||
}
|
||||
if len(members) > 0 {
|
||||
return nil, awserrors.Errorf(awserrors.ErrorIAMDeleteConflict, "Cannot delete entity, must remove users from group first.")
|
||||
return nil, errors.New(awserrors.ErrorIAMDeleteConflict)
|
||||
}
|
||||
|
||||
if err := s.groupsBucket.Delete(ctx, accountID+"."+groupName); err != nil {
|
||||
@@ -234,28 +234,19 @@ func (s *IAMServiceImpl) RemoveUserFromGroup(accountID string, input *iam.Remove
|
||||
groupName := *input.GroupName
|
||||
userName := *input.UserName
|
||||
|
||||
// Remove the membership reference without fetching the group, so a dangling
|
||||
// pointer to an already-deleted group is still cleanable.
|
||||
removed := false
|
||||
// Operate purely on the membership reference so a dangling pointer to an
|
||||
// already-deleted group is still cleanable; never fetch the group here.
|
||||
err := s.updateUserCAS(ctx, accountID, userName, func(u *User) (bool, error) {
|
||||
idx := slices.Index(u.Groups, groupName)
|
||||
if idx < 0 {
|
||||
return false, nil
|
||||
return false, errors.New(awserrors.ErrorIAMNoSuchEntity)
|
||||
}
|
||||
u.Groups = slices.Delete(u.Groups, idx, idx+1)
|
||||
removed = true
|
||||
return true, nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// AWS answers success for a non-member when the group exists.
|
||||
if !removed {
|
||||
if _, err := s.getGroup(ctx, accountID, groupName); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &iam.RemoveUserFromGroupOutput{}, nil
|
||||
}
|
||||
|
||||
slog.Info("IAM user removed from group", "accountID", accountID, "userName", userName, "groupName", groupName)
|
||||
return &iam.RemoveUserFromGroupOutput{}, nil
|
||||
@@ -274,7 +265,7 @@ func (s *IAMServiceImpl) ListGroupsForUser(accountID string, input *iam.ListGrou
|
||||
for _, name := range user.Groups {
|
||||
group, err := s.getGroup(ctx, accountID, name)
|
||||
if err != nil {
|
||||
if code, _ := awserrors.ResolveErrorCode(err); code == awserrors.ErrorIAMNoSuchEntity {
|
||||
if err.Error() == awserrors.ErrorIAMNoSuchEntity {
|
||||
// A dangling pointer to a vanished group is inert; skip it.
|
||||
// Mirrors GetUserPolicies — transient/corrupt errors fail closed.
|
||||
slog.Warn("ListGroupsForUser: member references missing group; skipping",
|
||||
@@ -303,7 +294,7 @@ func (s *IAMServiceImpl) AttachGroupPolicy(accountID string, input *iam.AttachGr
|
||||
|
||||
// AWS-managed ARNs are stored opaquely (like roles); customer-managed ARNs must exist.
|
||||
if !iamarn.IsAWSManagedPolicyARN(policyARN) {
|
||||
if err := s.attachablePolicy(ctx, accountID, policyARN); err != nil {
|
||||
if _, err := s.getPolicyByARN(ctx, accountID, policyARN); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
@@ -331,7 +322,7 @@ func (s *IAMServiceImpl) DetachGroupPolicy(accountID string, input *iam.DetachGr
|
||||
err := s.updateGroupCAS(ctx, accountID, groupName, func(g *Group) (bool, error) {
|
||||
idx := slices.Index(g.AttachedPolicies, policyARN)
|
||||
if idx < 0 {
|
||||
return false, policyNotFound(policyARN)
|
||||
return false, errors.New(awserrors.ErrorIAMNoSuchEntity)
|
||||
}
|
||||
g.AttachedPolicies = slices.Delete(g.AttachedPolicies, idx, idx+1)
|
||||
return true, nil
|
||||
@@ -417,7 +408,7 @@ func (s *IAMServiceImpl) GetGroupPolicy(accountID string, input *iam.GetGroupPol
|
||||
|
||||
doc, ok := group.InlinePolicies[policyName]
|
||||
if !ok {
|
||||
return nil, noSuchEntity("group policy", policyName)
|
||||
return nil, errors.New(awserrors.ErrorIAMNoSuchEntity)
|
||||
}
|
||||
|
||||
return &iam.GetGroupPolicyOutput{
|
||||
@@ -436,7 +427,7 @@ func (s *IAMServiceImpl) DeleteGroupPolicy(accountID string, input *iam.DeleteGr
|
||||
|
||||
err := s.updateGroupCAS(ctx, accountID, groupName, func(g *Group) (bool, error) {
|
||||
if _, ok := g.InlinePolicies[policyName]; !ok {
|
||||
return false, noSuchEntity("group policy", policyName)
|
||||
return false, errors.New(awserrors.ErrorIAMNoSuchEntity)
|
||||
}
|
||||
delete(g.InlinePolicies, policyName)
|
||||
return true, nil
|
||||
@@ -479,7 +470,7 @@ func (s *IAMServiceImpl) getGroup(ctx context.Context, accountID, groupName stri
|
||||
entry, err := s.groupsBucket.Get(ctx, accountID+"."+groupName)
|
||||
if err != nil {
|
||||
if errors.Is(err, jetstream.ErrKeyNotFound) {
|
||||
return nil, noSuchEntity("group", groupName)
|
||||
return nil, errors.New(awserrors.ErrorIAMNoSuchEntity)
|
||||
}
|
||||
return nil, fmt.Errorf("get group: %w", err)
|
||||
}
|
||||
|
||||
@@ -409,18 +409,6 @@ func TestRemoveUserFromGroup_NotMember(t *testing.T) {
|
||||
GroupName: aws.String("team"),
|
||||
UserName: aws.String("erin"),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
func TestRemoveUserFromGroup_GroupNotFound(t *testing.T) {
|
||||
t.Parallel()
|
||||
svc := setupTestIAMService(t)
|
||||
createTestUser(t, svc, "erin")
|
||||
|
||||
_, err := svc.RemoveUserFromGroup(testAccountID, &iam.RemoveUserFromGroupInput{
|
||||
GroupName: aws.String("ghost"),
|
||||
UserName: aws.String("erin"),
|
||||
})
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), awserrors.ErrorIAMNoSuchEntity)
|
||||
}
|
||||
|
||||
@@ -66,7 +66,7 @@ func (s *IAMServiceImpl) CreateInstanceProfile(accountID string, input *iam.Crea
|
||||
|
||||
if _, err := s.instanceProfilesBucket.Create(ctx, accountID+"."+profileName, data); err != nil {
|
||||
if errors.Is(err, jetstream.ErrKeyExists) {
|
||||
return nil, awserrors.Errorf(awserrors.ErrorIAMEntityAlreadyExists, "Instance Profile %s already exists.", profileName)
|
||||
return nil, errors.New(awserrors.ErrorIAMEntityAlreadyExists)
|
||||
}
|
||||
return nil, fmt.Errorf("store instance profile: %w", err)
|
||||
}
|
||||
@@ -172,7 +172,7 @@ func (s *IAMServiceImpl) DeleteInstanceProfile(accountID string, input *iam.Dele
|
||||
_, err := kvutil.DeleteIf(ctx, s.instanceProfilesBucket, accountID+"."+profileName, s.instanceProfileCASConfig(accountID, profileName),
|
||||
func(p *InstanceProfile) error {
|
||||
if p.RoleName != "" {
|
||||
return awserrors.Errorf(awserrors.ErrorIAMDeleteConflict, "Cannot delete entity, must remove roles from instance profile first.")
|
||||
return errors.New(awserrors.ErrorIAMDeleteConflict)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
@@ -195,7 +195,7 @@ func (s *IAMServiceImpl) AddRoleToInstanceProfile(accountID string, input *iam.A
|
||||
|
||||
err := s.updateInstanceProfileCAS(ctx, accountID, profileName, func(p *InstanceProfile) (bool, error) {
|
||||
if p.RoleName != "" {
|
||||
return false, awserrors.Errorf(awserrors.ErrorIAMLimitExceeded, "Cannot exceed quota for InstanceSessionsPerInstanceProfile: 1")
|
||||
return false, errors.New(awserrors.ErrorIAMLimitExceeded)
|
||||
}
|
||||
p.RoleName = roleName
|
||||
return true, nil
|
||||
@@ -216,7 +216,7 @@ func (s *IAMServiceImpl) RemoveRoleFromInstanceProfile(accountID string, input *
|
||||
|
||||
err := s.updateInstanceProfileCAS(ctx, accountID, profileName, func(p *InstanceProfile) (bool, error) {
|
||||
if p.RoleName == "" || p.RoleName != roleName {
|
||||
return false, awserrors.Errorf(awserrors.ErrorIAMNoSuchEntity, "Role %s in Instance Profile %s cannot be found.", roleName, profileName)
|
||||
return false, errors.New(awserrors.ErrorIAMNoSuchEntity)
|
||||
}
|
||||
p.RoleName = ""
|
||||
return true, nil
|
||||
@@ -357,16 +357,11 @@ func (s *IAMServiceImpl) ListInstanceProfileTags(accountID string, input *iam.Li
|
||||
}, nil
|
||||
}
|
||||
|
||||
// instanceProfileNotFound is NoSuchEntity with AWS's message for a missing instance profile.
|
||||
func instanceProfileNotFound(profileName string) error {
|
||||
return awserrors.Errorf(awserrors.ErrorIAMNoSuchEntity, "Instance Profile %s cannot be found.", profileName)
|
||||
}
|
||||
|
||||
func (s *IAMServiceImpl) getInstanceProfile(ctx context.Context, accountID, profileName string) (*InstanceProfile, error) {
|
||||
entry, err := s.instanceProfilesBucket.Get(ctx, accountID+"."+profileName)
|
||||
if err != nil {
|
||||
if errors.Is(err, jetstream.ErrKeyNotFound) {
|
||||
return nil, instanceProfileNotFound(profileName)
|
||||
return nil, errors.New(awserrors.ErrorIAMNoSuchEntity)
|
||||
}
|
||||
return nil, fmt.Errorf("get instance profile: %w", err)
|
||||
}
|
||||
@@ -390,7 +385,7 @@ func (s *IAMServiceImpl) updateInstanceProfileCAS(ctx context.Context, accountID
|
||||
func (s *IAMServiceImpl) instanceProfileCASConfig(accountID, profileName string) kvutil.CASConfig {
|
||||
return kvutil.CASConfig{
|
||||
Attempts: instanceProfileCASMaxRetries,
|
||||
NotFound: instanceProfileNotFound(profileName),
|
||||
NotFound: errors.New(awserrors.ErrorIAMNoSuchEntity),
|
||||
Exhausted: func(string, int) error {
|
||||
slog.Warn("IAM instance profile CAS exhausted retries",
|
||||
"accountID", accountID, "instanceProfileName", profileName, "attempts", instanceProfileCASMaxRetries)
|
||||
|
||||
@@ -778,7 +778,7 @@ func TestResolveInstanceProfile_NonCanonicalARNRejected(t *testing.T) {
|
||||
_, err := svc.ResolveInstanceProfile(testAccountID, arn)
|
||||
require.Error(t, err, arn)
|
||||
// The EC2 gateway maps this to InvalidIamInstanceProfile.NotFound by exact match.
|
||||
assert.Equal(t, awserrors.ErrorIAMNoSuchEntity, awserrors.ValidErrorCodeFromError(err), arn)
|
||||
assert.Equal(t, awserrors.ErrorIAMNoSuchEntity, err.Error(), arn)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -90,7 +90,7 @@ func (e *SystemInstanceRoleEnsurer) GetRole(_ string, in *iam.GetRoleInput) (*ia
|
||||
|
||||
r, ok := e.Roles[aws.StringValue(in.RoleName)]
|
||||
if !ok {
|
||||
return nil, awserrors.Errorf(awserrors.ErrorIAMNoSuchEntity, "The role with name %s cannot be found.", aws.StringValue(in.RoleName))
|
||||
return nil, errors.New(awserrors.ErrorIAMNoSuchEntity)
|
||||
}
|
||||
return &iam.GetRoleOutput{Role: copyRole(r)}, nil
|
||||
}
|
||||
@@ -104,7 +104,7 @@ func (e *SystemInstanceRoleEnsurer) CreateRole(accountID string, in *iam.CreateR
|
||||
e.LastRoleAcct = accountID
|
||||
name := aws.StringValue(in.RoleName)
|
||||
if _, ok := e.Roles[name]; ok {
|
||||
return nil, awserrors.Errorf(awserrors.ErrorIAMEntityAlreadyExists, "Role with name %s already exists.", name)
|
||||
return nil, errors.New(awserrors.ErrorIAMEntityAlreadyExists)
|
||||
}
|
||||
r := &iam.Role{
|
||||
RoleName: aws.String(name),
|
||||
@@ -132,7 +132,7 @@ func (e *SystemInstanceRoleEnsurer) GetInstanceProfile(_ string, in *iam.GetInst
|
||||
|
||||
p, ok := e.Profiles[aws.StringValue(in.InstanceProfileName)]
|
||||
if !ok {
|
||||
return nil, awserrors.Errorf(awserrors.ErrorIAMNoSuchEntity, "Instance Profile %s cannot be found.", aws.StringValue(in.InstanceProfileName))
|
||||
return nil, errors.New(awserrors.ErrorIAMNoSuchEntity)
|
||||
}
|
||||
return &iam.GetInstanceProfileOutput{InstanceProfile: copyProfile(p)}, nil
|
||||
}
|
||||
@@ -148,7 +148,7 @@ func (e *SystemInstanceRoleEnsurer) CreateInstanceProfile(accountID string, in *
|
||||
}
|
||||
name := aws.StringValue(in.InstanceProfileName)
|
||||
if _, ok := e.Profiles[name]; ok {
|
||||
return nil, awserrors.Errorf(awserrors.ErrorIAMEntityAlreadyExists, "Instance Profile %s already exists.", name)
|
||||
return nil, errors.New(awserrors.ErrorIAMEntityAlreadyExists)
|
||||
}
|
||||
arn := "arn:aws:iam::" + accountID + ":instance-profile/" + name
|
||||
if e.EmptyInstanceProfileARN {
|
||||
@@ -176,7 +176,7 @@ func (e *SystemInstanceRoleEnsurer) AddRoleToInstanceProfile(_ string, in *iam.A
|
||||
return nil, errors.New(awserrors.ErrorIAMNoSuchEntity)
|
||||
}
|
||||
if len(p.Roles) > 0 {
|
||||
return nil, awserrors.Errorf(awserrors.ErrorIAMLimitExceeded, "Cannot exceed quota for InstanceSessionsPerInstanceProfile: 1")
|
||||
return nil, errors.New(awserrors.ErrorIAMLimitExceeded)
|
||||
}
|
||||
p.Roles = []*iam.Role{{RoleName: in.RoleName}}
|
||||
return &iam.AddRoleToInstanceProfileOutput{}, nil
|
||||
|
||||
@@ -13,7 +13,7 @@ import (
|
||||
func TestGetRole_MissingReturnsNoSuchEntity(t *testing.T) {
|
||||
e := mock.New()
|
||||
_, err := e.GetRole("acct", &iam.GetRoleInput{RoleName: aws.String("r1")})
|
||||
if err == nil || awserrors.ValidErrorCodeFromError(err) != awserrors.ErrorIAMNoSuchEntity {
|
||||
if err == nil || err.Error() != awserrors.ErrorIAMNoSuchEntity {
|
||||
t.Fatalf("err = %v, want ErrorIAMNoSuchEntity", err)
|
||||
}
|
||||
}
|
||||
@@ -24,7 +24,7 @@ func TestCreateRole_DuplicateReturnsEntityAlreadyExists(t *testing.T) {
|
||||
if _, err := e.CreateRole("acct", in); err != nil {
|
||||
t.Fatalf("first CreateRole: %v", err)
|
||||
}
|
||||
if _, err := e.CreateRole("acct", in); err == nil || awserrors.ValidErrorCodeFromError(err) != awserrors.ErrorIAMEntityAlreadyExists {
|
||||
if _, err := e.CreateRole("acct", in); err == nil || err.Error() != awserrors.ErrorIAMEntityAlreadyExists {
|
||||
t.Fatalf("second CreateRole err = %v, want EntityAlreadyExists", err)
|
||||
}
|
||||
if e.CreateRoleCalls != 2 {
|
||||
@@ -48,7 +48,7 @@ func TestAddRoleToInstanceProfile_SecondAttachIsLimitExceeded(t *testing.T) {
|
||||
if _, err := e.AddRoleToInstanceProfile("acct", in); err != nil {
|
||||
t.Fatalf("first AddRoleToInstanceProfile: %v", err)
|
||||
}
|
||||
if _, err := e.AddRoleToInstanceProfile("acct", in); err == nil || awserrors.ValidErrorCodeFromError(err) != awserrors.ErrorIAMLimitExceeded {
|
||||
if _, err := e.AddRoleToInstanceProfile("acct", in); err == nil || err.Error() != awserrors.ErrorIAMLimitExceeded {
|
||||
t.Fatalf("second AddRoleToInstanceProfile err = %v, want LimitExceeded", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -178,7 +178,7 @@ func (s *IAMServiceImpl) CreateOpenIDConnectProvider(accountID string, input *ia
|
||||
|
||||
if _, err := kv.Create(ctx, OIDCProviderKey(issuer), data); err != nil {
|
||||
if errors.Is(err, jetstream.ErrKeyExists) {
|
||||
return nil, awserrors.Errorf(awserrors.ErrorIAMEntityAlreadyExists, "Provider with url %s already exists.", issuer)
|
||||
return nil, errors.New(awserrors.ErrorIAMEntityAlreadyExists)
|
||||
}
|
||||
return nil, fmt.Errorf("store OIDC provider: %w", err)
|
||||
}
|
||||
@@ -268,7 +268,7 @@ func (s *IAMServiceImpl) DeleteOpenIDConnectProvider(accountID string, input *ia
|
||||
kv, err := s.js.KeyValue(ctx, IAMAccountBucketName(accountID))
|
||||
if err != nil {
|
||||
if errors.Is(err, jetstream.ErrBucketNotFound) {
|
||||
return nil, oidcProviderNotFound(arn)
|
||||
return nil, errors.New(awserrors.ErrorIAMNoSuchEntity)
|
||||
}
|
||||
return nil, fmt.Errorf("open IAM account bucket: %w", err)
|
||||
}
|
||||
@@ -276,7 +276,7 @@ func (s *IAMServiceImpl) DeleteOpenIDConnectProvider(accountID string, input *ia
|
||||
key := OIDCProviderKey(issuer)
|
||||
if _, err := kv.Get(ctx, key); err != nil {
|
||||
if errors.Is(err, jetstream.ErrKeyNotFound) {
|
||||
return nil, oidcProviderNotFound(arn)
|
||||
return nil, errors.New(awserrors.ErrorIAMNoSuchEntity)
|
||||
}
|
||||
return nil, fmt.Errorf("get OIDC provider: %w", err)
|
||||
}
|
||||
@@ -356,7 +356,7 @@ func (s *IAMServiceImpl) updateOIDCProviderCAS(ctx context.Context, accountID, a
|
||||
kv, err := s.js.KeyValue(ctx, IAMAccountBucketName(accountID))
|
||||
if err != nil {
|
||||
if errors.Is(err, jetstream.ErrBucketNotFound) {
|
||||
return oidcProviderNotFound(arn)
|
||||
return errors.New(awserrors.ErrorIAMNoSuchEntity)
|
||||
}
|
||||
return fmt.Errorf("open IAM account bucket: %w", err)
|
||||
}
|
||||
@@ -373,14 +373,14 @@ func (s *IAMServiceImpl) getOIDCProvider(ctx context.Context, accountID, arn str
|
||||
kv, err := s.js.KeyValue(ctx, IAMAccountBucketName(accountID))
|
||||
if err != nil {
|
||||
if errors.Is(err, jetstream.ErrBucketNotFound) {
|
||||
return nil, oidcProviderNotFound(arn)
|
||||
return nil, errors.New(awserrors.ErrorIAMNoSuchEntity)
|
||||
}
|
||||
return nil, fmt.Errorf("open IAM account bucket: %w", err)
|
||||
}
|
||||
entry, err := kv.Get(ctx, OIDCProviderKey(issuer))
|
||||
if err != nil {
|
||||
if errors.Is(err, jetstream.ErrKeyNotFound) {
|
||||
return nil, oidcProviderNotFound(arn)
|
||||
return nil, errors.New(awserrors.ErrorIAMNoSuchEntity)
|
||||
}
|
||||
return nil, fmt.Errorf("get OIDC provider: %w", err)
|
||||
}
|
||||
|
||||
@@ -50,7 +50,7 @@ func TestCreateOpenIDConnectProvider_Idempotent(t *testing.T) {
|
||||
t.Fatalf("first create: %v", err)
|
||||
}
|
||||
_, err := svc.CreateOpenIDConnectProvider(acct, in)
|
||||
if err == nil || awserrors.ValidErrorCodeFromError(err) != awserrors.ErrorIAMEntityAlreadyExists {
|
||||
if err == nil || err.Error() != awserrors.ErrorIAMEntityAlreadyExists {
|
||||
t.Fatalf("duplicate create err = %v, want EntityAlreadyExists", err)
|
||||
}
|
||||
}
|
||||
@@ -132,7 +132,7 @@ func TestGetOpenIDConnectProvider_NotFound(t *testing.T) {
|
||||
_, err := svc.GetOpenIDConnectProvider("000000000001", &iam.GetOpenIDConnectProviderInput{
|
||||
OpenIDConnectProviderArn: aws.String(OIDCProviderARN("000000000001", "10.0.0.1:9999/oidc/eks/x/y/z")),
|
||||
})
|
||||
if err == nil || awserrors.ValidErrorCodeFromError(err) != awserrors.ErrorIAMNoSuchEntity {
|
||||
if err == nil || err.Error() != awserrors.ErrorIAMNoSuchEntity {
|
||||
t.Fatalf("err = %v, want NoSuchEntity", err)
|
||||
}
|
||||
}
|
||||
@@ -195,7 +195,7 @@ func TestDeleteOpenIDConnectProvider(t *testing.T) {
|
||||
_, err = svc.DeleteOpenIDConnectProvider(acct, &iam.DeleteOpenIDConnectProviderInput{
|
||||
OpenIDConnectProviderArn: created.OpenIDConnectProviderArn,
|
||||
})
|
||||
if err == nil || awserrors.ValidErrorCodeFromError(err) != awserrors.ErrorIAMNoSuchEntity {
|
||||
if err == nil || err.Error() != awserrors.ErrorIAMNoSuchEntity {
|
||||
t.Fatalf("second delete err = %v, want NoSuchEntity", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -111,11 +111,11 @@ func policyCASTarget(accountID, policyARN string) (string, kvutil.CASConfig, err
|
||||
if err != nil {
|
||||
slog.Debug("policyCASTarget: unparseable policy ARN",
|
||||
"accountID", accountID, "policyArn", policyARN, "err", err)
|
||||
return "", kvutil.CASConfig{}, policyNotFound(policyARN)
|
||||
return "", kvutil.CASConfig{}, errors.New(awserrors.ErrorIAMNoSuchEntity)
|
||||
}
|
||||
return accountID + "." + policyName, kvutil.CASConfig{
|
||||
Attempts: policyCASMaxRetries,
|
||||
NotFound: policyNotFound(policyARN),
|
||||
NotFound: errors.New(awserrors.ErrorIAMNoSuchEntity),
|
||||
Exhausted: func(string, int) error {
|
||||
slog.Error("IAM policy CAS retries exhausted under contention",
|
||||
"accountID", accountID, "policyName", policyName, "attempts", policyCASMaxRetries)
|
||||
|
||||
@@ -224,11 +224,11 @@ func TestTagUser_DoesNotRecreateConcurrentlyDeletedUser(t *testing.T) {
|
||||
close(gate.release)
|
||||
err = <-done
|
||||
require.Error(t, err)
|
||||
assert.Equal(t, awserrors.ErrorIAMNoSuchEntity, awserrors.ValidErrorCodeFromError(err))
|
||||
assert.Equal(t, awserrors.ErrorIAMNoSuchEntity, err.Error())
|
||||
|
||||
_, err = svc.getUser(t.Context(), testAccountID, *user.UserName)
|
||||
require.Error(t, err)
|
||||
assert.Equal(t, awserrors.ErrorIAMNoSuchEntity, awserrors.ValidErrorCodeFromError(err), "the deleted user must stay deleted")
|
||||
assert.Equal(t, awserrors.ErrorIAMNoSuchEntity, err.Error(), "the deleted user must stay deleted")
|
||||
}
|
||||
|
||||
// Every group writer that read the record before a DetachGroupPolicy and writes
|
||||
@@ -376,7 +376,7 @@ func TestOIDCProviderTagWriters_DoNotRecreateDeletedProvider(t *testing.T) {
|
||||
close(gate.release)
|
||||
err = <-done
|
||||
require.Error(t, err)
|
||||
assert.Equal(t, awserrors.ErrorIAMNoSuchEntity, awserrors.ValidErrorCodeFromError(err))
|
||||
assert.Equal(t, awserrors.ErrorIAMNoSuchEntity, err.Error())
|
||||
|
||||
_, err = kv.Get(t.Context(), OIDCProviderKey(testIssuer))
|
||||
assert.ErrorIs(t, err, jetstream.ErrKeyNotFound, "the deleted provider must stay deleted")
|
||||
|
||||
@@ -33,7 +33,7 @@ func (r *RoleResolver) ResolveRole(accountID, roleARN string) (passrole.Role, er
|
||||
}
|
||||
role, err := r.svc.getRole(context.Background(), accountID, name)
|
||||
if err != nil {
|
||||
if code, _ := awserrors.ResolveErrorCode(err); code == awserrors.ErrorIAMNoSuchEntity {
|
||||
if err.Error() == awserrors.ErrorIAMNoSuchEntity {
|
||||
return passrole.Role{}, passrole.ErrNotFound
|
||||
}
|
||||
return passrole.Role{}, err
|
||||
|
||||
@@ -106,7 +106,7 @@ func (s *IAMServiceImpl) CreateRole(accountID string, input *iam.CreateRoleInput
|
||||
kvKey := accountID + "." + roleName
|
||||
if _, err := s.rolesBucket.Create(ctx, kvKey, data); err != nil {
|
||||
if errors.Is(err, jetstream.ErrKeyExists) {
|
||||
return nil, awserrors.Errorf(awserrors.ErrorIAMEntityAlreadyExists, "Role with name %s already exists.", roleName)
|
||||
return nil, errors.New(awserrors.ErrorIAMEntityAlreadyExists)
|
||||
}
|
||||
return nil, fmt.Errorf("store role: %w", err)
|
||||
}
|
||||
@@ -195,11 +195,11 @@ func (s *IAMServiceImpl) DeleteRole(accountID string, input *iam.DeleteRoleInput
|
||||
}
|
||||
|
||||
if len(role.AttachedPolicies) > 0 {
|
||||
return nil, errDetachPoliciesFirst
|
||||
return nil, errors.New(awserrors.ErrorIAMDeleteConflict)
|
||||
}
|
||||
|
||||
if len(role.InlinePolicies) > 0 {
|
||||
return nil, errDeletePoliciesFirst
|
||||
return nil, errors.New(awserrors.ErrorIAMDeleteConflict)
|
||||
}
|
||||
|
||||
profiles, err := s.findInstanceProfilesForRole(ctx, accountID, roleName)
|
||||
@@ -207,7 +207,7 @@ func (s *IAMServiceImpl) DeleteRole(accountID string, input *iam.DeleteRoleInput
|
||||
return nil, fmt.Errorf("check role instance profiles: %w", err)
|
||||
}
|
||||
if len(profiles) > 0 {
|
||||
return nil, awserrors.Errorf(awserrors.ErrorIAMDeleteConflict, "Cannot delete entity, must remove roles from instance profile first.")
|
||||
return nil, errors.New(awserrors.ErrorIAMDeleteConflict)
|
||||
}
|
||||
|
||||
if err := s.rolesBucket.Delete(ctx, accountID+"."+roleName); err != nil {
|
||||
@@ -291,7 +291,7 @@ func (s *IAMServiceImpl) UpdateRoleDescription(accountID string, input *iam.Upda
|
||||
}
|
||||
|
||||
slog.Info("IAM role description updated", "accountID", accountID, "roleName", roleName)
|
||||
return &iam.UpdateRoleDescriptionOutput{Role: describedRoleToSDK(role)}, nil
|
||||
return &iam.UpdateRoleDescriptionOutput{Role: gotRoleToSDK(role)}, nil
|
||||
}
|
||||
|
||||
func (s *IAMServiceImpl) UpdateAssumeRolePolicy(accountID string, input *iam.UpdateAssumeRolePolicyInput) (*iam.UpdateAssumeRolePolicyOutput, error) {
|
||||
@@ -326,7 +326,7 @@ func (s *IAMServiceImpl) AttachRolePolicy(accountID string, input *iam.AttachRol
|
||||
// so ListAttachedRolePolicies / DescribeNodegroup round-trip instead of
|
||||
// failing NoSuchEntity. Customer-managed ARNs must still exist.
|
||||
if !iamarn.IsAWSManagedPolicyARN(policyARN) {
|
||||
if err := s.attachablePolicy(ctx, accountID, policyARN); err != nil {
|
||||
if _, err := s.getPolicyByARN(ctx, accountID, policyARN); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
@@ -354,7 +354,7 @@ func (s *IAMServiceImpl) DetachRolePolicy(accountID string, input *iam.DetachRol
|
||||
err := s.updateRoleCAS(ctx, accountID, roleName, func(role *Role) (bool, error) {
|
||||
idx := slices.Index(role.AttachedPolicies, policyARN)
|
||||
if idx < 0 {
|
||||
return false, policyNotFound(policyARN)
|
||||
return false, errors.New(awserrors.ErrorIAMNoSuchEntity)
|
||||
}
|
||||
role.AttachedPolicies = slices.Delete(role.AttachedPolicies, idx, idx+1)
|
||||
return true, nil
|
||||
@@ -444,7 +444,7 @@ func (s *IAMServiceImpl) GetRolePolicy(accountID string, input *iam.GetRolePolic
|
||||
|
||||
doc, ok := role.InlinePolicies[policyName]
|
||||
if !ok {
|
||||
return nil, noSuchEntity("role policy", policyName)
|
||||
return nil, errors.New(awserrors.ErrorIAMNoSuchEntity)
|
||||
}
|
||||
|
||||
return &iam.GetRolePolicyOutput{
|
||||
@@ -463,7 +463,7 @@ func (s *IAMServiceImpl) DeleteRolePolicy(accountID string, input *iam.DeleteRol
|
||||
|
||||
err := s.updateRoleCAS(ctx, accountID, roleName, func(role *Role) (bool, error) {
|
||||
if _, ok := role.InlinePolicies[policyName]; !ok {
|
||||
return false, noSuchEntity("role policy", policyName)
|
||||
return false, errors.New(awserrors.ErrorIAMNoSuchEntity)
|
||||
}
|
||||
delete(role.InlinePolicies, policyName)
|
||||
return true, nil
|
||||
@@ -600,7 +600,7 @@ func (s *IAMServiceImpl) getRole(ctx context.Context, accountID, roleName string
|
||||
entry, err := s.rolesBucket.Get(ctx, accountID+"."+roleName)
|
||||
if err != nil {
|
||||
if errors.Is(err, jetstream.ErrKeyNotFound) {
|
||||
return nil, noSuchEntity("role", roleName)
|
||||
return nil, errors.New(awserrors.ErrorIAMNoSuchEntity)
|
||||
}
|
||||
return nil, fmt.Errorf("get role: %w", err)
|
||||
}
|
||||
@@ -622,7 +622,7 @@ func (s *IAMServiceImpl) updateRoleCAS(ctx context.Context, accountID, roleName
|
||||
defer s.recordLocks.lock("role/" + accountID + "." + roleName)()
|
||||
_, err := kvutil.Update(ctx, s.rolesBucket, accountID+"."+roleName, kvutil.CASConfig{
|
||||
Attempts: roleCASMaxRetries,
|
||||
NotFound: noSuchEntity("role", roleName),
|
||||
NotFound: errors.New(awserrors.ErrorIAMNoSuchEntity),
|
||||
Exhausted: func(string, int) error {
|
||||
// Only a CAS conflict reaches here, so the role is contended rather than
|
||||
// broken; say so, or the caller's InternalError has no cause anywhere.
|
||||
@@ -698,16 +698,6 @@ func createdRoleToSDK(r *Role) *iam.Role {
|
||||
return out
|
||||
}
|
||||
|
||||
// describedRoleToSDK is UpdateRoleDescription's shape, which carries no
|
||||
// MaxSessionDuration, Tags or RoleLastUsed.
|
||||
func describedRoleToSDK(r *Role) *iam.Role {
|
||||
out := roleToSDK(r)
|
||||
if r.Description != "" {
|
||||
out.Description = aws.String(r.Description)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// listedRoleToSDK is ListRoles' shape, which carries no Tags or RoleLastUsed.
|
||||
func listedRoleToSDK(r *Role) *iam.Role {
|
||||
out := roleToSDK(r)
|
||||
|
||||
@@ -561,13 +561,7 @@ func TestUpdateRole_NotFound(t *testing.T) {
|
||||
func TestUpdateRoleDescription(t *testing.T) {
|
||||
t.Parallel()
|
||||
svc := setupTestIAMService(t)
|
||||
_, err := svc.CreateRole(testAccountID, &iam.CreateRoleInput{
|
||||
RoleName: aws.String("desc-role"),
|
||||
AssumeRolePolicyDocument: aws.String(validTrustPolicy()),
|
||||
MaxSessionDuration: aws.Int64(7200),
|
||||
Tags: []*iam.Tag{{Key: aws.String("team"), Value: aws.String("infra")}},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
createTestRole(t, svc, "desc-role")
|
||||
|
||||
out, err := svc.UpdateRoleDescription(testAccountID, &iam.UpdateRoleDescriptionInput{
|
||||
RoleName: aws.String("desc-role"),
|
||||
@@ -577,15 +571,7 @@ func TestUpdateRoleDescription(t *testing.T) {
|
||||
got, err := svc.GetRole(testAccountID, &iam.GetRoleInput{RoleName: aws.String("desc-role")})
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "new description", *got.Role.Description)
|
||||
assert.Equal(t, &iam.Role{
|
||||
Path: got.Role.Path,
|
||||
RoleName: got.Role.RoleName,
|
||||
RoleId: got.Role.RoleId,
|
||||
Arn: got.Role.Arn,
|
||||
CreateDate: got.Role.CreateDate,
|
||||
AssumeRolePolicyDocument: got.Role.AssumeRolePolicyDocument,
|
||||
Description: aws.String("new description"),
|
||||
}, out.Role, "AWS omits MaxSessionDuration, Tags and RoleLastUsed from this output")
|
||||
assert.Equal(t, got.Role, out.Role, "output carries the role in the GetRole shape")
|
||||
|
||||
_, err = svc.UpdateRoleDescription(testAccountID, &iam.UpdateRoleDescriptionInput{
|
||||
RoleName: aws.String("desc-role"),
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user