Compare commits

..

24 Commits

Author SHA1 Message Date
joshsiv-mulga cc2a0b8871 test(iam): account for UpdateUser as named by bare name
UpdateUser addresses its user by bare name, so the cross-tenant ARN table
cannot target another account; list it with the other byName IAM actions.
Add a focused test that a rename stays within the caller's account.
2026-11-02 17:07:36 +10:00
joshsiv-mulga 473042ce47 test(iam): check the resolved NoSuchEntity code in UpdateUser tests
IAM errors now carry a message, so err.Error() no longer equals the bare
code. Assert the resolved AWS error code instead.
2026-11-02 16:55:12 +10:00
joshsiv-mulga 5fcd86cb0e fix(lambda): match IAM error codes, not messages, in code-store provisioning
IAM errors now carry a message, so comparing err.Error() with the bare
code no longer matched EntityAlreadyExists or NoSuchEntity. Provisioning
then failed on every start after the first and could not adopt its own
role. Resolve the AWS error code instead.
2026-11-02 16:54:29 +10:00
joshsiv-mulga 0fd267263e fix(lambda): render the stored record in the CreateFunction response
CodeStored now returns the record the owner wrote, and CreateFunction
renders it instead of stamping its own time, so CreatedAt, LastModified
and RevisionId match an immediate GetFunction.
2026-11-02 16:49:45 +10:00
joshsiv-mulga a440ca9dba fix(lambda): return and accept only the default LoggingConfig
Every function configuration now carries LoggingConfig with LogFormat Text
and LogGroup /aws/lambda/<name>. CreateFunction accepts the default when
supplied and refuses a JSON format, another log group or a log level.

Adds an integration test that drives a signed CreateFunction and
GetFunction through the gateway, the NATS record owner and the code store,
then downloads Code.Location and checks the bytes. The STS trust check
moves to stsdomain.RoleTrusts so the test uses the production evaluation.
2026-11-02 16:49:45 +10:00
joshsiv-mulga 8bc6b3f47a feat(lambda): add unregistered CreateFunction and GetFunction
Function records live in a daemon-owned per-account KV bucket, keyed by
Region and name, claimed with one conditional create that carries the
function ID and a Pending state. A claim whose code copy never completes
expires, so a later create can take the name over; the original gateway
then fails to mark its code stored. Code copies are never deleted here.

CreateFunction checks the ZIP is readable, refuses an execution role that
does not trust lambda.amazonaws.com (using the STS trust evaluation), and
encrypts environment values in the gateway with the master key, bound to
the account and function ID. GetFunction returns Code.Location as a
10-minute presigned URL for the off-host Predastore address, signed by a
session that outlasts it; with no off-host address it fails instead of
naming an internal one. Lambda uses the REST-JSON error envelope.

The handlers are built in the gateway but not routed: no public Lambda API
is registered until a runtime image exists.
2026-11-02 16:49:45 +10:00
Julian Sommer 8ce3604449 fix(iam): name the missing or conflicting entity in NoSuchEntity, EntityAlreadyExists, DeleteConflict and LimitExceeded messages as AWS does (#1435) 2026-11-02 16:55:04 +11:00
Julian Sommer 50790d416c fix(iam): let RemoveUserFromGroup succeed for an existing user who is not a member, answering NoSuchEntity only for a missing user or group (#1440) 2026-11-02 16:54:47 +11:00
Julian Sommer 70f3543de7 feat(iam): implement UpdateUser so a user can be renamed or moved to a new path (#1439) 2026-11-02 16:54:06 +11:00
Julian Sommer dc830d82b6 fix(iam): return only AWS's role fields from UpdateRoleDescription, without MaxSessionDuration, Tags or RoleLastUsed (#1438) 2026-11-02 16:53:13 +11:00
Josh Siviour 1766fa2c92 feat(lambda): add the Regional Lambda code store (#1437)
Lambda code copies live in a system-account Predastore bucket reached
only through a short-lived session on a Regional role scoped to that
bucket. STS gains an in-process AssumeCodeStoreRole that derives the
Region's code-store role itself and assumes it as lambda.amazonaws.com;
it is not on the STS interface, NATS or HTTPS.

Provisioning creates or verifies the owner-tagged role, inline policy
and bucket on first use only, retries Lambda's own partial create,
and refuses an untagged or foreign resource with an operator-visible
reason. Copies are keyed by their SHA-256, so different bytes can never
replace a recorded revision. The gateway holds the root client and IAM;
the Lambda package receives only the bounded provisioning operations
and the scoped store.
2026-11-02 15:52:13 +10:00
Josh Siviour 18a02a3910 feat(admin): fence the account deletion record with an operation id and lease (#1434)
* test(admin): pin the unfenced deletion job record

* feat(admin): fence the account deletion record with an operation id and lease
2026-11-02 15:01:47 +10:00
Julian Sommer 7ee6a87381 fix(acm): refuse to import a certificate whose NotAfter has passed (#1433) 2026-11-02 15:47:38 +11:00
Julian Sommer 3ac6dd6a25 fix: key reconcile EIP intent by VPC so overlapping tenants keep their public IPs (#1429) 2026-11-02 15:42:27 +11:00
Julian Sommer 5bcef59ded fix: delete only the subnet's own DHCP_Options row in DeleteSubnet, not another VPC's row with the same CIDR (#1430) 2026-11-02 15:42:11 +11:00
Julian Sommer c00dc3eaaf fix: mint a secret ECS task credential ID instead of serving role credentials under the public task ID (#1431) 2026-11-02 15:41:53 +11:00
Julian Sommer 9979e3b6b2 fix: render ALB fixed-response content in single quotes so HAProxy cannot expand environment variables into the response, and validate ContentType and MessageBody at the API (#1423) 2026-11-02 15:40:22 +11:00
Josh Siviour 7b9260f8f7 feat: AWS-format EKS add-on versions, DescribeAddonVersions filters and declared refusal codes (#1426)
* test: pin AWS-format EKS add-on versions

* feat: return EKS add-on versions in AWS format and accept them as input

* test: DescribeAddonVersions query parameters, compatibilities and paging

* feat: honour DescribeAddonVersions filters and report compatibilities

* test: EKS parameter refusals must carry a code the operation declares

* fix: EKS parameter refusals use a code the operation declares

* docs: describe AWS-format EKS add-on versions

Document the v<x.y.z>-eksbuild.<n> form, the bare-form leniency, the
DescribeAddonVersions filters and compatibilities, and how to pin a
version in Terraform. Replace the leave-addon_version-unset guidance.
2026-11-02 14:39:48 +10:00
Julian Sommer 70c4e2e447 fix(ec2): refuse malformed image, instance and volume IDs on Describe instead of answering NotFound (#1425) 2026-11-02 15:29:26 +11:00
Julian Sommer e7c0f0ebae fix(ec2): omit network interface PrivateDnsName while the VPC has enableDnsHostnames off (#1424) 2026-11-02 15:15:20 +11:00
Julian Sommer d79e537330 fix(ecs): always return clusters and failures lists from DescribeClusters (#1418) 2026-11-02 14:53:10 +11:00
Julian Sommer fe349fb994 fix: return ClusterNotFoundException from ECS ListTasks on a missing cluster (#1419) 2026-11-02 14:52:49 +11:00
Julian Sommer 061ea49832 fix(ecs): reject task definitions whose container has no image or no memory setting (#1420) 2026-11-02 14:52:35 +11:00
Julian Sommer f4af10713d fix(elbv2): default TCP target groups to thresholds 5/2 and omit empty HealthCheckPath and Matcher (#1421) 2026-11-02 14:52:21 +11:00
159 changed files with 7975 additions and 787 deletions
+2 -2
View File
@@ -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, defaulting CredID to
// the taskID when the scheduler leaves it empty.
// serves its credentials at 169.254.170.2 under CredID, a scheduler-minted
// secret. An assign without a CredID gets no credentials.
CredID string `json:"credId,omitempty"`
TaskRoleARN string `json:"taskRoleArn,omitempty"`
// ExecutionRoleARN is the task's execution role (from the taskdef); when set,
+20 -1
View File
@@ -143,6 +143,25 @@ 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]
@@ -359,7 +378,7 @@ Control plane readiness and kubectl authentication above lists which clusters re
**`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, 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, use the AWS form from `DescribeAddonVersions` (see Add-on versions above), or leave `addon_version` unset so Spinifex selects the catalog default.
## Reference Architectures
+3 -3
View File
@@ -17,7 +17,7 @@ tags:
## Overview
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.
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.
| Service | Operations |
|---|---:|
@@ -27,9 +27,9 @@ The platform serves **405 operations** across the AWS APIs below. Every page nam
| [ECS](/coverage/ecs) | 31 |
| [EKS](/coverage/eks) | 27 |
| [ELBv2](/coverage/elbv2) | 36 |
| [IAM](/coverage/iam) | 81 |
| [IAM](/coverage/iam) | 82 |
| [RDS](/coverage/rds) | 27 |
| [Route 53](/coverage/route53) | 13 |
| [S3](/coverage/s3) | 26 |
| [STS](/coverage/sts) | 5 |
| **Total** | **405** |
| **Total** | **406** |
+4 -3
View File
@@ -2938,7 +2938,8 @@
"UpdateAccessKey",
"UpdateAssumeRolePolicy",
"UpdateRole",
"UpdateRoleDescription"
"UpdateRoleDescription",
"UpdateUser"
],
"implemented": [
"AddRoleToInstanceProfile",
@@ -3021,7 +3022,8 @@
"UpdateAccessKey",
"UpdateAssumeRolePolicy",
"UpdateRole",
"UpdateRoleDescription"
"UpdateRoleDescription",
"UpdateUser"
],
"stubbed": [],
"unsupported": [],
@@ -3121,7 +3123,6 @@
"UpdateServerCertificate",
"UpdateServiceSpecificCredential",
"UpdateSigningCertificate",
"UpdateUser",
"UploadSSHPublicKey",
"UploadServerCertificate",
"UploadSigningCertificate"
+2 -1
View File
@@ -18,7 +18,7 @@ tags:
## Overview
Spinifex implements **81 operations** in the IAM `2010-05-08` API model.
Spinifex implements **82 operations** in the IAM `2010-05-08` API model.
### Scope
@@ -109,3 +109,4 @@ All IAM operations are account-scoped. The root user of an account bypasses poli
| `UpdateAssumeRolePolicy` |
| `UpdateRole` |
| `UpdateRoleDescription` |
| `UpdateUser` |
+1 -2
View File
@@ -987,7 +987,7 @@ spot-price: On owned hardware there is no spot-to-on-demand price differential,
## iam
`2010-05-08` — 81 of 180 modelled operations implemented (45.0%), 0 stubbed, 19 not applicable, 0 registered outside the pinned model.
`2010-05-08` — 82 of 180 modelled operations implemented (45.6%), 0 stubbed, 19 not applicable, 0 registered outside the pinned model.
### Not implemented
@@ -1069,7 +1069,6 @@ spot-price: On owned hardware there is no spot-to-on-demand price differential,
- `UpdateOpenIDConnectProviderThumbprint`
- `UpdateSAMLProvider`
- `UpdateServerCertificate`
- `UpdateUser`
- `UploadServerCertificate`
### Not applicable — do not build
+1 -1
View File
@@ -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. 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` |
| 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` |
| 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,7 +79,8 @@ 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).
- Leave addon versions unset (the AWS provider and the catalog disagree on the version-string format).
- 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`.
- 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:**
@@ -520,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: the AWS provider demands a v-prefixed version that the catalog rejects,
# so let Spinifex default to its catalog version.
# omitted so Spinifex installs its catalog default. To pin one, use the AWS form
# from DescribeAddonVersions, for example v2.11.0-eksbuild.1.
# ---------------------------------------------------------------------------
resource "aws_eks_addon" "lbc" {
@@ -542,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 (see the LBC note).
# addon_version omitted: the catalog default is installed (see the LBC note).
# ---------------------------------------------------------------------------
resource "aws_eks_addon" "argocd" {
@@ -562,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 (see LBC note).
# so the app's state survives pod restarts. addon_version omitted: catalog default.
# ---------------------------------------------------------------------------
resource "aws_eks_addon" "ebs_csi" {
@@ -74,7 +74,8 @@ The LBC is installed as a managed **addon** (`aws_eks_addon`). Spinifex wires th
- **`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 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.
- **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.
- **`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`.
**Prerequisites:**
@@ -446,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: the AWS provider demands a v-prefixed version that the catalog rejects,
# so let Spinifex default to its catalog version.
# omitted so Spinifex installs its catalog default. To pin one, use the AWS form
# from DescribeAddonVersions, for example v2.11.0-eksbuild.1.
# ---------------------------------------------------------------------------
resource "aws_eks_addon" "lbc" {
+27 -20
View File
@@ -47,6 +47,13 @@ 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
@@ -71,7 +78,7 @@ type credEndpoint struct {
assume func(ctx context.Context, roleARN, sessionName string) (stsauth.Credentials, error)
mu sync.Mutex
roles map[string]string // credID -> taskRoleARN
roles map[string]credRole // credID -> task role
cache map[string]credEntry // credID -> cached creds
httpClient *http.Client
@@ -98,7 +105,7 @@ func newCredEndpoint(provider credentials.CredentialsProvider, region, gatewayUR
port: port,
proxyPort: defaultCredProxyPort,
run: run,
roles: map[string]string{},
roles: map[string]credRole{},
cache: map[string]credEntry{},
}
c.assume = c.assumeOverGateway
@@ -107,12 +114,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, roleARN string) {
func (c *credEndpoint) Register(credID, taskID, roleARN string) {
if credID == "" || roleARN == "" {
return
}
c.mu.Lock()
c.roles[credID] = roleARN
c.roles[credID] = credRole{roleARN: roleARN, taskID: taskID}
c.mu.Unlock()
}
@@ -142,25 +149,25 @@ func (c *credEndpoint) ServeHTTP(w http.ResponseWriter, r *http.Request) {
http.Error(w, "not found", http.StatusNotFound)
return
}
creds, roleARN, err := c.fetch(r.Context(), credID)
creds, role, 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", "credId", credID, "err", err)
slog.Error("ecs-agent: assume task role failed", "task", role.taskID, "role", role.roleARN, "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: roleARN,
RoleArn: role.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", "credId", credID, "err", err)
slog.Warn("ecs-agent: encode credential response", "task", role.taskID, "err", err)
}
}
@@ -169,28 +176,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, string, error) {
func (c *credEndpoint) fetch(ctx context.Context, credID string) (stsauth.Credentials, credRole, error) {
c.mu.Lock()
roleARN, known := c.roles[credID]
role, known := c.roles[credID]
if !known {
c.mu.Unlock()
return stsauth.Credentials{}, "", errUnknownCredID
return stsauth.Credentials{}, credRole{}, errUnknownCredID
}
if e, ok := c.cache[credID]; ok && e.roleARN == roleARN && credValid(e.creds, credRefreshMargin) {
if e, ok := c.cache[credID]; ok && e.roleARN == role.roleARN && credValid(e.creds, credRefreshMargin) {
c.mu.Unlock()
return e.creds, roleARN, nil
return e.creds, role, nil
}
c.mu.Unlock()
creds, err := c.assume(ctx, roleARN, sessionName(credID))
creds, err := c.assume(ctx, role.roleARN, sessionName(role.taskID))
if err != nil {
return stsauth.Credentials{}, "", err
return stsauth.Credentials{}, role, err
}
c.mu.Lock()
c.cache[credID] = credEntry{roleARN: roleARN, creds: creds}
c.cache[credID] = credEntry{roleARN: role.roleARN, creds: creds}
c.mu.Unlock()
return creds, roleARN, nil
return creds, role, nil
}
// AssumeProvider returns a CredentialsProvider that mints credentials by assuming
@@ -390,10 +397,10 @@ func credValid(c stsauth.Credentials, margin time.Duration) bool {
return time.Now().Add(margin).Before(c.Expiration)
}
// sessionName derives an AssumeRole RoleSessionName from credID, satisfying the
// sessionName derives an AssumeRole RoleSessionName from the task ID, satisfying the
// STS [\w+=,.@-]{2,64} constraint.
func sessionName(credID string) string {
name := "ecs-" + credID
func sessionName(taskID string) string {
name := "ecs-" + taskID
if len(name) > 64 {
name = name[:64]
}
+27 -7
View File
@@ -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", "arn:aws:iam::111122223333:role/task")
c.Register("cred-1", "task-1", "arn:aws:iam::111122223333:role/task")
rr := httptest.NewRecorder()
c.ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/v2/credentials/cred-1", nil))
@@ -51,6 +51,26 @@ 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()
@@ -65,7 +85,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", "arn:role")
c.Register("cred-1", "task-1", "arn:role")
rr := httptest.NewRecorder()
c.ServeHTTP(rr, httptest.NewRequest(http.MethodPost, "/v2/credentials/cred-1", nil))
if rr.Code != http.StatusMethodNotAllowed {
@@ -77,7 +97,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", "arn:role")
c.Register("cred-1", "task-1", "arn:role")
for hit := range 3 {
rr := httptest.NewRecorder()
c.ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/v2/credentials/cred-1", nil))
@@ -95,7 +115,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", "arn:role")
c.Register("cred-1", "task-1", "arn:role")
for range 2 {
rr := httptest.NewRecorder()
c.ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/v2/credentials/cred-1", nil))
@@ -107,7 +127,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", "arn:role")
c.Register("cred-1", "task-1", "arn:role")
c.Deregister("cred-1")
rr := httptest.NewRecorder()
c.ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/v2/credentials/cred-1", nil))
@@ -121,7 +141,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", "arn:role")
c.Register("cred-1", "task-1", "arn:role")
rr := httptest.NewRecorder()
c.ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/v2/credentials/cred-1", nil))
if rr.Code != http.StatusBadGateway {
@@ -133,7 +153,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", "arn:role")
c.Register("cred-1", "task-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)
+36 -1
View File
@@ -1,8 +1,12 @@
package agent
import (
"net/http"
"net/http/httptest"
"testing"
"github.com/mulgadc/spinifex/internal/stsauth"
ecsv1 "github.com/mulgadc/spinifex/contracts/ecs/v1"
)
@@ -13,7 +17,7 @@ func TestTaskCredID(t *testing.T) {
want string
}{
{"no role", ecsv1.Assign{TaskID: "t1"}, ""},
{"role, no credID -> taskID", ecsv1.Assign{TaskID: "t1", TaskRoleARN: "arn:role"}, "t1"},
{"role, no credID -> none", ecsv1.Assign{TaskID: "t1", TaskRoleARN: "arn:role"}, ""},
{"role + credID", ecsv1.Assign{TaskID: "t1", CredID: "c9", TaskRoleARN: "arn:role"}, "c9"},
}
for _, tc := range cases {
@@ -23,6 +27,37 @@ 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"}
+1 -1
View File
@@ -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"]; got != testTaskRole {
if got := a.cred.roles["t-001"].roleARN; 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 {
+7 -7
View File
@@ -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.TaskRoleARN)
a.cred.Register(credID, as.TaskID, as.TaskRoleARN)
return credID
}
// taskCredID is the credential ID a task's containers fetch credentials under:
// the scheduler-assigned CredID, falling back to the taskID. Empty when the task
// carries no IAM role.
// 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.
func taskCredID(as *ecsv1.Assign) string {
if as.TaskRoleARN == "" {
return ""
}
if as.CredID != "" {
return as.CredID
}
return as.TaskID
return as.CredID
}
// withCredEnv returns env with AWS_CONTAINER_CREDENTIALS_RELATIVE_URI set for
+27
View File
@@ -152,3 +152,30 @@ 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)
}
+5 -2
View File
@@ -223,10 +223,13 @@ 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)
var inUseBy []string
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.")
}
var inUseBy []string
createdAt := now
tags := tagsToMap(input.Tags)
reimport := certArn != ""
+9
View File
@@ -134,6 +134,9 @@ 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
@@ -257,6 +260,9 @@ 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) {
@@ -287,6 +293,9 @@ 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,6 +6,7 @@ import (
"strings"
"testing"
"github.com/mulgadc/spinifex/spinifex/foundation/aws/errors"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
@@ -30,3 +31,27 @@ 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,6 +5,7 @@ package image
import (
"context"
"errors"
"regexp"
"strings"
"github.com/aws/aws-sdk-go/service/ec2"
@@ -13,8 +14,12 @@ import (
"github.com/nats-io/nats.go"
)
// ValidateDescribeImagesInput accepts a nil input and rejects any ImageIds entry without the ami-
// prefix with InvalidAMIID.Malformed.
// 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.
func ValidateDescribeImagesInput(input *ec2.DescribeImagesInput) (err error) {
if input == nil {
return nil
@@ -22,9 +27,16 @@ func ValidateDescribeImagesInput(input *ec2.DescribeImagesInput) (err error) {
if input.ImageIds != nil {
for _, imageId := range input.ImageIds {
if imageId != nil && !strings.HasPrefix(*imageId, "ami-") {
if imageId == nil {
continue
}
suffix, ok := strings.CutPrefix(*imageId, "ami-")
if !ok {
return errors.New(awserrors.ErrorInvalidAMIIDMalformed)
}
if !imageIDSuffixPattern.MatchString(suffix) {
return awserrors.Errorf(awserrors.ErrorInvalidAMIIDMalformed, "Invalid id: %q", *imageId)
}
}
}
@@ -34,11 +46,6 @@ 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-111"),
aws.String("ami-222"),
aws.String("ami-1a2b3c4d"),
aws.String("ami-0123456789abcdef1"),
},
},
wantErr: false,
@@ -63,7 +63,7 @@ func TestValidateDescribeImagesInput(t *testing.T) {
name: "MixedValidAndInvalidImageIds",
input: &ec2.DescribeImagesInput{
ImageIds: []*string{
aws.String("ami-valid"),
aws.String("ami-0123456789abcdef0"),
aws.String("invalid-id"),
},
},
@@ -85,11 +85,24 @@ func TestValidateDescribeImagesInput(t *testing.T) {
err := ValidateDescribeImagesInput(tt.input)
if tt.wantErr {
assert.Error(t, err)
assert.Equal(t, tt.errMsg, err.Error())
code, ok := awserrors.ResolveErrorCode(err)
assert.True(t, ok)
assert.Equal(t, tt.errMsg, code)
} 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,13 +39,6 @@ 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,6 +89,12 @@ 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 err.Error() == awserrors.ErrorIAMNoSuchEntity {
if code, _ := awserrors.ResolveErrorCode(err); code == 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, errors.New(awserrors.ErrorIAMNoSuchEntity)
return nil, awserrors.Errorf(awserrors.ErrorIAMNoSuchEntity, "Instance Profile %s cannot be found.", nameOrARN)
}
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 errors.New(awserrors.ErrorInvalidInstanceIDMalformed)
return awserrors.Errorf(awserrors.ErrorInvalidInstanceIDMalformed, "Invalid id: %q", *id)
}
}
return nil
@@ -57,8 +57,10 @@ func TestValidateInstanceIDFormat(t *testing.T) {
}
for _, id := range malformed {
err := validateInstanceIDFormat([]*string{aws.String(id)})
require.Error(t, err, id)
assert.Equal(t, awserrors.ErrorInvalidInstanceIDMalformed, err.Error(), 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)
}
err := validateInstanceIDFormat([]*string{aws.String("i-0123456789abcdef0"), aws.String("i-abc")})
require.Error(t, err, "one malformed id fails the whole batch")
@@ -100,8 +102,9 @@ func TestLifecycleVerbs_MalformedIDRejectedBeforeNATS(t *testing.T) {
}
for name, call := range calls {
err := call()
require.Error(t, err, name)
assert.Equal(t, awserrors.ErrorInvalidInstanceIDMalformed, err.Error(), name)
code, ok := awserrors.ResolveErrorCode(err)
require.True(t, ok, name)
assert.Equal(t, awserrors.ErrorInvalidInstanceIDMalformed, code, 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,6 +5,7 @@ package volume
import (
"context"
"errors"
"regexp"
"strings"
"github.com/aws/aws-sdk-go/service/ec2"
@@ -13,8 +14,13 @@ import (
"github.com/nats-io/nats.go"
)
// ValidateDescribeVolumesInput accepts a nil input and rejects any VolumeIds entry without the
// vol- prefix with InvalidVolumeID.Malformed.
// 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.
func ValidateDescribeVolumesInput(input *ec2.DescribeVolumesInput) error {
if input == nil {
return nil
@@ -22,9 +28,17 @@ func ValidateDescribeVolumesInput(input *ec2.DescribeVolumesInput) error {
if input.VolumeIds != nil {
for _, volumeId := range input.VolumeIds {
if volumeId != nil && !strings.HasPrefix(*volumeId, "vol-") {
if volumeId == nil {
continue
}
suffix, ok := strings.CutPrefix(*volumeId, "vol-")
if !ok {
return errors.New(awserrors.ErrorInvalidVolumeIDMalformed)
}
if !volumeIDSuffixPattern.MatchString(suffix) {
return awserrors.Errorf(awserrors.ErrorInvalidParameterValue,
"Value (%s) for parameter volumes is invalid. Expected: 'vol-...'.", *volumeId)
}
}
}
@@ -35,11 +49,6 @@ 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,6 +5,7 @@ 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"
)
@@ -36,9 +37,9 @@ func TestValidateDescribeVolumesInput(t *testing.T) {
name: "ValidMultipleVolumeIds",
input: &ec2.DescribeVolumesInput{
VolumeIds: []*string{
aws.String("vol-abc123"),
aws.String("vol-def456"),
aws.String("vol-ghi789"),
aws.String("vol-0123456789abcdef1"),
aws.String("vol-1a2b3c4d"),
aws.String("vol-0123456789abcdef2"),
},
},
wantErr: false,
@@ -71,7 +72,7 @@ func TestValidateDescribeVolumesInput(t *testing.T) {
name: "MixedValidAndInvalid",
input: &ec2.DescribeVolumesInput{
VolumeIds: []*string{
aws.String("vol-valid123"),
aws.String("vol-0123456789abcdef3"),
aws.String("invalid-id"),
},
},
@@ -81,7 +82,7 @@ func TestValidateDescribeVolumesInput(t *testing.T) {
{
name: "NilVolumeIdInList",
input: &ec2.DescribeVolumesInput{
VolumeIds: []*string{nil, aws.String("vol-abc123")},
VolumeIds: []*string{nil, aws.String("vol-0123456789abcdef1")},
},
wantErr: false,
},
@@ -100,3 +101,15 @@ 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,13 +96,6 @@ 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 err.Error() == awserrors.ErrorIAMNoSuchEntity {
if code, _ := awserrors.ResolveErrorCode(err); code == 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: errors.New(awserrors.ErrorIAMNoSuchEntity)}, &fakeAssumer{})
svc, _ := newTestService(res, &fakeIAM{profileErr: awserrors.Errorf(awserrors.ErrorIAMNoSuchEntity, "Instance Profile gone cannot be found.")}, &fakeAssumer{})
h := withTapENI(svc.httpHandler(), testENI())
token := issueToken(t, h)
rec := get(t, h, prefixMetaData+"iam/info", token)
+22 -5
View File
@@ -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),
NetworkInterface: s.eniRecordToEC2(&record, accountID, groupNames, s.vpcDNSHostnames(ctx, accountID, record.VpcId)),
}, 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))
enis = append(enis, s.eniRecordToEC2(&record, accountID, groupNames, s.vpcDNSHostnames(ctx, accountID, record.VpcId)))
}
// If specific ENI IDs were requested but not found, return error
@@ -1076,9 +1076,26 @@ 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.
func (s *VPCServiceImpl) eniRecordToEC2(record *ENIRecord, accountID string, groupNames map[string]string) *ec2.NetworkInterface {
// 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 {
// ENIs with spinifex:managed-by tag are system-managed (e.g. by ELBv2)
requesterManaged := record.Tags["spinifex:managed-by"] != ""
@@ -1116,7 +1133,7 @@ func (s *VPCServiceImpl) eniRecordToEC2(record *ENIRecord, accountID string, gro
eni.Groups = groups
}
if s.privateDNSName != nil && record.PrivateIpAddress != "" {
if dnsHostnames && s.privateDNSName != nil && record.PrivateIpAddress != "" {
if name := s.privateDNSName(record.PrivateIpAddress); name != "" {
eni.PrivateDnsName = aws.String(name)
eni.PrivateIpAddresses[0].PrivateDnsName = aws.String(name)
+56 -13
View File
@@ -55,15 +55,11 @@ 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)
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)
assert.Nil(t, describeTestENI(t, svc, unnamed).PrivateDnsName)
svc.SetPrivateDNSNamer(func(ip string) string { return "name-of-" + ip })
out, err := svc.CreateNetworkInterface(context.Background(), &ec2.CreateNetworkInterfaceInput{
@@ -72,13 +68,8 @@ func TestNetworkInterface_NamesGroupsAndPrivateAddress(t *testing.T) {
}, testAccountID)
require.NoError(t, err)
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]} {
desc := describeTestENI(t, svc, *out.NetworkInterface.NetworkInterfaceId)
for name, eni := range map[string]*ec2.NetworkInterface{"create": out.NetworkInterface, "describe": desc} {
want := "name-of-" + aws.StringValue(eni.PrivateIpAddress)
assert.Equal(t, want, aws.StringValue(eni.PrivateDnsName), name)
require.Len(t, eni.PrivateIpAddresses, 1, name)
@@ -89,6 +80,58 @@ 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)
+9 -5
View File
@@ -272,11 +272,15 @@ 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"`
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"`
ContainerInstanceID string `json:"containerInstanceId,omitempty"`
ContainerInstanceARN string `json:"containerInstanceArn,omitempty"`
DesiredStatus string `json:"desiredStatus"`
+14 -3
View File
@@ -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{}
out := &ecs.DescribeClustersOutput{Clusters: []*ecs.Cluster{}, Failures: []*ecs.Failure{}}
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); err != nil {
if err := validateContainerDefs(input.ContainerDefinitions, aws.StringValue(input.Memory)); err != nil {
return nil, err
}
warnUnsupportedLogDrivers(ctx, family, input.ContainerDefinitions)
@@ -497,12 +497,23 @@ 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.
func validateContainerDefs(defs []*ecs.ContainerDefinition) error {
//
// 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 {
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)
}
+109
View File
@@ -2,7 +2,9 @@ package ecs
import (
"context"
"encoding/json"
"github.com/mulgadc/spinifex/spinifex/domains/ecs/taskdefinition"
"maps"
"strconv"
"testing"
"time"
@@ -302,6 +304,89 @@ 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) {
@@ -812,6 +897,12 @@ 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)
@@ -829,3 +920,21 @@ 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)
}
+2 -2
View File
@@ -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),
Name: aws.String("app"), Image: aws.String("registry/app:1"), Essential: aws.Bool(true), Memory: aws.Int64(256),
}},
}, 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),
Name: aws.String("app"), Image: aws.String("registry/app:1"), Essential: aws.Bool(true), Memory: aws.Int64(256),
}},
}, testAccountID)
require.NoError(t, err)
@@ -16,6 +16,8 @@ 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 {
+57 -18
View File
@@ -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),
Name: aws.String("app"), Image: aws.String("registry/app:1"), Essential: aws.Bool(true), Memory: aws.Int64(256),
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),
Name: aws.String("app"), Image: aws.String("registry/app:1"), Essential: aws.Bool(true), Memory: aws.Int64(256),
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),
Name: aws.String("app"), Image: aws.String("registry/app:1"), Essential: aws.Bool(true), Memory: aws.Int64(256),
}},
}, 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),
Name: aws.String("app"), Image: aws.String("registry/app:1"), Essential: aws.Bool(true), Memory: aws.Int64(256),
}},
}, 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),
Name: aws.String("app"), Image: aws.String("registry/app:1"), Essential: aws.Bool(true), Memory: aws.Int64(256),
}},
}, 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),
Name: aws.String("app"), Image: aws.String("registry/app:1"), Essential: aws.Bool(true), Memory: aws.Int64(256),
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),
Name: aws.String("app"), Image: aws.String("registry/app:1"), Essential: aws.Bool(true), Memory: aws.Int64(256),
}},
}, 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),
Name: aws.String("app"), Image: aws.String("registry/app:1"), Essential: aws.Bool(true), Memory: aws.Int64(256),
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),
Name: aws.String("app"), Image: aws.String("registry/app:1"), Essential: aws.Bool(true), Memory: aws.Int64(256),
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),
Name: aws.String("app"), Image: aws.String("registry/app:1"), Essential: aws.Bool(true), Memory: aws.Int64(256),
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),
Name: aws.String("app"), Image: aws.String("registry/app:1"), Essential: aws.Bool(true), Memory: aws.Int64(256),
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),
Name: aws.String("app"), Image: aws.String("registry/app:1"), Essential: aws.Bool(true), Memory: aws.Int64(256),
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),
Name: aws.String("app"), Image: aws.String("registry/app:1"), Essential: aws.Bool(true), Memory: aws.Int64(256),
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),
Name: aws.String("app"), Image: aws.String("registry/app:1"), Essential: aws.Bool(true), Memory: aws.Int64(256),
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),
Name: aws.String("app"), Image: aws.String("registry/app:1"), Essential: aws.Bool(true), Memory: aws.Int64(256),
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),
Name: aws.String("app"), Image: aws.String("registry/app:1"), Essential: aws.Bool(true), Memory: aws.Int64(256),
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),
Name: aws.String("app"), Image: aws.String("registry/app:1"), Essential: aws.Bool(true), Memory: aws.Int64(256),
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),
Name: aws.String("app"), Image: aws.String("registry/app:1"), Essential: aws.Bool(true), Memory: aws.Int64(256),
User: aws.String("1000"),
ReadonlyRootFilesystem: aws.Bool(true),
Privileged: aws.Bool(true),
@@ -608,3 +608,42 @@ 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)
})
}
}
+12
View File
@@ -237,6 +237,9 @@ 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})
}
@@ -257,6 +260,7 @@ 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,
@@ -303,6 +307,14 @@ 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
+75 -10
View File
@@ -4,8 +4,14 @@ 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"
@@ -18,6 +24,11 @@ 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
@@ -28,26 +39,26 @@ type Spec struct {
// catalog is the bundled add-on registry. Keep newest version first per slice.
var catalog = buildCatalog(
newSpec("aws-load-balancer-controller", true,
typed("networking", newSpec("aws-load-balancer-controller", true,
"Provisions ELBv2 load balancers for Kubernetes Service/Ingress resources.",
"2.11.0"),
newSpec("argocd", false,
"2.11.0")),
typed("gitops", newSpec("argocd", false,
"Declarative GitOps continuous delivery for Kubernetes.",
"3.0.23"),
newSpec("aws-ebs-csi-driver", true,
"3.0.23")),
typed("storage", 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(newSpec(NvidiaDevicePlugin, false,
hidden(typed("accelerator", 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(newSpec("spinifex-noop", false,
hidden(typed("fixture", 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
@@ -57,6 +68,12 @@ 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{
@@ -68,6 +85,10 @@ 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
}
@@ -111,6 +132,50 @@ 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,3 +100,78 @@ 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)
}
}
}
@@ -0,0 +1,105 @@
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")
}
+17 -2
View File
@@ -16,8 +16,23 @@ 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) (*eks.DescribeAddonVersionsOutput, error) {
return ekscluster.NewNATSEKSService(natsConn).DescribeAddonVersions(ctx, &eks.DescribeAddonVersionsInput{}, accountID)
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)
}
// CreateAddon — POST /clusters/{name}/addons.
+61
View File
@@ -0,0 +1,61 @@
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
}
+26 -4
View File
@@ -82,17 +82,39 @@ func (d Deps) dispatch(w http.ResponseWriter, inv dispatch.Invocation) error {
if err == nil {
return nil
}
if action, _, _, ok := router.Lookup(inv.Request.Method, inv.Request.URL.EscapedPath()); ok && IsInternalAction(action) {
action, _, _, routed := router.Lookup(inv.Request.Method, inv.Request.URL.EscapedPath())
if routed && IsInternalAction(action) {
return err
}
code, msg, ok := awserrors.ResolveErrorDetail(err)
if !ok || code != awserrors.ErrorInvalidParameterValue {
return err
}
if msg == "" {
return errors.New(awserrors.ErrorEKSInvalidParameter)
wire := awserrors.ErrorEKSInvalidParameter
if override, ok := parameterRefusalCodes[action]; ok {
wire = override
if msg == "" {
msg = "a parameter of the request is not valid"
}
}
return awserrors.Errorf(awserrors.ErrorEKSInvalidParameter, "%s", msg)
if msg == "" {
return errors.New(wire)
}
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,
}
// serve resolves the route, authorizes the request, then runs the action.
@@ -0,0 +1,98 @@
//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")))
}
+1 -1
View File
@@ -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)
return DescribeAddonVersions(ctx, nc, acct, b)
}},
{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) {
+1 -1
View File
@@ -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)
out2, err := DescribeAddonVersions(context.Background(), nc, acct, nil)
require.NoError(t, err)
assert.NotNil(t, out2)
@@ -0,0 +1,171 @@
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
}
@@ -0,0 +1,220 @@
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")
}
+120 -18
View File
@@ -2,8 +2,12 @@ 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"
@@ -35,15 +39,20 @@ func (s *EKSServiceImpl) ListAddons(ctx context.Context, input *eks.ListAddonsIn
return &eks.ListAddonsOutput{Addons: aws.StringSlice(names)}, nil
}
// 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)
// 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{}
}
specs := addon.Specs()
out := make([]*eks.AddonInfo, 0, len(specs))
for _, spec := range specs {
// 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() {
if filter != "" && spec.Name != filter {
continue
}
@@ -52,9 +61,51 @@ func (s *EKSServiceImpl) DescribeAddonVersions(ctx context.Context, input *eks.D
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}, nil
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
}
// validateAddonServiceAccountRoleArn rejects a non-empty value that is not
@@ -89,11 +140,12 @@ func (s *EKSServiceImpl) CreateAddon(ctx context.Context, input *eks.CreateAddon
if !ok {
return nil, errors.New(awserrors.ErrorInvalidParameterValue)
}
version := aws.StringValue(input.AddonVersion)
if version == "" {
version = spec.DefaultVersion
} else if !spec.SupportsVersion(version) {
return nil, errors.New(awserrors.ErrorInvalidParameterValue)
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
}
}
acctKV, err := s.acctKVForNewDependant(ctx, accountID, cluster)
if err != nil {
@@ -197,9 +249,13 @@ func (s *EKSServiceImpl) UpdateAddon(ctx context.Context, input *eks.UpdateAddon
version := aws.StringValue(input.AddonVersion)
if version != "" {
spec, ok := addon.Lookup(addonName)
if !ok || !spec.SupportsVersion(version) {
if !ok {
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,
@@ -246,7 +302,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(rec.AddonVersion),
AddonVersion: aws.String(awsAddonVersion(rec)),
ClusterName: aws.String(cluster),
Status: aws.String(string(rec.Status)),
CreatedAt: aws.Time(rec.CreatedAt),
@@ -269,17 +325,36 @@ func addonRecordToAWS(cluster string, rec *addon.Record) *eks.Addon {
return out
}
// addonSpecToAWS converts a catalog spec to the SDK AddonInfo shape.
// 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.
func addonSpecToAWS(spec addon.Spec) *eks.AddonInfo {
versions := make([]*eks.AddonVersionInfo, 0, len(spec.Versions))
for _, v := range spec.Versions {
for _, v := range spec.AWSVersions() {
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,
}
}
@@ -308,3 +383,30 @@ 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)
}
+8 -4
View File
@@ -98,7 +98,9 @@ func TestCreateAddon_UnknownVersionRejected(t *testing.T) {
ClusterName: aws.String("c1"), AddonName: aws.String(albController),
AddonVersion: aws.String("9.9.9-nope"),
}, testAccountID)
require.EqualError(t, err, awserrors.ErrorInvalidParameterValue)
code, ok := awserrors.ResolveErrorCode(err)
require.True(t, ok)
assert.Equal(t, awserrors.ErrorInvalidParameterValue, code)
}
func TestCreateAddon_CreatesStagingAndDefaultsVersion(t *testing.T) {
@@ -113,7 +115,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.DefaultVersion, aws.StringValue(out.Addon.AddonVersion))
assert.Equal(t, spec.AWSVersion(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)
@@ -214,7 +216,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.DefaultVersion, aws.StringValue(desc.Addon.AddonVersion))
assert.Equal(t, spec.AWSVersion(spec.DefaultVersion), aws.StringValue(desc.Addon.AddonVersion))
assert.Equal(t, string(addon.StatusUpdating), aws.StringValue(desc.Addon.Status))
}
@@ -230,7 +232,9 @@ func TestUpdateAddon_UnknownVersionRejected(t *testing.T) {
ClusterName: aws.String("c1"), AddonName: aws.String("argocd"),
AddonVersion: aws.String("9.9.9-nope"),
}, testAccountID)
require.EqualError(t, err, awserrors.ErrorInvalidParameterValue)
code, ok := awserrors.ResolveErrorCode(err)
require.True(t, ok)
assert.Equal(t, awserrors.ErrorInvalidParameterValue, code)
}
func TestUpdateAddon_MissingIsNotFound(t *testing.T) {
+3 -3
View File
@@ -886,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 err.Error() != awserrors.ErrorIAMNoSuchEntity {
if code, _ := awserrors.ResolveErrorCode(err); code != awserrors.ErrorIAMNoSuchEntity {
return "", fmt.Errorf("get instance profile %q: %w", profileName, err)
}
@@ -895,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 err.Error() != awserrors.ErrorIAMEntityAlreadyExists {
if code, _ := awserrors.ResolveErrorCode(err); code != awserrors.ErrorIAMEntityAlreadyExists {
return "", fmt.Errorf("create instance profile %q: %w", profileName, err)
}
got, gerr := s.iamEnsurer().GetInstanceProfile(accountID, &iam.GetInstanceProfileInput{
@@ -922,7 +922,7 @@ func (s *EKSServiceImpl) attachRoleToProfile(accountID, profileName, roleName, p
InstanceProfileName: aws.String(profileName),
RoleName: aws.String(roleName),
})
if err != nil && err.Error() != awserrors.ErrorIAMLimitExceeded {
if code, _ := awserrors.ResolveErrorCode(err); err != nil && code != awserrors.ErrorIAMLimitExceeded {
return "", fmt.Errorf("add role %q to instance profile %q: %w", roleName, profileName, err)
}
return profileARN, nil
+12 -12
View File
@@ -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 "{{.FixedResponse.ContentType}}"{{end}}{{if .FixedResponse.Body}} string "{{.FixedResponse.Body}}"{{end}}
http-request return status {{.FixedResponse.StatusCode}}{{if .FixedResponse.ContentType}} content-type {{squote .FixedResponse.ContentType}}{{end}}{{if .FixedResponse.Body}} string {{squote .FixedResponse.Body}}{{end}}
{{- else}}
balance roundrobin{{if .HealthCheckPath}}
option httpchk
@@ -65,7 +65,14 @@ backend {{.Name}}
{{end}}
`
var haproxyHTTPTmpl = template.Must(template.New("haproxy").Parse(haproxyConfigTemplate))
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, "'", `'\''`) + "'"
}
// HAProxyConfig holds the full configuration for rendering the HAProxy template.
type HAProxyConfig struct {
@@ -566,17 +573,10 @@ func validHTTPStatusCode(s string) bool {
return true
}
// validFixedResponseBody rejects bytes that would terminate or inject into the HAProxy directive.
// validFixedResponseBody rejects bytes that would end the single-line HAProxy
// directive the single-quoted body is rendered into.
func validFixedResponseBody(s string) bool {
if s == "" {
return false
}
for _, r := range s {
if r == '\n' || r == '\r' || r == '"' || r == '\\' || r == '#' {
return false
}
}
return true
return s != "" && !strings.ContainsAny(s, "\n\r\x00")
}
// buildRedirect translates a RedirectAction into an HAProxy redirect directive.
+54 -2
View File
@@ -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,7 +309,59 @@ 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`)
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")
}
// TestGenerateHAProxyConfig_RedirectSchemeDefault renders the canonical
+46 -6
View File
@@ -17,6 +17,8 @@ import (
"strings"
"sync"
"time"
"unicode"
"unicode/utf8"
"github.com/aws/aws-sdk-go/aws"
"github.com/aws/aws-sdk-go/service/ec2"
@@ -2701,8 +2703,9 @@ func listenerActionFromSDK(a *elbv2.Action) ListenerAction {
return action
}
// validateListenerAction enforces the per-type action contract. Redirect fields
// are validated here so invalid input fails at the API, not at render time.
// 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.
func validateListenerAction(a ListenerAction) error {
if a.Type == ActionTypeRedirect {
if a.Redirect == nil {
@@ -2710,6 +2713,39 @@ 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
}
@@ -3503,14 +3539,18 @@ 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),
Matcher: &elbv2.Matcher{
HttpCode: aws.String(r.HealthCheck.Matcher),
},
}
// 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)}
}
// Omitted for NLB (TCP/UDP/TLS) target groups, matching AWS, which returns
+2 -2
View File
@@ -591,8 +591,8 @@ func (s *ELBv2ServiceImpl) validateAndConvertRuleActions(ctx context.Context, in
return nil, err
}
case ActionTypeFixedResponse:
if action.FixedResponse == nil {
return nil, errors.New(awserrors.ErrorMissingParameter)
if err := validateFixedResponseAction(action.FixedResponse); err != nil {
return nil, err
}
default:
return nil, errors.New(awserrors.ErrorELBv2InvalidConfigurationRequest)
@@ -7,6 +7,7 @@ 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"
)
@@ -660,3 +661,53 @@ 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)
}
+5 -4
View File
@@ -459,11 +459,12 @@ 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.Empty(t, *tg.HealthCheckPath)
assert.Empty(t, *tg.Matcher.HttpCode)
assert.Nil(t, tg.HealthCheckPath)
assert.Nil(t, tg.Matcher)
assert.Equal(t, int64(30), *tg.HealthCheckIntervalSeconds)
assert.Equal(t, int64(10), *tg.HealthCheckTimeoutSeconds)
assert.Equal(t, int64(3), *tg.HealthyThresholdCount)
assert.Equal(t, int64(3), *tg.UnhealthyThresholdCount)
assert.Equal(t, int64(5), *tg.HealthyThresholdCount)
assert.Equal(t, int64(2), *tg.UnhealthyThresholdCount)
}
func TestCreateTargetGroup_NLBProtocols(t *testing.T) {
+2 -2
View File
@@ -99,8 +99,8 @@ const (
// Default health check values (NLB).
DefaultNLBHealthCheckInterval = 30
DefaultNLBHealthCheckTimeout = 10
DefaultNLBHealthyThreshold = 3
DefaultNLBUnhealthyThreshold = 3
DefaultNLBHealthyThreshold = 5
DefaultNLBUnhealthyThreshold = 2
DefaultNLBHealthCheckProtocol = ProtocolTCP
DefaultNLBHealthCheckPort = "traffic-port"
+2 -1
View File
@@ -48,6 +48,7 @@ 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"),
@@ -284,7 +285,7 @@ func lookupResult(t lookupTarget, resource string, err error) (string, error) {
}
return resource, nil
}
if err.Error() == awserrors.ErrorIAMNoSuchEntity {
if code, _ := awserrors.ResolveErrorCode(err); code == 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",
+1 -1
View File
@@ -33,7 +33,7 @@ func (s *resolverService) CanonicalResourceARN(_ string, kind arn.IAMResourceTyp
return resource, nil
}
}
return "", errors.New(awserrors.ErrorIAMNoSuchEntity)
return "", awserrors.Errorf(awserrors.ErrorIAMNoSuchEntity, "The %s with name %s cannot be found.", kind, name)
}
func (s *resolverService) LookupAccessKey(_ string) (*iamdomain.AccessKey, error) {
@@ -96,6 +96,9 @@ 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,6 +26,7 @@ type regService struct {
getUserName string
getUserCalls int
updateUser *iam.UpdateUserInput
addRoleCalls int
canonicalARN string
canonicalErr error
@@ -41,6 +42,11 @@ 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
@@ -268,6 +274,23 @@ func TestRegistration_AddRoleToInstanceProfilePassRole(t *testing.T) {
})
}
func TestRegistration_UpdateUser(t *testing.T) {
svc := &regService{}
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)
+9
View File
@@ -78,6 +78,15 @@ 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.
+186
View File
@@ -0,0 +1,186 @@
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)
})
}
}
+22 -13
View File
@@ -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, errors.New(awserrors.ErrorIAMEntityAlreadyExists)
return nil, awserrors.Errorf(awserrors.ErrorIAMEntityAlreadyExists, "Group with name %s already exists.", groupName)
}
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, errors.New(awserrors.ErrorIAMDeleteConflict)
return nil, errDetachPoliciesFirst
}
if len(group.InlinePolicies) > 0 {
return nil, errors.New(awserrors.ErrorIAMDeleteConflict)
return nil, errDeletePoliciesFirst
}
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, errors.New(awserrors.ErrorIAMDeleteConflict)
return nil, awserrors.Errorf(awserrors.ErrorIAMDeleteConflict, "Cannot delete entity, must remove users from group first.")
}
if err := s.groupsBucket.Delete(ctx, accountID+"."+groupName); err != nil {
@@ -234,19 +234,28 @@ func (s *IAMServiceImpl) RemoveUserFromGroup(accountID string, input *iam.Remove
groupName := *input.GroupName
userName := *input.UserName
// Operate purely on the membership reference so a dangling pointer to an
// already-deleted group is still cleanable; never fetch the group here.
// Remove the membership reference without fetching the group, so a dangling
// pointer to an already-deleted group is still cleanable.
removed := false
err := s.updateUserCAS(ctx, accountID, userName, func(u *User) (bool, error) {
idx := slices.Index(u.Groups, groupName)
if idx < 0 {
return false, errors.New(awserrors.ErrorIAMNoSuchEntity)
return false, nil
}
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
@@ -265,7 +274,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 err.Error() == awserrors.ErrorIAMNoSuchEntity {
if code, _ := awserrors.ResolveErrorCode(err); code == 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",
@@ -294,7 +303,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.getPolicyByARN(ctx, accountID, policyARN); err != nil {
if err := s.attachablePolicy(ctx, accountID, policyARN); err != nil {
return nil, err
}
}
@@ -322,7 +331,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, errors.New(awserrors.ErrorIAMNoSuchEntity)
return false, policyNotFound(policyARN)
}
g.AttachedPolicies = slices.Delete(g.AttachedPolicies, idx, idx+1)
return true, nil
@@ -408,7 +417,7 @@ func (s *IAMServiceImpl) GetGroupPolicy(accountID string, input *iam.GetGroupPol
doc, ok := group.InlinePolicies[policyName]
if !ok {
return nil, errors.New(awserrors.ErrorIAMNoSuchEntity)
return nil, noSuchEntity("group policy", policyName)
}
return &iam.GetGroupPolicyOutput{
@@ -427,7 +436,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, errors.New(awserrors.ErrorIAMNoSuchEntity)
return false, noSuchEntity("group policy", policyName)
}
delete(g.InlinePolicies, policyName)
return true, nil
@@ -470,7 +479,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, errors.New(awserrors.ErrorIAMNoSuchEntity)
return nil, noSuchEntity("group", groupName)
}
return nil, fmt.Errorf("get group: %w", err)
}
+12
View File
@@ -409,6 +409,18 @@ 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)
}
+11 -6
View File
@@ -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, errors.New(awserrors.ErrorIAMEntityAlreadyExists)
return nil, awserrors.Errorf(awserrors.ErrorIAMEntityAlreadyExists, "Instance Profile %s already exists.", profileName)
}
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 errors.New(awserrors.ErrorIAMDeleteConflict)
return awserrors.Errorf(awserrors.ErrorIAMDeleteConflict, "Cannot delete entity, must remove roles from instance profile first.")
}
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, errors.New(awserrors.ErrorIAMLimitExceeded)
return false, awserrors.Errorf(awserrors.ErrorIAMLimitExceeded, "Cannot exceed quota for InstanceSessionsPerInstanceProfile: 1")
}
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, errors.New(awserrors.ErrorIAMNoSuchEntity)
return false, awserrors.Errorf(awserrors.ErrorIAMNoSuchEntity, "Role %s in Instance Profile %s cannot be found.", roleName, profileName)
}
p.RoleName = ""
return true, nil
@@ -357,11 +357,16 @@ 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, errors.New(awserrors.ErrorIAMNoSuchEntity)
return nil, instanceProfileNotFound(profileName)
}
return nil, fmt.Errorf("get instance profile: %w", err)
}
@@ -385,7 +390,7 @@ func (s *IAMServiceImpl) updateInstanceProfileCAS(ctx context.Context, accountID
func (s *IAMServiceImpl) instanceProfileCASConfig(accountID, profileName string) kvutil.CASConfig {
return kvutil.CASConfig{
Attempts: instanceProfileCASMaxRetries,
NotFound: errors.New(awserrors.ErrorIAMNoSuchEntity),
NotFound: instanceProfileNotFound(profileName),
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, err.Error(), arn)
assert.Equal(t, awserrors.ErrorIAMNoSuchEntity, awserrors.ValidErrorCodeFromError(err), arn)
}
}
+5 -5
View File
@@ -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, errors.New(awserrors.ErrorIAMNoSuchEntity)
return nil, awserrors.Errorf(awserrors.ErrorIAMNoSuchEntity, "The role with name %s cannot be found.", aws.StringValue(in.RoleName))
}
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, errors.New(awserrors.ErrorIAMEntityAlreadyExists)
return nil, awserrors.Errorf(awserrors.ErrorIAMEntityAlreadyExists, "Role with name %s already exists.", name)
}
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, errors.New(awserrors.ErrorIAMNoSuchEntity)
return nil, awserrors.Errorf(awserrors.ErrorIAMNoSuchEntity, "Instance Profile %s cannot be found.", aws.StringValue(in.InstanceProfileName))
}
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, errors.New(awserrors.ErrorIAMEntityAlreadyExists)
return nil, awserrors.Errorf(awserrors.ErrorIAMEntityAlreadyExists, "Instance Profile %s already exists.", name)
}
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, errors.New(awserrors.ErrorIAMLimitExceeded)
return nil, awserrors.Errorf(awserrors.ErrorIAMLimitExceeded, "Cannot exceed quota for InstanceSessionsPerInstanceProfile: 1")
}
p.Roles = []*iam.Role{{RoleName: in.RoleName}}
return &iam.AddRoleToInstanceProfileOutput{}, nil
+3 -3
View File
@@ -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 || err.Error() != awserrors.ErrorIAMNoSuchEntity {
if err == nil || awserrors.ValidErrorCodeFromError(err) != 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 || err.Error() != awserrors.ErrorIAMEntityAlreadyExists {
if _, err := e.CreateRole("acct", in); err == nil || awserrors.ValidErrorCodeFromError(err) != 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 || err.Error() != awserrors.ErrorIAMLimitExceeded {
if _, err := e.AddRoleToInstanceProfile("acct", in); err == nil || awserrors.ValidErrorCodeFromError(err) != awserrors.ErrorIAMLimitExceeded {
t.Fatalf("second AddRoleToInstanceProfile err = %v, want LimitExceeded", err)
}
}
+6 -6
View File
@@ -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, errors.New(awserrors.ErrorIAMEntityAlreadyExists)
return nil, awserrors.Errorf(awserrors.ErrorIAMEntityAlreadyExists, "Provider with url %s already exists.", issuer)
}
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, errors.New(awserrors.ErrorIAMNoSuchEntity)
return nil, oidcProviderNotFound(arn)
}
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, errors.New(awserrors.ErrorIAMNoSuchEntity)
return nil, oidcProviderNotFound(arn)
}
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 errors.New(awserrors.ErrorIAMNoSuchEntity)
return oidcProviderNotFound(arn)
}
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, errors.New(awserrors.ErrorIAMNoSuchEntity)
return nil, oidcProviderNotFound(arn)
}
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, errors.New(awserrors.ErrorIAMNoSuchEntity)
return nil, oidcProviderNotFound(arn)
}
return nil, fmt.Errorf("get OIDC provider: %w", err)
}
+3 -3
View File
@@ -50,7 +50,7 @@ func TestCreateOpenIDConnectProvider_Idempotent(t *testing.T) {
t.Fatalf("first create: %v", err)
}
_, err := svc.CreateOpenIDConnectProvider(acct, in)
if err == nil || err.Error() != awserrors.ErrorIAMEntityAlreadyExists {
if err == nil || awserrors.ValidErrorCodeFromError(err) != 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 || err.Error() != awserrors.ErrorIAMNoSuchEntity {
if err == nil || awserrors.ValidErrorCodeFromError(err) != 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 || err.Error() != awserrors.ErrorIAMNoSuchEntity {
if err == nil || awserrors.ValidErrorCodeFromError(err) != awserrors.ErrorIAMNoSuchEntity {
t.Fatalf("second delete err = %v, want NoSuchEntity", err)
}
}
+2 -2
View File
@@ -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{}, errors.New(awserrors.ErrorIAMNoSuchEntity)
return "", kvutil.CASConfig{}, policyNotFound(policyARN)
}
return accountID + "." + policyName, kvutil.CASConfig{
Attempts: policyCASMaxRetries,
NotFound: errors.New(awserrors.ErrorIAMNoSuchEntity),
NotFound: policyNotFound(policyARN),
Exhausted: func(string, int) error {
slog.Error("IAM policy CAS retries exhausted under contention",
"accountID", accountID, "policyName", policyName, "attempts", policyCASMaxRetries)
+3 -3
View File
@@ -224,11 +224,11 @@ func TestTagUser_DoesNotRecreateConcurrentlyDeletedUser(t *testing.T) {
close(gate.release)
err = <-done
require.Error(t, err)
assert.Equal(t, awserrors.ErrorIAMNoSuchEntity, err.Error())
assert.Equal(t, awserrors.ErrorIAMNoSuchEntity, awserrors.ValidErrorCodeFromError(err))
_, err = svc.getUser(t.Context(), testAccountID, *user.UserName)
require.Error(t, err)
assert.Equal(t, awserrors.ErrorIAMNoSuchEntity, err.Error(), "the deleted user must stay deleted")
assert.Equal(t, awserrors.ErrorIAMNoSuchEntity, awserrors.ValidErrorCodeFromError(err), "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, err.Error())
assert.Equal(t, awserrors.ErrorIAMNoSuchEntity, awserrors.ValidErrorCodeFromError(err))
_, err = kv.Get(t.Context(), OIDCProviderKey(testIssuer))
assert.ErrorIs(t, err, jetstream.ErrKeyNotFound, "the deleted provider must stay deleted")
+1 -1
View File
@@ -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 err.Error() == awserrors.ErrorIAMNoSuchEntity {
if code, _ := awserrors.ResolveErrorCode(err); code == awserrors.ErrorIAMNoSuchEntity {
return passrole.Role{}, passrole.ErrNotFound
}
return passrole.Role{}, err
+21 -11
View File
@@ -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, errors.New(awserrors.ErrorIAMEntityAlreadyExists)
return nil, awserrors.Errorf(awserrors.ErrorIAMEntityAlreadyExists, "Role with name %s already exists.", roleName)
}
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, errors.New(awserrors.ErrorIAMDeleteConflict)
return nil, errDetachPoliciesFirst
}
if len(role.InlinePolicies) > 0 {
return nil, errors.New(awserrors.ErrorIAMDeleteConflict)
return nil, errDeletePoliciesFirst
}
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, errors.New(awserrors.ErrorIAMDeleteConflict)
return nil, awserrors.Errorf(awserrors.ErrorIAMDeleteConflict, "Cannot delete entity, must remove roles from instance profile first.")
}
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: gotRoleToSDK(role)}, nil
return &iam.UpdateRoleDescriptionOutput{Role: describedRoleToSDK(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.getPolicyByARN(ctx, accountID, policyARN); err != nil {
if err := s.attachablePolicy(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, errors.New(awserrors.ErrorIAMNoSuchEntity)
return false, policyNotFound(policyARN)
}
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, errors.New(awserrors.ErrorIAMNoSuchEntity)
return nil, noSuchEntity("role policy", policyName)
}
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, errors.New(awserrors.ErrorIAMNoSuchEntity)
return false, noSuchEntity("role policy", policyName)
}
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, errors.New(awserrors.ErrorIAMNoSuchEntity)
return nil, noSuchEntity("role", roleName)
}
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: errors.New(awserrors.ErrorIAMNoSuchEntity),
NotFound: noSuchEntity("role", roleName),
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,6 +698,16 @@ 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)
+16 -2
View File
@@ -561,7 +561,13 @@ func TestUpdateRole_NotFound(t *testing.T) {
func TestUpdateRoleDescription(t *testing.T) {
t.Parallel()
svc := setupTestIAMService(t)
createTestRole(t, svc, "desc-role")
_, 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)
out, err := svc.UpdateRoleDescription(testAccountID, &iam.UpdateRoleDescriptionInput{
RoleName: aws.String("desc-role"),
@@ -571,7 +577,15 @@ 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, got.Role, out.Role, "output carries the role in the GetRole shape")
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")
_, err = svc.UpdateRoleDescription(testAccountID, &iam.UpdateRoleDescriptionInput{
RoleName: aws.String("desc-role"),
+1
View File
@@ -15,6 +15,7 @@ type IAMService interface {
GetUser(accountID string, input *iam.GetUserInput) (*iam.GetUserOutput, error)
ListUsers(accountID string, input *iam.ListUsersInput) (*iam.ListUsersOutput, error)
DeleteUser(accountID string, input *iam.DeleteUserInput) (*iam.DeleteUserOutput, error)
UpdateUser(accountID string, input *iam.UpdateUserInput) (*iam.UpdateUserOutput, error)
// Access key lifecycle — account-scoped
CreateAccessKey(accountID string, input *iam.CreateAccessKeyInput) (*iam.CreateAccessKeyOutput, error)
+183 -18
View File
@@ -467,7 +467,7 @@ func (s *IAMServiceImpl) CreateUser(accountID string, input *iam.CreateUserInput
// Atomic create — fails if key already exists (race-safe)
if _, err := s.usersBucket.Create(ctx, kvKey, data); err != nil {
if errors.Is(err, jetstream.ErrKeyExists) {
return nil, errors.New(awserrors.ErrorIAMEntityAlreadyExists)
return nil, awserrors.Errorf(awserrors.ErrorIAMEntityAlreadyExists, "User with name %s already exists.", userName)
}
return nil, fmt.Errorf("store user: %w", err)
}
@@ -587,7 +587,7 @@ func (s *IAMServiceImpl) DeleteUser(accountID string, input *iam.DeleteUserInput
// block the delete either; removing the user record drops it.
for _, keyID := range user.AccessKeys {
if _, err := s.accessKeysBucket.Get(ctx, keyID); err == nil {
return nil, errors.New(awserrors.ErrorIAMDeleteConflict)
return nil, awserrors.Errorf(awserrors.ErrorIAMDeleteConflict, "Cannot delete entity, must delete access keys first.")
} else if !errors.Is(err, jetstream.ErrKeyNotFound) {
return nil, fmt.Errorf("get access key: %w", err)
}
@@ -595,13 +595,13 @@ func (s *IAMServiceImpl) DeleteUser(accountID string, input *iam.DeleteUserInput
"accountID", accountID, "userName", userName, "accessKeyID", keyID)
}
if len(user.AttachedPolicies) > 0 {
return nil, errors.New(awserrors.ErrorIAMDeleteConflict)
return nil, errDetachPoliciesFirst
}
if len(user.Groups) > 0 {
return nil, errors.New(awserrors.ErrorIAMDeleteConflict)
}
if len(user.InlinePolicies) > 0 {
return nil, errors.New(awserrors.ErrorIAMDeleteConflict)
return nil, errDeletePoliciesFirst
}
if err := s.usersBucket.Delete(ctx, kvKey); err != nil {
@@ -625,6 +625,133 @@ func (s *IAMServiceImpl) DeleteUser(accountID string, input *iam.DeleteUserInput
return &iam.DeleteUserOutput{}, nil
}
// UpdateUser applies NewPath and NewUserName to a user. A rename moves the
// record to its new key, keeping its ID, access keys, groups and policies.
func (s *IAMServiceImpl) UpdateUser(accountID string, input *iam.UpdateUserInput) (*iam.UpdateUserOutput, error) {
ctx := context.Background()
userName := *input.UserName
if input.NewUserName != nil {
if err := validateIAMName("newUserName", *input.NewUserName, 64); err != nil {
return nil, err
}
}
if input.NewPath != nil {
if err := validatePath(*input.NewPath); err != nil {
return nil, err
}
}
newName := aws.StringValue(input.NewUserName)
var err error
if newName == "" || newName == userName {
err = s.updateUserCAS(ctx, accountID, userName, func(u *User) (bool, error) {
if input.NewPath == nil || *input.NewPath == u.Path {
return false, nil
}
u.Path = *input.NewPath
u.ARN = arn.FormatIAMPath(arn.IAMUser, accountID, u.Path, u.UserName)
return true, nil
})
} else {
err = s.renameUser(ctx, accountID, userName, newName, input.NewPath)
}
if err != nil {
return nil, err
}
slog.Info("IAM user updated", "accountID", accountID, "userName", userName, "newUserName", newName)
return &iam.UpdateUserOutput{}, nil
}
// renameUser writes the user under newName and repoints its access keys before
// deleting the old record at the revision it read. A racing writer of the old
// record makes that delete fail, so the rename is undone and retried.
func (s *IAMServiceImpl) renameUser(ctx context.Context, accountID, oldName, newName string, newPath *string) error {
oldKey, newKey := accountID+"."+oldName, accountID+"."+newName
first, second := oldKey, newKey
if second < first {
first, second = second, first
}
defer s.recordLocks.lock("user/" + first)()
defer s.recordLocks.lock("user/" + second)()
for range roleCASMaxRetries {
entry, err := s.usersBucket.Get(ctx, oldKey)
if err != nil {
if errors.Is(err, jetstream.ErrKeyNotFound) {
return errors.New(awserrors.ErrorIAMNoSuchEntity)
}
return fmt.Errorf("get user: %w", err)
}
var user User
if err := json.Unmarshal(entry.Value(), &user); err != nil {
return fmt.Errorf("unmarshal user: %w", err)
}
user.UserName = newName
if newPath != nil {
user.Path = *newPath
}
user.ARN = arn.FormatIAMPath(arn.IAMUser, accountID, user.Path, newName)
data, err := json.Marshal(user)
if err != nil {
return fmt.Errorf("marshal user: %w", err)
}
newRev, err := s.usersBucket.Create(ctx, newKey, data)
if err != nil {
if errors.Is(err, jetstream.ErrKeyExists) {
return errors.New(awserrors.ErrorIAMEntityAlreadyExists)
}
return fmt.Errorf("store renamed user: %w", err)
}
err = s.setAccessKeysOwner(ctx, accountID, user.AccessKeys, newName)
if err == nil {
err = s.usersBucket.Delete(ctx, oldKey, jetstream.LastRevision(entry.Revision()))
if err == nil {
return nil
}
}
s.undoRenameUser(ctx, accountID, user.AccessKeys, oldName, newKey, newRev)
if !errors.Is(err, jetstream.ErrKeyRevisionMismatch) {
return fmt.Errorf("rename user: %w", err)
}
}
slog.Error("IAM user rename lost every revision race on the old record",
"accountID", accountID, "userName", oldName, "attempts", roleCASMaxRetries)
return errors.New(awserrors.ErrorServerInternal)
}
// setAccessKeysOwner points each access key at userName. A listed key with no
// record is skipped, as ListAccessKeys skips it.
func (s *IAMServiceImpl) setAccessKeysOwner(ctx context.Context, accountID string, keyIDs []string, userName string) error {
for _, keyID := range keyIDs {
cfg := iamRecordCASConfig("access key", accountID, keyID)
cfg.NotFound = nil
_, err := kvutil.Update(ctx, s.accessKeysBucket, keyID, cfg, func(ak *AccessKey) (bool, error) {
if ak.UserName == userName {
return false, nil
}
ak.UserName = userName
return true, nil
})
if err != nil && !errors.Is(err, jetstream.ErrKeyNotFound) {
return fmt.Errorf("repoint access key %s: %w", keyID, err)
}
}
return nil
}
// undoRenameUser returns the access keys to oldName and drops the record
// written under the new name, unless a writer has changed it since.
func (s *IAMServiceImpl) undoRenameUser(ctx context.Context, accountID string, keyIDs []string, oldName, newKey string, newRev uint64) {
if err := s.setAccessKeysOwner(ctx, accountID, keyIDs, oldName); err != nil {
slog.Error("Rollback failed: access keys left on renamed user", "accountID", accountID, "userName", oldName, "err", err)
}
if err := s.usersBucket.Delete(ctx, newKey, jetstream.LastRevision(newRev)); err != nil {
slog.Error("Rollback failed: renamed user record left behind", "accountID", accountID, "userName", oldName, "key", newKey, "err", err)
}
}
// ---------------------------------------------------------------------------
// Access Key Lifecycle
// ---------------------------------------------------------------------------
@@ -753,7 +880,7 @@ func (s *IAMServiceImpl) DeleteAccessKey(accountID string, input *iam.DeleteAcce
err := s.updateUserCAS(ctx, accountID, userName, func(u *User) (bool, error) {
idx := slices.Index(u.AccessKeys, accessKeyID)
if idx < 0 {
return false, errors.New(awserrors.ErrorIAMNoSuchEntity)
return false, awserrors.Errorf(awserrors.ErrorIAMNoSuchEntity, "The Access Key with id %s cannot be found.", accessKeyID)
}
u.AccessKeys = slices.Delete(u.AccessKeys, idx, idx+1)
return true, nil
@@ -1355,7 +1482,8 @@ func (s *IAMServiceImpl) CreatePolicy(accountID string, input *iam.CreatePolicyI
if _, err := s.policiesBucket.Create(ctx, kvKey, data); err != nil {
if errors.Is(err, jetstream.ErrKeyExists) {
return nil, errors.New(awserrors.ErrorIAMEntityAlreadyExists)
return nil, awserrors.Errorf(awserrors.ErrorIAMEntityAlreadyExists,
"A policy called %s already exists. Duplicate names are not allowed.", policyName)
}
return nil, fmt.Errorf("store policy: %w", err)
}
@@ -1502,7 +1630,7 @@ func (s *IAMServiceImpl) DeletePolicy(accountID string, input *iam.DeletePolicyI
// Revision-guarded, so a version created after the checks cannot be deleted unseen.
policy, err := kvutil.DeleteIf(ctx, s.policiesBucket, key, cfg, func(p *Policy) error {
if p.ARN != policyARN {
return errors.New(awserrors.ErrorIAMNoSuchEntity)
return policyNotFound(policyARN)
}
if len(p.OtherVersions) > 0 {
return awserrors.Errorf(awserrors.ErrorIAMDeleteConflict,
@@ -1513,7 +1641,7 @@ func (s *IAMServiceImpl) DeletePolicy(accountID string, input *iam.DeletePolicyI
return fmt.Errorf("check policy attachments: %w", err)
}
if counts[p.ARN] > 0 {
return errors.New(awserrors.ErrorIAMDeleteConflict)
return awserrors.Errorf(awserrors.ErrorIAMDeleteConflict, "Cannot delete a policy attached to entities.")
}
return nil
})
@@ -1612,7 +1740,7 @@ func (s *IAMServiceImpl) AttachUserPolicy(accountID string, input *iam.AttachUse
// grant document is modeled in builtinManagedPolicyDoc and resolved at
// evaluation time. Customer-managed ARNs must still exist.
if !iamarn.IsAWSManagedPolicyARN(policyARN) {
if _, err := s.getPolicyByARN(ctx, accountID, policyARN); err != nil {
if err := s.attachablePolicy(ctx, accountID, policyARN); err != nil {
return nil, err
}
}
@@ -1640,7 +1768,7 @@ func (s *IAMServiceImpl) DetachUserPolicy(accountID string, input *iam.DetachUse
err := s.updateUserCAS(ctx, accountID, userName, func(u *User) (bool, error) {
idx := slices.Index(u.AttachedPolicies, policyARN)
if idx < 0 {
return false, errors.New(awserrors.ErrorIAMNoSuchEntity)
return false, policyNotFound(policyARN)
}
u.AttachedPolicies = slices.Delete(u.AttachedPolicies, idx, idx+1)
return true, nil
@@ -1774,7 +1902,7 @@ func (s *IAMServiceImpl) GetUserPolicy(accountID string, input *iam.GetUserPolic
doc, ok := user.InlinePolicies[policyName]
if !ok {
return nil, errors.New(awserrors.ErrorIAMNoSuchEntity)
return nil, noSuchEntity("user policy", policyName)
}
return &iam.GetUserPolicyOutput{
@@ -1793,7 +1921,7 @@ func (s *IAMServiceImpl) DeleteUserPolicy(accountID string, input *iam.DeleteUse
err := s.updateUserCAS(ctx, accountID, userName, func(u *User) (bool, error) {
if _, ok := u.InlinePolicies[policyName]; !ok {
return false, errors.New(awserrors.ErrorIAMNoSuchEntity)
return false, noSuchEntity("user policy", policyName)
}
delete(u.InlinePolicies, policyName)
return true, nil
@@ -1984,7 +2112,7 @@ func (s *IAMServiceImpl) GetUserPolicies(accountID, userName string) ([]PolicyDo
for _, groupName := range user.Groups {
group, err := s.getGroup(ctx, accountID, groupName)
if err != nil {
if err.Error() == awserrors.ErrorIAMNoSuchEntity {
if code, _ := awserrors.ResolveErrorCode(err); code == awserrors.ErrorIAMNoSuchEntity {
slog.Warn("GetUserPolicies: member references missing group; skipping",
"accountID", accountID, "user", userName, "group", groupName)
continue
@@ -2025,7 +2153,7 @@ func (s *IAMServiceImpl) getPolicyByARN(ctx context.Context, accountID, policyAR
entry, err := s.policiesBucket.Get(ctx, kvKey)
if err != nil {
if errors.Is(err, jetstream.ErrKeyNotFound) {
return "", errors.New(awserrors.ErrorIAMNoSuchEntity)
return "", policyNotFound(policyARN)
}
return "", fmt.Errorf("get policy: %w", err)
}
@@ -2042,9 +2170,9 @@ func (s *IAMServiceImpl) getPolicyByARN(ctx context.Context, accountID, policyAR
// otherwise a malformed ARN is indistinguishable from a missing policy.
slog.Debug("getPolicyByARN: unparseable policy ARN",
"accountID", accountID, "policyArn", policyARN, "err", err)
return nil, errors.New(awserrors.ErrorIAMNoSuchEntity)
return nil, policyNotFound(policyARN)
case errors.Is(err, iamarn.ErrPolicyARNMismatch):
return nil, errors.New(awserrors.ErrorIAMNoSuchEntity)
return nil, policyNotFound(policyARN)
case err != nil:
return nil, err
}
@@ -2166,12 +2294,45 @@ func (s *IAMServiceImpl) buildAttachmentCounts(ctx context.Context, accountID st
return counts, nil
}
// noSuchEntity is NoSuchEntity with AWS's message naming the missing user,
// role, group or inline policy, e.g. "The role with name X cannot be found.".
func noSuchEntity(kind, name string) error {
return awserrors.Errorf(awserrors.ErrorIAMNoSuchEntity, "The %s with name %s cannot be found.", kind, name)
}
// policyNotFound is NoSuchEntity with AWS's message for a managed policy ARN
// that names no stored policy, or none attached to the entity.
func policyNotFound(policyARN string) error {
return awserrors.Errorf(awserrors.ErrorIAMNoSuchEntity, "Policy %s was not found.", policyARN)
}
// oidcProviderNotFound is NoSuchEntity with AWS's message for a missing OIDC provider.
func oidcProviderNotFound(providerARN string) error {
return awserrors.Errorf(awserrors.ErrorIAMNoSuchEntity, "OpenIDConnect Provider not found for arn %s", providerARN)
}
// The DeleteConflict messages AWS shares across users, roles and groups.
var (
errDetachPoliciesFirst = awserrors.Errorf(awserrors.ErrorIAMDeleteConflict, "Cannot delete entity, must detach all policies first.")
errDeletePoliciesFirst = awserrors.Errorf(awserrors.ErrorIAMDeleteConflict, "Cannot delete entity, must delete policies first.")
)
// attachablePolicy refuses a customer-managed policy ARN that names no stored
// policy, with the message AWS gives on attach.
func (s *IAMServiceImpl) attachablePolicy(ctx context.Context, accountID, policyARN string) error {
_, err := s.getPolicyByARN(ctx, accountID, policyARN)
if code, _ := awserrors.ResolveErrorCode(err); code == awserrors.ErrorIAMNoSuchEntity {
return awserrors.Errorf(awserrors.ErrorIAMNoSuchEntity, "Policy %s does not exist or is not attachable.", policyARN)
}
return err
}
func (s *IAMServiceImpl) getUser(ctx context.Context, accountID, userName string) (*User, error) {
kvKey := accountID + "." + userName
entry, err := s.usersBucket.Get(ctx, kvKey)
if err != nil {
if errors.Is(err, jetstream.ErrKeyNotFound) {
return nil, errors.New(awserrors.ErrorIAMNoSuchEntity)
return nil, noSuchEntity("user", userName)
}
return nil, fmt.Errorf("get user: %w", err)
}
@@ -2195,9 +2356,13 @@ func (s *IAMServiceImpl) updateUserCAS(ctx context.Context, accountID, userName
// iamRecordCASConfig is the CAS policy for an IAM user, group or OIDC provider
// record: a missing record is NoSuchEntity and exhausted retries are logged.
func iamRecordCASConfig(kind, accountID, name string) kvutil.CASConfig {
notFound := noSuchEntity(kind, name)
if kind == "oidc-provider" {
notFound = oidcProviderNotFound(name)
}
return kvutil.CASConfig{
Attempts: roleCASMaxRetries,
NotFound: errors.New(awserrors.ErrorIAMNoSuchEntity),
NotFound: notFound,
Exhausted: func(string, int) error {
slog.Error("IAM record CAS retries exhausted under contention",
"kind", kind, "accountID", accountID, "name", name, "attempts", roleCASMaxRetries)
+6 -6
View File
@@ -48,7 +48,7 @@ func EnsureSystemInstanceProfile(e SystemInstanceRoleEnsurer, accountID, roleNam
// there draws credentials from it, so provisioning one would be unasked-for.
func ConvergeSystemRolePolicy(e SystemInstanceRoleEnsurer, accountID, roleName, policyName, policyDoc string) error {
if _, err := e.GetRole(accountID, &iam.GetRoleInput{RoleName: aws.String(roleName)}); err != nil {
if err.Error() == awserrors.ErrorIAMNoSuchEntity {
if code, _ := awserrors.ResolveErrorCode(err); code == awserrors.ErrorIAMNoSuchEntity {
return nil
}
return fmt.Errorf("get role %q: %w", roleName, err)
@@ -68,7 +68,7 @@ func ConvergeSystemRolePolicy(e SystemInstanceRoleEnsurer, accountID, roleName,
func ensureSystemRole(e SystemInstanceRoleEnsurer, accountID, roleName string) error {
if _, err := e.GetRole(accountID, &iam.GetRoleInput{RoleName: aws.String(roleName)}); err == nil {
return nil
} else if err.Error() != awserrors.ErrorIAMNoSuchEntity {
} else if code, _ := awserrors.ResolveErrorCode(err); code != awserrors.ErrorIAMNoSuchEntity {
return fmt.Errorf("get role %q: %w", roleName, err)
}
@@ -77,7 +77,7 @@ func ensureSystemRole(e SystemInstanceRoleEnsurer, accountID, roleName string) e
AssumeRolePolicyDocument: aws.String(EC2InstanceTrustPolicy),
Description: aws.String("Spinifex-managed system instance role"),
})
if err == nil || err.Error() == awserrors.ErrorIAMEntityAlreadyExists {
if code, _ := awserrors.ResolveErrorCode(err); err == nil || code == awserrors.ErrorIAMEntityAlreadyExists {
return nil
}
return fmt.Errorf("create role %q: %w", roleName, err)
@@ -94,7 +94,7 @@ func ensureSystemInstanceProfile(e SystemInstanceRoleEnsurer, accountID, roleNam
return attachSystemRole(e, accountID, roleName,
aws.StringValue(out.InstanceProfile.Arn), len(out.InstanceProfile.Roles) > 0)
}
if err.Error() != awserrors.ErrorIAMNoSuchEntity {
if code, _ := awserrors.ResolveErrorCode(err); code != awserrors.ErrorIAMNoSuchEntity {
return "", fmt.Errorf("get instance profile %q: %w", roleName, err)
}
@@ -102,7 +102,7 @@ func ensureSystemInstanceProfile(e SystemInstanceRoleEnsurer, accountID, roleNam
InstanceProfileName: aws.String(roleName),
})
if err != nil {
if err.Error() != awserrors.ErrorIAMEntityAlreadyExists {
if code, _ := awserrors.ResolveErrorCode(err); code != awserrors.ErrorIAMEntityAlreadyExists {
return "", fmt.Errorf("create instance profile %q: %w", roleName, err)
}
got, gerr := e.GetInstanceProfile(accountID, &iam.GetInstanceProfileInput{
@@ -128,7 +128,7 @@ func attachSystemRole(e SystemInstanceRoleEnsurer, accountID, roleName, profileA
InstanceProfileName: aws.String(roleName),
RoleName: aws.String(roleName),
})
if err != nil && err.Error() != awserrors.ErrorIAMLimitExceeded {
if code, _ := awserrors.ResolveErrorCode(err); err != nil && code != awserrors.ErrorIAMLimitExceeded {
return "", fmt.Errorf("add role %q to instance profile %q: %w", roleName, roleName, err)
}
return profileARN, nil
+5 -5
View File
@@ -460,7 +460,7 @@ func TestCreate_InvalidTagsRefusedAndNotStored(t *testing.T) {
_, err = ops.list(svc, id)
require.Error(t, err)
assert.Equal(t, awserrors.ErrorIAMNoSuchEntity, err.Error())
assert.Equal(t, awserrors.ErrorIAMNoSuchEntity, awserrors.ValidErrorCodeFromError(err))
})
}
}
@@ -577,15 +577,15 @@ func TestResourceTagging_MissingEntity(t *testing.T) {
err := ops.tag(svc, ops.missingID, []*iam.Tag{sdkTag("env", "prod")})
require.Error(t, err)
assert.Equal(t, awserrors.ErrorIAMNoSuchEntity, err.Error())
assert.Equal(t, awserrors.ErrorIAMNoSuchEntity, awserrors.ValidErrorCodeFromError(err))
err = ops.untag(svc, ops.missingID, []*string{aws.String("env")})
require.Error(t, err)
assert.Equal(t, awserrors.ErrorIAMNoSuchEntity, err.Error())
assert.Equal(t, awserrors.ErrorIAMNoSuchEntity, awserrors.ValidErrorCodeFromError(err))
_, err = ops.list(svc, ops.missingID)
require.Error(t, err)
assert.Equal(t, awserrors.ErrorIAMNoSuchEntity, err.Error())
assert.Equal(t, awserrors.ErrorIAMNoSuchEntity, awserrors.ValidErrorCodeFromError(err))
})
}
}
@@ -608,7 +608,7 @@ func TestTagUser_MergedLimitExceeded(t *testing.T) {
Tags: []*iam.Tag{sdkTag("overflow", "v")},
})
require.Error(t, err)
assert.Equal(t, awserrors.ErrorIAMLimitExceeded, err.Error())
assert.Equal(t, awserrors.ErrorIAMLimitExceeded, awserrors.ValidErrorCodeFromError(err))
// Upserting an existing key does not grow the set and stays allowed.
_, err = svc.TagUser(testAccountID, &iam.TagUserInput{
+203
View File
@@ -0,0 +1,203 @@
package iam
import (
"context"
"encoding/json"
"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/nats-io/nats.go/jetstream"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestUpdateUser_RenameKeepsRelationships(t *testing.T) {
t.Parallel()
svc := setupTestIAMService(t)
before := createTestUser(t, svc, "bob")
policy := createTestPolicy(t, svc, "bob-managed")
createTestGroup(t, svc, "devs")
_, err := svc.AttachUserPolicy(testAccountID, &iam.AttachUserPolicyInput{UserName: aws.String("bob"), PolicyArn: policy.Arn})
require.NoError(t, err)
_, err = svc.PutUserPolicy(testAccountID, &iam.PutUserPolicyInput{
UserName: aws.String("bob"), PolicyName: aws.String("inline"), PolicyDocument: aws.String(validPolicyDocument()),
})
require.NoError(t, err)
_, err = svc.AddUserToGroup(testAccountID, &iam.AddUserToGroupInput{UserName: aws.String("bob"), GroupName: aws.String("devs")})
require.NoError(t, err)
key, err := svc.CreateAccessKey(testAccountID, &iam.CreateAccessKeyInput{UserName: aws.String("bob")})
require.NoError(t, err)
out, err := svc.UpdateUser(testAccountID, &iam.UpdateUserInput{
UserName: aws.String("bob"), NewUserName: aws.String("robert"), NewPath: aws.String("/eng/"),
})
require.NoError(t, err)
assert.Equal(t, &iam.UpdateUserOutput{}, out)
_, err = svc.GetUser(testAccountID, &iam.GetUserInput{UserName: aws.String("bob")})
require.Error(t, err)
code, ok := awserrors.ResolveErrorCode(err)
require.True(t, ok, "no AWS code on %v", err)
assert.Equal(t, awserrors.ErrorIAMNoSuchEntity, code)
got, err := svc.GetUser(testAccountID, &iam.GetUserInput{UserName: aws.String("robert")})
require.NoError(t, err)
assert.Equal(t, *before.UserId, *got.User.UserId)
assert.Equal(t, "/eng/", *got.User.Path)
assert.Equal(t, "arn:aws:iam::"+testAccountID+":user/eng/robert", *got.User.Arn)
ak, err := svc.LookupAccessKey(*key.AccessKey.AccessKeyId)
require.NoError(t, err)
assert.Equal(t, "robert", ak.UserName)
keys, err := svc.ListAccessKeys(testAccountID, &iam.ListAccessKeysInput{UserName: aws.String("robert")})
require.NoError(t, err)
require.Len(t, keys.AccessKeyMetadata, 1)
assert.Equal(t, *key.AccessKey.AccessKeyId, *keys.AccessKeyMetadata[0].AccessKeyId)
attached, err := svc.ListAttachedUserPolicies(testAccountID, &iam.ListAttachedUserPoliciesInput{UserName: aws.String("robert")})
require.NoError(t, err)
require.Len(t, attached.AttachedPolicies, 1)
assert.Equal(t, *policy.Arn, *attached.AttachedPolicies[0].PolicyArn)
inline, err := svc.ListUserPolicies(testAccountID, &iam.ListUserPoliciesInput{UserName: aws.String("robert")})
require.NoError(t, err)
assert.Equal(t, []*string{aws.String("inline")}, inline.PolicyNames)
groups, err := svc.ListGroupsForUser(testAccountID, &iam.ListGroupsForUserInput{UserName: aws.String("robert")})
require.NoError(t, err)
require.Len(t, groups.Groups, 1)
assert.Equal(t, "devs", *groups.Groups[0].GroupName)
members, err := svc.GetGroup(testAccountID, &iam.GetGroupInput{GroupName: aws.String("devs")})
require.NoError(t, err)
require.Len(t, members.Users, 1)
assert.Equal(t, "robert", *members.Users[0].UserName)
}
func TestUpdateUser_PathOnly(t *testing.T) {
t.Parallel()
svc := setupTestIAMService(t)
createTestUser(t, svc, "carol")
_, err := svc.UpdateUser(testAccountID, &iam.UpdateUserInput{UserName: aws.String("carol"), NewPath: aws.String("/ops/")})
require.NoError(t, err)
got, err := svc.GetUser(testAccountID, &iam.GetUserInput{UserName: aws.String("carol")})
require.NoError(t, err)
assert.Equal(t, "/ops/", *got.User.Path)
assert.Equal(t, "arn:aws:iam::"+testAccountID+":user/ops/carol", *got.User.Arn)
}
func TestUpdateUser_Errors(t *testing.T) {
t.Parallel()
svc := setupTestIAMService(t)
createTestUser(t, svc, "dave")
createTestUser(t, svc, "erin")
tests := []struct {
name string
input *iam.UpdateUserInput
want string
}{
{"missing user", &iam.UpdateUserInput{UserName: aws.String("ghost"), NewPath: aws.String("/x/")}, awserrors.ErrorIAMNoSuchEntity},
{"missing user rename", &iam.UpdateUserInput{UserName: aws.String("ghost"), NewUserName: aws.String("ghost2")}, awserrors.ErrorIAMNoSuchEntity},
{"taken name", &iam.UpdateUserInput{UserName: aws.String("dave"), NewUserName: aws.String("erin")}, awserrors.ErrorIAMEntityAlreadyExists},
{"bad name", &iam.UpdateUserInput{UserName: aws.String("dave"), NewUserName: aws.String("no spaces")}, awserrors.ErrorValidationError},
{"bad path", &iam.UpdateUserInput{UserName: aws.String("dave"), NewPath: aws.String("noslash")}, awserrors.ErrorValidationError},
}
for _, tc := range tests {
_, err := svc.UpdateUser(testAccountID, tc.input)
require.Error(t, err, tc.name)
assert.True(t, awserrors.IsErrorCode(err, tc.want), "%s: got %v want %s", tc.name, err, tc.want)
}
got, err := svc.GetUser(testAccountID, &iam.GetUserInput{UserName: aws.String("dave")})
require.NoError(t, err)
assert.Equal(t, "/", *got.User.Path, "a refused update leaves the user unchanged")
}
// racingUsersKV writes the old user record once, right after the renamed
// record is created, as a concurrent AttachUserPolicy on another node would.
type racingUsersKV struct {
jetstream.KeyValue
oldKey string
raced bool
}
func (k *racingUsersKV) Create(ctx context.Context, key string, value []byte, opts ...jetstream.KVCreateOpt) (uint64, error) {
rev, err := k.KeyValue.Create(ctx, key, value, opts...)
if err != nil || k.raced {
return rev, err
}
k.raced = true
entry, gerr := k.Get(ctx, k.oldKey)
if gerr != nil {
return rev, gerr
}
var u User
if uerr := json.Unmarshal(entry.Value(), &u); uerr != nil {
return rev, uerr
}
u.AttachedPolicies = append(u.AttachedPolicies, "arn:aws:iam::"+testAccountID+":policy/raced")
data, _ := json.Marshal(u)
if _, perr := k.Update(ctx, k.oldKey, data, entry.Revision()); perr != nil {
return rev, perr
}
return rev, nil
}
func TestUpdateUser_RenameRetriesAfterRacingWrite(t *testing.T) {
t.Parallel()
svc := setupTestIAMService(t)
createTestUser(t, svc, "frank")
key, err := svc.CreateAccessKey(testAccountID, &iam.CreateAccessKeyInput{UserName: aws.String("frank")})
require.NoError(t, err)
svc.usersBucket = &racingUsersKV{KeyValue: svc.usersBucket, oldKey: testAccountID + ".frank"}
_, err = svc.UpdateUser(testAccountID, &iam.UpdateUserInput{UserName: aws.String("frank"), NewUserName: aws.String("franklin")})
require.NoError(t, err)
user, err := svc.getUser(t.Context(), testAccountID, "franklin")
require.NoError(t, err)
assert.Equal(t, []string{"arn:aws:iam::" + testAccountID + ":policy/raced"}, user.AttachedPolicies,
"the racing write survives the rename")
_, err = svc.getUser(t.Context(), testAccountID, "frank")
code, ok := awserrors.ResolveErrorCode(err)
require.True(t, ok, "no AWS code on %v", err)
assert.Equal(t, awserrors.ErrorIAMNoSuchEntity, code)
ak, err := svc.LookupAccessKey(*key.AccessKey.AccessKeyId)
require.NoError(t, err)
assert.Equal(t, "franklin", ak.UserName)
}
// UpdateUser names its user by bare name, so the caller's account is the only
// scope: a rename must never reach a same-named user in another account.
func TestUpdateUser_ScopedToCallerAccount(t *testing.T) {
t.Parallel()
svc := setupTestIAMService(t)
const other = "999988887777"
mine := createTestUser(t, svc, "bob")
theirs, err := svc.CreateUser(other, &iam.CreateUserInput{UserName: aws.String("bob")})
require.NoError(t, err)
_, err = svc.CreateUser(other, &iam.CreateUserInput{UserName: aws.String("carol")})
require.NoError(t, err)
_, err = svc.UpdateUser(testAccountID, &iam.UpdateUserInput{UserName: aws.String("bob"), NewUserName: aws.String("robert")})
require.NoError(t, err)
got, err := svc.GetUser(other, &iam.GetUserInput{UserName: aws.String("bob")})
require.NoError(t, err)
assert.Equal(t, *theirs.User.UserId, *got.User.UserId)
assert.NotEqual(t, *mine.UserId, *got.User.UserId)
_, err = svc.UpdateUser(testAccountID, &iam.UpdateUserInput{UserName: aws.String("carol"), NewUserName: aws.String("caroline")})
require.Error(t, err)
code, ok := awserrors.ResolveErrorCode(err)
require.True(t, ok, "no AWS code on %v", err)
assert.Equal(t, awserrors.ErrorIAMNoSuchEntity, code)
_, err = svc.GetUser(other, &iam.GetUserInput{UserName: aws.String("carol")})
require.NoError(t, err)
}
+168
View File
@@ -0,0 +1,168 @@
package awsapi
import (
"context"
"crypto/rand"
"encoding/base64"
"encoding/hex"
"fmt"
"log/slog"
"net/http"
"strconv"
"time"
"github.com/aws/aws-sdk-go/aws"
"github.com/aws/aws-sdk-go/service/lambda"
"github.com/mulgadc/spinifex/spinifex/domains/lambda/codestore"
"github.com/mulgadc/spinifex/spinifex/domains/lambda/function"
"github.com/mulgadc/spinifex/spinifex/foundation/aws/arn"
awserrors "github.com/mulgadc/spinifex/spinifex/foundation/aws/errors"
)
const (
rollbackDeadline = 10 * time.Second
lastModifiedFmt = "2006-01-02T15:04:05.000-0700"
)
func randomID() (string, error) {
b := make([]byte, 16)
if _, err := rand.Read(b); err != nil {
return "", err
}
return hex.EncodeToString(b), nil
}
func serviceError(msg string) error {
return awserrors.Errorf(awserrors.ErrorServiceException, "%s", msg)
}
// createFunction implements CreateFunction. The name is claimed first, the code
// copy written second, and the claim completed last, so a failure at any point
// leaves either a free name or a claim that expires on its own.
func (d Deps) createFunction(ctx context.Context, c *call) (reply, error) {
p, err := validateCreate(c.Create)
if err != nil {
return reply{}, err
}
functionID, err := randomID()
if err != nil {
return reply{}, serviceError("could not generate a function identifier")
}
revisionID, err := randomID()
if err != nil {
return reply{}, serviceError("could not generate a revision identifier")
}
var envCipher string
if len(p.Env) > 0 {
if d.AEAD == nil {
return reply{}, serviceError("environment encryption is unavailable")
}
if envCipher, err = function.SealEnv(d.AEAD, c.AccountID, functionID, p.Env); err != nil {
slog.ErrorContext(ctx, "Lambda: seal environment failed", "function_id", functionID, "err", err)
return reply{}, serviceError("could not protect the function environment")
}
}
rec := function.Record{
FormatVersion: function.FormatVersion,
FunctionID: functionID,
AccountID: c.AccountID,
Region: c.Region,
FunctionName: p.Name,
Role: p.Role,
Runtime: supportedRuntime,
Handler: p.Handler,
Description: p.Description,
Timeout: p.TimeoutSec,
MemorySize: p.MemorySize,
EnvCipher: envCipher,
State: function.StatePending,
Code: function.Code{RevisionID: revisionID, State: function.CodeStoring},
}
createRev, err := d.Meta.Create(ctx, c.AccountID, c.Region, rec)
if err != nil {
return reply{}, err
}
ref := codestore.Ref{AccountID: c.AccountID, FunctionID: functionID, RevisionID: revisionID}
store, err := d.Codes(ctx)
if err != nil {
d.rollback(ctx, c, p.Name, createRev, ref, "code store unavailable", err)
return reply{}, serviceError("the function code could not be stored")
}
sha, err := store.Put(ctx, ref, p.Code)
if err != nil {
d.rollback(ctx, c, p.Name, createRev, ref, "code write failed", err)
return reply{}, serviceError("the function code could not be stored")
}
stored, storedRev, err := d.Meta.CodeStored(ctx, c.AccountID, c.Region, p.Name, createRev, sha, int64(len(p.Code)))
if err != nil {
d.rollback(ctx, c, p.Name, createRev, ref, "code-stored was not recorded", err)
return reply{}, serviceError("the function could not be completed")
}
cfg, err := configuration(stored, storedRev, p.Env)
if err != nil {
return reply{}, serviceError("could not build the function configuration")
}
return reply{Status: http.StatusCreated, Body: cfg}, nil
}
// rollback makes one compare-and-swap attempt to free the name. It cannot undo
// a create that completed, because the revision it holds has moved on. The code
// object, if one landed, is left in place and recorded as a cleanup obligation.
func (d Deps) rollback(ctx context.Context, c *call, name string, createRev uint64, ref codestore.Ref, why string, cause error) {
slog.ErrorContext(ctx, "Lambda: create did not complete", "reason", why, "function_name", name,
"function_id", ref.FunctionID, "err", cause)
rctx, cancel := context.WithTimeout(context.WithoutCancel(ctx), rollbackDeadline)
defer cancel()
if err := d.Meta.RollbackCreate(rctx, c.AccountID, c.Region, name, createRev); err != nil {
slog.WarnContext(ctx, "Lambda: rollback of the name claim failed; it expires on its own",
"function_name", name, "function_id", ref.FunctionID, "err", err)
}
slog.WarnContext(ctx, "Lambda: code object cleanup is owed",
"account_id", ref.AccountID, "function_id", ref.FunctionID, "revision_id", ref.RevisionID)
}
// configuration renders the record as a FunctionConfiguration.
func configuration(rec function.Record, revision uint64, env map[string]string) (*lambda.FunctionConfiguration, error) {
cfg := &lambda.FunctionConfiguration{
FunctionName: aws.String(rec.FunctionName),
FunctionArn: aws.String(arn.FormatLambdaFunction(rec.Region, rec.AccountID, rec.FunctionName)),
Runtime: aws.String(rec.Runtime),
Role: aws.String(rec.Role),
Handler: aws.String(rec.Handler),
Description: aws.String(rec.Description),
Timeout: aws.Int64(rec.Timeout),
MemorySize: aws.Int64(rec.MemorySize),
LastModified: aws.String(rec.LastModified.UTC().Format(lastModifiedFmt)),
Version: aws.String("$LATEST"),
State: aws.String(rec.State),
PackageType: aws.String(lambda.PackageTypeZip),
Architectures: aws.StringSlice([]string{lambda.ArchitectureX8664}),
RevisionId: aws.String(strconv.FormatUint(revision, 10)),
LastUpdateStatus: aws.String(lambda.LastUpdateStatusSuccessful),
EphemeralStorage: &lambda.EphemeralStorage{Size: aws.Int64(512)},
LoggingConfig: &lambda.LoggingConfig{LogFormat: aws.String(lambda.LogFormatText), LogGroup: aws.String(logGroup(rec.FunctionName))},
}
if rec.StateReason != "" {
cfg.StateReason = aws.String(rec.StateReason)
cfg.StateReasonCode = aws.String(rec.StateReasonCode)
}
if rec.Code.State == function.CodeStored {
raw, err := hex.DecodeString(rec.Code.SHA256)
if err != nil {
return nil, fmt.Errorf("stored digest is not hex: %w", err)
}
cfg.CodeSha256 = aws.String(base64.StdEncoding.EncodeToString(raw))
cfg.CodeSize = aws.Int64(rec.Code.Size)
}
if len(env) > 0 {
vars := make(map[string]*string, len(env))
for k, v := range env {
vars[k] = aws.String(v)
}
cfg.Environment = &lambda.EnvironmentResponse{Variables: vars}
}
return cfg, nil
}
+82
View File
@@ -0,0 +1,82 @@
package awsapi
import (
"context"
"errors"
"log/slog"
"net/http"
"time"
"github.com/aws/aws-sdk-go/aws"
"github.com/aws/aws-sdk-go/service/lambda"
"github.com/mulgadc/spinifex/spinifex/domains/lambda/codestore"
"github.com/mulgadc/spinifex/spinifex/domains/lambda/function"
"github.com/mulgadc/spinifex/spinifex/foundation/aws/arn"
awserrors "github.com/mulgadc/spinifex/spinifex/foundation/aws/errors"
)
// codeURLTTL is how long the presigned code location stays valid.
const codeURLTTL = 10 * time.Minute
func notFound(region, accountID, name string) error {
return awserrors.Errorf(awserrors.ErrorResourceNotFoundException, "Function not found: %s",
arn.FormatLambdaFunction(region, accountID, name))
}
// getFunction implements GetFunction for the $LATEST version.
func (d Deps) getFunction(ctx context.Context, c *call) (reply, error) {
name, q, err := resolveFunctionName(c.Region, c.AccountID, c.Params[0])
if err != nil {
return reply{}, err
}
if query := c.Query.Get("Qualifier"); query != "" {
if q != "" && q != query {
return reply{}, invalid("The derived qualifier from the function name does not match the specified qualifier.")
}
q = query
}
if q != "" && q != "$LATEST" {
return reply{}, notFound(c.Region, c.AccountID, name+":"+q)
}
rec, revision, err := d.Meta.Get(ctx, c.AccountID, c.Region, name)
if errors.Is(err, function.ErrNotFound) {
return reply{}, notFound(c.Region, c.AccountID, name)
}
if err != nil {
return reply{}, err
}
var env map[string]string
if rec.EnvCipher != "" {
if d.AEAD == nil {
return reply{}, serviceError("environment decryption is unavailable")
}
if env, err = function.OpenEnv(d.AEAD, rec.AccountID, rec.FunctionID, rec.EnvCipher); err != nil {
slog.ErrorContext(ctx, "Lambda: open environment failed", "function_id", rec.FunctionID, "err", err)
return reply{}, serviceError("could not read the function environment")
}
}
cfg, err := configuration(rec, revision, env)
if err != nil {
return reply{}, serviceError("could not build the function configuration")
}
out := &lambda.GetFunctionOutput{
Configuration: cfg,
Code: &lambda.FunctionCodeLocation{RepositoryType: aws.String("S3")},
}
if rec.Code.State == function.CodeStored {
store, err := d.Codes(ctx)
if err != nil {
slog.ErrorContext(ctx, "Lambda: code store unavailable for GetFunction", "function_id", rec.FunctionID, "err", err)
return reply{}, serviceError("the function code location is unavailable")
}
ref := codestore.Ref{AccountID: rec.AccountID, FunctionID: rec.FunctionID, RevisionID: rec.Code.RevisionID}
location, err := store.PresignGet(ctx, ref, rec.Code.SHA256, codeURLTTL)
if err != nil {
slog.ErrorContext(ctx, "Lambda: presign code location failed", "function_id", rec.FunctionID, "err", err)
return reply{}, serviceError("the function code location is unavailable")
}
out.Code.Location = aws.String(location)
}
return reply{Status: http.StatusOK, Body: out}, nil
}
@@ -0,0 +1,669 @@
package awsapi_test
import (
"archive/zip"
"bytes"
"context"
"crypto/sha256"
"encoding/base64"
"encoding/hex"
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"runtime"
"strings"
"sync"
"testing"
"time"
"github.com/aws/aws-sdk-go/aws"
"github.com/aws/aws-sdk-go/service/lambda"
"github.com/mulgadc/bluebottle/pkg/auth"
"github.com/mulgadc/bluebottle/pkg/masterkey"
"github.com/mulgadc/spinifex/internal/testkit"
"github.com/mulgadc/spinifex/internal/testkit/gwtest"
iamdomain "github.com/mulgadc/spinifex/spinifex/domains/iam"
"github.com/mulgadc/spinifex/spinifex/domains/lambda/awsapi"
"github.com/mulgadc/spinifex/spinifex/domains/lambda/codestore"
"github.com/mulgadc/spinifex/spinifex/domains/lambda/function"
awserrors "github.com/mulgadc/spinifex/spinifex/foundation/aws/errors"
"github.com/mulgadc/spinifex/spinifex/foundation/aws/passrole"
"github.com/nats-io/nats.go/jetstream"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
const (
roleARN = "arn:aws:iam::" + gwtest.AccountID + ":role/lambda-exec"
foreignRoleARN = "arn:aws:iam::999999999999:role/lambda-exec"
)
// localClient reaches a real MetaService without NATS.
type localClient struct {
svc *function.MetaService
mu sync.Mutex
failCodeStored bool
passOwnerClock bool
rollbacks int
}
var _ function.Client = (*localClient)(nil)
func (c *localClient) Create(ctx context.Context, account, region string, rec function.Record) (uint64, error) {
r, err := c.svc.Create(ctx, &function.CreateRequest{Region: region, Record: rec}, account)
if err != nil {
return 0, err
}
return r.Revision, nil
}
func (c *localClient) Get(ctx context.Context, account, region, name string) (function.Record, uint64, error) {
r, err := c.svc.Get(ctx, &function.GetRequest{Region: region, FunctionName: name}, account)
if err != nil {
return function.Record{}, 0, err
}
if !r.Found {
return function.Record{}, 0, function.ErrNotFound
}
return r.Record, r.Revision, nil
}
func (c *localClient) CodeStored(ctx context.Context, account, region, name string, rev uint64, sha string, size int64) (function.Record, uint64, error) {
c.mu.Lock()
fail, passClock := c.failCodeStored, c.passOwnerClock
c.mu.Unlock()
if fail {
return function.Record{}, 0, errors.New("nats: timeout")
}
r, err := c.svc.CodeStored(ctx, &function.CodeStoredRequest{Region: region, FunctionName: name, Revision: rev, SHA256: sha, Size: size}, account)
if err != nil {
return function.Record{}, 0, err
}
// Let the wall clock pass the owner's millisecond, so a timestamp the
// gateway reads for itself would differ from the stored one.
for passClock && !time.Now().Truncate(time.Millisecond).After(r.Record.LastModified.Truncate(time.Millisecond)) {
runtime.Gosched()
}
return r.Record, r.Revision, nil
}
func (c *localClient) RollbackCreate(ctx context.Context, account, region, name string, rev uint64) error {
c.mu.Lock()
c.rollbacks++
c.mu.Unlock()
_, err := c.svc.RollbackCreate(ctx, &function.RollbackCreateRequest{Region: region, FunctionName: name, Revision: rev}, account)
return err
}
// memStore is an in-memory code store; objects is keyed by Ref.
type memStore struct {
mu sync.Mutex
objects map[codestore.Ref][]byte
putErr error
urlErr error
}
var _ awsapi.CodeStore = (*memStore)(nil)
func (m *memStore) Put(_ context.Context, ref codestore.Ref, code []byte) (string, error) {
m.mu.Lock()
defer m.mu.Unlock()
if m.putErr != nil {
return "", m.putErr
}
m.objects[ref] = append([]byte(nil), code...)
sum := sha256.Sum256(code)
return hex.EncodeToString(sum[:]), nil
}
func (m *memStore) PresignGet(_ context.Context, ref codestore.Ref, sha string, ttl time.Duration) (string, error) {
if m.urlErr != nil {
return "", m.urlErr
}
return "https://code.test/" + ref.FunctionID + "/" + sha + "?ttl=" + ttl.String(), nil
}
func (m *memStore) count() int {
m.mu.Lock()
defer m.mu.Unlock()
return len(m.objects)
}
type roleSet map[string]bool
func (r roleSet) ResolveRole(accountID, arn string) (passrole.Role, error) {
acct, name, err := auth.ParseRoleARN(arn)
if err != nil {
return passrole.Role{}, passrole.ErrMalformed
}
if acct != accountID {
return passrole.Role{}, passrole.ErrForeignAccount
}
if !r[arn] {
return passrole.Role{}, passrole.ErrNotFound
}
return passrole.Role{ARN: arn, Name: name}, nil
}
func newHarness(t *testing.T, statements ...iamdomain.Statement) (*gwtest.Harness, *localClient, *memStore, func(*testing.T) jetstream.KeyValue, *masterkey.Key) {
t.Helper()
_, _, js := testutil.StartTestJetStream(t)
key, err := masterkey.New(make([]byte, 32))
require.NoError(t, err)
meta := &localClient{svc: function.NewMetaService(js)}
store := &memStore{objects: map[codestore.Ref][]byte{}}
kv := func(t *testing.T) jetstream.KeyValue {
t.Helper()
b, err := js.KeyValue(t.Context(), function.KVAccountBucket(gwtest.AccountID))
require.NoError(t, err)
return b
}
return gwtest.Scoped(statements...), meta, store, kv, key
}
type env struct {
h *gwtest.Harness
meta *localClient
store *memStore
kv func(*testing.T) jetstream.KeyValue
deps awsapi.Deps
}
func newEnv(t *testing.T, statements ...iamdomain.Statement) *env {
t.Helper()
if len(statements) == 0 {
statements = []iamdomain.Statement{gwtest.Statement("Allow", "*", "*")}
}
h, meta, store, kv, key := newHarness(t, statements...)
e := &env{h: h, meta: meta, store: store, kv: kv}
e.deps = awsapi.Deps{
Meta: meta,
Codes: func(context.Context) (awsapi.CodeStore, error) {
return store, nil
},
Roles: roleSet{roleARN: true},
Trusts: func(string, string) (bool, error) { return true, nil },
AEAD: key.AEAD,
}
return e
}
func (e *env) do(method, path string, body any) (*httptest.ResponseRecorder, error) {
var raw []byte
if body != nil {
raw, _ = json.Marshal(body)
}
rec := httptest.NewRecorder()
req := httptest.NewRequest(method, path, strings.NewReader(string(raw)))
err := e.h.Dispatch(awsapi.NewRegistration(e.deps), awsapi.ServiceName, rec, req)
return rec, err
}
var testZip = buildZip("hello")
func buildZip(content string) []byte {
var buf bytes.Buffer
w := zip.NewWriter(&buf)
f, err := w.Create("bootstrap")
if err == nil {
_, err = f.Write([]byte(content))
}
if err == nil {
err = w.Close()
}
if err != nil {
panic(err)
}
return buf.Bytes()
}
func mustZip(t *testing.T, content string) []byte {
t.Helper()
return buildZip(content)
}
func createBody(name string, mutate func(map[string]any)) map[string]any {
b := map[string]any{
"FunctionName": name,
"Runtime": "provided.al2023",
"Role": roleARN,
"Handler": "bootstrap",
"Code": map[string]any{"ZipFile": testZip},
}
if mutate != nil {
mutate(b)
}
return b
}
func requireCode(t *testing.T, err error, code string) {
t.Helper()
require.Error(t, err)
got, ok := awserrors.ResolveErrorCode(err)
require.True(t, ok, "no AWS code on %v", err)
assert.Equal(t, code, got)
}
func TestCreateFunction_Success(t *testing.T) {
e := newEnv(t)
code := testZip
rec, err := e.do(http.MethodPost, "/2015-03-31/functions", createBody("hello", func(b map[string]any) {
b["Environment"] = map[string]any{"Variables": map[string]string{"DB_PASSWORD": "hunter2"}}
b["Description"] = "demo"
b["Timeout"] = 30
b["MemorySize"] = 256
}))
require.NoError(t, err)
require.Equal(t, http.StatusCreated, rec.Code)
var cfg lambda.FunctionConfiguration
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &cfg))
sum := sha256.Sum256(code)
assert.Equal(t, base64.StdEncoding.EncodeToString(sum[:]), *cfg.CodeSha256, "AWS reports base64 of the raw digest")
assert.Equal(t, int64(len(code)), *cfg.CodeSize)
assert.Equal(t, "Pending", *cfg.State)
assert.Equal(t, "Creating", *cfg.StateReasonCode)
assert.Equal(t, "arn:aws:lambda:"+gwtest.Region+":"+gwtest.AccountID+":function:hello", *cfg.FunctionArn)
assert.Equal(t, "$LATEST", *cfg.Version)
assert.Equal(t, "Zip", *cfg.PackageType)
assert.Equal(t, int64(30), *cfg.Timeout)
assert.Equal(t, int64(256), *cfg.MemorySize)
assert.Equal(t, "hunter2", *cfg.Environment.Variables["DB_PASSWORD"])
assert.Regexp(t, `^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}\+0000$`, *cfg.LastModified)
require.Equal(t, 1, e.store.count())
for _, stored := range e.store.objects {
assert.Equal(t, code, stored, "the code copy is retrievable")
}
entry, err := e.kv(t).Get(t.Context(), function.KVFunctionKey(gwtest.Region, "hello"))
require.NoError(t, err)
assert.NotContains(t, string(entry.Value()), "hunter2", "the environment is never stored in plaintext")
assert.Contains(t, string(entry.Value()), "envCipher")
}
func TestCreateFunction_RendersTheStoredTimestamps(t *testing.T) {
e := newEnv(t)
e.meta.passOwnerClock = true
rec, err := e.do(http.MethodPost, "/2015-03-31/functions", createBody("hello", nil))
require.NoError(t, err)
var created lambda.FunctionConfiguration
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &created))
rec, err = e.do(http.MethodGet, "/2015-03-31/functions/hello", nil)
require.NoError(t, err)
var got lambda.GetFunctionOutput
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &got))
assert.Equal(t, *got.Configuration.LastModified, *created.LastModified)
assert.Equal(t, *got.Configuration.RevisionId, *created.RevisionId)
}
func TestCreateFunction_DuplicateNameLeavesFirstUntouched(t *testing.T) {
e := newEnv(t)
_, err := e.do(http.MethodPost, "/2015-03-31/functions", createBody("hello", nil))
require.NoError(t, err)
first, _, err := e.meta.Get(t.Context(), gwtest.AccountID, gwtest.Region, "hello")
require.NoError(t, err)
_, err = e.do(http.MethodPost, "/2015-03-31/functions", createBody("hello", func(b map[string]any) {
b["Code"] = map[string]any{"ZipFile": mustZip(t, "different")}
}))
requireCode(t, err, awserrors.ErrorResourceConflictException)
assert.Contains(t, err.Error(), "Function already exist: hello")
assert.Equal(t, 1, e.store.count(), "the loser wrote no code")
after, _, err := e.meta.Get(t.Context(), gwtest.AccountID, gwtest.Region, "hello")
require.NoError(t, err)
assert.Equal(t, first, after)
}
func TestCreateFunction_CodePutFailureFreesTheName(t *testing.T) {
e := newEnv(t)
e.store.putErr = errors.New("predastore down")
_, err := e.do(http.MethodPost, "/2015-03-31/functions", createBody("hello", nil))
requireCode(t, err, awserrors.ErrorServiceException)
assert.NotContains(t, err.Error(), "predastore down")
assert.Equal(t, 1, e.meta.rollbacks)
_, _, err = e.meta.Get(t.Context(), gwtest.AccountID, gwtest.Region, "hello")
require.ErrorIs(t, err, function.ErrNotFound)
e.store.putErr = nil
rec, err := e.do(http.MethodPost, "/2015-03-31/functions", createBody("hello", nil))
require.NoError(t, err)
assert.Equal(t, http.StatusCreated, rec.Code)
}
func TestCreateFunction_CodeStoredFailureRollsBackOnce(t *testing.T) {
e := newEnv(t)
e.meta.failCodeStored = true
_, err := e.do(http.MethodPost, "/2015-03-31/functions", createBody("hello", nil))
requireCode(t, err, awserrors.ErrorServiceException)
assert.Equal(t, 1, e.meta.rollbacks)
assert.Equal(t, 1, e.store.count(), "the code object is left in place, never deleted here")
_, _, err = e.meta.Get(t.Context(), gwtest.AccountID, gwtest.Region, "hello")
require.ErrorIs(t, err, function.ErrNotFound, "the still-storing claim was withdrawn")
}
func TestCreateFunction_RefusesUnsupportedInput(t *testing.T) {
cases := map[string]func(map[string]any){
"Code.S3Bucket": func(b map[string]any) { b["Code"] = map[string]any{"S3Bucket": "b", "S3Key": "k"} },
"Code.S3Key": func(b map[string]any) { b["Code"] = map[string]any{"ZipFile": []byte("x"), "S3Key": "k"} },
"Code.S3ObjectVersion": func(b map[string]any) { b["Code"] = map[string]any{"ZipFile": []byte("x"), "S3ObjectVersion": "v"} },
"Code.ImageUri": func(b map[string]any) { b["Code"] = map[string]any{"ImageUri": "x"} },
"Code.SourceKMSKeyArn": func(b map[string]any) { b["Code"] = map[string]any{"ZipFile": []byte("x"), "SourceKMSKeyArn": "k"} },
"Layers": func(b map[string]any) { b["Layers"] = []string{"arn:aws:lambda:r:1:layer:l:1"} },
"VpcConfig": func(b map[string]any) { b["VpcConfig"] = map[string]any{"SubnetIds": []string{"subnet-1"}} },
"KMSKeyArn": func(b map[string]any) { b["KMSKeyArn"] = "arn:aws:kms:r:1:key/k" },
"DeadLetterConfig": func(b map[string]any) { b["DeadLetterConfig"] = map[string]any{"TargetArn": "arn:aws:sqs:r:1:q"} },
"TracingConfig": func(b map[string]any) { b["TracingConfig"] = map[string]any{"Mode": "Active"} },
"FileSystemConfigs": func(b map[string]any) {
b["FileSystemConfigs"] = []map[string]any{{"Arn": "a", "LocalMountPath": "/mnt/x"}}
},
"ImageConfig": func(b map[string]any) { b["ImageConfig"] = map[string]any{"Command": []string{"x"}} },
"Architectures": func(b map[string]any) { b["Architectures"] = []string{"arm64"} },
"Publish": func(b map[string]any) { b["Publish"] = true },
"SnapStart": func(b map[string]any) { b["SnapStart"] = map[string]any{"ApplyOn": "PublishedVersions"} },
"EphemeralStorage": func(b map[string]any) { b["EphemeralStorage"] = map[string]any{"Size": 1024} },
"CodeSigningConfigArn": func(b map[string]any) { b["CodeSigningConfigArn"] = "arn:aws:lambda:r:1:code-signing-config:c" },
"LoggingConfig": func(b map[string]any) { b["LoggingConfig"] = map[string]any{"LogFormat": "JSON"} },
"Tags": func(b map[string]any) { b["Tags"] = map[string]string{"a": "b"} },
"TenancyConfig": func(b map[string]any) { b["TenancyConfig"] = map[string]any{"TenantIsolationMode": "PER_TENANT"} },
}
for field, mutate := range cases {
t.Run(field, func(t *testing.T) {
e := newEnv(t)
_, err := e.do(http.MethodPost, "/2015-03-31/functions", createBody("hello", mutate))
requireCode(t, err, awserrors.ErrorInvalidParameterValueException)
assert.Zero(t, e.store.count())
_, _, getErr := e.meta.Get(t.Context(), gwtest.AccountID, gwtest.Region, "hello")
require.ErrorIs(t, getErr, function.ErrNotFound, "a refused request claims nothing")
})
}
}
func TestCreateFunction_AcceptsDefaultsEquivalentToAbsent(t *testing.T) {
e := newEnv(t)
rec, err := e.do(http.MethodPost, "/2015-03-31/functions", createBody("hello", func(b map[string]any) {
b["Architectures"] = []string{"x86_64"}
b["Publish"] = false
b["EphemeralStorage"] = map[string]any{"Size": 512}
b["TracingConfig"] = map[string]any{"Mode": "PassThrough"}
b["PackageType"] = "Zip"
b["Layers"] = []string{}
b["Tags"] = map[string]string{}
b["LoggingConfig"] = map[string]any{"LogFormat": "Text", "LogGroup": "/aws/lambda/hello"}
}))
require.NoError(t, err)
assert.Equal(t, http.StatusCreated, rec.Code)
}
func TestCreateFunction_RefusesNonDefaultLoggingConfig(t *testing.T) {
for _, cfg := range []map[string]any{
{"LogGroup": "/custom/group"},
{"LogFormat": "Text", "LogGroup": "/aws/lambda/other"},
{"LogFormat": "Text", "ApplicationLogLevel": "DEBUG"},
{"SystemLogLevel": "WARN"},
} {
e := newEnv(t)
_, err := e.do(http.MethodPost, "/2015-03-31/functions", createBody("hello", func(b map[string]any) { b["LoggingConfig"] = cfg }))
requireCode(t, err, awserrors.ErrorInvalidParameterValueException)
_, _, getErr := e.meta.Get(t.Context(), gwtest.AccountID, gwtest.Region, "hello")
require.ErrorIs(t, getErr, function.ErrNotFound, "%v claims nothing", cfg)
}
}
func TestFunctionResponses_CarryTheDefaultLoggingConfig(t *testing.T) {
e := newEnv(t)
rec, err := e.do(http.MethodPost, "/2015-03-31/functions", createBody("hello", nil))
require.NoError(t, err)
var created lambda.FunctionConfiguration
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &created))
want := &lambda.LoggingConfig{LogFormat: aws.String("Text"), LogGroup: aws.String("/aws/lambda/hello")}
assert.Equal(t, want, created.LoggingConfig)
rec, err = e.do(http.MethodGet, "/2015-03-31/functions/hello", nil)
require.NoError(t, err)
var got lambda.GetFunctionOutput
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &got))
assert.Equal(t, want, got.Configuration.LoggingConfig)
}
func TestCreateFunction_ParameterValidation(t *testing.T) {
cases := map[string]func(map[string]any){
"bad name": func(b map[string]any) { b["FunctionName"] = "has space" },
"arn name": func(b map[string]any) { b["FunctionName"] = "arn:aws:lambda:r:1:function:x" },
"long name": func(b map[string]any) { b["FunctionName"] = strings.Repeat("a", 65) },
"runtime": func(b map[string]any) { b["Runtime"] = "python3.12" },
"no runtime": func(b map[string]any) { delete(b, "Runtime") },
"package type": func(b map[string]any) { b["PackageType"] = "Image" },
"no code": func(b map[string]any) { delete(b, "Code") },
"empty zip": func(b map[string]any) { b["Code"] = map[string]any{"ZipFile": []byte{}} },
"no handler": func(b map[string]any) { delete(b, "Handler") },
"no role": func(b map[string]any) { delete(b, "Role") },
"timeout low": func(b map[string]any) { b["Timeout"] = 0 },
"timeout high": func(b map[string]any) { b["Timeout"] = 901 },
"memory low": func(b map[string]any) { b["MemorySize"] = 64 },
"memory high": func(b map[string]any) { b["MemorySize"] = 10241 },
"cross account": func(b map[string]any) { b["Role"] = foreignRoleARN },
"missing role": func(b map[string]any) { b["Role"] = "arn:aws:iam::" + gwtest.AccountID + ":role/ghost" },
"not a role arn": func(b map[string]any) { b["Role"] = "arn:aws:iam::" + gwtest.AccountID + ":user/alice" },
"malformed body": func(b map[string]any) { b["Timeout"] = "soon" },
"empty env name": func(b map[string]any) { b["Environment"] = map[string]any{"Variables": map[string]string{"": "x"}} },
"description len": func(b map[string]any) { b["Description"] = strings.Repeat("d", 257) },
}
for name, mutate := range cases {
t.Run(name, func(t *testing.T) {
e := newEnv(t)
_, err := e.do(http.MethodPost, "/2015-03-31/functions", createBody("hello", mutate))
requireCode(t, err, awserrors.ErrorInvalidParameterValueException)
assert.Zero(t, e.store.count())
})
}
}
func TestCreateFunction_RoleErrorsDoNotLeakAcrossAccounts(t *testing.T) {
e := newEnv(t)
_, err := e.do(http.MethodPost, "/2015-03-31/functions", createBody("hello", func(b map[string]any) { b["Role"] = foreignRoleARN }))
requireCode(t, err, awserrors.ErrorInvalidParameterValueException)
assert.Contains(t, err.Error(), "cannot be assumed by Lambda")
}
func TestCreateFunction_RefusesBytesThatAreNotAZip(t *testing.T) {
e := newEnv(t)
_, err := e.do(http.MethodPost, "/2015-03-31/functions", createBody("hello", func(b map[string]any) {
b["Code"] = map[string]any{"ZipFile": []byte("PK\x03\x04 not really a zip")}
}))
requireCode(t, err, awserrors.ErrorInvalidParameterValueException)
assert.Contains(t, err.Error(), "Could not unzip uploaded file")
_, _, err = e.meta.Get(t.Context(), gwtest.AccountID, gwtest.Region, "hello")
require.ErrorIs(t, err, function.ErrNotFound)
assert.Empty(t, e.store.objects)
}
func TestCreateFunction_RoleLambdaCannotAssumeRecordsNothing(t *testing.T) {
e := newEnv(t)
e.deps.Trusts = func(string, string) (bool, error) { return false, nil }
_, err := e.do(http.MethodPost, "/2015-03-31/functions", createBody("hello", nil))
requireCode(t, err, awserrors.ErrorInvalidParameterValueException)
assert.Contains(t, err.Error(), "cannot be assumed by Lambda")
_, _, err = e.meta.Get(t.Context(), gwtest.AccountID, gwtest.Region, "hello")
require.ErrorIs(t, err, function.ErrNotFound)
assert.Empty(t, e.store.objects)
}
func TestCreateFunction_MissingRoleCheckFailsClosed(t *testing.T) {
for name, mutate := range map[string]func(*awsapi.Deps){
"no resolver": func(d *awsapi.Deps) { d.Roles = nil },
"no trust checker": func(d *awsapi.Deps) { d.Trusts = nil },
} {
t.Run(name, func(t *testing.T) {
e := newEnv(t)
mutate(&e.deps)
_, err := e.do(http.MethodPost, "/2015-03-31/functions", createBody("hello", nil))
requireCode(t, err, awserrors.ErrorServiceException)
_, _, err = e.meta.Get(t.Context(), gwtest.AccountID, gwtest.Region, "hello")
require.ErrorIs(t, err, function.ErrNotFound)
})
}
}
func TestGetFunction_DecryptsEnvAndLocatesStoredCode(t *testing.T) {
e := newEnv(t)
_, err := e.do(http.MethodPost, "/2015-03-31/functions", createBody("hello", func(b map[string]any) {
b["Environment"] = map[string]any{"Variables": map[string]string{"MODE": "prod"}}
}))
require.NoError(t, err)
rec, err := e.do(http.MethodGet, "/2015-03-31/functions/hello", nil)
require.NoError(t, err)
require.Equal(t, http.StatusOK, rec.Code)
var out lambda.GetFunctionOutput
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &out))
assert.Equal(t, "prod", *out.Configuration.Environment.Variables["MODE"])
assert.Equal(t, "S3", *out.Code.RepositoryType)
require.NotNil(t, out.Code.Location)
assert.Contains(t, *out.Code.Location, "https://code.test/")
assert.Contains(t, *out.Code.Location, "ttl=10m0s")
rec, err = e.do(http.MethodGet, "/2015-03-31/functions/hello?Qualifier=%24LATEST", nil)
require.NoError(t, err)
assert.Equal(t, http.StatusOK, rec.Code)
_, err = e.do(http.MethodGet, "/2015-03-31/functions/hello?Qualifier=3", nil)
requireCode(t, err, awserrors.ErrorResourceNotFoundException)
}
func TestGetFunction_AcceptsSameAccountARNForms(t *testing.T) {
e := newEnv(t)
_, err := e.do(http.MethodPost, "/2015-03-31/functions", createBody("hello", nil))
require.NoError(t, err)
full := "arn:aws:lambda:" + gwtest.Region + ":" + gwtest.AccountID + ":function:hello"
esc := func(s string) string { return strings.ReplaceAll(strings.ReplaceAll(s, ":", "%3A"), "$", "%24") }
for _, name := range []string{full, full + ":$LATEST", gwtest.AccountID + ":function:hello", "hello:$LATEST"} {
t.Run(name, func(t *testing.T) {
rec, err := e.do(http.MethodGet, "/2015-03-31/functions/"+esc(name), nil)
require.NoError(t, err)
require.Equal(t, http.StatusOK, rec.Code)
var out lambda.GetFunctionOutput
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &out))
assert.Equal(t, full, *out.Configuration.FunctionArn)
})
}
notFound := map[string]string{
"other account": "arn:aws:lambda:" + gwtest.Region + ":999999999999:function:hello",
"other region": "arn:aws:lambda:us-west-2:" + gwtest.AccountID + ":function:hello",
"partial foreign": "999999999999:function:hello",
"version": full + ":3",
}
for label, name := range notFound {
t.Run(label, func(t *testing.T) {
_, err := e.do(http.MethodGet, "/2015-03-31/functions/"+esc(name), nil)
requireCode(t, err, awserrors.ErrorResourceNotFoundException)
})
}
_, err = e.do(http.MethodGet, "/2015-03-31/functions/"+esc(full+":$LATEST")+"?Qualifier=3", nil)
requireCode(t, err, awserrors.ErrorInvalidParameterValueException)
_, err = e.do(http.MethodGet, "/2015-03-31/functions/"+esc("arn:aws:s3:::bucket"), nil)
requireCode(t, err, awserrors.ErrorInvalidParameterValueException)
}
func TestGetFunction_NoLocationUntilCodeIsStored(t *testing.T) {
e := newEnv(t)
_, err := e.meta.Create(t.Context(), gwtest.AccountID, gwtest.Region, function.Record{
FunctionID: "f1", FunctionName: "pending", Role: roleARN, Runtime: "provided.al2023", Handler: "h",
Timeout: 3, MemorySize: 128, Code: function.Code{RevisionID: "r1"},
})
require.NoError(t, err)
rec, err := e.do(http.MethodGet, "/2015-03-31/functions/pending", nil)
require.NoError(t, err)
var out lambda.GetFunctionOutput
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &out))
assert.Nil(t, out.Code.Location)
assert.Nil(t, out.Configuration.CodeSha256)
assert.Equal(t, "Pending", *out.Configuration.State)
}
func TestGetFunction_FailsWithoutAnOffHostCodeURL(t *testing.T) {
e := newEnv(t)
_, err := e.do(http.MethodPost, "/2015-03-31/functions", createBody("hello", nil))
require.NoError(t, err)
e.store.urlErr = codestore.ErrNoPresignEndpoint
rec, err := e.do(http.MethodGet, "/2015-03-31/functions/hello", nil)
requireCode(t, err, awserrors.ErrorServiceException)
assert.NotContains(t, rec.Body.String(), "Location")
}
func TestGetFunction_NotFoundNamesTheARN(t *testing.T) {
e := newEnv(t)
_, err := e.do(http.MethodGet, "/2015-03-31/functions/ghost", nil)
requireCode(t, err, awserrors.ErrorResourceNotFoundException)
assert.Contains(t, err.Error(), "Function not found: arn:aws:lambda:"+gwtest.Region+":"+gwtest.AccountID+":function:ghost")
}
func TestGetFunction_FunctionsAreRegional(t *testing.T) {
e := newEnv(t)
_, err := e.do(http.MethodPost, "/2015-03-31/functions", createBody("hello", nil))
require.NoError(t, err)
e.h.Region = "us-west-2"
_, err = e.do(http.MethodGet, "/2015-03-31/functions/hello", nil)
requireCode(t, err, awserrors.ErrorResourceNotFoundException)
}
func TestAuthorization(t *testing.T) {
create := func(e *env) error {
_, err := e.do(http.MethodPost, "/2015-03-31/functions", createBody("hello", nil))
return err
}
get := func(e *env) error {
_, err := e.do(http.MethodGet, "/2015-03-31/functions/hello", nil)
return err
}
t.Run("create needs lambda:CreateFunction", func(t *testing.T) {
e := newEnv(t, gwtest.Statement("Allow", "lambda:GetFunction", "*"), gwtest.Statement("Allow", "iam:PassRole", "*"))
gwtest.AssertDenied(t, create(e))
assert.Zero(t, e.store.count())
})
t.Run("create needs iam:PassRole", func(t *testing.T) {
e := newEnv(t, gwtest.Statement("Allow", "lambda:*", "*"))
gwtest.AssertDenied(t, create(e))
assert.Zero(t, e.store.count())
})
t.Run("create is scoped to the function ARN", func(t *testing.T) {
arn := "arn:aws:lambda:" + gwtest.Region + ":" + gwtest.AccountID + ":function:hello"
e := newEnv(t,
gwtest.Statement("Allow", "lambda:*", "*"),
gwtest.Statement("Allow", "iam:PassRole", "*"),
gwtest.Statement("Deny", "lambda:CreateFunction", arn))
gwtest.AssertDenied(t, create(e))
})
t.Run("create passes with both grants", func(t *testing.T) {
e := newEnv(t, gwtest.Statement("Allow", "lambda:CreateFunction", "*"), gwtest.Statement("Allow", "iam:PassRole", "*"))
require.NoError(t, create(e))
})
t.Run("get needs lambda:GetFunction", func(t *testing.T) {
e := newEnv(t, gwtest.Statement("Allow", "lambda:CreateFunction", "*"))
gwtest.AssertDenied(t, get(e))
})
t.Run("a denied caller does not learn a role is absent", func(t *testing.T) {
e := newEnv(t, gwtest.Statement("Allow", "lambda:*", "*"))
_, err := e.do(http.MethodPost, "/2015-03-31/functions", createBody("hello", func(b map[string]any) {
b["Role"] = "arn:aws:iam::" + gwtest.AccountID + ":role/ghost"
}))
gwtest.AssertDenied(t, err)
})
}
func TestRegistration_ResolvesActionsAndInventory(t *testing.T) {
reg := awsapi.NewRegistration(awsapi.Deps{})
assert.Equal(t, []string{"CreateFunction", "GetFunction"}, reg.Inventory.Registered)
assert.Equal(t, "CreateFunction", reg.ResolveAction(httptest.NewRequest(http.MethodPost, "/2015-03-31/functions", nil)))
assert.Equal(t, "GetFunction", reg.ResolveAction(httptest.NewRequest(http.MethodGet, "/2015-03-31/functions/x", nil)))
assert.Empty(t, reg.ResolveAction(httptest.NewRequest(http.MethodDelete, "/2015-03-31/functions/x", nil)))
}
@@ -0,0 +1,196 @@
// Package awsapi serves the Lambda REST-JSON control plane (API version
// 2015-03-31) for the actions this build supports.
package awsapi
import (
"context"
"crypto/cipher"
"errors"
"io"
"log/slog"
"net/http"
"time"
"github.com/aws/aws-sdk-go/aws"
"github.com/aws/aws-sdk-go/private/protocol/json/jsonutil"
"github.com/mulgadc/spinifex/spinifex/domains/lambda/codestore"
"github.com/mulgadc/spinifex/spinifex/domains/lambda/function"
"github.com/mulgadc/spinifex/spinifex/foundation/aws/arn"
awserrors "github.com/mulgadc/spinifex/spinifex/foundation/aws/errors"
"github.com/mulgadc/spinifex/spinifex/foundation/aws/passrole"
"github.com/mulgadc/spinifex/spinifex/ingress/aws/dispatch"
"github.com/mulgadc/spinifex/spinifex/ingress/aws/envelope"
)
// ServiceName is the SigV4 credential-scope service Lambda clients sign for.
const ServiceName = "lambda"
// servicePrincipal is the service a function execution role is passed to.
const servicePrincipal = "lambda.amazonaws.com"
// CodeStore is what the handlers need of the Regional code store.
type CodeStore interface {
Put(ctx context.Context, ref codestore.Ref, code []byte) (string, error)
PresignGet(ctx context.Context, ref codestore.Ref, sha256Hex string, ttl time.Duration) (string, error)
}
var _ CodeStore = (*codestore.Store)(nil)
// CodeStoreFunc opens the code store on first use; it fails while the store is
// not ready.
type CodeStoreFunc func(ctx context.Context) (CodeStore, error)
// Deps are the composed capabilities the Lambda control plane dispatches to.
type Deps struct {
Meta function.Client
Codes CodeStoreFunc
Roles passrole.Resolver
// Trusts reports whether the role's trust policy lets Lambda assume it for
// the account; false is a refusal, an error an outage.
Trusts func(accountID, roleARN string) (bool, error)
// AEAD seals function environment variables; it is the master key's cipher.
AEAD cipher.AEAD
}
// NewRegistration returns the Lambda REST-JSON registration.
func NewRegistration(deps Deps) dispatch.Registration {
return dispatch.Registration{
Service: ServiceName,
ResolveAction: resolveAction,
Dispatch: deps.dispatch,
Errors: dispatch.ErrorEnvelopeRESTJSON,
Inventory: dispatch.Inventory{Registered: actionNames()},
}
}
func resolveAction(r *http.Request) string {
action, _, _, _ := router.Lookup(r.Method, r.URL.EscapedPath())
return action
}
func readBody(r *http.Request) ([]byte, error) {
body, err := io.ReadAll(io.LimitReader(r.Body, maxBodyBytes+1))
if err != nil {
return nil, errors.New(awserrors.ErrorInvalidParameterValueException)
}
if len(body) > maxBodyBytes {
return nil, errors.New(awserrors.ErrorRequestEntityTooLarge)
}
return body, nil
}
// functionARN names the function the request is about, or "*" when the request
// does not yet name a legal one, so a malformed request stays the handler's
// validation fault rather than becoming an authorization failure.
func functionARN(region, accountID, name string) string {
if region == "" || accountID == "" || !nameRE.MatchString(name) {
return "*"
}
return arn.FormatLambdaFunction(region, accountID, name)
}
// serve resolves the route, authorizes the request, then runs the action.
func (d Deps) serve(w http.ResponseWriter, inv dispatch.Invocation) error {
r := inv.Request
action, params, handler, ok := router.Lookup(r.Method, r.URL.EscapedPath())
if !ok {
return errors.New(awserrors.ErrorInvalidAction)
}
if inv.AccountID == "" {
slog.ErrorContext(r.Context(), "Lambda dispatch: no account ID in auth context")
return errors.New(awserrors.ErrorInternalError)
}
body, err := readBody(r)
if err != nil {
return err
}
c := &call{AccountID: inv.AccountID, Region: inv.Region, Params: params, Query: r.URL.Query(), Body: body}
// The region is the gateway's own, never the caller-supplied credential
// scope, so a caller cannot sign for another region to slide out from
// under a Deny.
resource := "*"
var decodeErr error
switch action {
case "CreateFunction":
c.Create, decodeErr = decodeCreate(body)
if decodeErr == nil {
resource = functionARN(inv.Region, inv.AccountID, aws.StringValue(c.Create.FunctionName))
}
case "GetFunction":
if name, _, err := resolveFunctionName(inv.Region, inv.AccountID, params[0]); err == nil {
resource = functionARN(inv.Region, inv.AccountID, name)
}
}
if err := inv.Authorize(ServiceName, action, []string{resource}, nil); err != nil {
return err
}
if decodeErr != nil {
return decodeErr
}
if action == "CreateFunction" {
if err := d.checkPassRole(inv, aws.StringValue(c.Create.Role)); err != nil {
return err
}
}
if d.Meta == nil || d.Codes == nil {
return errors.New(awserrors.ErrorServiceException)
}
out, err := handler(r.Context(), d, c)
if err != nil {
return err
}
writeJSON(w, out)
return nil
}
func (d Deps) dispatch(w http.ResponseWriter, inv dispatch.Invocation) error {
return d.serve(w, inv)
}
// checkPassRole enforces iam:PassRole on the execution role and that its trust
// policy admits Lambda. Only the exact stored ARN resolves, and a missing
// resolver fails closed.
func (d Deps) checkPassRole(inv dispatch.Invocation, roleARN string) error {
if roleARN == "" {
return nil
}
if d.Roles == nil || d.Trusts == nil {
return errors.New(awserrors.ErrorServiceException)
}
role, err := passrole.Check(d.Roles, passrole.AuthorizeFunc(inv.Authorize), inv.AccountID, roleARN, servicePrincipal)
switch {
case err == nil:
trusted, terr := d.Trusts(inv.AccountID, role.ARN)
if terr != nil {
return terr
}
if !trusted {
return invalid("The role defined for the function cannot be assumed by Lambda.")
}
return nil
case errors.Is(err, passrole.ErrMalformed), errors.Is(err, passrole.ErrForeignAccount):
return invalid("The role defined for the function cannot be assumed by Lambda.")
case errors.Is(err, passrole.ErrNotFound):
return invalid("The role defined for the function cannot be assumed by Lambda.")
default:
return err
}
}
func writeJSON(w http.ResponseWriter, out reply) {
body, err := jsonutil.BuildJSON(out.Body)
if err != nil {
slog.Error("Failed to marshal Lambda response", "err", err)
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusInternalServerError)
_, _ = w.Write(envelope.JSONBody(awserrors.ErrorServiceException, "failed to marshal response"))
return
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(out.Status)
if _, err := w.Write(body); err != nil {
slog.Error("Failed to write Lambda response", "err", err)
}
}
+56
View File
@@ -0,0 +1,56 @@
package awsapi
import (
"context"
"net/url"
"slices"
"github.com/aws/aws-sdk-go/service/lambda"
"github.com/mulgadc/spinifex/spinifex/ingress/aws/rest"
)
// call is one authorized request as a handler sees it.
type call struct {
AccountID string
Region string
Params []string
Query url.Values
Body []byte
// Create is the decoded CreateFunction body, decoded once ahead of the
// authorization gate and the PassRole check that both read it.
Create *lambda.CreateFunctionInput
}
// reply is a handler's success: the HTTP status and the SDK shape to encode.
type reply struct {
Status int
Body any
}
type route = rest.Route[routeHandler]
type routeHandler func(ctx context.Context, d Deps, c *call) (reply, error)
// routes is the dispatch table. Lambda API version 2015-03-31.
var routes = []route{
{Method: "POST", Pattern: "/2015-03-31/functions", Action: "CreateFunction",
Handler: func(ctx context.Context, d Deps, c *call) (reply, error) {
return d.createFunction(ctx, c)
}},
{Method: "GET", Pattern: "/2015-03-31/functions/{FunctionName}", Action: "GetFunction",
Handler: func(ctx context.Context, d Deps, c *call) (reply, error) {
return d.getFunction(ctx, c)
}},
}
// actionNames returns the distinct actions in routes in stable order.
func actionNames() []string {
names := make([]string, 0, len(routes))
for _, r := range routes {
names = append(names, r.Action)
}
slices.Sort(names)
return slices.Compact(names)
}
var router = rest.NewRouter("lambda", routes)
+259
View File
@@ -0,0 +1,259 @@
package awsapi
import (
"archive/zip"
"bytes"
"encoding/json"
"reflect"
"regexp"
"strings"
"github.com/aws/aws-sdk-go/aws"
"github.com/aws/aws-sdk-go/service/lambda"
"github.com/mulgadc/spinifex/spinifex/domains/lambda/codestore"
"github.com/mulgadc/spinifex/spinifex/foundation/aws/arn"
awserrors "github.com/mulgadc/spinifex/spinifex/foundation/aws/errors"
)
const (
supportedRuntime = "provided.al2023"
defaultTimeout = 3
maxTimeout = 900
defaultMemory = 128
maxMemory = 10240
maxDescription = 256
maxEnvironmentLen = 4096
// maxBodyBytes bounds a CreateFunction body: the base64 of the largest
// code copy plus room for the rest of the document.
maxBodyBytes = (codestore.MaxCodeBytes/3+1)*4 + 1<<20
)
var nameRE = regexp.MustCompile(`^[a-zA-Z0-9-_]{1,64}$`)
func invalid(format string, args ...any) error {
return awserrors.Errorf(awserrors.ErrorInvalidParameterValueException, format, args...)
}
func unsupported(field string) error {
return invalid("%s is not supported", field)
}
// decodeCreate reads a CreateFunction body strictly: a member this build does
// not model is refused rather than silently dropped.
func decodeCreate(body []byte) (*lambda.CreateFunctionInput, error) {
in := new(lambda.CreateFunctionInput)
dec := json.NewDecoder(bytes.NewReader(body))
dec.DisallowUnknownFields()
if err := dec.Decode(in); err != nil {
return nil, invalid("the request body is not a valid CreateFunction document: %v", err)
}
return in, nil
}
// createParams is a CreateFunction request after validation, defaults applied.
type createParams struct {
Name string
Role string
Handler string
Description string
TimeoutSec int64
MemorySize int64
Code []byte
Env map[string]string
}
// isSet reports whether a modelled optional member carries anything: a non-nil
// pointer to a non-zero value, a non-empty list or map, or a struct with any
// such member.
func isSet(v any) bool {
return valueSet(reflect.ValueOf(v))
}
func valueSet(v reflect.Value) bool {
switch v.Kind() {
case reflect.Pointer, reflect.Interface:
return !v.IsNil() && valueSet(v.Elem())
case reflect.Slice, reflect.Map:
return v.Len() > 0
case reflect.Struct:
for i := range v.NumField() {
if v.Type().Field(i).IsExported() && valueSet(v.Field(i)) {
return true
}
}
return false
case reflect.Invalid:
return false
default:
return !v.IsZero()
}
}
// refuseUnsupported names the first supplied member this build cannot honour.
func refuseUnsupported(in *lambda.CreateFunctionInput) error {
checks := []struct {
field string
set bool
}{
{"Code.S3Bucket", in.Code != nil && isSet(in.Code.S3Bucket)},
{"Code.S3Key", in.Code != nil && isSet(in.Code.S3Key)},
{"Code.S3ObjectVersion", in.Code != nil && isSet(in.Code.S3ObjectVersion)},
{"Code.ImageUri", in.Code != nil && isSet(in.Code.ImageUri)},
{"Layers", isSet(in.Layers)},
{"VpcConfig", isSet(in.VpcConfig)},
{"KMSKeyArn", isSet(in.KMSKeyArn)},
{"DeadLetterConfig", isSet(in.DeadLetterConfig)},
{"TracingConfig", in.TracingConfig != nil && aws.StringValue(in.TracingConfig.Mode) == lambda.TracingModeActive},
{"FileSystemConfigs", isSet(in.FileSystemConfigs)},
{"ImageConfig", isSet(in.ImageConfig)},
{"Architectures", isSet(in.Architectures) && (len(in.Architectures) != 1 || aws.StringValue(in.Architectures[0]) != lambda.ArchitectureX8664)},
{"Publish", aws.BoolValue(in.Publish)},
{"SnapStart", in.SnapStart != nil && aws.StringValue(in.SnapStart.ApplyOn) != "" && aws.StringValue(in.SnapStart.ApplyOn) != lambda.SnapStartApplyOnNone},
{"EphemeralStorage", in.EphemeralStorage != nil && in.EphemeralStorage.Size != nil && aws.Int64Value(in.EphemeralStorage.Size) != 512},
{"CodeSigningConfigArn", isSet(in.CodeSigningConfigArn)},
{"Tags", isSet(in.Tags)},
}
for _, c := range checks {
if c.set {
return unsupported(c.field)
}
}
return nil
}
// validateCreate checks a decoded CreateFunction and applies the defaults.
// resolveFunctionName accepts the GetFunction forms AWS does: a name, a partial
// ARN (account:function:name) or a full ARN, each with an optional qualifier.
// Another account or Region is reported not found under the ARN it named.
func resolveFunctionName(region, accountID, raw string) (name, qualifier string, err error) {
parts := strings.Split(raw, ":")
qualified := false
switch {
case len(parts) <= 2:
name = parts[0]
if len(parts) == 2 {
qualifier, qualified = parts[1], true
}
case len(parts) <= 4 && parts[1] == "function":
if parts[0] != accountID {
return "", "", awserrors.Errorf(awserrors.ErrorResourceNotFoundException, "Function not found: %s",
arn.FormatLambdaFunction(region, parts[0], strings.Join(parts[2:], ":")))
}
name = parts[2]
if len(parts) == 4 {
qualifier, qualified = parts[3], true
}
case len(parts) >= 7 && len(parts) <= 8 && parts[0] == "arn" && parts[2] == "lambda" && parts[5] == "function":
if parts[1] != "aws" || parts[3] != region || parts[4] != accountID {
return "", "", awserrors.Errorf(awserrors.ErrorResourceNotFoundException, "Function not found: %s", raw)
}
name = parts[6]
if len(parts) == 8 {
qualifier, qualified = parts[7], true
}
default:
return "", "", invalidFunctionName()
}
if !nameRE.MatchString(name) || (qualified && qualifier == "") {
return "", "", invalidFunctionName()
}
return name, qualifier, nil
}
func invalidFunctionName() error {
return invalid("1 validation error detected: Value at 'functionName' failed to satisfy constraint: Member must satisfy regular expression pattern: (arn:(aws[a-zA-Z-]*)?:lambda:)?([a-z]{2}(-gov)?-[a-z]+-\\d{1}:)?(\\d{12}:)?(function:)?([a-zA-Z0-9-_]+)(:(\\$LATEST|[a-zA-Z0-9-_]+))?")
}
// logGroup is the log group AWS names for a function by default.
func logGroup(name string) string { return "/aws/lambda/" + name }
// defaultLogging reports whether c is absent or spells AWS's default exactly;
// without a logs capability any other value would be inert.
func defaultLogging(c *lambda.LoggingConfig, name string) bool {
if c == nil {
return true
}
format, group := aws.StringValue(c.LogFormat), aws.StringValue(c.LogGroup)
return (format == "" || format == lambda.LogFormatText) && (group == "" || group == logGroup(name)) &&
c.ApplicationLogLevel == nil && c.SystemLogLevel == nil
}
func validateCreate(in *lambda.CreateFunctionInput) (*createParams, error) {
if err := refuseUnsupported(in); err != nil {
return nil, err
}
name := aws.StringValue(in.FunctionName)
if strings.HasPrefix(name, "arn:") || !nameRE.MatchString(name) {
return nil, invalid("1 validation error detected: Value at 'functionName' failed to satisfy constraint: Member must satisfy regular expression pattern: [a-zA-Z0-9-_]+ and have length 1 to 64")
}
if !defaultLogging(in.LoggingConfig, name) {
return nil, unsupported("A LoggingConfig other than LogFormat Text with log group " + logGroup(name))
}
if aws.StringValue(in.Runtime) != supportedRuntime {
return nil, invalid("Runtime must be %s", supportedRuntime)
}
if pt := aws.StringValue(in.PackageType); pt != "" && pt != lambda.PackageTypeZip {
return nil, invalid("PackageType must be %s", lambda.PackageTypeZip)
}
if in.Code == nil || len(in.Code.ZipFile) == 0 {
return nil, invalid("Code.ZipFile is required")
}
if len(in.Code.ZipFile) > codestore.MaxCodeBytes {
return nil, awserrors.Errorf(awserrors.ErrorRequestEntityTooLarge, "Code.ZipFile exceeds %d bytes", codestore.MaxCodeBytes)
}
if _, err := zip.NewReader(bytes.NewReader(in.Code.ZipFile), int64(len(in.Code.ZipFile))); err != nil {
return nil, invalid("Could not unzip uploaded file. Please check your file, then try to upload again.")
}
handler := aws.StringValue(in.Handler)
if handler == "" {
return nil, invalid("Handler is required")
}
role := aws.StringValue(in.Role)
if role == "" {
return nil, invalid("Role is required")
}
limit, memory := int64(defaultTimeout), int64(defaultMemory)
if in.Timeout != nil {
limit = *in.Timeout
}
if limit < 1 || limit > maxTimeout {
return nil, invalid("Timeout must be between 1 and %d", maxTimeout)
}
if in.MemorySize != nil {
memory = *in.MemorySize
}
if memory < 128 || memory > maxMemory {
return nil, invalid("MemorySize must be between 128 and %d", maxMemory)
}
desc := aws.StringValue(in.Description)
if len(desc) > maxDescription {
return nil, invalid("Description must be at most %d characters", maxDescription)
}
env, err := envVars(in.Environment)
if err != nil {
return nil, err
}
return &createParams{
Name: name, Role: role, Handler: handler, Description: desc,
TimeoutSec: limit, MemorySize: memory, Code: in.Code.ZipFile, Env: env,
}, nil
}
func envVars(e *lambda.Environment) (map[string]string, error) {
if e == nil || len(e.Variables) == 0 {
return nil, nil
}
vars := make(map[string]string, len(e.Variables))
size := 0
for k, v := range e.Variables {
if k == "" {
return nil, invalid("Environment variable names must not be empty")
}
vars[k] = aws.StringValue(v)
size += len(k) + len(vars[k])
}
if size > maxEnvironmentLen {
return nil, invalid("Environment variables exceed %d bytes", maxEnvironmentLen)
}
return vars, nil
}
@@ -0,0 +1,113 @@
package codestore
import (
"errors"
"fmt"
"log/slog"
"sync"
"time"
"github.com/aws/aws-sdk-go/aws"
"github.com/aws/aws-sdk-go/aws/credentials"
"github.com/aws/aws-sdk-go/service/sts"
telemetry "github.com/mulgadc/spinifex/spinifex/foundation/telemetry"
)
const (
// refreshMargin re-assumes this long before a session expires.
refreshMargin = 5 * time.Minute
// retryInterval spaces re-assume attempts while an old session is still valid.
retryInterval = 30 * time.Second
)
// AssumeFunc mints a code-store session. The composition layer binds it to the
// in-process STS entry point for one Region.
type AssumeFunc func() (*sts.AssumeRoleOutput, error)
// sessionProvider re-assumes the role ahead of expiry. A failed re-assume keeps
// the current session in use until it expires, then fails closed.
type sessionProvider struct {
assume AssumeFunc
now func() time.Time
mu sync.Mutex
value credentials.Value
expires time.Time
lastError time.Time
// minRemaining makes the next Retrieve re-assume unless the session has at
// least this long left; it is cleared by that Retrieve.
minRemaining time.Duration
}
var (
_ credentials.Provider = (*sessionProvider)(nil)
_ credentials.Expirer = (*sessionProvider)(nil)
)
func newSessionProvider(assume AssumeFunc, now func() time.Time) *sessionProvider {
return &sessionProvider{assume: assume, now: now}
}
func (p *sessionProvider) Retrieve() (credentials.Value, error) {
p.mu.Lock()
defer p.mu.Unlock()
now := p.now()
valid := p.value.AccessKeyID != "" && now.Before(p.expires)
need := p.minRemaining
p.minRemaining = 0
if need > 0 {
if valid && p.expires.Sub(now) >= need {
return p.value, nil
}
} else if valid && (now.Before(p.expires.Add(-refreshMargin)) || now.Before(p.lastError.Add(retryInterval))) {
return p.value, nil
}
out, err := p.assume()
if err == nil && (out == nil || out.Credentials == nil) {
err = errors.New("empty session")
}
if err != nil {
p.lastError = now
if valid {
slog.Warn("Lambda code store: re-assume failed; keeping current session",
"remaining_ms", telemetry.Millis(p.expires.Sub(now)), "err", err)
return p.value, nil
}
return credentials.Value{}, fmt.Errorf("assume code-store role: %w", err)
}
c := out.Credentials
p.value = credentials.Value{
AccessKeyID: aws.StringValue(c.AccessKeyId),
SecretAccessKey: aws.StringValue(c.SecretAccessKey),
SessionToken: aws.StringValue(c.SessionToken),
ProviderName: "LambdaCodeStoreSession",
}
p.expires = aws.TimeValue(c.Expiration)
p.lastError = time.Time{}
return p.value, nil
}
// IsExpired reports true inside the refresh margin so the SDK calls Retrieve.
func (p *sessionProvider) IsExpired() bool {
p.mu.Lock()
defer p.mu.Unlock()
return p.value.AccessKeyID == "" || !p.now().Before(p.expires.Add(-refreshMargin))
}
// ExpiresAt is when the current session stops working.
func (p *sessionProvider) ExpiresAt() time.Time {
p.mu.Lock()
defer p.mu.Unlock()
return p.expires
}
// requireRemaining makes the next Retrieve draw a new session unless the
// current one outlasts d.
func (p *sessionProvider) requireRemaining(d time.Duration) {
p.mu.Lock()
defer p.mu.Unlock()
p.minRemaining = d
}
@@ -0,0 +1,94 @@
package codestore
import (
"errors"
"testing"
"time"
"github.com/aws/aws-sdk-go/aws"
"github.com/aws/aws-sdk-go/service/sts"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
type fakeAssume struct {
calls int
err error
expires time.Time
}
func (f *fakeAssume) assume() (*sts.AssumeRoleOutput, error) {
f.calls++
if f.err != nil {
return nil, f.err
}
return &sts.AssumeRoleOutput{Credentials: &sts.Credentials{
AccessKeyId: aws.String("ASIA" + string(rune('A'+f.calls))),
SecretAccessKey: aws.String("secret"),
SessionToken: aws.String("token"),
Expiration: aws.Time(f.expires),
}}, nil
}
type clock struct{ t time.Time }
func (c *clock) now() time.Time { return c.t }
func TestSessionProvider_ReusesUntilRefreshMargin(t *testing.T) {
c := &clock{t: time.Unix(1_000_000, 0)}
a := &fakeAssume{expires: c.t.Add(time.Hour)}
p := newSessionProvider(a.assume, c.now)
first, err := p.Retrieve()
require.NoError(t, err)
c.t = c.t.Add(54 * time.Minute)
assert.False(t, p.IsExpired())
again, err := p.Retrieve()
require.NoError(t, err)
assert.Equal(t, first.AccessKeyID, again.AccessKeyID)
assert.Equal(t, 1, a.calls)
c.t = c.t.Add(2 * time.Minute)
a.expires = c.t.Add(time.Hour)
assert.True(t, p.IsExpired())
fresh, err := p.Retrieve()
require.NoError(t, err)
assert.NotEqual(t, first.AccessKeyID, fresh.AccessKeyID)
assert.Equal(t, 2, a.calls)
}
// A failed re-assume keeps the still-valid session, spaces retries, and fails
// closed once the session has expired.
func TestSessionProvider_KeepsSessionOnFailureUntilExpiry(t *testing.T) {
c := &clock{t: time.Unix(1_000_000, 0)}
a := &fakeAssume{expires: c.t.Add(time.Hour)}
p := newSessionProvider(a.assume, c.now)
first, err := p.Retrieve()
require.NoError(t, err)
a.err = errors.New("sts unavailable")
c.t = c.t.Add(58 * time.Minute)
kept, err := p.Retrieve()
require.NoError(t, err)
assert.Equal(t, first.AccessKeyID, kept.AccessKeyID)
assert.Equal(t, 2, a.calls)
c.t = c.t.Add(10 * time.Second)
_, err = p.Retrieve()
require.NoError(t, err)
assert.Equal(t, 2, a.calls, "no re-assume inside the retry interval")
c.t = c.t.Add(2 * time.Minute)
_, err = p.Retrieve()
require.Error(t, err)
assert.Equal(t, 3, a.calls)
}
func TestSessionProvider_FirstFailureFails(t *testing.T) {
a := &fakeAssume{err: errors.New("AccessDenied")}
p := newSessionProvider(a.assume, time.Now)
_, err := p.Retrieve()
require.Error(t, err)
assert.True(t, p.IsExpired())
}
@@ -0,0 +1,74 @@
package codestore
import (
"context"
"errors"
"fmt"
"log/slog"
"sync"
"time"
telemetry "github.com/mulgadc/spinifex/spinifex/foundation/telemetry"
)
// ErrNotReady means the code store is not provisioned or its role is not
// assumable yet; callers report ServiceException.
var ErrNotReady = errors.New("lambda code store not ready")
const (
initialBackoff = time.Second
maxBackoff = 5 * time.Minute
)
// Manager provisions and opens the store on first use, so a process that never
// uses Lambda creates nothing. After a failure, calls inside the capped backoff
// window fail fast with the last reason instead of retrying.
type Manager struct {
provision func(context.Context) error
open func() (*Store, error)
now func() time.Time
mu sync.Mutex
store *Store
reason error
backoff time.Duration
retryAt time.Time
}
// NewManager binds the bounded provisioning step and the scoped opener. It does
// no I/O.
func NewManager(provision func(context.Context) error, open func() (*Store, error)) *Manager {
return &Manager{provision: provision, open: open, now: time.Now}
}
// Store returns the opened store, initialising it on first use. An ErrNotOwned
// refusal is retried after backoff, so an operator's fix takes effect without a
// restart.
func (m *Manager) Store(ctx context.Context) (*Store, error) {
m.mu.Lock()
defer m.mu.Unlock()
if m.store != nil {
return m.store, nil
}
if now := m.now(); now.Before(m.retryAt) {
return nil, fmt.Errorf("%w: %w", ErrNotReady, m.reason)
}
store, err := m.attempt(ctx)
if err == nil {
m.store, m.reason, m.backoff = store, nil, 0
slog.InfoContext(ctx, "Lambda code store ready")
return store, nil
}
m.backoff = min(max(m.backoff*2, initialBackoff), maxBackoff)
m.reason, m.retryAt = err, m.now().Add(m.backoff)
slog.WarnContext(ctx, "Lambda code store not ready", "err", err, "retry_ms", telemetry.Millis(m.backoff))
return nil, fmt.Errorf("%w: %w", ErrNotReady, err)
}
func (m *Manager) attempt(ctx context.Context) (*Store, error) {
if err := m.provision(ctx); err != nil {
return nil, err
}
return m.open()
}
@@ -0,0 +1,158 @@
package codestore
import (
"errors"
"net/url"
"strings"
"testing"
"time"
"github.com/aws/aws-sdk-go/aws"
"github.com/aws/aws-sdk-go/aws/credentials"
"github.com/aws/aws-sdk-go/aws/session"
"github.com/aws/aws-sdk-go/service/s3"
ststypes "github.com/aws/aws-sdk-go/service/sts"
"github.com/mulgadc/spinifex/spinifex/providers/objectstore"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestStore_PresignGet(t *testing.T) {
sess, err := session.NewSession(&aws.Config{
Endpoint: aws.String("https://predastore.test:8443"),
Region: aws.String("ap-southeast-2"),
Credentials: credentials.NewStaticCredentials("AKIATEST", "secret", "session-token"),
S3ForcePathStyle: aws.Bool(true),
})
require.NoError(t, err)
s := newStore(testRegion, objectstore.NewMemoryObjectStore())
s.presign = presignWith(s3.New(sess))
sum := "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
raw, err := s.PresignGet(t.Context(), testRef, sum, 10*time.Minute)
require.NoError(t, err)
u, err := url.Parse(raw)
require.NoError(t, err)
assert.Equal(t, "predastore.test:8443", u.Host)
assert.Equal(t, "/"+BucketName(testRegion)+"/"+copyKey(sum), u.Path)
q := u.Query()
assert.Equal(t, "600", q.Get("X-Amz-Expires"))
assert.Equal(t, "session-token", q.Get("X-Amz-Security-Token"))
assert.NotEmpty(t, q.Get("X-Amz-Signature"))
}
func TestStore_PresignGetRefusals(t *testing.T) {
s := newStore(testRegion, objectstore.NewMemoryObjectStore())
sum := "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
_, err := s.PresignGet(t.Context(), testRef, sum, time.Minute)
require.Error(t, err, "a store without a signer cannot presign")
s.presign = func(*s3.GetObjectInput, time.Duration) (string, error) { return "unused", nil }
_, err = s.PresignGet(t.Context(), testRef, "not-a-digest", time.Minute)
require.ErrorIs(t, err, ErrInvalidRef)
}
func presignStoreOn(t *testing.T, c *clock, a *fakeAssume) *Store {
t.Helper()
provider := newSessionProvider(a.assume, c.now)
creds := credentials.NewCredentials(provider)
sess, err := session.NewSession(&aws.Config{
Endpoint: aws.String("https://predastore.test:8443"), Region: aws.String("ap-southeast-2"),
Credentials: creds, S3ForcePathStyle: aws.Bool(true),
})
require.NoError(t, err)
s := newStore(testRegion, objectstore.NewMemoryObjectStore())
s.presign = presignWith(s3.New(sess))
s.creds, s.provider, s.now = creds, provider, c.now
return s
}
func signedKeyID(t *testing.T, raw string) string {
t.Helper()
u, err := url.Parse(raw)
require.NoError(t, err)
return strings.SplitN(u.Query().Get("X-Amz-Credential"), "/", 2)[0]
}
func TestStore_PresignGetRefreshesAShortSession(t *testing.T) {
sum := "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
c := &clock{t: time.Unix(1_000_000, 0)}
a := &fakeAssume{expires: c.t.Add(7 * time.Minute)}
s := presignStoreOn(t, c, a)
_, err := s.creds.Get()
require.NoError(t, err)
first := "ASIAB"
require.Equal(t, 1, a.calls)
a.expires = c.t.Add(time.Hour)
raw, err := s.PresignGet(t.Context(), testRef, sum, 10*time.Minute)
require.NoError(t, err)
assert.Equal(t, 2, a.calls, "a 7 minute session is re-assumed before a 10 minute signature")
assert.NotEqual(t, first, signedKeyID(t, raw))
assert.Equal(t, "ASIAC", signedKeyID(t, raw), "the URL is signed with the new session")
_, err = s.PresignGet(t.Context(), testRef, sum, 10*time.Minute)
require.NoError(t, err)
assert.Equal(t, 2, a.calls, "a long session is reused")
}
func TestStore_PresignGetRefusesWhenSessionStaysShort(t *testing.T) {
sum := "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
c := &clock{t: time.Unix(1_000_000, 0)}
a := &fakeAssume{expires: c.t.Add(7 * time.Minute)}
s := presignStoreOn(t, c, a)
_, err := s.PresignGet(t.Context(), testRef, sum, 10*time.Minute)
require.Error(t, err, "a re-assume that is still short signs nothing")
assert.Equal(t, 2, a.calls)
a.err = errors.New("AccessDenied")
_, err = s.PresignGet(t.Context(), testRef, sum, 10*time.Minute)
require.Error(t, err, "a failed re-assume does not fall back to the short session")
}
// Open signs download URLs for the off-host endpoint while its own reads and
// writes keep the local one, so a client outside the node can fetch the copy.
func TestOpen_PresignsForTheOffHostEndpoint(t *testing.T) {
assume := func() (*ststypes.AssumeRoleOutput, error) {
return &ststypes.AssumeRoleOutput{Credentials: &ststypes.Credentials{
AccessKeyId: aws.String("ASIATEST"),
SecretAccessKey: aws.String("secret"),
SessionToken: aws.String("session-token"),
Expiration: aws.Time(time.Now().Add(time.Hour)),
}}, nil
}
s, err := Open(OpenConfig{
Region: testRegion,
Endpoint: "https://127.0.0.1:8443",
SigningRegion: "ap-southeast-2",
Assume: assume,
PresignEndpoint: "https://192.0.2.15:8443",
})
require.NoError(t, err)
raw, err := s.PresignGet(t.Context(), testRef, strings.Repeat("a", 64), 10*time.Minute)
require.NoError(t, err)
u, err := url.Parse(raw)
require.NoError(t, err)
assert.Equal(t, "192.0.2.15:8443", u.Host)
}
func TestOpen_NoOffHostEndpointRefusesDownloadURLs(t *testing.T) {
assume := func() (*ststypes.AssumeRoleOutput, error) {
return &ststypes.AssumeRoleOutput{Credentials: &ststypes.Credentials{
AccessKeyId: aws.String("ASIATEST"),
SecretAccessKey: aws.String("secret"),
SessionToken: aws.String("session-token"),
Expiration: aws.Time(time.Now().Add(time.Hour)),
}}, nil
}
s, err := Open(OpenConfig{Region: testRegion, Endpoint: "https://127.0.0.1:8443", SigningRegion: "ap-southeast-2", Assume: assume})
require.NoError(t, err)
raw, err := s.PresignGet(t.Context(), testRef, strings.Repeat("a", 64), 10*time.Minute)
require.ErrorIs(t, err, ErrNoPresignEndpoint)
assert.Empty(t, raw)
}
@@ -0,0 +1,178 @@
// Package codestore keeps Lambda code copies in the Region's system-account
// bucket, reached only through a short-lived session of the Region's scoped
// code-store role. The composition layer owns every privileged client.
package codestore
import (
"context"
"encoding/json"
"errors"
"fmt"
"reflect"
)
const (
// OwnerTagKey marks the role and bucket Lambda intends to own. It names the
// intended owner; any principal able to tag could also set it.
OwnerTagKey = "spinifex:owner"
// OwnerTagValue is the owner tag's value.
OwnerTagValue = "lambda-code-store"
// PolicyName is the role's only inline policy.
PolicyName = "lambda-code-store"
)
// TrustPolicy lets only the in-process lambda.amazonaws.com path assume the role.
const TrustPolicy = `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":{"Service":"lambda.amazonaws.com"},"Action":"sts:AssumeRole"}]}`
var (
// ErrExists is returned by an admin Create when the resource already exists
// under the system account.
ErrExists = errors.New("already exists")
// ErrNotFound is returned by RoleAdmin.Describe for an absent role.
ErrNotFound = errors.New("not found")
// ErrOwnedElsewhere is returned by BucketAdmin.Create when another account
// owns the bucket name.
ErrOwnedElsewhere = errors.New("owned by another account")
// ErrNotOwned is a refusal that persists until an operator resolves it.
ErrNotOwned = errors.New("lambda code store: existing resource is not Lambda's")
)
// BucketName is the Region's code bucket in the system account.
func BucketName(region string) string {
return "lambda-code-" + region
}
type policyStatement struct {
Effect string `json:"Effect"`
Action []string `json:"Action"`
Resource string `json:"Resource"`
}
type policyDocument struct {
Version string `json:"Version"`
Statement []policyStatement `json:"Statement"`
}
// RolePolicy grants object access to the Region's code bucket and nothing else.
func RolePolicy(region string) (string, error) {
b := "arn:aws:s3:::" + BucketName(region)
doc, err := json.Marshal(policyDocument{
Version: "2012-10-17",
Statement: []policyStatement{
{Effect: "Allow", Action: []string{"s3:PutObject", "s3:GetObject", "s3:DeleteObject"}, Resource: b + "/*"},
{Effect: "Allow", Action: []string{"s3:ListBucket"}, Resource: b},
},
})
if err != nil {
return "", fmt.Errorf("lambda code store policy: %w", err)
}
return string(doc), nil
}
// RoleState is what provisioning reads back about an existing role.
type RoleState struct {
TrustPolicy string
Tags map[string]string
InlinePolicies []string
AttachedPolicies int
}
// RoleAdmin is bound by the composition layer to the one code-store role.
type RoleAdmin interface {
Create(trustPolicy string, tags map[string]string) error
Describe() (*RoleState, error)
PutPolicy(name, document string) error
}
// BucketAdmin is bound by the composition layer to the one code bucket.
// Create tags the bucket only when it created it, never an existing one.
type BucketAdmin interface {
Create(ctx context.Context, tags map[string]string) error
Tags(ctx context.Context) (map[string]string, error)
}
func ownerTags() map[string]string {
return map[string]string{OwnerTagKey: OwnerTagValue}
}
// Provision creates or verifies the Region's role, policy and bucket. A retry
// completes Lambda's own partial run; any state it cannot attribute to Lambda
// is refused with ErrNotOwned and left untouched.
func Provision(ctx context.Context, region string, role RoleAdmin, bucket BucketAdmin) error {
if err := ensureRole(role); err != nil {
return err
}
policy, err := RolePolicy(region)
if err != nil {
return err
}
if err := role.PutPolicy(PolicyName, policy); err != nil {
return fmt.Errorf("put code-store role policy: %w", err)
}
return ensureBucket(ctx, region, bucket)
}
func ensureRole(role RoleAdmin) error {
err := role.Create(TrustPolicy, ownerTags())
if err == nil {
return nil
}
if !errors.Is(err, ErrExists) {
return fmt.Errorf("create code-store role: %w", err)
}
st, err := role.Describe()
if err != nil {
return fmt.Errorf("describe code-store role: %w", err)
}
return checkRole(st)
}
// checkRole accepts only Lambda's own shape: owner tag, exact trust, no attached
// policies, and no inline policy but Lambda's. No inline policy at all is the
// state a crash between CreateRole and PutRolePolicy leaves, so it is retried.
func checkRole(st *RoleState) error {
switch {
case st.Tags[OwnerTagKey] != OwnerTagValue:
return fmt.Errorf("%w: role has no %s=%s tag", ErrNotOwned, OwnerTagKey, OwnerTagValue)
case !sameJSON(st.TrustPolicy, TrustPolicy):
return fmt.Errorf("%w: role trust policy differs from the code-store trust policy", ErrNotOwned)
case st.AttachedPolicies > 0:
return fmt.Errorf("%w: role has %d attached managed policies", ErrNotOwned, st.AttachedPolicies)
}
for _, name := range st.InlinePolicies {
if name != PolicyName {
return fmt.Errorf("%w: role has inline policy %q", ErrNotOwned, name)
}
}
return nil
}
func ensureBucket(ctx context.Context, region string, bucket BucketAdmin) error {
err := bucket.Create(ctx, ownerTags())
if err == nil {
return nil
}
if errors.Is(err, ErrOwnedElsewhere) {
return fmt.Errorf("%w: bucket %s is owned by another account", ErrNotOwned, BucketName(region))
}
if !errors.Is(err, ErrExists) {
return fmt.Errorf("create code bucket: %w", err)
}
tags, err := bucket.Tags(ctx)
if err != nil {
return fmt.Errorf("read code bucket tags: %w", err)
}
if tags[OwnerTagKey] != OwnerTagValue {
return fmt.Errorf("%w: bucket %s exists without the %s=%s tag; an operator must tag or delete it",
ErrNotOwned, BucketName(region), OwnerTagKey, OwnerTagValue)
}
return nil
}
func sameJSON(a, b string) bool {
var x, y any
if json.Unmarshal([]byte(a), &x) != nil || json.Unmarshal([]byte(b), &y) != nil {
return false
}
return reflect.DeepEqual(x, y)
}
@@ -0,0 +1,165 @@
package codestore
import (
"context"
"errors"
"slices"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
const testRegion = "ap-southeast-2"
type fakeRole struct {
state *RoleState
created int
policies map[string]string
}
func (f *fakeRole) Create(trust string, tags map[string]string) error {
if f.state != nil {
return ErrExists
}
f.created++
f.state = &RoleState{TrustPolicy: trust, Tags: tags}
return nil
}
func (f *fakeRole) Describe() (*RoleState, error) {
if f.state == nil {
return nil, ErrNotFound
}
return f.state, nil
}
func (f *fakeRole) PutPolicy(name, doc string) error {
if f.policies == nil {
f.policies = map[string]string{}
}
f.policies[name] = doc
if !slices.Contains(f.state.InlinePolicies, name) {
f.state.InlinePolicies = append(f.state.InlinePolicies, name)
}
return nil
}
type fakeBucket struct {
exists, foreign bool
tags map[string]string
tagErr error
tagWrites int
}
func (f *fakeBucket) Create(_ context.Context, tags map[string]string) error {
switch {
case f.foreign:
return ErrOwnedElsewhere
case f.exists:
return ErrExists
}
f.exists = true
if f.tagErr != nil {
return f.tagErr
}
f.tagWrites++
f.tags = tags
return nil
}
func (f *fakeBucket) Tags(context.Context) (map[string]string, error) { return f.tags, nil }
func mustPolicy(t *testing.T) string {
t.Helper()
p, err := RolePolicy(testRegion)
require.NoError(t, err)
return p
}
func ownRole(inline ...string) *fakeRole {
return &fakeRole{state: &RoleState{TrustPolicy: TrustPolicy, Tags: ownerTags(), InlinePolicies: inline}}
}
func TestProvision_FreshCreatesTaggedRolePolicyAndBucket(t *testing.T) {
role, bucket := &fakeRole{}, &fakeBucket{}
require.NoError(t, Provision(t.Context(), testRegion, role, bucket))
assert.Equal(t, 1, role.created)
assert.Equal(t, OwnerTagValue, role.state.Tags[OwnerTagKey])
assert.JSONEq(t, mustPolicy(t), role.policies[PolicyName])
assert.Equal(t, OwnerTagValue, bucket.tags[OwnerTagKey])
}
// A crash between CreateRole and PutRolePolicy leaves Lambda's tagged role with
// no inline policy; the next run adds it.
func TestProvision_RetriesOwnRoleWithoutPolicy(t *testing.T) {
role, bucket := ownRole(), &fakeBucket{}
require.NoError(t, Provision(t.Context(), testRegion, role, bucket))
assert.Zero(t, role.created)
assert.JSONEq(t, mustPolicy(t), role.policies[PolicyName])
}
func TestProvision_RestoresDriftedOwnPolicy(t *testing.T) {
role, bucket := ownRole(PolicyName), &fakeBucket{}
role.policies = map[string]string{PolicyName: `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"s3:*","Resource":"*"}]}`}
require.NoError(t, Provision(t.Context(), testRegion, role, bucket))
assert.JSONEq(t, mustPolicy(t), role.policies[PolicyName])
}
func TestProvision_RefusesRoleNotLambdas(t *testing.T) {
cases := map[string]*RoleState{
"no owner tag": {TrustPolicy: TrustPolicy},
"other trust": {TrustPolicy: `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":{"AWS":"arn:aws:iam::000000000000:root"},"Action":"sts:AssumeRole"}]}`, Tags: ownerTags()},
"attached policy": {TrustPolicy: TrustPolicy, Tags: ownerTags(), AttachedPolicies: 1},
"foreign inline policy": {TrustPolicy: TrustPolicy, Tags: ownerTags(), InlinePolicies: []string{PolicyName, "admin"}},
}
for name, st := range cases {
t.Run(name, func(t *testing.T) {
role, bucket := &fakeRole{state: st}, &fakeBucket{}
err := Provision(t.Context(), testRegion, role, bucket)
require.ErrorIs(t, err, ErrNotOwned)
assert.Nil(t, role.policies, "no policy written to a role that is not Lambda's")
assert.False(t, bucket.exists, "provisioning stops before the bucket")
})
}
}
func TestProvision_AcceptsOwnTaggedBucket(t *testing.T) {
bucket := &fakeBucket{exists: true, tags: ownerTags()}
require.NoError(t, Provision(t.Context(), testRegion, &fakeRole{}, bucket))
assert.Zero(t, bucket.tagWrites)
}
// A crash after CreateBucket and before its tags leaves an untagged bucket that
// cannot be told apart from another system component's, so it is refused.
func TestProvision_RefusesUntaggedBucketAfterPartialCreate(t *testing.T) {
bucket := &fakeBucket{tagErr: errors.New("connection reset")}
require.Error(t, Provision(t.Context(), testRegion, &fakeRole{}, bucket))
bucket.tagErr = nil
err := Provision(t.Context(), testRegion, &fakeRole{}, bucket)
require.ErrorIs(t, err, ErrNotOwned)
assert.Contains(t, err.Error(), "lambda-code-ap-southeast-2 exists without the spinifex:owner=lambda-code-store tag")
assert.Zero(t, bucket.tagWrites, "an existing bucket is never tagged into use")
}
func TestProvision_RefusesBucketOwnedElsewhere(t *testing.T) {
err := Provision(t.Context(), testRegion, &fakeRole{}, &fakeBucket{foreign: true})
require.ErrorIs(t, err, ErrNotOwned)
}
func TestRolePolicy_ScopesToRegionalBucket(t *testing.T) {
assert.JSONEq(t, `{"Version":"2012-10-17","Statement":[
{"Effect":"Allow","Action":["s3:PutObject","s3:GetObject","s3:DeleteObject"],"Resource":"arn:aws:s3:::lambda-code-ap-southeast-2/*"},
{"Effect":"Allow","Action":["s3:ListBucket"],"Resource":"arn:aws:s3:::lambda-code-ap-southeast-2"}]}`,
mustPolicy(t))
}
+297
View File
@@ -0,0 +1,297 @@
package codestore
import (
"bytes"
"context"
"crypto/sha256"
"encoding/hex"
"errors"
"fmt"
"io"
"net/http"
"regexp"
"time"
"github.com/aws/aws-sdk-go/aws"
"github.com/aws/aws-sdk-go/aws/credentials"
"github.com/aws/aws-sdk-go/aws/session"
"github.com/aws/aws-sdk-go/service/s3"
"github.com/mulgadc/spinifex/spinifex/providers/objectstore"
)
// MaxCodeBytes bounds one stored code copy, AWS's direct-upload ZIP limit.
const MaxCodeBytes = 50 << 20
var (
// ErrCodeNotFound means no copy exists for the reference.
ErrCodeNotFound = errors.New("lambda code copy not found")
// ErrDigestMismatch means the stored bytes do not match the recorded SHA-256.
ErrDigestMismatch = errors.New("lambda code copy digest mismatch")
// ErrCodeTooLarge means the copy exceeds MaxCodeBytes.
ErrCodeTooLarge = errors.New("lambda code copy too large")
// ErrInvalidRef means a reference segment is empty or unsafe as a key segment.
ErrInvalidRef = errors.New("invalid lambda code reference")
// ErrNoPresignEndpoint means no off-host Predastore address is configured.
ErrNoPresignEndpoint = errors.New("lambda code store: no off-host endpoint for download URLs")
)
var (
refSegment = regexp.MustCompile(`^[A-Za-z0-9_-][A-Za-z0-9._-]{0,127}$`)
digestHex = regexp.MustCompile(`^[0-9a-f]{64}$`)
)
// Ref names one code revision; keys group by account for S11 inventory.
type Ref struct {
AccountID string
FunctionID string
RevisionID string
}
func (r Ref) prefix() (string, error) {
for _, seg := range []string{r.AccountID, r.FunctionID, r.RevisionID} {
if !refSegment.MatchString(seg) {
return "", fmt.Errorf("%w: %q", ErrInvalidRef, seg)
}
}
return r.AccountID + "/" + r.FunctionID + "/" + r.RevisionID + "/", nil
}
// key names a copy by its digest, so different bytes for the same revision land
// on a different object and can never replace the recorded copy.
func (r Ref) key(sha256Hex string) (string, error) {
p, err := r.prefix()
if err != nil {
return "", err
}
if !digestHex.MatchString(sha256Hex) {
return "", fmt.Errorf("%w: digest %q", ErrInvalidRef, sha256Hex)
}
return p + sha256Hex + ".zip", nil
}
// Store reads and writes code copies in one Region's code bucket.
type Store struct {
bucket string
objects objectstore.ObjectStore
presign presignFunc
// creds and provider are the scoped session behind presign; nil on stores
// built without one.
creds *credentials.Credentials
provider *sessionProvider
now func() time.Time
}
// presignMargin is slack kept between a signed URL's end and its session's.
const presignMargin = 30 * time.Second
// presignFunc signs a GET for the input; nil on stores that cannot presign.
type presignFunc func(in *s3.GetObjectInput, ttl time.Duration) (string, error)
// presignWith signs GETs with the session credentials behind client.
func presignWith(client *s3.S3) presignFunc {
return func(in *s3.GetObjectInput, ttl time.Duration) (string, error) {
req, _ := client.GetObjectRequest(in)
return req.Presign(ttl)
}
}
// OpenConfig is what the composition layer supplies to open the scoped store.
type OpenConfig struct {
Region string // Spinifex Region naming the bucket
Endpoint string // Predastore URL
SigningRegion string // Predastore SigV4 Region
Assume AssumeFunc
HTTPClient *http.Client
now func() time.Time
// PresignEndpoint is the Predastore URL off-host clients reach; download
// URLs are signed for it. Empty disables download URLs.
PresignEndpoint string
}
// Open builds the store on a refreshing code-store session and draws the first
// session, so an unassumable role fails here rather than on first use.
func Open(cfg OpenConfig) (*Store, error) {
if cfg.Region == "" || cfg.Endpoint == "" || cfg.Assume == nil {
return nil, errors.New("lambda code store: region, endpoint and assume are required")
}
now := cfg.now
if now == nil {
now = time.Now
}
client := cfg.HTTPClient
if client == nil {
client = &http.Client{Timeout: 120 * time.Second}
}
provider := newSessionProvider(cfg.Assume, now)
creds := credentials.NewCredentials(provider)
if _, err := creds.Get(); err != nil {
return nil, err
}
sess, err := session.NewSession(&aws.Config{
Endpoint: aws.String(cfg.Endpoint),
Region: aws.String(cfg.SigningRegion),
Credentials: creds,
S3ForcePathStyle: aws.Bool(true),
HTTPClient: client,
})
if err != nil {
return nil, fmt.Errorf("lambda code store session: %w", err)
}
s3Client := s3.New(sess)
store := newStore(cfg.Region, objectstore.NewS3ObjectStore(s3Client))
// Without an off-host address a download URL would name an internal one.
if cfg.PresignEndpoint != "" {
store.presign = presignWith(s3.New(sess, &aws.Config{Endpoint: aws.String(cfg.PresignEndpoint)}))
}
store.creds, store.provider, store.now = creds, provider, now
return store, nil
}
func newStore(region string, objects objectstore.ObjectStore) *Store {
return &Store{bucket: BucketName(region), objects: objects}
}
// Put stores code under its digest and returns the hex SHA-256 the revision
// record keeps. A retry with the same bytes rewrites identical content.
func (s *Store) Put(ctx context.Context, ref Ref, code []byte) (string, error) {
if len(code) > MaxCodeBytes {
return "", ErrCodeTooLarge
}
sum := sha256.Sum256(code)
digest := hex.EncodeToString(sum[:])
key, err := ref.key(digest)
if err != nil {
return "", err
}
if _, err := s.objects.PutObject(ctx, &s3.PutObjectInput{
Bucket: aws.String(s.bucket),
Key: aws.String(key),
Body: bytes.NewReader(code),
ContentLength: aws.Int64(int64(len(code))),
}); err != nil {
return "", fmt.Errorf("put lambda code copy: %w", err)
}
return digest, nil
}
// Get returns the copy only if it matches wantSHA256, the recorded digest.
func (s *Store) Get(ctx context.Context, ref Ref, wantSHA256 string) ([]byte, error) {
key, err := ref.key(wantSHA256)
if err != nil {
return nil, err
}
out, err := s.objects.GetObject(ctx, &s3.GetObjectInput{Bucket: aws.String(s.bucket), Key: aws.String(key)})
if err != nil {
if objectstore.IsNoSuchKeyError(err) {
return nil, ErrCodeNotFound
}
return nil, fmt.Errorf("get lambda code copy: %w", err)
}
defer out.Body.Close()
code, err := io.ReadAll(io.LimitReader(out.Body, MaxCodeBytes+1))
if err != nil {
return nil, fmt.Errorf("read lambda code copy: %w", err)
}
if len(code) > MaxCodeBytes {
return nil, ErrCodeTooLarge
}
sum := sha256.Sum256(code)
if hex.EncodeToString(sum[:]) != wantSHA256 {
return nil, ErrDigestMismatch
}
return code, nil
}
// ensureSession guarantees the signing session outlasts ttl, drawing a new one
// if it does not, and fails rather than sign a URL that would outlive it.
func (s *Store) ensureSession(ttl time.Duration) error {
if s.creds == nil || s.provider == nil {
return nil
}
need := ttl + presignMargin
if _, err := s.creds.Get(); err != nil {
return fmt.Errorf("lambda code store session: %w", err)
}
for attempt := range 2 {
exp, err := s.creds.ExpiresAt()
if err != nil {
return fmt.Errorf("lambda code store session expiry: %w", err)
}
if !exp.Before(s.now().Add(need)) {
return nil
}
if attempt == 1 {
break
}
s.provider.requireRemaining(need)
s.creds.Expire()
if _, err := s.creds.Get(); err != nil {
return fmt.Errorf("lambda code store session: %w", err)
}
}
return errors.New("lambda code store: session expires before the presigned URL would")
}
// PresignGet returns a time-limited GET URL for the copy with the recorded
// digest, signed by a session that outlasts the URL.
func (s *Store) PresignGet(ctx context.Context, ref Ref, sha256Hex string, ttl time.Duration) (string, error) {
if s.presign == nil {
return "", ErrNoPresignEndpoint
}
key, err := ref.key(sha256Hex)
if err != nil {
return "", err
}
if err := ctx.Err(); err != nil {
return "", err
}
if err := s.ensureSession(ttl); err != nil {
return "", err
}
url, err := s.presign(&s3.GetObjectInput{Bucket: aws.String(s.bucket), Key: aws.String(key)}, ttl)
if err != nil {
return "", fmt.Errorf("presign lambda code copy: %w", err)
}
return url, nil
}
// Delete removes the copy with the recorded digest; an absent copy is already
// deleted.
func (s *Store) Delete(ctx context.Context, ref Ref, sha256Hex string) error {
key, err := ref.key(sha256Hex)
if err != nil {
return err
}
return s.deleteKey(ctx, key)
}
// DeleteRevision removes every copy under the revision, including ones a failed
// or superseded attempt left. Callers own the guarantee that no create or update
// for the revision is still in progress.
func (s *Store) DeleteRevision(ctx context.Context, ref Ref) error {
prefix, err := ref.prefix()
if err != nil {
return err
}
objects, _, err := objectstore.ListAll(ctx, s.objects, &s3.ListObjectsV2Input{
Bucket: aws.String(s.bucket), Prefix: aws.String(prefix),
})
if err != nil {
return fmt.Errorf("list lambda code copies: %w", err)
}
for _, o := range objects {
if err := s.deleteKey(ctx, aws.StringValue(o.Key)); err != nil {
return err
}
}
return nil
}
func (s *Store) deleteKey(ctx context.Context, key string) error {
_, err := s.objects.DeleteObject(ctx, &s3.DeleteObjectInput{Bucket: aws.String(s.bucket), Key: aws.String(key)})
if err != nil && !objectstore.IsNoSuchKeyError(err) {
return fmt.Errorf("delete lambda code copy: %w", err)
}
return nil
}

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