mirror of
https://github.com/mulgadc/spinifex.git
synced 2026-11-02 09:13:33 +00:00
Compare commits
11 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e4055d4d89 | |||
| a7753557dc | |||
| fe97c45793 | |||
| 1713c711c4 | |||
| bab4e1e53c | |||
| 1a91d5de9e | |||
| e958e4a57b | |||
| a44fd01b85 | |||
| 72f2786360 | |||
| 8916904285 | |||
| 2630d29e13 |
@@ -1,4 +1,4 @@
|
|||||||
# spinifex-unit-version: 5
|
# spinifex-unit-version: 6
|
||||||
[Unit]
|
[Unit]
|
||||||
Description=Spinifex Daemon (VM Orchestration)
|
Description=Spinifex Daemon (VM Orchestration)
|
||||||
PartOf=spinifex.target
|
PartOf=spinifex.target
|
||||||
@@ -15,6 +15,7 @@ Slice=spinifex-system.slice
|
|||||||
User=spinifex-daemon
|
User=spinifex-daemon
|
||||||
Group=spinifex
|
Group=spinifex
|
||||||
ExecStartPre=/usr/local/bin/spx admin hostnet check
|
ExecStartPre=/usr/local/bin/spx admin hostnet check
|
||||||
|
ExecStartPre=-+/usr/local/bin/spx admin node-facts collect
|
||||||
ExecStart=/usr/local/bin/spx service spinifex start
|
ExecStart=/usr/local/bin/spx service spinifex start
|
||||||
Restart=on-failure
|
Restart=on-failure
|
||||||
RestartSec=5
|
RestartSec=5
|
||||||
|
|||||||
@@ -0,0 +1,211 @@
|
|||||||
|
package clusterv1
|
||||||
|
|
||||||
|
import "regexp"
|
||||||
|
|
||||||
|
const (
|
||||||
|
// NodeFactsSubject fans out a request for each daemon's hardware and
|
||||||
|
// cabling description. Its reply payload is NodeFacts.
|
||||||
|
NodeFactsSubject = "spinifex.node.facts"
|
||||||
|
|
||||||
|
nodeNetTestPrefix = "spinifex.node.nettest."
|
||||||
|
)
|
||||||
|
|
||||||
|
var nodeIDPattern = regexp.MustCompile(`^[A-Za-z0-9_-]{1,64}$`)
|
||||||
|
|
||||||
|
// ValidNodeID reports whether a node name is safe to place in a subject: no
|
||||||
|
// NATS token separator or wildcard can get through.
|
||||||
|
func ValidNodeID(node string) bool {
|
||||||
|
return nodeIDPattern.MatchString(node)
|
||||||
|
}
|
||||||
|
|
||||||
|
// NetTestServeSubject asks one node to start a one-shot throughput server.
|
||||||
|
func NetTestServeSubject(node string) string {
|
||||||
|
return nodeNetTestPrefix + node + ".serve"
|
||||||
|
}
|
||||||
|
|
||||||
|
// NetTestRunSubject asks one node to run the client side of a throughput test.
|
||||||
|
func NetTestRunSubject(node string) string {
|
||||||
|
return nodeNetTestPrefix + node + ".run"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Planes a node's NICs can carry, named as the installer names them.
|
||||||
|
const (
|
||||||
|
PlaneWAN = "wan"
|
||||||
|
PlaneLAN = "lan"
|
||||||
|
PlaneVPC = "vpc"
|
||||||
|
)
|
||||||
|
|
||||||
|
// NetTestPort is the TCP port reserved for node-to-node throughput tests.
|
||||||
|
const NetTestPort = 5001
|
||||||
|
|
||||||
|
// NodeFacts describes one node's hardware, OS and cabling. Fields a host
|
||||||
|
// cannot report are left empty, and Sources says why.
|
||||||
|
type NodeFacts struct {
|
||||||
|
Node string `json:"node"`
|
||||||
|
CollectedAt string `json:"collected_at,omitempty"`
|
||||||
|
FormFactor string `json:"form_factor"`
|
||||||
|
Virt string `json:"virt,omitempty"`
|
||||||
|
System SystemFacts `json:"system"`
|
||||||
|
OS OSFacts `json:"os"`
|
||||||
|
CPU CPUFacts `json:"cpu"`
|
||||||
|
Memory MemoryFacts `json:"memory"`
|
||||||
|
NICs []NICFacts `json:"nics"`
|
||||||
|
Planes []PlaneFacts `json:"planes"`
|
||||||
|
Sources map[string]string `json:"sources"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// SystemFacts is the machine's identity from DMI.
|
||||||
|
type SystemFacts struct {
|
||||||
|
Vendor string `json:"vendor,omitempty"`
|
||||||
|
Product string `json:"product,omitempty"`
|
||||||
|
Family string `json:"family,omitempty"`
|
||||||
|
Version string `json:"version,omitempty"`
|
||||||
|
Serial string `json:"serial,omitempty"`
|
||||||
|
UUID string `json:"uuid,omitempty"`
|
||||||
|
ChassisType string `json:"chassis_type,omitempty"`
|
||||||
|
ChassisAsset string `json:"chassis_asset,omitempty"`
|
||||||
|
BoardVendor string `json:"board_vendor,omitempty"`
|
||||||
|
BoardProduct string `json:"board_product,omitempty"`
|
||||||
|
BoardSerial string `json:"board_serial,omitempty"`
|
||||||
|
BIOSVendor string `json:"bios_vendor,omitempty"`
|
||||||
|
BIOSVersion string `json:"bios_version,omitempty"`
|
||||||
|
BIOSDate string `json:"bios_date,omitempty"`
|
||||||
|
DeviceModel string `json:"device_model,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// OSFacts names the running OS and Spinifex build.
|
||||||
|
type OSFacts struct {
|
||||||
|
Name string `json:"name,omitempty"`
|
||||||
|
Kernel string `json:"kernel,omitempty"`
|
||||||
|
Arch string `json:"arch,omitempty"`
|
||||||
|
Version string `json:"spx_version,omitempty"`
|
||||||
|
Commit string `json:"spx_commit,omitempty"`
|
||||||
|
UptimeSeconds int64 `json:"uptime_seconds,omitempty"`
|
||||||
|
Hostname string `json:"hostname,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// CPUFacts summarises the sockets. Threads and Cores are totals. Features are
|
||||||
|
// the flags worth naming; Flags is every flag the CPU reports.
|
||||||
|
type CPUFacts struct {
|
||||||
|
Model string `json:"model,omitempty"`
|
||||||
|
Vendor string `json:"vendor,omitempty"`
|
||||||
|
Sockets int `json:"sockets"`
|
||||||
|
CoresPerSocket int `json:"cores_per_socket,omitempty"`
|
||||||
|
Cores int `json:"cores"`
|
||||||
|
Threads int `json:"threads"`
|
||||||
|
MaxMHz int `json:"max_mhz,omitempty"`
|
||||||
|
CurrentMHz int `json:"current_mhz,omitempty"`
|
||||||
|
Virtualization string `json:"virtualization,omitempty"`
|
||||||
|
Features []string `json:"features,omitempty"`
|
||||||
|
Flags []string `json:"flags,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// MemoryFacts groups identical DIMMs so a 32-slot board reads as one line.
|
||||||
|
type MemoryFacts struct {
|
||||||
|
TotalBytes uint64 `json:"total_bytes"`
|
||||||
|
SlotsUsed int `json:"slots_used"`
|
||||||
|
SlotsTotal int `json:"slots_total"`
|
||||||
|
MaxCapacity string `json:"max_capacity,omitempty"`
|
||||||
|
ECC string `json:"ecc,omitempty"`
|
||||||
|
Groups []MemoryGroup `json:"groups"`
|
||||||
|
OSTotalBytes uint64 `json:"os_total_bytes,omitempty"`
|
||||||
|
OSAvailBytes uint64 `json:"os_available_bytes,omitempty"`
|
||||||
|
HugePageBytes uint64 `json:"huge_page_bytes,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// MemoryGroup is a set of DIMMs alike in every field shown. ConfiguredMTs
|
||||||
|
// below SpeedMTs means the board runs them slower than rated.
|
||||||
|
type MemoryGroup struct {
|
||||||
|
Count int `json:"count"`
|
||||||
|
SizeBytes uint64 `json:"size_bytes"`
|
||||||
|
Type string `json:"type,omitempty"`
|
||||||
|
TypeDetail string `json:"type_detail,omitempty"`
|
||||||
|
FormFactor string `json:"form_factor,omitempty"`
|
||||||
|
SpeedMTs int `json:"speed_mts,omitempty"`
|
||||||
|
ConfiguredMTs int `json:"configured_mts,omitempty"`
|
||||||
|
Manufacturer string `json:"manufacturer,omitempty"`
|
||||||
|
PartNumber string `json:"part_number,omitempty"`
|
||||||
|
Rank string `json:"rank,omitempty"`
|
||||||
|
Serials []string `json:"serials,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// NICFacts is one physical network interface and, where LLDP reports it, the
|
||||||
|
// switch port it is cabled to.
|
||||||
|
type NICFacts struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
MAC string `json:"mac,omitempty"`
|
||||||
|
Driver string `json:"driver,omitempty"`
|
||||||
|
Firmware string `json:"firmware,omitempty"`
|
||||||
|
BusInfo string `json:"bus_info,omitempty"`
|
||||||
|
Model string `json:"model,omitempty"`
|
||||||
|
SpeedMbps int `json:"speed_mbps,omitempty"`
|
||||||
|
MaxMbps int `json:"max_mbps,omitempty"`
|
||||||
|
Duplex string `json:"duplex,omitempty"`
|
||||||
|
Port string `json:"port,omitempty"`
|
||||||
|
MTU int `json:"mtu,omitempty"`
|
||||||
|
State string `json:"state"`
|
||||||
|
Master string `json:"master,omitempty"`
|
||||||
|
Planes []string `json:"planes,omitempty"`
|
||||||
|
Neighbor *LLDPNeighbor `json:"neighbor,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// LLDPNeighbor is what the switch on the other end of a NIC advertises.
|
||||||
|
type LLDPNeighbor struct {
|
||||||
|
ChassisID string `json:"chassis_id"`
|
||||||
|
SystemName string `json:"system_name,omitempty"`
|
||||||
|
SystemDescription string `json:"system_description,omitempty"`
|
||||||
|
PortID string `json:"port_id,omitempty"`
|
||||||
|
PortDescription string `json:"port_description,omitempty"`
|
||||||
|
Capabilities []string `json:"capabilities,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// PlaneFacts is the address a plane uses on this node and the NICs under it.
|
||||||
|
type PlaneFacts struct {
|
||||||
|
Plane string `json:"plane"`
|
||||||
|
Bridge string `json:"bridge,omitempty"`
|
||||||
|
Address string `json:"address,omitempty"`
|
||||||
|
Prefix int `json:"prefix,omitempty"`
|
||||||
|
NICs []string `json:"nics,omitempty"`
|
||||||
|
MTU int `json:"mtu,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// NetTestServeRequest starts a server bound to the node's address on Plane.
|
||||||
|
type NetTestServeRequest struct {
|
||||||
|
Plane string `json:"plane"`
|
||||||
|
DurationSeconds int `json:"duration_seconds"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// NetTestServeResponse names the address the server listens on.
|
||||||
|
type NetTestServeResponse struct {
|
||||||
|
Address string `json:"address,omitempty"`
|
||||||
|
Port int `json:"port,omitempty"`
|
||||||
|
Error string `json:"error,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// NetTestRunRequest runs the client towards Address from the node's own
|
||||||
|
// address on Plane.
|
||||||
|
type NetTestRunRequest struct {
|
||||||
|
Plane string `json:"plane"`
|
||||||
|
Address string `json:"address"`
|
||||||
|
Port int `json:"port"`
|
||||||
|
DurationSeconds int `json:"duration_seconds"`
|
||||||
|
Streams int `json:"streams"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// NetTestRunResponse is one direction of a pair's measurement.
|
||||||
|
type NetTestRunResponse struct {
|
||||||
|
SourceAddress string `json:"source_address,omitempty"`
|
||||||
|
BitsPerSecond float64 `json:"bits_per_second"`
|
||||||
|
Bytes int64 `json:"bytes"`
|
||||||
|
Seconds float64 `json:"seconds"`
|
||||||
|
Streams int `json:"streams"`
|
||||||
|
StreamBPS []float64 `json:"stream_bps,omitempty"`
|
||||||
|
RTTMinMillis float64 `json:"rtt_min_ms,omitempty"`
|
||||||
|
RTTAvgMillis float64 `json:"rtt_avg_ms,omitempty"`
|
||||||
|
RTTMaxMillis float64 `json:"rtt_max_ms,omitempty"`
|
||||||
|
PacketLoss float64 `json:"packet_loss_percent,omitempty"`
|
||||||
|
LinkMbps int `json:"link_mbps,omitempty"`
|
||||||
|
MTU int `json:"mtu,omitempty"`
|
||||||
|
Tool string `json:"tool,omitempty"`
|
||||||
|
Error string `json:"error,omitempty"`
|
||||||
|
}
|
||||||
@@ -126,8 +126,8 @@ type Assign struct {
|
|||||||
ENIPrivateIP string `json:"eniPrivateIp,omitempty"`
|
ENIPrivateIP string `json:"eniPrivateIp,omitempty"`
|
||||||
ENISubnetID string `json:"eniSubnetId,omitempty"`
|
ENISubnetID string `json:"eniSubnetId,omitempty"`
|
||||||
// TaskRoleARN is the task's IAM role (from the taskdef); when set, the agent
|
// TaskRoleARN is the task's IAM role (from the taskdef); when set, the agent
|
||||||
// serves its credentials at 169.254.170.2 under CredID, a scheduler-minted
|
// serves its credentials at 169.254.170.2 under CredID, defaulting CredID to
|
||||||
// secret. An assign without a CredID gets no credentials.
|
// the taskID when the scheduler leaves it empty.
|
||||||
CredID string `json:"credId,omitempty"`
|
CredID string `json:"credId,omitempty"`
|
||||||
TaskRoleARN string `json:"taskRoleArn,omitempty"`
|
TaskRoleARN string `json:"taskRoleArn,omitempty"`
|
||||||
// ExecutionRoleARN is the task's execution role (from the taskdef); when set,
|
// ExecutionRoleARN is the task's execution role (from the taskdef); when set,
|
||||||
|
|||||||
@@ -0,0 +1,53 @@
|
|||||||
|
package telemetryv1
|
||||||
|
|
||||||
|
import "regexp"
|
||||||
|
|
||||||
|
// Node live subjects mirror the instance ones, keyed by node name. Only the
|
||||||
|
// super-admin account's gateway handler subscribes to them.
|
||||||
|
const (
|
||||||
|
nodeLiveWatchSubjectPrefix = "telemetry.watch.node."
|
||||||
|
nodeLiveSampleSubjectPrefix = "telemetry.live.node."
|
||||||
|
)
|
||||||
|
|
||||||
|
var liveNodePattern = regexp.MustCompile(`^[A-Za-z0-9_-]{1,64}$`)
|
||||||
|
|
||||||
|
// ValidLiveNode reports whether a node name is safe to place in a subject.
|
||||||
|
func ValidLiveNode(node string) bool {
|
||||||
|
return liveNodePattern.MatchString(node)
|
||||||
|
}
|
||||||
|
|
||||||
|
// NodeLiveWatchSubject is where a viewer asks a node to keep sampling itself.
|
||||||
|
// A request on it is answered with a NodeLiveRing.
|
||||||
|
func NodeLiveWatchSubject(node string) string {
|
||||||
|
return nodeLiveWatchSubjectPrefix + node
|
||||||
|
}
|
||||||
|
|
||||||
|
// NodeLiveSampleSubject carries one NodeLiveSample per period while watched.
|
||||||
|
func NodeLiveSampleSubject(node string) string {
|
||||||
|
return nodeLiveSampleSubjectPrefix + node
|
||||||
|
}
|
||||||
|
|
||||||
|
// NodeLiveRing answers a watch with the samples newer than its SinceMillis.
|
||||||
|
type NodeLiveRing struct {
|
||||||
|
Samples []NodeLiveSample `json:"samples"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// NodeLiveSample is one host measurement. Rates are per second over the
|
||||||
|
// interval ending at TimeMillis.
|
||||||
|
type NodeLiveSample struct {
|
||||||
|
TimeMillis int64 `json:"t"`
|
||||||
|
CPUPercent float64 `json:"cpu"`
|
||||||
|
// MemoryUsedPercent is used memory less page cache, as free(1) reports it.
|
||||||
|
MemoryUsedPercent float64 `json:"mem"`
|
||||||
|
Load1 float64 `json:"load1"`
|
||||||
|
// Planes holds the traffic of each plane's physical NICs, keyed wan, lan, vpc.
|
||||||
|
Planes map[string]NodeLivePlane `json:"planes"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// NodeLivePlane is one plane's traffic from the host's side.
|
||||||
|
type NodeLivePlane struct {
|
||||||
|
NetworkIn float64 `json:"netIn"`
|
||||||
|
NetworkOut float64 `json:"netOut"`
|
||||||
|
PacketsIn float64 `json:"pktIn"`
|
||||||
|
PacketsOut float64 `json:"pktOut"`
|
||||||
|
}
|
||||||
@@ -143,25 +143,6 @@ No user-facing operation replaces a control plane, and automatic replacement doe
|
|||||||
Recreate the cluster to adopt it.
|
Recreate the cluster to adopt it.
|
||||||
User-workload injection through a cluster created with an older image is not measured.
|
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
|
## Prerequisites
|
||||||
|
|
||||||
> [!IMPORTANT]
|
> [!IMPORTANT]
|
||||||
@@ -378,7 +359,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.
|
**`create-cluster` rejected with an authentication-mode error.** `CONFIG_MAP` is the one mode Spinifex rejects, because it has no `aws-auth` ConfigMap path. Set `authentication_mode` to `API` or `API_AND_CONFIG_MAP`, or leave it unset, and grant access with access entries.
|
||||||
|
|
||||||
**Addon install fails.** Only addons bundled into the registered `eks-node` image install successfully. If you pin an addon version in Terraform, use the AWS form from `DescribeAddonVersions` (see Add-on versions above), or leave `addon_version` unset so Spinifex selects the catalog default.
|
**Addon install fails.** Only addons bundled into the registered `eks-node` image install successfully. If you pin an addon version in Terraform, leave `addon_version` unset so Spinifex selects the catalog default.
|
||||||
|
|
||||||
## Reference Architectures
|
## Reference Architectures
|
||||||
|
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ tags:
|
|||||||
|
|
||||||
## Overview
|
## Overview
|
||||||
|
|
||||||
The platform serves **406 operations** across the AWS APIs below. Every page names the operations its service implements from the pinned model, generated from the dispatch tables on each build rather than written by hand.
|
The platform serves **405 operations** across the AWS APIs below. Every page names the operations its service implements from the pinned model, generated from the dispatch tables on each build rather than written by hand.
|
||||||
|
|
||||||
| Service | Operations |
|
| Service | Operations |
|
||||||
|---|---:|
|
|---|---:|
|
||||||
@@ -27,9 +27,9 @@ The platform serves **406 operations** across the AWS APIs below. Every page nam
|
|||||||
| [ECS](/coverage/ecs) | 31 |
|
| [ECS](/coverage/ecs) | 31 |
|
||||||
| [EKS](/coverage/eks) | 27 |
|
| [EKS](/coverage/eks) | 27 |
|
||||||
| [ELBv2](/coverage/elbv2) | 36 |
|
| [ELBv2](/coverage/elbv2) | 36 |
|
||||||
| [IAM](/coverage/iam) | 82 |
|
| [IAM](/coverage/iam) | 81 |
|
||||||
| [RDS](/coverage/rds) | 27 |
|
| [RDS](/coverage/rds) | 27 |
|
||||||
| [Route 53](/coverage/route53) | 13 |
|
| [Route 53](/coverage/route53) | 13 |
|
||||||
| [S3](/coverage/s3) | 26 |
|
| [S3](/coverage/s3) | 26 |
|
||||||
| [STS](/coverage/sts) | 5 |
|
| [STS](/coverage/sts) | 5 |
|
||||||
| **Total** | **406** |
|
| **Total** | **405** |
|
||||||
|
|||||||
@@ -2938,8 +2938,7 @@
|
|||||||
"UpdateAccessKey",
|
"UpdateAccessKey",
|
||||||
"UpdateAssumeRolePolicy",
|
"UpdateAssumeRolePolicy",
|
||||||
"UpdateRole",
|
"UpdateRole",
|
||||||
"UpdateRoleDescription",
|
"UpdateRoleDescription"
|
||||||
"UpdateUser"
|
|
||||||
],
|
],
|
||||||
"implemented": [
|
"implemented": [
|
||||||
"AddRoleToInstanceProfile",
|
"AddRoleToInstanceProfile",
|
||||||
@@ -3022,8 +3021,7 @@
|
|||||||
"UpdateAccessKey",
|
"UpdateAccessKey",
|
||||||
"UpdateAssumeRolePolicy",
|
"UpdateAssumeRolePolicy",
|
||||||
"UpdateRole",
|
"UpdateRole",
|
||||||
"UpdateRoleDescription",
|
"UpdateRoleDescription"
|
||||||
"UpdateUser"
|
|
||||||
],
|
],
|
||||||
"stubbed": [],
|
"stubbed": [],
|
||||||
"unsupported": [],
|
"unsupported": [],
|
||||||
@@ -3123,6 +3121,7 @@
|
|||||||
"UpdateServerCertificate",
|
"UpdateServerCertificate",
|
||||||
"UpdateServiceSpecificCredential",
|
"UpdateServiceSpecificCredential",
|
||||||
"UpdateSigningCertificate",
|
"UpdateSigningCertificate",
|
||||||
|
"UpdateUser",
|
||||||
"UploadSSHPublicKey",
|
"UploadSSHPublicKey",
|
||||||
"UploadServerCertificate",
|
"UploadServerCertificate",
|
||||||
"UploadSigningCertificate"
|
"UploadSigningCertificate"
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ tags:
|
|||||||
|
|
||||||
## Overview
|
## Overview
|
||||||
|
|
||||||
Spinifex implements **82 operations** in the IAM `2010-05-08` API model.
|
Spinifex implements **81 operations** in the IAM `2010-05-08` API model.
|
||||||
|
|
||||||
### Scope
|
### Scope
|
||||||
|
|
||||||
@@ -109,4 +109,3 @@ All IAM operations are account-scoped. The root user of an account bypasses poli
|
|||||||
| `UpdateAssumeRolePolicy` |
|
| `UpdateAssumeRolePolicy` |
|
||||||
| `UpdateRole` |
|
| `UpdateRole` |
|
||||||
| `UpdateRoleDescription` |
|
| `UpdateRoleDescription` |
|
||||||
| `UpdateUser` |
|
|
||||||
|
|||||||
@@ -987,7 +987,7 @@ spot-price: On owned hardware there is no spot-to-on-demand price differential,
|
|||||||
|
|
||||||
## iam
|
## iam
|
||||||
|
|
||||||
`2010-05-08` — 82 of 180 modelled operations implemented (45.6%), 0 stubbed, 19 not applicable, 0 registered outside the pinned model.
|
`2010-05-08` — 81 of 180 modelled operations implemented (45.0%), 0 stubbed, 19 not applicable, 0 registered outside the pinned model.
|
||||||
|
|
||||||
### Not implemented
|
### Not implemented
|
||||||
|
|
||||||
@@ -1069,6 +1069,7 @@ spot-price: On owned hardware there is no spot-to-on-demand price differential,
|
|||||||
- `UpdateOpenIDConnectProviderThumbprint`
|
- `UpdateOpenIDConnectProviderThumbprint`
|
||||||
- `UpdateSAMLProvider`
|
- `UpdateSAMLProvider`
|
||||||
- `UpdateServerCertificate`
|
- `UpdateServerCertificate`
|
||||||
|
- `UpdateUser`
|
||||||
- `UploadServerCertificate`
|
- `UploadServerCertificate`
|
||||||
|
|
||||||
### Not applicable — do not build
|
### Not applicable — do not build
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ There is no generation, so the record mixes desired fields with the latest obser
|
|||||||
| Durable record | `clusters/{cluster}/addons/{addon}` holds `Record` (`domains/eks/addon/record.go`): `addonName`, `addonVersion`, `status`, `serviceAccountRoleArn`, `configurationValues`, `health`, `arn`, `tags`, `createdAt`, `modifiedAt` (the optional ones `omitempty`). `clusters/{cluster}/addons/{addon}/manifest` holds `Manifest` (`domains/eks/addon/record.go`): `addonName`, `addonVersion`, `serviceAccountRoleArn`, `configurationValues`. Listing skips the manifest sub-key by segment count. | `TestAddonPersistedLayout_RecordAndManifestKeysAndFieldNames`, `TestAddonRecord_DecodesHandWrittenJSON`, `domains/eks/addon/store_test.go` `TestRecord_RoundTrip`, `TestList_SkipsManifestSubKeys` |
|
| 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` |
|
| 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` |
|
| Readers | `ListAddons`, `DescribeAddon`, `CreateAddon` (existence check) and the reconciler read the record. `ListStagedAddonManifests` reads every `/manifest` key for the cluster (`addon.ListManifests`); it is served on `eks.ListStagedAddonManifests` (`daemon/daemon.go`) and reached only through the gateway's internal-addons route. | `handlers/eks/addons_test.go` `TestAddon_DescribeListDelete`, `TestListStagedAddonManifests` |
|
||||||
| Catalogue | Static in-binary catalogue (`domains/eks/addon/catalog.go`): `aws-load-balancer-controller`, `argocd`, `aws-ebs-csi-driver`, and hidden-but-creatable `nvidia-device-plugin` and `spinifex-noop`; one version each, the first being the default. Versions are stored and used internally in the bare upstream form, and API responses show the AWS form `v<x.y.z>-eksbuild.<n>` (`Spec.AWSVersion`). Create and update accept the AWS form or the exact bare form and reject unknown names and other version strings with `InvalidParameterValue`, which the gateway returns as `InvalidParameterException`. | `domains/eks/addon/catalog_test.go`, `handlers/eks/addons_test.go` `TestDescribeAddonVersions_ReturnsCatalog`, `TestCreateAddon_UnknownAddonRejected`, `TestCreateAddon_UnknownVersionRejected`, `TestUpdateAddon_UnknownVersionRejected` |
|
| Catalogue | Static in-binary catalogue (`domains/eks/addon/catalog.go`): `aws-load-balancer-controller`, `argocd`, `aws-ebs-csi-driver`, and hidden-but-creatable `nvidia-device-plugin` and `spinifex-noop`; one version each, the first being the default. Create and update reject unknown names and versions with `InvalidParameterValue`. | `domains/eks/addon/catalog_test.go`, `handlers/eks/addons_test.go` `TestDescribeAddonVersions_ReturnsCatalog`, `TestCreateAddon_UnknownAddonRejected`, `TestCreateAddon_UnknownVersionRejected`, `TestUpdateAddon_UnknownVersionRejected` |
|
||||||
| Desired state | Version, `serviceAccountRoleArn`, `configurationValues` and create-time tags. `resolveConflicts`, `podIdentityAssociations` and `clientRequestToken` are accepted and dropped; `preserve` on delete is ignored. `configurationValues` reaches the guest but the agent discards it (`mulga-eks-addon-sync.sh` `sync_once`), so it has no effect. | `TestAddonPersistedLayout_RecordAndManifestKeysAndFieldNames`, `TestDeleteAddon_ReturnsDeletingAfterErasingRecordAndManifest` |
|
| 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` |
|
| 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` |
|
| 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` |
|
||||||
|
|||||||
@@ -99,6 +99,7 @@ behavior in other words.
|
|||||||
| 4432 | Formation server | HTTPS | Cluster (bootstrap only) | Cluster join coordination; active only while a join token is valid. Binds the node's `--bind` (lan) address. See *Formation port lifecycle* below. | Short-lived bearer token + TLS¹ |
|
| 4432 | Formation server | HTTPS | Cluster (bootstrap only) | Cluster join coordination; active only while a join token is valid. Binds the node's `--bind` (lan) address. See *Formation port lifecycle* below. | Short-lived bearer token + TLS¹ |
|
||||||
| 4222 | spinifex-nats (client) | NATS + TLS | Cluster | Internal service bus for EC2/EBS/VPC/S3 handlers | Token + mutual TLS (cluster CA) |
|
| 4222 | spinifex-nats (client) | NATS + TLS | Cluster | Internal service bus for EC2/EBS/VPC/S3 handlers | Token + mutual TLS (cluster CA) |
|
||||||
| 4248 | spinifex-nats (cluster) | NATS + TLS | Cluster | Inter-node NATS federation | Token + mutual TLS (cluster CA) |
|
| 4248 | spinifex-nats (cluster) | NATS + TLS | Cluster | Inter-node NATS federation | Token + mutual TLS (cluster CA) |
|
||||||
|
| 5001 | iperf2 (admin network test) | TCP | Cluster, any plane | Throughput test between two nodes, started only by the super-admin account. Listens on one plane address for the length of one test, then exits. Runs with no capabilities. | None — peer-scoped by firewall, and the client may only target its own plane subnet |
|
||||||
| 5300 | northstar | DNS (UDP + TCP) | Cluster | Forward target for every node's per-instance DNS shim, dialled cross-node. Binds the wildcard by design. | None — restrict by firewall |
|
| 5300 | northstar | DNS (UDP + TCP) | Cluster | Forward target for every node's per-instance DNS shim, dialled cross-node. Binds the wildcard by design. | None — restrict by firewall |
|
||||||
| 6660 | predastore (blob node) | QUIC / UDP | Cluster | Erasure-coded object shard transport between hosts. Multi-node clusters only — see *Predastore ports* below. | TLS 1.3, server certificate verified against the cluster CA |
|
| 6660 | predastore (blob node) | QUIC / UDP | Cluster | Erasure-coded object shard transport between hosts. Multi-node clusters only — see *Predastore ports* below. | TLS 1.3, server certificate verified against the cluster CA |
|
||||||
| 7660 | predastore (meta node) | QUIC / UDP | Cluster | Raft consensus over global state — buckets and the object index — between hosts. Multi-node clusters only. | TLS 1.3, server certificate verified against the cluster CA |
|
| 7660 | predastore (meta node) | QUIC / UDP | Cluster | Raft consensus over global state — buckets and the object index — between hosts. Multi-node clusters only. | TLS 1.3, server certificate verified against the cluster CA |
|
||||||
@@ -221,7 +222,7 @@ table inet spinifex_filter {
|
|||||||
|
|
||||||
# Cluster, from peer nodes only. Replace with your nodes' lan-plane
|
# Cluster, from peer nodes only. Replace with your nodes' lan-plane
|
||||||
# addresses — a CIDR does not generalise to nodes on different subnets.
|
# addresses — a CIDR does not generalise to nodes on different subnets.
|
||||||
ip saddr { 10.0.1.1, 10.0.1.2, 10.0.1.3 } tcp dport { 4222, 4248, 4432, 5300, 6641, 6642, 6643, 6644, 8660 } accept
|
ip saddr { 10.0.1.1, 10.0.1.2, 10.0.1.3 } tcp dport { 4222, 4248, 4432, 5001, 5300, 6641, 6642, 6643, 6644, 8660 } accept
|
||||||
ip saddr { 10.0.1.1, 10.0.1.2, 10.0.1.3 } udp dport { 5300, 6660, 7660 } accept
|
ip saddr { 10.0.1.1, 10.0.1.2, 10.0.1.3 } udp dport { 5300, 6660, 7660 } accept
|
||||||
|
|
||||||
# Encap, from peer chassis only. Replace with your nodes' vpc-plane
|
# Encap, from peer chassis only. Replace with your nodes' vpc-plane
|
||||||
|
|||||||
@@ -79,8 +79,7 @@ Terraform manages the cluster, the addons, the ACM cert, and the HTTPS Ingress;
|
|||||||
- The `argocd` and `aws-ebs-csi-driver` bundles must be baked into the `eks-node` AMI — `describe-addon` returns `no baked bundle` otherwise.
|
- The `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**.
|
- 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).
|
- If the PVC stays `Pending`, the app still serves (the counter falls back to in-memory) — see [Troubleshooting](#troubleshooting).
|
||||||
- Addon versions are unset, so Spinifex installs each catalog default.
|
- Leave addon versions unset (the AWS provider and the catalog disagree on the version-string format).
|
||||||
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.
|
- 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:**
|
**Prerequisites:**
|
||||||
|
|||||||
@@ -520,8 +520,8 @@ resource "aws_eks_node_group" "workers" {
|
|||||||
#
|
#
|
||||||
# Spinifex wires the controller's AWS credentials and ELB-eligible subnets at the
|
# 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
|
# node level, so no IRSA role or subnet tagging is needed here. addon_version is
|
||||||
# omitted so Spinifex installs its catalog default. To pin one, use the AWS form
|
# omitted: the AWS provider demands a v-prefixed version that the catalog rejects,
|
||||||
# from DescribeAddonVersions, for example v2.11.0-eksbuild.1.
|
# so let Spinifex default to its catalog version.
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
resource "aws_eks_addon" "lbc" {
|
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
|
# 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
|
# 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.
|
# the git repo and creates the Argo CD Application that syncs the demo app.
|
||||||
# addon_version omitted: the catalog default is installed (see the LBC note).
|
# addon_version omitted (see the LBC note).
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
resource "aws_eks_addon" "argocd" {
|
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
|
# 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
|
# demo app's git manifests dynamically provisions a Viperblock-backed EBS volume
|
||||||
# so the app's state survives pod restarts. addon_version omitted: catalog default.
|
# so the app's state survives pod restarts. addon_version omitted (see LBC note).
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
resource "aws_eks_addon" "ebs_csi" {
|
resource "aws_eks_addon" "ebs_csi" {
|
||||||
|
|||||||
@@ -74,8 +74,7 @@ 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.
|
- **`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.
|
- 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.
|
- Spinifex ACM supports **ImportCertificate only** — the cert is generated by the `tls` provider and imported, not requested.
|
||||||
- **`addon_version` is unset**, so Spinifex installs its catalog default.
|
- **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.
|
||||||
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:**
|
**Prerequisites:**
|
||||||
|
|
||||||
|
|||||||
@@ -446,8 +446,8 @@ resource "aws_eks_node_group" "workers" {
|
|||||||
#
|
#
|
||||||
# Spinifex wires the controller's AWS credentials and ELB-eligible subnets at the
|
# 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
|
# node level, so no IRSA role or subnet tagging is needed here. addon_version is
|
||||||
# omitted so Spinifex installs its catalog default. To pin one, use the AWS form
|
# omitted: the AWS provider demands a v-prefixed version that the catalog rejects,
|
||||||
# from DescribeAddonVersions, for example v2.11.0-eksbuild.1.
|
# so let Spinifex default to its catalog version.
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
resource "aws_eks_addon" "lbc" {
|
resource "aws_eks_addon" "lbc" {
|
||||||
|
|||||||
+78
-21
@@ -24,7 +24,7 @@ All colours are OKLCH and come from mulgadc.com, so product and site match.
|
|||||||
| Token | Use |
|
| Token | Use |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `background` | The app page. Warm off-white in light, ink `#14181D` in dark. |
|
| `background` | The app page. Warm off-white in light, ink `#14181D` in dark. |
|
||||||
| `card`, `popover` | Raised surfaces. White in light, about `#1B2027` in dark. |
|
| `card`, `popover` | Raised surfaces. White in light, about `#1B2027` in dark. `popover` is every floating surface: tooltips, popovers, menus and pickers. |
|
||||||
| `muted`, `secondary`, `accent` | Hover rows, secondary buttons, quiet fills. |
|
| `muted`, `secondary`, `accent` | Hover rows, secondary buttons, quiet fills. |
|
||||||
| `border` | Every divider and card edge. `#DCD8CE` in light, `oklch(1 0 0 / 8%)` in dark. |
|
| `border` | Every divider and card edge. `#DCD8CE` in light, `oklch(1 0 0 / 8%)` in dark. |
|
||||||
| `brand` | Brand orange `#EE4B12`, for the logo and decoration only. It fails text contrast, so it never carries text. |
|
| `brand` | Brand orange `#EE4B12`, for the logo and decoration only. It fails text contrast, so it never carries text. |
|
||||||
@@ -39,15 +39,22 @@ Each status has a solid token plus `-foreground` (text on a tinted background),
|
|||||||
|
|
||||||
| Status | Hue | AWS states |
|
| Status | Hue | AWS states |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `success` | green | running, available, completed, healthy |
|
| `success` | green | settled and good: running, available, active, associated, attached, in-use, healthy, ok, main |
|
||||||
| `warning` | brand yellow `#F5B500` | pending, stopping, creating, modifying, deleting, draining |
|
| `warning` | brand yellow `#F5B500` | changing state and will settle: pending, stopping, creating, modifying, deleting, draining |
|
||||||
| `danger` | cool red, hue 17 | stopped, error, failed, unhealthy, unavailable |
|
| `caution` | orange, hue 58 | works but needs attention: degraded, impaired, insufficient-data |
|
||||||
|
| `danger` | cool red, hue 17 | stopped, error, failed, update-failed, unhealthy, unavailable, blackhole |
|
||||||
| `info` | blue | informational notes |
|
| `info` | blue | informational notes |
|
||||||
| `neutral` | grey `#9AA1A8` | terminated (muted text), and any unmapped state |
|
| `neutral` | grey `#9AA1A8` | gone or absent (terminated, deleted, no) in muted text, and any unmapped state |
|
||||||
|
|
||||||
`stopped` is danger because EC2 reads it that way, although RDS treats it as a resting state; splitting the map by service is its own change.
|
`stopped` is danger because EC2 reads it that way, although RDS treats it as a resting state; splitting the map by service is its own change.
|
||||||
|
|
||||||
`StateBadge` (`src/components/state-badge.tsx`) is the only mapping from an AWS state to a tone, and it exposes the result as `data-tone` for tests.
|
`StateBadge` (`src/components/state-badge.tsx`) is the only mapping from an AWS state to a tone, and it exposes the result as `data-tone` for tests.
|
||||||
|
Its tones are `success`, `progress` (the yellow `warning` token), `caution`, `danger`, `muted` and `neutral`.
|
||||||
|
A `progress` state carries the submit button's spinning loader, so a launching instance reads as work under way; `StatusBadge` does not, because its yellow can mean waiting on the user, as a zone's `pending nameservers` does.
|
||||||
|
It shows every state lowercase with hyphens, as EC2 writes them, so EKS's `ACTIVE` reads `active` and Bedrock's `IN_PROGRESS` reads `in-progress`; the API value, filters and CLI panels keep AWS's case.
|
||||||
|
A good or settled state is always green, never grey, and a status that is not an API state, such as a cluster's health, uses `StatusBadge` with a tone rather than a `Badge`.
|
||||||
|
The orange of `caution` is kept clear of the primary accent's hue, which marks actions and never a status.
|
||||||
|
`Badge`'s default variant is the brand primary and is never used for a status; a label that is not a status, such as `default v3` or `Leader`, uses a `secondary` or `outline` `Badge`.
|
||||||
Every status badge measures above 4.8:1 against its tinted background in both themes.
|
Every status badge measures above 4.8:1 against its tinted background in both themes.
|
||||||
|
|
||||||
**Danger is separated from the primary accent by hue alone in dark mode**: the two have the same lightness.
|
**Danger is separated from the primary accent by hue alone in dark mode**: the two have the same lightness.
|
||||||
@@ -67,18 +74,21 @@ Colour is never the only key: label every series.
|
|||||||
| UI text, tables and forms | Geist (`font-sans`) |
|
| UI text, tables and forms | Geist (`font-sans`) |
|
||||||
| IDs, ARNs, IPs, CIDRs, code and eyebrow labels | JetBrains Mono (`font-mono`), with `tabular-nums` for figures |
|
| IDs, ARNs, IPs, CIDRs, code and eyebrow labels | JetBrains Mono (`font-mono`), with `tabular-nums` for figures |
|
||||||
|
|
||||||
| Step | Size | Use |
|
| Step | rem | At the default 16px browser font | Use |
|
||||||
|---|---|---|
|
|---|---|---|---|
|
||||||
| `2xl` | 26px | page title (`PageHeading`) |
|
| `2xl` | 1.625 | 28.6px | page title (`PageHeading`) |
|
||||||
| `xl` | 22px | section heading |
|
| `xl` | 1.375 | 24.2px | section heading |
|
||||||
| `lg` | 17px | |
|
| `lg` | 1.0625 | 18.7px | |
|
||||||
| `base` | 15px | |
|
| `base` | 0.9375 | 16.5px | |
|
||||||
| `sm` | 14px | body and table default |
|
| `sm` | 0.875 | 15.4px | body and table default |
|
||||||
| `xs` | 13px | badges, secondary text |
|
| `xs` | 0.8125 | 14.3px | badges, secondary text |
|
||||||
| `2xs` | 12px | mono eyebrow labels |
|
| `2xs` | 0.75 | 13.2px | mono eyebrow labels |
|
||||||
|
|
||||||
The scale sits a step above Tailwind's defaults, as consoles such as Vultr's do, because 13px body text read as cramped on a wide screen.
|
The root font size is 110% (`html` in `src/styles.css`), so one rem is 17.6px at the browser default.
|
||||||
Code blocks are set at 12px (`--text-code-block`) so a long command still fits.
|
Everything is sized in rem, so text, spacing and breakpoints scale together, as they would at 110% browser zoom, and a user's own font-size setting still applies.
|
||||||
|
The scale sits above Tailwind's defaults, as consoles such as Vultr's do, because the console read as small on a wide screen at 100%.
|
||||||
|
Code blocks are set at 0.75rem (`--text-code-block`) so a long command still fits.
|
||||||
|
Size new things in rem through the type and spacing scales, never in px, or they will not scale with the rest.
|
||||||
|
|
||||||
### Primary and contrast
|
### Primary and contrast
|
||||||
|
|
||||||
@@ -169,6 +179,22 @@ Every other list is a `DataTable`, and the test also fails when an allowed file
|
|||||||
| S13 | a component outside `ui/` renders a raw `<input>`, `<select>` or `<textarea>` |
|
| S13 | a component outside `ui/` renders a raw `<input>`, `<select>` or `<textarea>` |
|
||||||
| S15 | a route imports the `ui/alert` primitive instead of using `ErrorBanner` |
|
| S15 | a route imports the `ui/alert` primitive instead of using `ErrorBanner` |
|
||||||
|
|
||||||
|
Rules that ADR-0016 does not yet number are named by what they hold until their amendment is accepted:
|
||||||
|
|
||||||
|
| Rule | Fails when |
|
||||||
|
|---|---|
|
||||||
|
| status pills come from StateBadge or StatusBadge | a component outside `ui/` renders `<Badge>` with the default variant |
|
||||||
|
| status pills come from StateBadge or StatusBadge | a file outside `state-badge.tsx` builds a `rounded-full` pill on a status colour |
|
||||||
|
| floating surfaces follow the theme | a file fills with `bg-foreground` or `text-background`, or a tooltip, popover, hover card, menu, select or combobox is not on `bg-popover` and `text-popover-foreground` |
|
||||||
|
| expanding a panel keeps its tab | a `fullPage` link does not pass `search={{ tab }}` |
|
||||||
|
| resource tags are edited in place | `PanelTags` or `TagsEditor` comes back |
|
||||||
|
| lists filter through one FilterBar | a component other than `FilterBar` imports `ui/toggle-group` |
|
||||||
|
| a component handed a form reads errors through `useFormState` | a file typed with `UseFormReturn` reads `form.formState` |
|
||||||
|
|
||||||
|
The last one matters because the console builds with the React compiler and the unit tests run without it.
|
||||||
|
With the compiler on, a child handed a react-hook-form object is cached on that object, whose identity never changes, so errors read from `form.formState` never draw and the submit looks dead.
|
||||||
|
`useFormState({ control })` subscribes the child itself.
|
||||||
|
|
||||||
An exception is named in that rule's allowlist with its reason, and the test fails when an allowed file stops needing it.
|
An exception is named in that rule's allowlist with its reason, and the test fails when an allowed file stops needing it.
|
||||||
S11, S14 and S16 are held by review, since none has a shape in the source a test can read without false alarms.
|
S11, S14 and S16 are held by review, since none has a shape in the source a test can read without false alarms.
|
||||||
|
|
||||||
@@ -177,7 +203,7 @@ S11, S14 and S16 are held by review, since none has a shape in the source a test
|
|||||||
`src/lib/nav.ts` is the one description of the console's navigation.
|
`src/lib/nav.ts` is the one description of the console's navigation.
|
||||||
The sidebar, the command palette and the breadcrumbs all read it, and `nav.test.ts` checks every destination against the generated route tree, so a renamed route fails a test rather than leaving a dead link.
|
The sidebar, the command palette and the breadcrumbs all read it, and `nav.test.ts` checks every destination against the generated route tree, so a renamed route fails a test rather than leaving a dead link.
|
||||||
|
|
||||||
- **Sidebar.** Groups take generic names rather than AWS's service marks: Compute, Databases, Authentication and Object Storage, not EC2, RDS, IAM and S3. Pages and the rest of the console keep the AWS names, and each group's `service` keeps its AWS name findable in the command palette, where typing it exactly ranks that group first. Each service group folds, and a folded group stays folded across reloads (`localStorage["spinifex-ui-nav-collapsed"]`). Navigating into a folded group unfolds it so the active item shows, and it can then be folded again, the current group included. On a phone the sidebar is a sheet, and any navigation closes it.
|
- **Sidebar.** Groups take generic names rather than AWS's service marks: Compute, Databases, Authentication and Object Storage, not EC2, RDS, IAM and S3. Pages and the rest of the console keep the AWS names, and each group's `service` keeps its AWS name findable in the command palette, where typing it exactly ranks that group first. Each service group folds, and a folded group stays folded across reloads (`localStorage["spinifex-ui-nav-collapsed"]`). Navigating into a folded group unfolds it so the active item shows, and it can then be folded again, the current group included. Folding animates through the `nav` variant of `CollapsibleContent`, opening over 200ms and closing over 150ms, and switches at once under reduced motion. On a phone the sidebar is a sheet, and any navigation closes it.
|
||||||
- **Command palette.** ⌘K or Ctrl K, or the Search button in the header. It lists every page the user can reach, every create flow (`create` on a nav item), and a theme toggle. It is navigation only, with no resource search yet.
|
- **Command palette.** ⌘K or Ctrl K, or the Search button in the header. It lists every page the user can reach, every create flow (`create` on a nav item), and a theme toggle. It is navigation only, with no resource search yet.
|
||||||
- **Breadcrumbs.** Service, then list page, then the resource or the create flow. A resource ID is set in mono. On a phone only the last crumb shows.
|
- **Breadcrumbs.** Service, then list page, then the resource or the create flow. A resource ID is set in mono. On a phone only the last crumb shows.
|
||||||
- **Sign in.** The sign-in card sits over the shell on a light, barely blurred overlay rather than on a page of its own, so the console stays in view. The shell behind it is `inert`, which keeps pointer, keyboard and screen reader out of it until the user signs in.
|
- **Sign in.** The sign-in card sits over the shell on a light, barely blurred overlay rather than on a page of its own, so the console stays in view. The shell behind it is `inert`, which keeps pointer, keyboard and screen reader out of it until the user signs in.
|
||||||
@@ -202,8 +228,13 @@ So columns drop out when a side panel narrows the list as well as on a small scr
|
|||||||
List pages keep their filter, state and sort in the URL, so a filtered view can be bookmarked, shared and survives a reload.
|
List pages keep their filter, state and sort in the URL, so a filtered view can be bookmarked, shared and survives a reload.
|
||||||
EC2 Instances is the reference page (`ec2/(instances)/-components/instances-page.tsx`):
|
EC2 Instances is the reference page (`ec2/(instances)/-components/instances-page.tsx`):
|
||||||
|
|
||||||
- The first column names the resource: the `Name` tag in the primary colour, with the ID in mono beneath it, opening its side panel.
|
- The first column is the instance ID in mono, opening its side panel, and the second its `Name` tag.
|
||||||
|
- A long value ends in an ellipsis and shows in full on hover (`TruncatedText`), so it never widens its column.
|
||||||
|
- A time column shows a relative time, `33 min ago` or `8 months ago`, with the full timestamp on hover (`RelativeTime`).
|
||||||
|
- A Tags column shows one tag key as a pill and `+N` for the rest, and hover lists every tag with its value (`TagPills`); it leaves out `Name`, which has its own column.
|
||||||
|
- Below `md`, only the ID, Name and State columns remain.
|
||||||
- Bulk actions appear above the table once a row is selected. Each action counts only the selected instances it applies to, so a mixed selection never sends a call that must fail, and Terminate asks first and lists every ID.
|
- Bulk actions appear above the table once a row is selected. Each action counts only the selected instances it applies to, so a mixed selection never sends a call that must fail, and Terminate asks first and lists every ID.
|
||||||
|
- Search and state filters sit in one `FilterBar` (`src/components/filter-bar.tsx`) above the table: a search box, an optional set of choices with counts, and Clear filters while anything is filtered.
|
||||||
- An empty account gets a call to action, and an empty filter result says the filters are why.
|
- An empty account gets a call to action, and an empty filter result says the filters are why.
|
||||||
|
|
||||||
VPCs and Internet gateways follow the same pattern.
|
VPCs and Internet gateways follow the same pattern.
|
||||||
@@ -219,6 +250,12 @@ Every list with a delete action has row checkboxes. Selecting a row shows `Selec
|
|||||||
- **The bulk delete and the panel's delete are the same function.** Where a single delete is several calls, such as removing dependants first, the sequence lives in one place and both use it.
|
- **The bulk delete and the panel's delete are the same function.** Where a single delete is several calls, such as removing dependants first, the sequence lives in one place and both use it.
|
||||||
- The verb follows the AWS console: Release for an Elastic IP, Deregister for an image.
|
- The verb follows the AWS console: Release for an Elastic IP, Deregister for an image.
|
||||||
|
|
||||||
|
### Lists inside a panel
|
||||||
|
|
||||||
|
A list inside a panel tab, such as a hosted zone's records, is a `DataTable` with row checkboxes, and a click on a row opens it for editing in a `FormPanel`.
|
||||||
|
Rows the user cannot change, such as a zone's own SOA and NS, are muted and cannot be selected (`isRowActionable`).
|
||||||
|
A batch delete uses the service's batch call where it has one, so Route 53 removes the whole selection in one `ChangeResourceRecordSets`.
|
||||||
|
|
||||||
### Typed confirmation
|
### Typed confirmation
|
||||||
|
|
||||||
A delete that cannot be walked back asks the user to type a word first, as the AWS console does before deleting a volume.
|
A delete that cannot be walked back asks the user to type a word first, as the AWS console does before deleting a volume.
|
||||||
@@ -264,13 +301,24 @@ Modelled on UniFi's console: a click on a row opens the resource in a panel besi
|
|||||||
- **URL.** The panel is search state on the list route: `selected=<id>`, `tab=overview|monitoring|settings`, and `create=true`. Defaults are stripped, so a closed panel leaves the plain list address. Opening or closing a panel pushes a history entry, so Back closes it; a tab or filter change replaces the entry.
|
- **URL.** The panel is search state on the list route: `selected=<id>`, `tab=overview|monitoring|settings`, and `create=true`. Defaults are stripped, so a closed panel leaves the plain list address. Opening or closing a panel pushes a history entry, so Back closes it; a tab or filter change replaces the entry.
|
||||||
- **Layout.** From `lg` (1024px) the panel is fixed to the right edge, full height below the header, flush with no outer margin, and it slides in over the list. The list keeps its width and columns underneath, so nothing reflows when a panel opens, and a soft gradient on the panel's left edge reads as the panel lying on top. Below `lg` there is no room to share, so the panel replaces the list as a full page with a back arrow. `useWideLayout` decides, through `matchMedia`.
|
- **Layout.** From `lg` (1024px) the panel is fixed to the right edge, full height below the header, flush with no outer margin, and it slides in over the list. The list keeps its width and columns underneath, so nothing reflows when a panel opens, and a soft gradient on the panel's left edge reads as the panel lying on top. Below `lg` there is no room to share, so the panel replaces the list as a full page with a back arrow. `useWideLayout` decides, through `matchMedia`.
|
||||||
- **`DetailPanel`.** A header with the resource's own page (left) and close (right), the title and its ID, a summary of state and quick actions, then icon tabs with tooltips. Escape closes it unless a dialog or menu is open over it. Closing slides the panel out over 150ms, or at once under reduced motion. Each tab, and a create form, sits in its own error boundary, so a failed query shows in the panel and the list keeps working.
|
- **`DetailPanel`.** A header with the resource's own page (left) and close (right), the title and its ID, a summary of state and quick actions, then icon tabs with tooltips. Escape closes it unless a dialog or menu is open over it. Closing slides the panel out over 150ms, or at once under reduced motion. Each tab, and a create form, sits in its own error boundary, so a failed query shows in the panel and the list keeps working.
|
||||||
- **Contents.** `PanelSection` is a folding card, `PanelFields` and `PanelField` a label and value list, with `copy` for a one-click copy of IDs and addresses, and `PanelTags` the tag list. Create forms take an `onClose(id?)` prop: embedded, they hide their back link and heading, and a create selects the new resource.
|
- **Contents.** `PanelSection` is a folding card, `PanelFields` and `PanelField` a label and value list, with `copy` for a one-click copy of IDs and addresses, and `InlineTags` the tag list. Create forms take an `onClose(id?)` prop: embedded, they hide their back link and heading, and a create selects the new resource.
|
||||||
- **One component is both the panel and the page.** Each resource has a single details component (`InstanceDetails`, `VpcDetails`, `InternetGatewayDetails`) that takes `layout="auto" | "page"`. The list renders it as the panel; `/ec2/describe-instances/$id` and its kind render the same component as a page, as one centred column under a standard left-aligned heading, with state and actions on the right and text tabs. A feature added to the panel is on the page by construction, and the page ignores Escape and has a back arrow in place of close. Expanding the panel to its page keeps the tab, and query results such as fetched console output come along from the cache.
|
- **One component is both the panel and the page.** Each resource has a single details component (`InstanceDetails`, `VpcDetails`, `InternetGatewayDetails`) that takes `layout="auto" | "page"`. The list renders it as the panel; `/ec2/describe-instances/$id` and its kind render the same component as a page, as one centred column under a standard left-aligned heading, with state and actions on the right and text tabs. A feature added to the panel is on the page by construction, and the page ignores Escape and has a back arrow in place of close. Expanding the panel to its page keeps the tab, and query results such as fetched console output come along from the cache.
|
||||||
- **A click on the list's background closes the panel**, as UniFi's does. A row selects another resource instead, and links, buttons and inputs keep their own behaviour. Selecting rows for a bulk action closes it too, so the bulk bar and the panel never compete.
|
- **A click on the list's background closes the panel**, as UniFi's does. A row selects another resource instead, and links, buttons and inputs keep their own behaviour. Selecting rows for a bulk action closes it too, so the bulk bar and the panel never compete.
|
||||||
- **Actions.** State actions sit in the summary as a button group, and the rest go in an Actions menu, Unikraft style. Stop, Reboot and every destructive action confirm in a dialog.
|
- **Actions.** State actions sit in the summary as a button group, and the rest go in an Actions menu, Unikraft style. Stop, Reboot and every destructive action confirm in a dialog.
|
||||||
- **Loading.** A panel or page still loading shows `PanelSpinner`, a centred spinner on the background, not a grey skeleton block.
|
- **Loading.** A panel or page still loading shows `PanelSpinner`, a centred spinner on the background, not a grey skeleton block.
|
||||||
- **`DetailCard`, `DetailRow` and `ListCard` are retired.** Every resource page uses the panel sections instead.
|
- **`DetailCard`, `DetailRow` and `ListCard` are retired.** Every resource page uses the panel sections instead.
|
||||||
|
|
||||||
|
### Tags
|
||||||
|
|
||||||
|
`InlineTags` (`src/components/inline-tags.tsx`) shows a resource's tags in its panel and edits them in place.
|
||||||
|
Each row has a pencil, which turns the key and value into inputs and becomes a tick to save, and a bin to remove the tag; `+ Add tag` appends a row being edited.
|
||||||
|
Enter saves and Escape cancels the row without closing the panel.
|
||||||
|
Every row saves on its own request through the service's own tagging call, and a failure shows beside its row.
|
||||||
|
AWS has no rename, so changing a key puts the new key and then removes the old one, and a failure between the two leaves both rather than neither.
|
||||||
|
Keys are 1 to 128 characters with no `aws:` prefix and no duplicates, and values up to 256.
|
||||||
|
Without `onPut` and `onRemove` it is read-only, as for a system image or an AWS-managed policy.
|
||||||
|
Panels use the per-service wrappers in `src/components/resource-tags.tsx` and the writers in `src/mutations/tags.ts` rather than wiring the calls themselves.
|
||||||
|
|
||||||
### Forms from a resource's panel
|
### Forms from a resource's panel
|
||||||
|
|
||||||
A form that acts on an open resource, such as attaching a volume, scaling a node group or adding a listener, opens in `FormPanel` (`src/components/form-panel.tsx`), never in a dialog.
|
A form that acts on an open resource, such as attaching a volume, scaling a node group or adding a listener, opens in `FormPanel` (`src/components/form-panel.tsx`), never in a dialog.
|
||||||
@@ -285,8 +333,16 @@ Every create flow opens in the side panel over its list, through `CreatePanel`,
|
|||||||
|
|
||||||
- **URL.** `create=true` opens it, plus a list-local field where the flow needs context (`volume` for a snapshot, `instance` for an RDS snapshot, `launch=service|task` on ECS clusters). The old `create-*` addresses redirect to the list with the panel open, so links and bookmarks keep working.
|
- **URL.** `create=true` opens it, plus a list-local field where the flow needs context (`volume` for a snapshot, `instance` for an RDS snapshot, `launch=service|task` on ECS clusters). The old `create-*` addresses redirect to the list with the panel open, so links and bookmarks keep working.
|
||||||
- **Form.** The fields come in the order AWS asks for them. Settings that are on or off are `Switch`es, and a multi-select picker (subnets, security groups) is a `SwitchList`, a boxed list with one switch per row. Checkboxes only select table rows.
|
- **Form.** The fields come in the order AWS asks for them. Settings that are on or off are `Switch`es, and a multi-select picker (subnets, security groups) is a `SwitchList`, a boxed list with one switch per row. Checkboxes only select table rows.
|
||||||
- **CLI.** Every create form ends with a `CliCommandPanel` showing the AWS CLI commands that send the same request, built from the form's live values and only the flags the console actually sends.
|
- **CLI.** Every create form ends with a `CliCommandPanel` showing the AWS CLI commands that send the same request, built from the form's live values and only the flags the console actually sends. The commands wrap to the panel's width and never scroll sideways; Copy takes the unwrapped text.
|
||||||
- **Submit.** `FormActions` turns the submit button into a spinner with its pending label in place, and `FormLock`, a native `<fieldset disabled>`, disables every control above it until the request settles. Nothing above the form appears or moves, and the panel does not scroll.
|
- **Sections.** Related fields are grouped in `PanelSection` boxes, as the instance launch form groups Image, Instance, Network and Root volume.
|
||||||
|
- **Nothing to pick, no field.** An optional picker with nothing usable to choose, such as Launch Template or Placement Group with none available, is left out of the form rather than shown holding only `none`.
|
||||||
|
- **Advanced settings.** Settings most users leave at their defaults sit in a collapsed Advanced settings section, which opens by itself when a field inside it fails validation.
|
||||||
|
- **Choices with a mark.** A few named products, such as database engines or distributions, are `ChoiceTile`s with the product's own mark from `src/assets/`, not a select. One that cannot be used stays visible and disabled, saying why.
|
||||||
|
- **Durations and windows.** A duration is a select of presets with the AWS default first and named (`Auto (5 min)` for a record's TTL), keeping a custom value the API returned. A time window, such as an RDS backup or maintenance window, is a day, start time and length picker that refuses an overlap before submitting.
|
||||||
|
- **Labels by type.** Where one field means different things by type, its label follows the type, as a record's value reads `IPv4 address` for A and `Mail server` for MX.
|
||||||
|
- **Long pickers.** A `SwitchList` that can run long, such as security groups, scrolls inside its box rather than pushing the form down.
|
||||||
|
- **Help.** A field's longer explanation goes in an (i) beside its label, through the `info` prop of `TextField` and `SelectField`, rather than in text under the field. Text under a field is for what the user must know before submitting.
|
||||||
|
- **Submit.** `FormActions` cross-fades the submit label into a spinner with its pending label over 150ms, at one width, so the button never changes size, and `FormLock`, a native `<fieldset disabled>`, disables every control above it until the request settles. Nothing above the form appears or moves, and the panel does not scroll.
|
||||||
- **Failure.** A refused request shows beside the button through `FormActions`'s `error`, and the form unlocks. The handler wraps only the mutation call in `try`, with an empty `catch` that returns, because react-hook-form rethrows whatever the handler throws and the error is already on screen from the mutation's state.
|
- **Failure.** A refused request shows beside the button through `FormActions`'s `error`, and the form unlocks. The handler wraps only the mutation call in `try`, with an empty `catch` that returns, because react-hook-form rethrows whatever the handler throws and the error is already on screen from the mutation's state.
|
||||||
- **Success.** `onClose(id)` closes the panel and selects the new resource, where the list has a panel to select it in.
|
- **Success.** `onClose(id)` closes the panel and selects the new resource, where the list has a panel to select it in.
|
||||||
- **Appliance images.** A create that needs an ECS, EKS or RDS appliance image shows the missing-image callout inside its panel, never on the list, and only the step that needs the image is blocked.
|
- **Appliance images.** A create that needs an ECS, EKS or RDS appliance image shows the missing-image callout inside its panel, never on the list, and only the step that needs the image is blocked.
|
||||||
@@ -306,6 +362,7 @@ A gap of more than three sample periods breaks the line rather than drawing acro
|
|||||||
## Dates
|
## Dates
|
||||||
|
|
||||||
`formatDateTime` writes the AWS console's form in the viewer's zone, `2026/06/04 02:00 GMT-5`, with a 24-hour clock and UTC as plain `GMT`.
|
`formatDateTime` writes the AWS console's form in the viewer's zone, `2026/06/04 02:00 GMT-5`, with a 24-hour clock and UTC as plain `GMT`.
|
||||||
|
In a table the time is relative (`relativeTime` in `src/lib/relative-time.ts`), rounded down to the largest whole unit, and the full form shows on hover.
|
||||||
|
|
||||||
## Testing
|
## Testing
|
||||||
|
|
||||||
|
|||||||
+4
-2
@@ -732,7 +732,8 @@ table inet spinifex_filter {
|
|||||||
# inventory and running instances to anyone who asks. 8660 is
|
# inventory and running instances to anyone who asks. 8660 is
|
||||||
# predastore's unauthenticated /healthz and /readyz, which name the
|
# predastore's unauthenticated /healthz and /readyz, which name the
|
||||||
# peers a gate cannot reach, so it belongs here for the same reason.
|
# peers a gate cannot reach, so it belongs here for the same reason.
|
||||||
ip saddr $spinifex_peers tcp dport { 4222, 4248, 4432, 5300, 6641, 6642, 6643, 6644, 8660 } accept
|
# 5001 is the admin network test, listening only while one runs.
|
||||||
|
ip saddr $spinifex_peers tcp dport { 4222, 4248, 4432, 5001, 5300, 6641, 6642, 6643, 6644, 8660 } accept
|
||||||
ip saddr $spinifex_peers udp dport { 5300, 6660, 7660 } accept
|
ip saddr $spinifex_peers udp dport { 5300, 6660, 7660 } accept
|
||||||
|
|
||||||
# Encap plane, peer-scoped: Geneve, IKE, NAT-T and ESP. Geneve is a
|
# Encap plane, peer-scoped: Geneve, IKE, NAT-T and ESP. Geneve is a
|
||||||
@@ -1309,6 +1310,7 @@ qemu-utils gdisk ovmf qemu-efi-aarch64 less
|
|||||||
libvirt-daemon-system libvirt-clients
|
libvirt-daemon-system libvirt-clients
|
||||||
pciutils
|
pciutils
|
||||||
jq curl iproute2 iputils-arping ethtool netcat-openbsd wget unzip xz-utils file
|
jq curl iproute2 iputils-arping ethtool netcat-openbsd wget unzip xz-utils file
|
||||||
|
dmidecode iperf
|
||||||
ovn-central ovn-host openvswitch-switch openvswitch-ipsec strongswan-charon dhcpcd-base
|
ovn-central ovn-host openvswitch-switch openvswitch-ipsec strongswan-charon dhcpcd-base
|
||||||
chrony nftables"
|
chrony nftables"
|
||||||
|
|
||||||
@@ -1318,7 +1320,7 @@ chrony nftables"
|
|||||||
OL_RUNTIME_PACKAGES="nbdkit nbdkit-basic-filters
|
OL_RUNTIME_PACKAGES="nbdkit nbdkit-basic-filters
|
||||||
qemu-kvm qemu-img edk2-ovmf seabios-bin less
|
qemu-kvm qemu-img edk2-ovmf seabios-bin less
|
||||||
pciutils
|
pciutils
|
||||||
jq curl iproute iputils ethtool nmap-ncat wget unzip xz file
|
jq curl iproute iputils ethtool nmap-ncat wget unzip xz file dmidecode
|
||||||
ovn25.03-central ovn25.03-host openvswitch3.5 openvswitch3.5-ipsec libreswan dhcpcd
|
ovn25.03-central ovn25.03-host openvswitch3.5 openvswitch3.5-ipsec libreswan dhcpcd
|
||||||
chrony nftables iptables-nft systemd-resolved"
|
chrony nftables iptables-nft systemd-resolved"
|
||||||
|
|
||||||
|
|||||||
@@ -47,13 +47,6 @@ type credResponse struct {
|
|||||||
|
|
||||||
// credEntry caches one task's assumed-role credentials alongside the role it was
|
// credEntry caches one task's assumed-role credentials alongside the role it was
|
||||||
// assumed from.
|
// 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 {
|
type credEntry struct {
|
||||||
roleARN string
|
roleARN string
|
||||||
creds stsauth.Credentials
|
creds stsauth.Credentials
|
||||||
@@ -78,7 +71,7 @@ type credEndpoint struct {
|
|||||||
assume func(ctx context.Context, roleARN, sessionName string) (stsauth.Credentials, error)
|
assume func(ctx context.Context, roleARN, sessionName string) (stsauth.Credentials, error)
|
||||||
|
|
||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
roles map[string]credRole // credID -> task role
|
roles map[string]string // credID -> taskRoleARN
|
||||||
cache map[string]credEntry // credID -> cached creds
|
cache map[string]credEntry // credID -> cached creds
|
||||||
httpClient *http.Client
|
httpClient *http.Client
|
||||||
|
|
||||||
@@ -105,7 +98,7 @@ func newCredEndpoint(provider credentials.CredentialsProvider, region, gatewayUR
|
|||||||
port: port,
|
port: port,
|
||||||
proxyPort: defaultCredProxyPort,
|
proxyPort: defaultCredProxyPort,
|
||||||
run: run,
|
run: run,
|
||||||
roles: map[string]credRole{},
|
roles: map[string]string{},
|
||||||
cache: map[string]credEntry{},
|
cache: map[string]credEntry{},
|
||||||
}
|
}
|
||||||
c.assume = c.assumeOverGateway
|
c.assume = c.assumeOverGateway
|
||||||
@@ -114,12 +107,12 @@ func newCredEndpoint(provider credentials.CredentialsProvider, region, gatewayUR
|
|||||||
|
|
||||||
// Register associates a credID with the task role it serves; Deregister removes
|
// 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.
|
// it and drops any cached credentials. Both are no-ops for an empty credID.
|
||||||
func (c *credEndpoint) Register(credID, taskID, roleARN string) {
|
func (c *credEndpoint) Register(credID, roleARN string) {
|
||||||
if credID == "" || roleARN == "" {
|
if credID == "" || roleARN == "" {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
c.mu.Lock()
|
c.mu.Lock()
|
||||||
c.roles[credID] = credRole{roleARN: roleARN, taskID: taskID}
|
c.roles[credID] = roleARN
|
||||||
c.mu.Unlock()
|
c.mu.Unlock()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -149,25 +142,25 @@ func (c *credEndpoint) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
|||||||
http.Error(w, "not found", http.StatusNotFound)
|
http.Error(w, "not found", http.StatusNotFound)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
creds, role, err := c.fetch(r.Context(), credID)
|
creds, roleARN, err := c.fetch(r.Context(), credID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, errUnknownCredID) {
|
if errors.Is(err, errUnknownCredID) {
|
||||||
http.Error(w, "not found", http.StatusNotFound)
|
http.Error(w, "not found", http.StatusNotFound)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
slog.Error("ecs-agent: assume task role failed", "task", role.taskID, "role", role.roleARN, "err", err)
|
slog.Error("ecs-agent: assume task role failed", "credId", credID, "err", err)
|
||||||
http.Error(w, "credentials unavailable", http.StatusBadGateway)
|
http.Error(w, "credentials unavailable", http.StatusBadGateway)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
w.Header().Set("Content-Type", "application/json")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
if err := json.NewEncoder(w).Encode(credResponse{
|
if err := json.NewEncoder(w).Encode(credResponse{
|
||||||
RoleArn: role.roleARN,
|
RoleArn: roleARN,
|
||||||
AccessKeyID: creds.AccessKeyID,
|
AccessKeyID: creds.AccessKeyID,
|
||||||
SecretAccessKey: creds.SecretAccessKey,
|
SecretAccessKey: creds.SecretAccessKey,
|
||||||
Token: creds.SessionToken,
|
Token: creds.SessionToken,
|
||||||
Expiration: creds.Expiration.UTC().Format(time.RFC3339),
|
Expiration: creds.Expiration.UTC().Format(time.RFC3339),
|
||||||
}); err != nil {
|
}); err != nil {
|
||||||
slog.Warn("ecs-agent: encode credential response", "task", role.taskID, "err", err)
|
slog.Warn("ecs-agent: encode credential response", "credId", credID, "err", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -176,28 +169,28 @@ var errUnknownCredID = fmt.Errorf("unknown credId")
|
|||||||
|
|
||||||
// fetch returns valid credentials for credID, reusing the cache until within the
|
// fetch returns valid credentials for credID, reusing the cache until within the
|
||||||
// refresh margin and otherwise assuming the task role over the gateway.
|
// refresh margin and otherwise assuming the task role over the gateway.
|
||||||
func (c *credEndpoint) fetch(ctx context.Context, credID string) (stsauth.Credentials, credRole, error) {
|
func (c *credEndpoint) fetch(ctx context.Context, credID string) (stsauth.Credentials, string, error) {
|
||||||
c.mu.Lock()
|
c.mu.Lock()
|
||||||
role, known := c.roles[credID]
|
roleARN, known := c.roles[credID]
|
||||||
if !known {
|
if !known {
|
||||||
c.mu.Unlock()
|
c.mu.Unlock()
|
||||||
return stsauth.Credentials{}, credRole{}, errUnknownCredID
|
return stsauth.Credentials{}, "", errUnknownCredID
|
||||||
}
|
}
|
||||||
if e, ok := c.cache[credID]; ok && e.roleARN == role.roleARN && credValid(e.creds, credRefreshMargin) {
|
if e, ok := c.cache[credID]; ok && e.roleARN == roleARN && credValid(e.creds, credRefreshMargin) {
|
||||||
c.mu.Unlock()
|
c.mu.Unlock()
|
||||||
return e.creds, role, nil
|
return e.creds, roleARN, nil
|
||||||
}
|
}
|
||||||
c.mu.Unlock()
|
c.mu.Unlock()
|
||||||
|
|
||||||
creds, err := c.assume(ctx, role.roleARN, sessionName(role.taskID))
|
creds, err := c.assume(ctx, roleARN, sessionName(credID))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return stsauth.Credentials{}, role, err
|
return stsauth.Credentials{}, "", err
|
||||||
}
|
}
|
||||||
|
|
||||||
c.mu.Lock()
|
c.mu.Lock()
|
||||||
c.cache[credID] = credEntry{roleARN: role.roleARN, creds: creds}
|
c.cache[credID] = credEntry{roleARN: roleARN, creds: creds}
|
||||||
c.mu.Unlock()
|
c.mu.Unlock()
|
||||||
return creds, role, nil
|
return creds, roleARN, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// AssumeProvider returns a CredentialsProvider that mints credentials by assuming
|
// AssumeProvider returns a CredentialsProvider that mints credentials by assuming
|
||||||
@@ -397,10 +390,10 @@ func credValid(c stsauth.Credentials, margin time.Duration) bool {
|
|||||||
return time.Now().Add(margin).Before(c.Expiration)
|
return time.Now().Add(margin).Before(c.Expiration)
|
||||||
}
|
}
|
||||||
|
|
||||||
// sessionName derives an AssumeRole RoleSessionName from the task ID, satisfying the
|
// sessionName derives an AssumeRole RoleSessionName from credID, satisfying the
|
||||||
// STS [\w+=,.@-]{2,64} constraint.
|
// STS [\w+=,.@-]{2,64} constraint.
|
||||||
func sessionName(taskID string) string {
|
func sessionName(credID string) string {
|
||||||
name := "ecs-" + taskID
|
name := "ecs-" + credID
|
||||||
if len(name) > 64 {
|
if len(name) > 64 {
|
||||||
name = name[:64]
|
name = name[:64]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ func TestCredEndpoint_ServesRegisteredCredID(t *testing.T) {
|
|||||||
c, _ := newTestCredEndpoint(stsauth.Credentials{
|
c, _ := newTestCredEndpoint(stsauth.Credentials{
|
||||||
AccessKeyID: "AKIA", SecretAccessKey: "secret", SessionToken: "tok", Expiration: exp,
|
AccessKeyID: "AKIA", SecretAccessKey: "secret", SessionToken: "tok", Expiration: exp,
|
||||||
})
|
})
|
||||||
c.Register("cred-1", "task-1", "arn:aws:iam::111122223333:role/task")
|
c.Register("cred-1", "arn:aws:iam::111122223333:role/task")
|
||||||
|
|
||||||
rr := httptest.NewRecorder()
|
rr := httptest.NewRecorder()
|
||||||
c.ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/v2/credentials/cred-1", nil))
|
c.ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/v2/credentials/cred-1", nil))
|
||||||
@@ -51,26 +51,6 @@ func TestCredEndpoint_ServesRegisteredCredID(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// The STS session name lands in the assumed-role ARN and in STS logs, so it is
|
|
||||||
// built from the public task ID and never from the secret credID.
|
|
||||||
func TestCredEndpoint_SessionNameIsTaskIDNotCredID(t *testing.T) {
|
|
||||||
c, _ := newTestCredEndpoint(stsauth.Credentials{})
|
|
||||||
var gotSession string
|
|
||||||
c.assume = func(ctx context.Context, roleARN, session string) (stsauth.Credentials, error) {
|
|
||||||
gotSession = session
|
|
||||||
return stsauth.Credentials{AccessKeyID: "AKIA", SecretAccessKey: "secret"}, nil
|
|
||||||
}
|
|
||||||
c.Register("secret-cred", "task-1", "arn:role")
|
|
||||||
rr := httptest.NewRecorder()
|
|
||||||
c.ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/v2/credentials/secret-cred", nil))
|
|
||||||
if rr.Code != http.StatusOK {
|
|
||||||
t.Fatalf("status = %d, want 200", rr.Code)
|
|
||||||
}
|
|
||||||
if gotSession != "ecs-task-1" {
|
|
||||||
t.Errorf("session name = %q, want ecs-task-1", gotSession)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestCredEndpoint_UnknownCredIDIs404(t *testing.T) {
|
func TestCredEndpoint_UnknownCredIDIs404(t *testing.T) {
|
||||||
c, calls := newTestCredEndpoint(stsauth.Credentials{AccessKeyID: "x", SecretAccessKey: "y"})
|
c, calls := newTestCredEndpoint(stsauth.Credentials{AccessKeyID: "x", SecretAccessKey: "y"})
|
||||||
rr := httptest.NewRecorder()
|
rr := httptest.NewRecorder()
|
||||||
@@ -85,7 +65,7 @@ func TestCredEndpoint_UnknownCredIDIs404(t *testing.T) {
|
|||||||
|
|
||||||
func TestCredEndpoint_WrongMethodIs405(t *testing.T) {
|
func TestCredEndpoint_WrongMethodIs405(t *testing.T) {
|
||||||
c, _ := newTestCredEndpoint(stsauth.Credentials{AccessKeyID: "x", SecretAccessKey: "y"})
|
c, _ := newTestCredEndpoint(stsauth.Credentials{AccessKeyID: "x", SecretAccessKey: "y"})
|
||||||
c.Register("cred-1", "task-1", "arn:role")
|
c.Register("cred-1", "arn:role")
|
||||||
rr := httptest.NewRecorder()
|
rr := httptest.NewRecorder()
|
||||||
c.ServeHTTP(rr, httptest.NewRequest(http.MethodPost, "/v2/credentials/cred-1", nil))
|
c.ServeHTTP(rr, httptest.NewRequest(http.MethodPost, "/v2/credentials/cred-1", nil))
|
||||||
if rr.Code != http.StatusMethodNotAllowed {
|
if rr.Code != http.StatusMethodNotAllowed {
|
||||||
@@ -97,7 +77,7 @@ func TestCredEndpoint_CachesUntilExpiry(t *testing.T) {
|
|||||||
c, calls := newTestCredEndpoint(stsauth.Credentials{
|
c, calls := newTestCredEndpoint(stsauth.Credentials{
|
||||||
AccessKeyID: "AKIA", SecretAccessKey: "secret", Expiration: time.Now().Add(time.Hour),
|
AccessKeyID: "AKIA", SecretAccessKey: "secret", Expiration: time.Now().Add(time.Hour),
|
||||||
})
|
})
|
||||||
c.Register("cred-1", "task-1", "arn:role")
|
c.Register("cred-1", "arn:role")
|
||||||
for hit := range 3 {
|
for hit := range 3 {
|
||||||
rr := httptest.NewRecorder()
|
rr := httptest.NewRecorder()
|
||||||
c.ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/v2/credentials/cred-1", nil))
|
c.ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/v2/credentials/cred-1", nil))
|
||||||
@@ -115,7 +95,7 @@ func TestCredEndpoint_RefreshesNearExpiry(t *testing.T) {
|
|||||||
c, calls := newTestCredEndpoint(stsauth.Credentials{
|
c, calls := newTestCredEndpoint(stsauth.Credentials{
|
||||||
AccessKeyID: "AKIA", SecretAccessKey: "secret", Expiration: time.Now().Add(time.Minute),
|
AccessKeyID: "AKIA", SecretAccessKey: "secret", Expiration: time.Now().Add(time.Minute),
|
||||||
})
|
})
|
||||||
c.Register("cred-1", "task-1", "arn:role")
|
c.Register("cred-1", "arn:role")
|
||||||
for range 2 {
|
for range 2 {
|
||||||
rr := httptest.NewRecorder()
|
rr := httptest.NewRecorder()
|
||||||
c.ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/v2/credentials/cred-1", nil))
|
c.ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/v2/credentials/cred-1", nil))
|
||||||
@@ -127,7 +107,7 @@ func TestCredEndpoint_RefreshesNearExpiry(t *testing.T) {
|
|||||||
|
|
||||||
func TestCredEndpoint_DeregisterDropsCredID(t *testing.T) {
|
func TestCredEndpoint_DeregisterDropsCredID(t *testing.T) {
|
||||||
c, _ := newTestCredEndpoint(stsauth.Credentials{AccessKeyID: "x", SecretAccessKey: "y"})
|
c, _ := newTestCredEndpoint(stsauth.Credentials{AccessKeyID: "x", SecretAccessKey: "y"})
|
||||||
c.Register("cred-1", "task-1", "arn:role")
|
c.Register("cred-1", "arn:role")
|
||||||
c.Deregister("cred-1")
|
c.Deregister("cred-1")
|
||||||
rr := httptest.NewRecorder()
|
rr := httptest.NewRecorder()
|
||||||
c.ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/v2/credentials/cred-1", nil))
|
c.ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/v2/credentials/cred-1", nil))
|
||||||
@@ -141,7 +121,7 @@ func TestCredEndpoint_AssumeFailureIs502(t *testing.T) {
|
|||||||
c.assume = func(ctx context.Context, roleARN, sessionName string) (stsauth.Credentials, error) {
|
c.assume = func(ctx context.Context, roleARN, sessionName string) (stsauth.Credentials, error) {
|
||||||
return stsauth.Credentials{}, context.DeadlineExceeded
|
return stsauth.Credentials{}, context.DeadlineExceeded
|
||||||
}
|
}
|
||||||
c.Register("cred-1", "task-1", "arn:role")
|
c.Register("cred-1", "arn:role")
|
||||||
rr := httptest.NewRecorder()
|
rr := httptest.NewRecorder()
|
||||||
c.ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/v2/credentials/cred-1", nil))
|
c.ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/v2/credentials/cred-1", nil))
|
||||||
if rr.Code != http.StatusBadGateway {
|
if rr.Code != http.StatusBadGateway {
|
||||||
@@ -153,7 +133,7 @@ func TestCredEndpoint_StartServesOverLoopback(t *testing.T) {
|
|||||||
c, _ := newTestCredEndpoint(stsauth.Credentials{
|
c, _ := newTestCredEndpoint(stsauth.Credentials{
|
||||||
AccessKeyID: "AKIA", SecretAccessKey: "secret", Expiration: time.Now().Add(time.Hour),
|
AccessKeyID: "AKIA", SecretAccessKey: "secret", Expiration: time.Now().Add(time.Hour),
|
||||||
})
|
})
|
||||||
c.Register("cred-1", "task-1", "arn:role")
|
c.Register("cred-1", "arn:role")
|
||||||
c.port = 0 // bind an ephemeral port instead of privileged 80
|
c.port = 0 // bind an ephemeral port instead of privileged 80
|
||||||
if err := c.Start(); err != nil {
|
if err := c.Start(); err != nil {
|
||||||
t.Fatalf("start: %v", err)
|
t.Fatalf("start: %v", err)
|
||||||
|
|||||||
@@ -1,12 +1,8 @@
|
|||||||
package agent
|
package agent
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"net/http"
|
|
||||||
"net/http/httptest"
|
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/mulgadc/spinifex/internal/stsauth"
|
|
||||||
|
|
||||||
ecsv1 "github.com/mulgadc/spinifex/contracts/ecs/v1"
|
ecsv1 "github.com/mulgadc/spinifex/contracts/ecs/v1"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -17,7 +13,7 @@ func TestTaskCredID(t *testing.T) {
|
|||||||
want string
|
want string
|
||||||
}{
|
}{
|
||||||
{"no role", ecsv1.Assign{TaskID: "t1"}, ""},
|
{"no role", ecsv1.Assign{TaskID: "t1"}, ""},
|
||||||
{"role, no credID -> none", ecsv1.Assign{TaskID: "t1", TaskRoleARN: "arn:role"}, ""},
|
{"role, no credID -> taskID", ecsv1.Assign{TaskID: "t1", TaskRoleARN: "arn:role"}, "t1"},
|
||||||
{"role + credID", ecsv1.Assign{TaskID: "t1", CredID: "c9", TaskRoleARN: "arn:role"}, "c9"},
|
{"role + credID", ecsv1.Assign{TaskID: "t1", CredID: "c9", TaskRoleARN: "arn:role"}, "c9"},
|
||||||
}
|
}
|
||||||
for _, tc := range cases {
|
for _, tc := range cases {
|
||||||
@@ -27,37 +23,6 @@ func TestTaskCredID(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// The task ID is public (it is the task ARN suffix), so naming another task's ID
|
|
||||||
// at the credential endpoint must not yield that task's role.
|
|
||||||
func TestRegisterTaskCreds_TaskIDDoesNotServeCredentials(t *testing.T) {
|
|
||||||
c, _ := newTestCredEndpoint(stsauth.Credentials{AccessKeyID: "AKIA", SecretAccessKey: "secret"})
|
|
||||||
a := Agent{cred: c}
|
|
||||||
get := func(id string) int {
|
|
||||||
rr := httptest.NewRecorder()
|
|
||||||
c.ServeHTTP(rr, httptest.NewRequest(http.MethodGet, credRelativeURI(id), nil))
|
|
||||||
return rr.Code
|
|
||||||
}
|
|
||||||
|
|
||||||
legacy := ecsv1.Assign{TaskID: "victim-task", TaskRoleARN: "arn:aws:iam::000000000010:role/high"}
|
|
||||||
if got := a.registerTaskCreds(&legacy); got != "" {
|
|
||||||
t.Errorf("assign without CredID registered credID %q", got)
|
|
||||||
}
|
|
||||||
if code := get("victim-task"); code != http.StatusNotFound {
|
|
||||||
t.Errorf("GET by task ID of CredID-less assign = %d, want 404", code)
|
|
||||||
}
|
|
||||||
|
|
||||||
minted := ecsv1.Assign{TaskID: "victim-2", CredID: "secret-cred", TaskRoleARN: "arn:aws:iam::000000000010:role/high"}
|
|
||||||
if got := a.registerTaskCreds(&minted); got != "secret-cred" {
|
|
||||||
t.Fatalf("registerTaskCreds = %q, want secret-cred", got)
|
|
||||||
}
|
|
||||||
if code := get("victim-2"); code != http.StatusNotFound {
|
|
||||||
t.Errorf("GET by task ID = %d, want 404", code)
|
|
||||||
}
|
|
||||||
if code := get("secret-cred"); code != http.StatusOK {
|
|
||||||
t.Errorf("GET by CredID = %d, want 200", code)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestWithCredEnv(t *testing.T) {
|
func TestWithCredEnv(t *testing.T) {
|
||||||
base := map[string]string{"FOO": "bar"}
|
base := map[string]string{"FOO": "bar"}
|
||||||
|
|
||||||
|
|||||||
@@ -63,7 +63,7 @@ func TestReconcile_AdoptsRunningLabeledContainer(t *testing.T) {
|
|||||||
if !adopted["t-001"] {
|
if !adopted["t-001"] {
|
||||||
t.Fatalf("t-001 not adopted; got %v", adopted)
|
t.Fatalf("t-001 not adopted; got %v", adopted)
|
||||||
}
|
}
|
||||||
if got := a.cred.roles["t-001"].roleARN; got != testTaskRole {
|
if got := a.cred.roles["t-001"]; got != testTaskRole {
|
||||||
t.Errorf("cred not re-registered: roles[t-001] = %q, want %q", got, testTaskRole)
|
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 {
|
if n := countStatus(cp.taskStates(), "t-001", ecsv1.TaskStatusRunning); n != 1 {
|
||||||
|
|||||||
@@ -306,24 +306,24 @@ func (a *Agent) pullResolver(as *ecsv1.Assign) ctrruntime.Resolver {
|
|||||||
// credential endpoint and returns the credID (empty when the task has no role).
|
// credential endpoint and returns the credID (empty when the task has no role).
|
||||||
func (a *Agent) registerTaskCreds(as *ecsv1.Assign) string {
|
func (a *Agent) registerTaskCreds(as *ecsv1.Assign) string {
|
||||||
credID := taskCredID(as)
|
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 {
|
if credID == "" || a.cred == nil {
|
||||||
return credID
|
return credID
|
||||||
}
|
}
|
||||||
a.cred.Register(credID, as.TaskID, as.TaskRoleARN)
|
a.cred.Register(credID, as.TaskRoleARN)
|
||||||
return credID
|
return credID
|
||||||
}
|
}
|
||||||
|
|
||||||
// taskCredID is the credential ID a task's containers fetch credentials under:
|
// taskCredID is the credential ID a task's containers fetch credentials under:
|
||||||
// the scheduler-minted secret CredID. Empty when the task carries no IAM role or
|
// the scheduler-assigned CredID, falling back to the taskID. Empty when the task
|
||||||
// no CredID; the public task ID is never used, since any API caller can learn it.
|
// carries no IAM role.
|
||||||
func taskCredID(as *ecsv1.Assign) string {
|
func taskCredID(as *ecsv1.Assign) string {
|
||||||
if as.TaskRoleARN == "" {
|
if as.TaskRoleARN == "" {
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
return as.CredID
|
if as.CredID != "" {
|
||||||
|
return as.CredID
|
||||||
|
}
|
||||||
|
return as.TaskID
|
||||||
}
|
}
|
||||||
|
|
||||||
// withCredEnv returns env with AWS_CONTAINER_CREDENTIALS_RELATIVE_URI set for
|
// withCredEnv returns env with AWS_CONTAINER_CREDENTIALS_RELATIVE_URI set for
|
||||||
|
|||||||
@@ -152,30 +152,3 @@ func TestImportCertificate_SerialZeroRejected(t *testing.T) {
|
|||||||
assert.Equal(t, awserrors.ErrorValidationException, code)
|
assert.Equal(t, awserrors.ErrorValidationException, code)
|
||||||
assert.Equal(t, "The serial number in the certificate is not supported by ACM.", msg)
|
assert.Equal(t, "The serial number in the certificate is not supported by ACM.", msg)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestImportCertificate_ExpiredRejected(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
svc := setupACMService(t)
|
|
||||||
key, err := rsa.GenerateKey(rand.Reader, 1024)
|
|
||||||
require.NoError(t, err)
|
|
||||||
tmpl := &x509.Certificate{
|
|
||||||
SerialNumber: big.NewInt(time.Now().UnixNano()),
|
|
||||||
Subject: pkix.Name{CommonName: "expired.example.com"},
|
|
||||||
NotBefore: time.Now().Add(-48 * time.Hour),
|
|
||||||
NotAfter: time.Now().Add(-24 * time.Hour),
|
|
||||||
}
|
|
||||||
der, err := x509.CreateCertificate(rand.Reader, tmpl, tmpl, &key.PublicKey, key)
|
|
||||||
require.NoError(t, err)
|
|
||||||
_, err = svc.ImportCertificate(context.Background(), &acm.ImportCertificateInput{
|
|
||||||
Certificate: pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der}),
|
|
||||||
PrivateKey: pem.EncodeToMemory(&pem.Block{Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(key)}),
|
|
||||||
}, testAccountID)
|
|
||||||
code, msg, ok := awserrors.ResolveErrorDetail(err)
|
|
||||||
require.True(t, ok)
|
|
||||||
assert.Equal(t, awserrors.ErrorValidationException, code)
|
|
||||||
assert.Equal(t, "The certificate is expired. You can Import a certificate only during its validity period.", msg)
|
|
||||||
|
|
||||||
list, err := svc.ListCertificates(context.Background(), &acm.ListCertificatesInput{}, testAccountID)
|
|
||||||
require.NoError(t, err)
|
|
||||||
assert.Empty(t, list.CertificateSummaryList)
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -223,13 +223,10 @@ func (s *ACMServiceImpl) ImportCertificate(ctx context.Context, input *acm.Impor
|
|||||||
if leaf.SerialNumber.Sign() == 0 {
|
if leaf.SerialNumber.Sign() == 0 {
|
||||||
return nil, awserrors.Errorf(awserrors.ErrorValidationException, "The serial number in the certificate is not supported by ACM.")
|
return nil, awserrors.Errorf(awserrors.ErrorValidationException, "The serial number in the certificate is not supported by ACM.")
|
||||||
}
|
}
|
||||||
certArn := aws.StringValue(input.CertificateArn)
|
|
||||||
now := time.Now().UTC()
|
|
||||||
if certArn == "" && now.After(leaf.NotAfter) {
|
|
||||||
return nil, awserrors.Errorf(awserrors.ErrorValidationException, "The certificate is expired. You can Import a certificate only during its validity period.")
|
|
||||||
}
|
|
||||||
|
|
||||||
|
certArn := aws.StringValue(input.CertificateArn)
|
||||||
var inUseBy []string
|
var inUseBy []string
|
||||||
|
now := time.Now().UTC()
|
||||||
createdAt := now
|
createdAt := now
|
||||||
tags := tagsToMap(input.Tags)
|
tags := tagsToMap(input.Tags)
|
||||||
reimport := certArn != ""
|
reimport := certArn != ""
|
||||||
|
|||||||
@@ -134,9 +134,6 @@ func marshalEC2Response(action string, output any) ([]byte, error) {
|
|||||||
|
|
||||||
var ec2Actions = map[string]ec2Action{
|
var ec2Actions = map[string]ec2Action{
|
||||||
"DescribeInstances": ec2Handler(func(ctx context.Context, input *ec2.DescribeInstancesInput, c *call, accountID string) (any, error) {
|
"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)
|
out, err := ec2instanceapi.DescribeInstancesChecked(ctx, input, c.NATS, 0, c.NodeIDs, accountID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return out, err
|
return out, err
|
||||||
@@ -260,9 +257,6 @@ var ec2Actions = map[string]ec2Action{
|
|||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
"DescribeImages": ec2Handler(func(ctx context.Context, input *ec2.DescribeImagesInput, c *call, accountID string) (any, error) {
|
"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)
|
return ec2imageapi.DescribeImages(ctx, input, c.NATS, accountID)
|
||||||
}),
|
}),
|
||||||
"CreateImage": ec2Handler(func(ctx context.Context, input *ec2.CreateImageInput, c *call, accountID string) (any, error) {
|
"CreateImage": ec2Handler(func(ctx context.Context, input *ec2.CreateImageInput, c *call, accountID string) (any, error) {
|
||||||
@@ -293,9 +287,6 @@ var ec2Actions = map[string]ec2Action{
|
|||||||
return ec2zoneapi.DescribeAvailabilityZones(input, c.Region, c.AZ)
|
return ec2zoneapi.DescribeAvailabilityZones(input, c.Region, c.AZ)
|
||||||
}),
|
}),
|
||||||
"DescribeVolumes": ec2Handler(func(ctx context.Context, input *ec2.DescribeVolumesInput, c *call, accountID string) (any, error) {
|
"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)
|
return ec2volumeapi.DescribeVolumes(ctx, input, c.NATS, accountID)
|
||||||
}),
|
}),
|
||||||
"ModifyVolume": ec2Handler(func(ctx context.Context, input *ec2.ModifyVolumeInput, c *call, accountID string) (any, error) {
|
"ModifyVolume": ec2Handler(func(ctx context.Context, input *ec2.ModifyVolumeInput, c *call, accountID string) (any, error) {
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/mulgadc/spinifex/spinifex/foundation/aws/errors"
|
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
)
|
)
|
||||||
@@ -31,27 +30,3 @@ func TestImportKeyPair_Base64PaddingWorkaround(t *testing.T) {
|
|||||||
assert.NotContains(t, q["PublicKeyMaterial"], "%3D",
|
assert.NotContains(t, q["PublicKeyMaterial"], "%3D",
|
||||||
"Expected no URL-encoded padding remaining")
|
"Expected no URL-encoded padding remaining")
|
||||||
}
|
}
|
||||||
|
|
||||||
// A well-prefixed ID of the wrong shape is refused before any service is
|
|
||||||
// asked, so it can never come back as NotFound.
|
|
||||||
func TestDescribe_MalformedIDRejectedBeforeLookup(t *testing.T) {
|
|
||||||
tests := []struct {
|
|
||||||
action, param, id, code, msg string
|
|
||||||
}{
|
|
||||||
{"DescribeInstances", "InstanceId.1", "i-zzz", awserrors.ErrorInvalidInstanceIDMalformed, `Invalid id: "i-zzz"`},
|
|
||||||
{"DescribeImages", "ImageId.1", "ami-zzz", awserrors.ErrorInvalidAMIIDMalformed, `Invalid id: "ami-zzz"`},
|
|
||||||
{"DescribeVolumes", "VolumeId.1", "vol-zzz", awserrors.ErrorInvalidParameterValue, "Value (vol-zzz) for parameter volumes is invalid. Expected: 'vol-...'."},
|
|
||||||
}
|
|
||||||
for _, tt := range tests {
|
|
||||||
t.Run(tt.action, func(t *testing.T) {
|
|
||||||
h := ec2Actions[tt.action]
|
|
||||||
input, err := h.parse(map[string]string{tt.param: tt.id})
|
|
||||||
require.NoError(t, err)
|
|
||||||
_, err = h.dispatch(t.Context(), tt.action, input, &call{Handler: NewHandler(Deps{})}, "123456789012")
|
|
||||||
code, msg, ok := awserrors.ResolveErrorDetail(err)
|
|
||||||
require.True(t, ok)
|
|
||||||
assert.Equal(t, tt.code, code)
|
|
||||||
assert.Equal(t, tt.msg, msg)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ package image
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
"regexp"
|
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/aws/aws-sdk-go/service/ec2"
|
"github.com/aws/aws-sdk-go/service/ec2"
|
||||||
@@ -14,12 +13,8 @@ import (
|
|||||||
"github.com/nats-io/nats.go"
|
"github.com/nats-io/nats.go"
|
||||||
)
|
)
|
||||||
|
|
||||||
// imageIDSuffixPattern is the AWS AMI ID shape after ami-: 8 (legacy) or 17
|
// ValidateDescribeImagesInput accepts a nil input and rejects any ImageIds entry without the ami-
|
||||||
// lowercase hex characters.
|
// prefix with InvalidAMIID.Malformed.
|
||||||
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) {
|
func ValidateDescribeImagesInput(input *ec2.DescribeImagesInput) (err error) {
|
||||||
if input == nil {
|
if input == nil {
|
||||||
return nil
|
return nil
|
||||||
@@ -27,16 +22,9 @@ func ValidateDescribeImagesInput(input *ec2.DescribeImagesInput) (err error) {
|
|||||||
|
|
||||||
if input.ImageIds != nil {
|
if input.ImageIds != nil {
|
||||||
for _, imageId := range input.ImageIds {
|
for _, imageId := range input.ImageIds {
|
||||||
if imageId == nil {
|
if imageId != nil && !strings.HasPrefix(*imageId, "ami-") {
|
||||||
continue
|
|
||||||
}
|
|
||||||
suffix, ok := strings.CutPrefix(*imageId, "ami-")
|
|
||||||
if !ok {
|
|
||||||
return errors.New(awserrors.ErrorInvalidAMIIDMalformed)
|
return errors.New(awserrors.ErrorInvalidAMIIDMalformed)
|
||||||
}
|
}
|
||||||
if !imageIDSuffixPattern.MatchString(suffix) {
|
|
||||||
return awserrors.Errorf(awserrors.ErrorInvalidAMIIDMalformed, "Invalid id: %q", *imageId)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -46,6 +34,11 @@ func ValidateDescribeImagesInput(input *ec2.DescribeImagesInput) (err error) {
|
|||||||
// DescribeImages implements the EC2 DescribeImages action, returning the AMIs visible to
|
// DescribeImages implements the EC2 DescribeImages action, returning the AMIs visible to
|
||||||
// accountID from the NATS image service.
|
// accountID from the NATS image service.
|
||||||
func DescribeImages(ctx context.Context, input *ec2.DescribeImagesInput, natsConn *nats.Conn, accountID string) (output ec2.DescribeImagesOutput, err error) {
|
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)
|
imageService := ec2image.NewNATSImageService(natsConn, 0)
|
||||||
result, err := imageService.DescribeImages(ctx, input, accountID)
|
result, err := imageService.DescribeImages(ctx, input, accountID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -53,8 +53,8 @@ func TestValidateDescribeImagesInput(t *testing.T) {
|
|||||||
name: "MultipleValidImageIds",
|
name: "MultipleValidImageIds",
|
||||||
input: &ec2.DescribeImagesInput{
|
input: &ec2.DescribeImagesInput{
|
||||||
ImageIds: []*string{
|
ImageIds: []*string{
|
||||||
aws.String("ami-1a2b3c4d"),
|
aws.String("ami-111"),
|
||||||
aws.String("ami-0123456789abcdef1"),
|
aws.String("ami-222"),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
wantErr: false,
|
wantErr: false,
|
||||||
@@ -63,7 +63,7 @@ func TestValidateDescribeImagesInput(t *testing.T) {
|
|||||||
name: "MixedValidAndInvalidImageIds",
|
name: "MixedValidAndInvalidImageIds",
|
||||||
input: &ec2.DescribeImagesInput{
|
input: &ec2.DescribeImagesInput{
|
||||||
ImageIds: []*string{
|
ImageIds: []*string{
|
||||||
aws.String("ami-0123456789abcdef0"),
|
aws.String("ami-valid"),
|
||||||
aws.String("invalid-id"),
|
aws.String("invalid-id"),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -85,24 +85,11 @@ func TestValidateDescribeImagesInput(t *testing.T) {
|
|||||||
err := ValidateDescribeImagesInput(tt.input)
|
err := ValidateDescribeImagesInput(tt.input)
|
||||||
|
|
||||||
if tt.wantErr {
|
if tt.wantErr {
|
||||||
code, ok := awserrors.ResolveErrorCode(err)
|
assert.Error(t, err)
|
||||||
assert.True(t, ok)
|
assert.Equal(t, tt.errMsg, err.Error())
|
||||||
assert.Equal(t, tt.errMsg, code)
|
|
||||||
} else {
|
} else {
|
||||||
assert.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// AWS answers a well-prefixed ID whose suffix is not 8 or 17 lowercase hex
|
|
||||||
// characters with Malformed, not NotFound.
|
|
||||||
func TestValidateDescribeImagesInput_MalformedSuffix(t *testing.T) {
|
|
||||||
for _, id := range []string{"ami-zzz", "ami-0123456789ABCDEF0", "ami-0123456789abcdef", "ami-"} {
|
|
||||||
err := ValidateDescribeImagesInput(&ec2.DescribeImagesInput{ImageIds: []*string{aws.String(id)}})
|
|
||||||
code, msg, ok := awserrors.ResolveErrorDetail(err)
|
|
||||||
assert.True(t, ok, id)
|
|
||||||
assert.Equal(t, awserrors.ErrorInvalidAMIIDMalformed, code, id)
|
|
||||||
assert.Equal(t, `Invalid id: "`+id+`"`, msg, id)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -39,6 +39,13 @@ func TestCreateImage_NilNATS(t *testing.T) {
|
|||||||
assert.Error(t, err)
|
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) {
|
func TestDescribeImages_NilNATS(t *testing.T) {
|
||||||
_, err := DescribeImages(context.Background(), nil, nil, "acct-123")
|
_, err := DescribeImages(context.Background(), nil, nil, "acct-123")
|
||||||
assert.Error(t, err)
|
assert.Error(t, err)
|
||||||
|
|||||||
@@ -89,12 +89,6 @@ func DescribeInstances(ctx context.Context, input *ec2.DescribeInstancesInput, n
|
|||||||
return &ec2.DescribeInstancesOutput{Reservations: reservations}, nil
|
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
|
// DescribeInstancesChecked is the customer-facing variant served by the
|
||||||
// gateway's DescribeInstances action. On top of DescribeInstances' aggregation,
|
// gateway's DescribeInstances action. On top of DescribeInstances' aggregation,
|
||||||
// it asserts InvalidInstanceID.NotFound for any explicitly named instance ID
|
// 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)
|
profile, err := iamSvc.ResolveInstanceProfile(accountID, nameOrARN)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if code, _ := awserrors.ResolveErrorCode(err); code == awserrors.ErrorIAMNoSuchEntity {
|
if err.Error() == awserrors.ErrorIAMNoSuchEntity {
|
||||||
return nil, errors.New(awserrors.ErrorInvalidIamInstanceProfileNotFound)
|
return nil, errors.New(awserrors.ErrorInvalidIamInstanceProfileNotFound)
|
||||||
}
|
}
|
||||||
return nil, err
|
return nil, err
|
||||||
|
|||||||
@@ -45,7 +45,7 @@ func (f *fakeIAMService) ResolveInstanceProfile(accountID, nameOrARN string) (*i
|
|||||||
if f.resolveFn != nil {
|
if f.resolveFn != nil {
|
||||||
return f.resolveFn(accountID, nameOrARN)
|
return f.resolveFn(accountID, nameOrARN)
|
||||||
}
|
}
|
||||||
return nil, awserrors.Errorf(awserrors.ErrorIAMNoSuchEntity, "Instance Profile %s cannot be found.", nameOrARN)
|
return nil, errors.New(awserrors.ErrorIAMNoSuchEntity)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (f *fakeIAMService) CanonicalResourceARN(accountID string, kind arn.IAMResourceType, name string) (string, error) {
|
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 {
|
func validateInstanceIDFormat(ids []*string) error {
|
||||||
for _, id := range ids {
|
for _, id := range ids {
|
||||||
if id != nil && !instanceIDPattern.MatchString(*id) {
|
if id != nil && !instanceIDPattern.MatchString(*id) {
|
||||||
return awserrors.Errorf(awserrors.ErrorInvalidInstanceIDMalformed, "Invalid id: %q", *id)
|
return errors.New(awserrors.ErrorInvalidInstanceIDMalformed)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
|
|||||||
@@ -57,10 +57,8 @@ func TestValidateInstanceIDFormat(t *testing.T) {
|
|||||||
}
|
}
|
||||||
for _, id := range malformed {
|
for _, id := range malformed {
|
||||||
err := validateInstanceIDFormat([]*string{aws.String(id)})
|
err := validateInstanceIDFormat([]*string{aws.String(id)})
|
||||||
code, msg, ok := awserrors.ResolveErrorDetail(err)
|
require.Error(t, err, id)
|
||||||
require.True(t, ok, id)
|
assert.Equal(t, awserrors.ErrorInvalidInstanceIDMalformed, err.Error(), 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")})
|
err := validateInstanceIDFormat([]*string{aws.String("i-0123456789abcdef0"), aws.String("i-abc")})
|
||||||
require.Error(t, err, "one malformed id fails the whole batch")
|
require.Error(t, err, "one malformed id fails the whole batch")
|
||||||
@@ -102,9 +100,8 @@ func TestLifecycleVerbs_MalformedIDRejectedBeforeNATS(t *testing.T) {
|
|||||||
}
|
}
|
||||||
for name, call := range calls {
|
for name, call := range calls {
|
||||||
err := call()
|
err := call()
|
||||||
code, ok := awserrors.ResolveErrorCode(err)
|
require.Error(t, err, name)
|
||||||
require.True(t, ok, name)
|
assert.Equal(t, awserrors.ErrorInvalidInstanceIDMalformed, err.Error(), name)
|
||||||
assert.Equal(t, awserrors.ErrorInvalidInstanceIDMalformed, code, name)
|
|
||||||
}
|
}
|
||||||
require.NoError(t, nc.Flush())
|
require.NoError(t, nc.Flush())
|
||||||
assert.Zero(t, requests.Load(), "no NATS request may be sent for a batch holding a malformed id")
|
assert.Zero(t, requests.Load(), "no NATS request may be sent for a batch holding a malformed id")
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ package volume
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
"regexp"
|
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/aws/aws-sdk-go/service/ec2"
|
"github.com/aws/aws-sdk-go/service/ec2"
|
||||||
@@ -14,13 +13,8 @@ import (
|
|||||||
"github.com/nats-io/nats.go"
|
"github.com/nats-io/nats.go"
|
||||||
)
|
)
|
||||||
|
|
||||||
// volumeIDSuffixPattern is the AWS volume ID shape after vol-: 8 (legacy) or
|
// ValidateDescribeVolumesInput accepts a nil input and rejects any VolumeIds entry without the
|
||||||
// 17 lowercase hex characters.
|
// vol- prefix with InvalidVolumeID.Malformed.
|
||||||
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 {
|
func ValidateDescribeVolumesInput(input *ec2.DescribeVolumesInput) error {
|
||||||
if input == nil {
|
if input == nil {
|
||||||
return nil
|
return nil
|
||||||
@@ -28,17 +22,9 @@ func ValidateDescribeVolumesInput(input *ec2.DescribeVolumesInput) error {
|
|||||||
|
|
||||||
if input.VolumeIds != nil {
|
if input.VolumeIds != nil {
|
||||||
for _, volumeId := range input.VolumeIds {
|
for _, volumeId := range input.VolumeIds {
|
||||||
if volumeId == nil {
|
if volumeId != nil && !strings.HasPrefix(*volumeId, "vol-") {
|
||||||
continue
|
|
||||||
}
|
|
||||||
suffix, ok := strings.CutPrefix(*volumeId, "vol-")
|
|
||||||
if !ok {
|
|
||||||
return errors.New(awserrors.ErrorInvalidVolumeIDMalformed)
|
return errors.New(awserrors.ErrorInvalidVolumeIDMalformed)
|
||||||
}
|
}
|
||||||
if !volumeIDSuffixPattern.MatchString(suffix) {
|
|
||||||
return awserrors.Errorf(awserrors.ErrorInvalidParameterValue,
|
|
||||||
"Value (%s) for parameter volumes is invalid. Expected: 'vol-...'.", *volumeId)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -49,6 +35,11 @@ func ValidateDescribeVolumesInput(input *ec2.DescribeVolumesInput) error {
|
|||||||
func DescribeVolumes(ctx context.Context, input *ec2.DescribeVolumesInput, natsConn *nats.Conn, accountID string) (ec2.DescribeVolumesOutput, error) {
|
func DescribeVolumes(ctx context.Context, input *ec2.DescribeVolumesInput, natsConn *nats.Conn, accountID string) (ec2.DescribeVolumesOutput, error) {
|
||||||
var output ec2.DescribeVolumesOutput
|
var output ec2.DescribeVolumesOutput
|
||||||
|
|
||||||
|
err := ValidateDescribeVolumesInput(input)
|
||||||
|
if err != nil {
|
||||||
|
return output, err
|
||||||
|
}
|
||||||
|
|
||||||
volumeService := ec2volume.NewNATSVolumeService(natsConn)
|
volumeService := ec2volume.NewNATSVolumeService(natsConn)
|
||||||
result, err := volumeService.DescribeVolumes(ctx, input, accountID)
|
result, err := volumeService.DescribeVolumes(ctx, input, accountID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import (
|
|||||||
|
|
||||||
"github.com/aws/aws-sdk-go/aws"
|
"github.com/aws/aws-sdk-go/aws"
|
||||||
"github.com/aws/aws-sdk-go/service/ec2"
|
"github.com/aws/aws-sdk-go/service/ec2"
|
||||||
"github.com/mulgadc/spinifex/spinifex/foundation/aws/errors"
|
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -37,9 +36,9 @@ func TestValidateDescribeVolumesInput(t *testing.T) {
|
|||||||
name: "ValidMultipleVolumeIds",
|
name: "ValidMultipleVolumeIds",
|
||||||
input: &ec2.DescribeVolumesInput{
|
input: &ec2.DescribeVolumesInput{
|
||||||
VolumeIds: []*string{
|
VolumeIds: []*string{
|
||||||
aws.String("vol-0123456789abcdef1"),
|
aws.String("vol-abc123"),
|
||||||
aws.String("vol-1a2b3c4d"),
|
aws.String("vol-def456"),
|
||||||
aws.String("vol-0123456789abcdef2"),
|
aws.String("vol-ghi789"),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
wantErr: false,
|
wantErr: false,
|
||||||
@@ -72,7 +71,7 @@ func TestValidateDescribeVolumesInput(t *testing.T) {
|
|||||||
name: "MixedValidAndInvalid",
|
name: "MixedValidAndInvalid",
|
||||||
input: &ec2.DescribeVolumesInput{
|
input: &ec2.DescribeVolumesInput{
|
||||||
VolumeIds: []*string{
|
VolumeIds: []*string{
|
||||||
aws.String("vol-0123456789abcdef3"),
|
aws.String("vol-valid123"),
|
||||||
aws.String("invalid-id"),
|
aws.String("invalid-id"),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -82,7 +81,7 @@ func TestValidateDescribeVolumesInput(t *testing.T) {
|
|||||||
{
|
{
|
||||||
name: "NilVolumeIdInList",
|
name: "NilVolumeIdInList",
|
||||||
input: &ec2.DescribeVolumesInput{
|
input: &ec2.DescribeVolumesInput{
|
||||||
VolumeIds: []*string{nil, aws.String("vol-0123456789abcdef1")},
|
VolumeIds: []*string{nil, aws.String("vol-abc123")},
|
||||||
},
|
},
|
||||||
wantErr: false,
|
wantErr: false,
|
||||||
},
|
},
|
||||||
@@ -101,15 +100,3 @@ func TestValidateDescribeVolumesInput(t *testing.T) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// AWS answers a well-prefixed ID whose suffix is not 8 or 17 lowercase hex
|
|
||||||
// characters with InvalidParameterValue, not NotFound.
|
|
||||||
func TestValidateDescribeVolumesInput_MalformedSuffix(t *testing.T) {
|
|
||||||
for _, id := range []string{"vol-zzz", "vol-0123456789ABCDEF0", "vol-0123456789abcdef", "vol-"} {
|
|
||||||
err := ValidateDescribeVolumesInput(&ec2.DescribeVolumesInput{VolumeIds: []*string{aws.String(id)}})
|
|
||||||
code, msg, ok := awserrors.ResolveErrorDetail(err)
|
|
||||||
assert.True(t, ok, id)
|
|
||||||
assert.Equal(t, awserrors.ErrorInvalidParameterValue, code, id)
|
|
||||||
assert.Equal(t, "Value ("+id+") for parameter volumes is invalid. Expected: 'vol-...'.", msg, id)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -96,6 +96,13 @@ func TestModifyVolume_NilNATS(t *testing.T) {
|
|||||||
assert.Error(t, err)
|
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) {
|
func TestDescribeVolumes_NilNATS(t *testing.T) {
|
||||||
_, err := DescribeVolumes(context.Background(), nil, nil, "acct-123")
|
_, err := DescribeVolumes(context.Background(), nil, nil, "acct-123")
|
||||||
assert.Error(t, err)
|
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)
|
profile, err := s.iam.ResolveInstanceProfile(ctx, eni.iamAccountID(), inst.iamInstanceProfileArn)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if code, _ := awserrors.ResolveErrorCode(err); code == awserrors.ErrorIAMNoSuchEntity {
|
if err.Error() == awserrors.ErrorIAMNoSuchEntity {
|
||||||
return nil, roleMissProfileDeleted, nil // profile deleted; treat as no role
|
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)
|
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.
|
// genuine backend error (TestHTTP_*BackendError500) is a 500.
|
||||||
func TestHTTP_IAMInfoDeletedProfile404(t *testing.T) {
|
func TestHTTP_IAMInfoDeletedProfile404(t *testing.T) {
|
||||||
res := &fakeResolver{eni: testENI(), inst: &instanceFacts{iamInstanceProfileArn: "arn:aws:iam::111122223333:instance-profile/gone"}}
|
res := &fakeResolver{eni: testENI(), inst: &instanceFacts{iamInstanceProfileArn: "arn:aws:iam::111122223333:instance-profile/gone"}}
|
||||||
svc, _ := newTestService(res, &fakeIAM{profileErr: awserrors.Errorf(awserrors.ErrorIAMNoSuchEntity, "Instance Profile gone cannot be found.")}, &fakeAssumer{})
|
svc, _ := newTestService(res, &fakeIAM{profileErr: errors.New(awserrors.ErrorIAMNoSuchEntity)}, &fakeAssumer{})
|
||||||
h := withTapENI(svc.httpHandler(), testENI())
|
h := withTapENI(svc.httpHandler(), testENI())
|
||||||
token := issueToken(t, h)
|
token := issueToken(t, h)
|
||||||
rec := get(t, h, prefixMetaData+"iam/info", token)
|
rec := get(t, h, prefixMetaData+"iam/info", token)
|
||||||
|
|||||||
@@ -272,15 +272,11 @@ type TaskRecord struct {
|
|||||||
// Group / StartedBy mirror the AWS task fields. A service's tasks carry
|
// Group / StartedBy mirror the AWS task fields. A service's tasks carry
|
||||||
// Group="service:{name}" so the reconciler counts them and the task-state
|
// Group="service:{name}" so the reconciler counts them and the task-state
|
||||||
// hook resolves a RUNNING/STOPPED task back to its owning service.
|
// hook resolves a RUNNING/STOPPED task back to its owning service.
|
||||||
Group string `json:"group,omitempty"`
|
Group string `json:"group,omitempty"`
|
||||||
StartedBy string `json:"startedBy,omitempty"`
|
StartedBy string `json:"startedBy,omitempty"`
|
||||||
TaskDefFamily string `json:"taskDefFamily"`
|
TaskDefFamily string `json:"taskDefFamily"`
|
||||||
TaskDefRevision int `json:"taskDefRevision"`
|
TaskDefRevision int `json:"taskDefRevision"`
|
||||||
TaskDefARN string `json:"taskDefArn"`
|
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"`
|
ContainerInstanceID string `json:"containerInstanceId,omitempty"`
|
||||||
ContainerInstanceARN string `json:"containerInstanceArn,omitempty"`
|
ContainerInstanceARN string `json:"containerInstanceArn,omitempty"`
|
||||||
DesiredStatus string `json:"desiredStatus"`
|
DesiredStatus string `json:"desiredStatus"`
|
||||||
|
|||||||
@@ -2,9 +2,7 @@ package ecs
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
|
||||||
"github.com/mulgadc/spinifex/spinifex/domains/ecs/taskdefinition"
|
"github.com/mulgadc/spinifex/spinifex/domains/ecs/taskdefinition"
|
||||||
"maps"
|
|
||||||
"strconv"
|
"strconv"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
@@ -304,89 +302,6 @@ func TestService_RunTask_AssignCarriesTaskRole(t *testing.T) {
|
|||||||
assert.Equal(t, roleARN, poll.Assignments[0].TaskRoleARN)
|
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;
|
// 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.
|
// acking it (then STOPPED) drains the inbox so it is never re-delivered.
|
||||||
func TestService_PollAssignments_AckAndReclaim(t *testing.T) {
|
func TestService_PollAssignments_AckAndReclaim(t *testing.T) {
|
||||||
|
|||||||
@@ -237,9 +237,6 @@ func (s *Service) newTaskRecord(accountID, cluster, taskID string, td *taskdefin
|
|||||||
GPU: gpu,
|
GPU: gpu,
|
||||||
CreatedAt: now,
|
CreatedAt: now,
|
||||||
}
|
}
|
||||||
if td.TaskRoleArn != "" {
|
|
||||||
rec.CredID = uuid.NewV4().String()
|
|
||||||
}
|
|
||||||
for _, c := range td.Containers {
|
for _, c := range td.Containers {
|
||||||
rec.Containers = append(rec.Containers, ContainerState{Name: c.Name, Status: TaskStatusPending})
|
rec.Containers = append(rec.Containers, ContainerState{Name: c.Name, Status: TaskStatusPending})
|
||||||
}
|
}
|
||||||
@@ -260,7 +257,6 @@ func (s *Service) publishAssign(ctx context.Context, kv jetstream.KeyValue, acco
|
|||||||
TaskDefFamily: td.Family,
|
TaskDefFamily: td.Family,
|
||||||
TaskDefRevision: td.Revision,
|
TaskDefRevision: td.Revision,
|
||||||
TaskRoleARN: td.TaskRoleArn,
|
TaskRoleARN: td.TaskRoleArn,
|
||||||
CredID: rec.CredID,
|
|
||||||
ExecutionRoleARN: td.ExecutionRoleArn,
|
ExecutionRoleARN: td.ExecutionRoleArn,
|
||||||
ENIID: rec.ENIID,
|
ENIID: rec.ENIID,
|
||||||
ENIMacAddress: rec.ENIMacAddress,
|
ENIMacAddress: rec.ENIMacAddress,
|
||||||
|
|||||||
@@ -4,14 +4,8 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"maps"
|
"maps"
|
||||||
"slices"
|
"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),
|
// NvidiaDevicePlugin is staged by a GPU node group's launch (EnsureGPUDevicePlugin),
|
||||||
// never user-requested directly.
|
// never user-requested directly.
|
||||||
const NvidiaDevicePlugin = "nvidia-device-plugin"
|
const NvidiaDevicePlugin = "nvidia-device-plugin"
|
||||||
@@ -24,11 +18,6 @@ type Spec struct {
|
|||||||
// Versions lists supported versions newest-first; [0] is the default.
|
// Versions lists supported versions newest-first; [0] is the default.
|
||||||
Versions []string
|
Versions []string
|
||||||
DefaultVersion 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 indicates the add-on needs an IAM role for service-account binding.
|
||||||
RequiresIRSA bool
|
RequiresIRSA bool
|
||||||
Description string
|
Description string
|
||||||
@@ -39,26 +28,26 @@ type Spec struct {
|
|||||||
|
|
||||||
// catalog is the bundled add-on registry. Keep newest version first per slice.
|
// catalog is the bundled add-on registry. Keep newest version first per slice.
|
||||||
var catalog = buildCatalog(
|
var catalog = buildCatalog(
|
||||||
typed("networking", newSpec("aws-load-balancer-controller", true,
|
newSpec("aws-load-balancer-controller", true,
|
||||||
"Provisions ELBv2 load balancers for Kubernetes Service/Ingress resources.",
|
"Provisions ELBv2 load balancers for Kubernetes Service/Ingress resources.",
|
||||||
"2.11.0")),
|
"2.11.0"),
|
||||||
typed("gitops", newSpec("argocd", false,
|
newSpec("argocd", false,
|
||||||
"Declarative GitOps continuous delivery for Kubernetes.",
|
"Declarative GitOps continuous delivery for Kubernetes.",
|
||||||
"3.0.23")),
|
"3.0.23"),
|
||||||
typed("storage", newSpec("aws-ebs-csi-driver", true,
|
newSpec("aws-ebs-csi-driver", true,
|
||||||
"Container Storage Interface driver for Amazon EBS (Viperblock) volumes.",
|
"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
|
// Staged automatically for GPU node groups, so hidden from the public
|
||||||
// catalogue like spinifex-noop but still creatable by name.
|
// catalogue like spinifex-noop but still creatable by name.
|
||||||
hidden(typed("accelerator", newSpec(NvidiaDevicePlugin, false,
|
hidden(newSpec(NvidiaDevicePlugin, false,
|
||||||
"NVIDIA device plugin exposing nvidia.com/gpu allocatable via CDI on GPU-tainted nodes.",
|
"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
|
// spinifex-noop is the delivery-transport fixture: a trivial bundle
|
||||||
// (Namespace + ConfigMap) the add-on e2e uses to prove stage, render,
|
// (Namespace + ConfigMap) the add-on e2e uses to prove stage, render,
|
||||||
// auto-deploy, ACTIVE and delete round-trip without a real workload.
|
// auto-deploy, ACTIVE and delete round-trip without a real workload.
|
||||||
hidden(typed("fixture", newSpec("spinifex-noop", false,
|
hidden(newSpec("spinifex-noop", false,
|
||||||
"No-op delivery-transport fixture (Namespace + ConfigMap).",
|
"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
|
// hidden marks a spec as internal: still creatable, but absent from the
|
||||||
@@ -68,12 +57,6 @@ func hidden(s Spec) Spec {
|
|||||||
return s
|
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.
|
// newSpec builds a spec with the first version as its default.
|
||||||
func newSpec(name string, requiresIRSA bool, description string, versions ...string) Spec {
|
func newSpec(name string, requiresIRSA bool, description string, versions ...string) Spec {
|
||||||
spec := Spec{
|
spec := Spec{
|
||||||
@@ -85,10 +68,6 @@ func newSpec(name string, requiresIRSA bool, description string, versions ...str
|
|||||||
if len(versions) > 0 {
|
if len(versions) > 0 {
|
||||||
spec.DefaultVersion = versions[0]
|
spec.DefaultVersion = versions[0]
|
||||||
}
|
}
|
||||||
spec.Builds = make(map[string]int, len(versions))
|
|
||||||
for _, v := range versions {
|
|
||||||
spec.Builds[v] = firstBuild
|
|
||||||
}
|
|
||||||
return spec
|
return spec
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -132,50 +111,6 @@ func (s Spec) SupportsVersion(version string) bool {
|
|||||||
return slices.Contains(s.Versions, version)
|
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.
|
// Specs returns every catalogue entry, hidden ones included, sorted by name.
|
||||||
func Specs() []Spec {
|
func Specs() []Spec {
|
||||||
names := slices.Sorted(maps.Keys(catalog))
|
names := slices.Sorted(maps.Keys(catalog))
|
||||||
|
|||||||
@@ -100,78 +100,3 @@ func TestValidateCatalog_ChecksTheBundledCatalog(t *testing.T) {
|
|||||||
t.Cleanup(func() { catalog = previous })
|
t.Cleanup(func() { catalog = previous })
|
||||||
require.EqualError(t, ValidateCatalog(), `add-on "broken" has no versions`)
|
require.EqualError(t, ValidateCatalog(), `add-on "broken" has no versions`)
|
||||||
}
|
}
|
||||||
|
|
||||||
// buildSpec is a two-version spec whose newest version is on EKS build 2, so
|
|
||||||
// a test cannot pass by hardcoding build 1.
|
|
||||||
func buildSpec() Spec {
|
|
||||||
return Spec{
|
|
||||||
Name: "fixture",
|
|
||||||
Versions: []string{"2.0.0", "1.0.0"},
|
|
||||||
Builds: map[string]int{"2.0.0": 2, "1.0.0": 1},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestSpec_AWSVersionUsesTheCatalogBuildNumber(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
spec := buildSpec()
|
|
||||||
assert.Equal(t, "v2.0.0-eksbuild.2", spec.AWSVersion("2.0.0"))
|
|
||||||
assert.Equal(t, "v1.0.0-eksbuild.1", spec.AWSVersion("1.0.0"))
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestSpec_AWSVersionIsIdempotent(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
spec := buildSpec()
|
|
||||||
assert.Equal(t, "v2.0.0-eksbuild.2", spec.AWSVersion("v2.0.0-eksbuild.2"),
|
|
||||||
"a record already holding the AWS form must not be wrapped twice")
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestSpec_AWSVersionsAreNewestFirst(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
assert.Equal(t, []string{"v2.0.0-eksbuild.2", "v1.0.0-eksbuild.1"}, buildSpec().AWSVersions())
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestSpec_ParseVersionAcceptsOnlyTheExactForms(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
spec := buildSpec()
|
|
||||||
for input, want := range map[string]string{
|
|
||||||
"v2.0.0-eksbuild.2": "2.0.0",
|
|
||||||
"2.0.0": "2.0.0",
|
|
||||||
"v1.0.0-eksbuild.1": "1.0.0",
|
|
||||||
"1.0.0": "1.0.0",
|
|
||||||
} {
|
|
||||||
got, ok := spec.ParseVersion(input)
|
|
||||||
assert.True(t, ok, "%q must be accepted", input)
|
|
||||||
assert.Equal(t, want, got, "%q", input)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestSpec_ParseVersionRefusesEverythingElse(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
spec := buildSpec()
|
|
||||||
for _, input := range []string{
|
|
||||||
"v2.0.0-eksbuild.1", // wrong build for 2.0.0
|
|
||||||
"v2.0.0-eksbuild.3",
|
|
||||||
"v1.0.0-eksbuild.2", // wrong build for 1.0.0
|
|
||||||
"v2.0.0", // missing build
|
|
||||||
"2.0", // partial
|
|
||||||
"v2.0.0-eksbuild.",
|
|
||||||
"2.0.0-eksbuild.2", // missing v
|
|
||||||
"V2.0.0-eksbuild.2",
|
|
||||||
" 2.0.0",
|
|
||||||
"9.9.9",
|
|
||||||
"",
|
|
||||||
} {
|
|
||||||
_, ok := spec.ParseVersion(input)
|
|
||||||
assert.False(t, ok, "%q must be refused", input)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestCatalog_EveryVersionHasAnExplicitBuild(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
for _, spec := range Specs() {
|
|
||||||
for _, v := range spec.Versions {
|
|
||||||
assert.Equal(t, 1, spec.Builds[v], "%s %s needs an explicit build number", spec.Name, v)
|
|
||||||
assert.Equal(t, "v"+v+"-eksbuild.1", spec.AWSVersion(v), "%s %s", spec.Name, v)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,105 +0,0 @@
|
|||||||
package awsapi
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"encoding/json"
|
|
||||||
"sync"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"github.com/aws/aws-sdk-go/aws"
|
|
||||||
"github.com/aws/aws-sdk-go/service/eks"
|
|
||||||
"github.com/mulgadc/spinifex/internal/testkit"
|
|
||||||
"github.com/nats-io/nats.go"
|
|
||||||
"github.com/stretchr/testify/assert"
|
|
||||||
"github.com/stretchr/testify/require"
|
|
||||||
)
|
|
||||||
|
|
||||||
// captureDescribeAddonVersions answers eks.DescribeAddonVersions and returns a
|
|
||||||
// function reading the last input the gateway sent to the service.
|
|
||||||
func captureDescribeAddonVersions(t *testing.T, nc *nats.Conn) func() eks.DescribeAddonVersionsInput {
|
|
||||||
t.Helper()
|
|
||||||
var (
|
|
||||||
mu sync.Mutex
|
|
||||||
last eks.DescribeAddonVersionsInput
|
|
||||||
)
|
|
||||||
sub, err := nc.Subscribe("eks.DescribeAddonVersions", func(m *nats.Msg) {
|
|
||||||
mu.Lock()
|
|
||||||
last = eks.DescribeAddonVersionsInput{}
|
|
||||||
_ = json.Unmarshal(m.Data, &last)
|
|
||||||
mu.Unlock()
|
|
||||||
_ = m.Respond([]byte(`{}`))
|
|
||||||
})
|
|
||||||
require.NoError(t, err)
|
|
||||||
t.Cleanup(func() { _ = sub.Unsubscribe() })
|
|
||||||
return func() eks.DescribeAddonVersionsInput {
|
|
||||||
mu.Lock()
|
|
||||||
defer mu.Unlock()
|
|
||||||
return last
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// The gateway folds a GET query string into the body as a JSON object of
|
|
||||||
// string lists; the model binds every field below from the query.
|
|
||||||
func TestDescribeAddonVersions_PassesEveryModelQueryParameter(t *testing.T) {
|
|
||||||
_, nc := testutil.StartTestNATS(t)
|
|
||||||
got := captureDescribeAddonVersions(t, nc)
|
|
||||||
|
|
||||||
query := []byte(`{"addonName":["aws-ebs-csi-driver"],"kubernetesVersion":["1.32"],` +
|
|
||||||
`"maxResults":["2"],"nextToken":["tok"],"types":["storage","networking"],` +
|
|
||||||
`"publishers":["eks"],"owners":["aws"]}`)
|
|
||||||
_, err := DescribeAddonVersions(context.Background(), nc, acct, query)
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
in := got()
|
|
||||||
assert.Equal(t, "aws-ebs-csi-driver", aws.StringValue(in.AddonName))
|
|
||||||
assert.Equal(t, "1.32", aws.StringValue(in.KubernetesVersion))
|
|
||||||
assert.Equal(t, int64(2), aws.Int64Value(in.MaxResults))
|
|
||||||
assert.Equal(t, "tok", aws.StringValue(in.NextToken))
|
|
||||||
assert.Equal(t, []string{"storage", "networking"}, aws.StringValueSlice(in.Types))
|
|
||||||
assert.Equal(t, []string{"eks"}, aws.StringValueSlice(in.Publishers))
|
|
||||||
assert.Equal(t, []string{"aws"}, aws.StringValueSlice(in.Owners))
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestDescribeAddonVersions_NoQueryLeavesEveryFilterUnset(t *testing.T) {
|
|
||||||
_, nc := testutil.StartTestNATS(t)
|
|
||||||
got := captureDescribeAddonVersions(t, nc)
|
|
||||||
|
|
||||||
_, err := DescribeAddonVersions(context.Background(), nc, acct, nil)
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
in := got()
|
|
||||||
assert.Nil(t, in.AddonName)
|
|
||||||
assert.Nil(t, in.KubernetesVersion)
|
|
||||||
assert.Nil(t, in.MaxResults)
|
|
||||||
assert.Nil(t, in.NextToken)
|
|
||||||
assert.Empty(t, in.Types)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestDescribeAddonVersions_RefusesMalformedQuery(t *testing.T) {
|
|
||||||
_, nc := testutil.StartTestNATS(t)
|
|
||||||
captureDescribeAddonVersions(t, nc)
|
|
||||||
|
|
||||||
_, err := DescribeAddonVersions(context.Background(), nc, acct, []byte(`{"maxResults":["many"]}`))
|
|
||||||
require.Error(t, err)
|
|
||||||
assert.Contains(t, err.Error(), "maxResults")
|
|
||||||
|
|
||||||
_, err = DescribeAddonVersions(context.Background(), nc, acct, []byte(`not json`))
|
|
||||||
require.Error(t, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// The route must hand the folded query to the wrapper, not drop it.
|
|
||||||
func TestDescribeAddonVersionsRoute_PassesTheQuery(t *testing.T) {
|
|
||||||
_, nc := testutil.StartTestNATS(t)
|
|
||||||
got := captureDescribeAddonVersions(t, nc)
|
|
||||||
|
|
||||||
for _, r := range routes {
|
|
||||||
if r.Action != "DescribeAddonVersions" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
_, err := r.Handler(context.Background(), nc, acct, "", nil, []byte(`{"addonName":["argocd"]}`))
|
|
||||||
require.NoError(t, err)
|
|
||||||
assert.Equal(t, "argocd", aws.StringValue(got().AddonName))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
t.Fatal("no DescribeAddonVersions route")
|
|
||||||
}
|
|
||||||
@@ -16,23 +16,8 @@ func ListAddons(ctx context.Context, natsConn *nats.Conn, accountID, cluster str
|
|||||||
}
|
}
|
||||||
|
|
||||||
// DescribeAddonVersions — GET /addons/supported-versions.
|
// DescribeAddonVersions — GET /addons/supported-versions.
|
||||||
func DescribeAddonVersions(ctx context.Context, natsConn *nats.Conn, accountID string, query []byte) (*eks.DescribeAddonVersionsOutput, error) {
|
func DescribeAddonVersions(ctx context.Context, natsConn *nats.Conn, accountID string) (*eks.DescribeAddonVersionsOutput, error) {
|
||||||
q, err := parseListQuery(query)
|
return ekscluster.NewNATSEKSService(natsConn).DescribeAddonVersions(ctx, &eks.DescribeAddonVersionsInput{}, accountID)
|
||||||
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.
|
// CreateAddon — POST /clusters/{name}/addons.
|
||||||
|
|||||||
@@ -1,61 +0,0 @@
|
|||||||
package awsapi
|
|
||||||
|
|
||||||
import (
|
|
||||||
"encoding/json"
|
|
||||||
"strconv"
|
|
||||||
|
|
||||||
"github.com/aws/aws-sdk-go/aws"
|
|
||||||
awserrors "github.com/mulgadc/spinifex/spinifex/foundation/aws/errors"
|
|
||||||
)
|
|
||||||
|
|
||||||
// listQuery holds the query-string inputs of a GET action, which the gateway
|
|
||||||
// folds into the body as a JSON object of string lists.
|
|
||||||
type listQuery map[string][]string
|
|
||||||
|
|
||||||
// parseListQuery decodes the folded query of a GET action. An empty body means
|
|
||||||
// the request carried no query string.
|
|
||||||
func parseListQuery(body []byte) (listQuery, error) {
|
|
||||||
q := listQuery{}
|
|
||||||
if len(body) == 0 {
|
|
||||||
return q, nil
|
|
||||||
}
|
|
||||||
if err := json.Unmarshal(body, (*map[string][]string)(&q)); err != nil {
|
|
||||||
return nil, awserrors.Errorf(awserrors.ErrorEKSInvalidParameter, "malformed query string")
|
|
||||||
}
|
|
||||||
return q, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// str returns the first value of name, or nil when it is absent or empty.
|
|
||||||
func (q listQuery) str(name string) *string {
|
|
||||||
if v := q[name]; len(v) > 0 && v[0] != "" {
|
|
||||||
return aws.String(v[0])
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// strs returns every non-empty value of name, or nil when there are none.
|
|
||||||
func (q listQuery) strs(name string) []*string {
|
|
||||||
var out []string
|
|
||||||
for _, v := range q[name] {
|
|
||||||
if v != "" {
|
|
||||||
out = append(out, v)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if len(out) == 0 {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return aws.StringSlice(out)
|
|
||||||
}
|
|
||||||
|
|
||||||
// int64 returns the first value of name as an integer, or nil when absent.
|
|
||||||
func (q listQuery) int64(name string) (*int64, error) {
|
|
||||||
s := q.str(name)
|
|
||||||
if s == nil {
|
|
||||||
return nil, nil
|
|
||||||
}
|
|
||||||
n, err := strconv.ParseInt(*s, 10, 64)
|
|
||||||
if err != nil {
|
|
||||||
return nil, awserrors.Errorf(awserrors.ErrorEKSInvalidParameter, "%s must be an integer", name)
|
|
||||||
}
|
|
||||||
return aws.Int64(n), nil
|
|
||||||
}
|
|
||||||
@@ -82,39 +82,17 @@ func (d Deps) dispatch(w http.ResponseWriter, inv dispatch.Invocation) error {
|
|||||||
if err == nil {
|
if err == nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
action, _, _, routed := router.Lookup(inv.Request.Method, inv.Request.URL.EscapedPath())
|
if action, _, _, ok := router.Lookup(inv.Request.Method, inv.Request.URL.EscapedPath()); ok && IsInternalAction(action) {
|
||||||
if routed && IsInternalAction(action) {
|
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
code, msg, ok := awserrors.ResolveErrorDetail(err)
|
code, msg, ok := awserrors.ResolveErrorDetail(err)
|
||||||
if !ok || code != awserrors.ErrorInvalidParameterValue {
|
if !ok || code != awserrors.ErrorInvalidParameterValue {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
wire := awserrors.ErrorEKSInvalidParameter
|
|
||||||
if override, ok := parameterRefusalCodes[action]; ok {
|
|
||||||
wire = override
|
|
||||||
if msg == "" {
|
|
||||||
msg = "a parameter of the request is not valid"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if msg == "" {
|
if msg == "" {
|
||||||
return errors.New(wire)
|
return errors.New(awserrors.ErrorEKSInvalidParameter)
|
||||||
}
|
}
|
||||||
return awserrors.Errorf(wire, "%s", msg)
|
return awserrors.Errorf(awserrors.ErrorEKSInvalidParameter, "%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.
|
// serve resolves the route, authorizes the request, then runs the action.
|
||||||
|
|||||||
@@ -1,98 +0,0 @@
|
|||||||
//test:in-package — routes is unexported, and the table must cover exactly the
|
|
||||||
// actions the registration serves.
|
|
||||||
|
|
||||||
package awsapi
|
|
||||||
|
|
||||||
import (
|
|
||||||
"net/http"
|
|
||||||
"net/http/httptest"
|
|
||||||
"net/url"
|
|
||||||
"strings"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"github.com/mulgadc/bluebottle/pkg/iampolicy"
|
|
||||||
"github.com/mulgadc/spinifex/internal/awsmodel"
|
|
||||||
"github.com/mulgadc/spinifex/internal/testkit"
|
|
||||||
awserrors "github.com/mulgadc/spinifex/spinifex/foundation/aws/errors"
|
|
||||||
"github.com/mulgadc/spinifex/spinifex/ingress/aws/dispatch"
|
|
||||||
"github.com/nats-io/nats.go"
|
|
||||||
"github.com/stretchr/testify/assert"
|
|
||||||
"github.com/stretchr/testify/require"
|
|
||||||
)
|
|
||||||
|
|
||||||
// routePath fills a route pattern with placeholder segments. The tag routes
|
|
||||||
// take an ARN, which has to be escaped to stay one path segment.
|
|
||||||
func routePath(pattern string) string {
|
|
||||||
arn := url.PathEscape("arn:aws:eks:us-east-1:111122223333:cluster/c")
|
|
||||||
path := strings.ReplaceAll(pattern, "*", arn)
|
|
||||||
for {
|
|
||||||
start := strings.Index(path, "{")
|
|
||||||
if start < 0 {
|
|
||||||
return path
|
|
||||||
}
|
|
||||||
end := strings.Index(path[start:], "}")
|
|
||||||
path = path[:start] + "x" + path[start+end+1:]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// A parameter refusal must reach the wire under a code the operation declares
|
|
||||||
// in the EKS model, or SDK clients cannot match it to a typed error.
|
|
||||||
func TestRegistration_ParameterRefusalCodeIsDeclaredByOperation(t *testing.T) {
|
|
||||||
_, nc := testutil.StartTestNATS(t)
|
|
||||||
sub, err := nc.Subscribe("eks.>", func(m *nats.Msg) {
|
|
||||||
if m.Reply != "" {
|
|
||||||
_ = m.Respond(awserrors.GenerateErrorPayload(awserrors.ErrorInvalidParameterValue))
|
|
||||||
}
|
|
||||||
})
|
|
||||||
require.NoError(t, err)
|
|
||||||
t.Cleanup(func() { _ = sub.Unsubscribe() })
|
|
||||||
|
|
||||||
model, err := awsmodel.Load(awsmodel.EKS)
|
|
||||||
require.NoError(t, err)
|
|
||||||
registration := NewRegistration(Deps{NATS: nc})
|
|
||||||
|
|
||||||
for _, route := range routes {
|
|
||||||
if IsInternalAction(route.Action) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
// The handler takes no input and the model declares no client error.
|
|
||||||
if route.Action == "ListAccessPolicies" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
t.Run(route.Action, func(t *testing.T) {
|
|
||||||
operation, ok := model.Operation(route.Action)
|
|
||||||
require.True(t, ok, "%s is not an operation in the EKS model", route.Action)
|
|
||||||
declared := make([]string, 0, len(operation.Errors))
|
|
||||||
for _, ref := range operation.Errors {
|
|
||||||
declared = append(declared, ref.Shape)
|
|
||||||
}
|
|
||||||
|
|
||||||
req := httptest.NewRequest(route.Method, routePath(route.Pattern), strings.NewReader(`{}`))
|
|
||||||
inv := dispatch.Invocation{
|
|
||||||
Request: req,
|
|
||||||
AccountID: "111122223333",
|
|
||||||
Region: "us-east-1",
|
|
||||||
Authorize: func(string, string, []string, iampolicy.ConditionKeys) error { return nil },
|
|
||||||
Actor: func() (dispatch.Actor, error) {
|
|
||||||
return dispatch.Actor{AccountID: "111122223333", PrincipalType: "user", UserName: "alice"}, nil
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
err := registration.Dispatch(httptest.NewRecorder(), inv)
|
|
||||||
|
|
||||||
require.Error(t, err)
|
|
||||||
code, ok := awserrors.ResolveErrorCode(err)
|
|
||||||
require.True(t, ok, "unresolvable error code: %v", err)
|
|
||||||
assert.Contains(t, declared, code,
|
|
||||||
"%s %s refuses a parameter with %s, which the operation does not declare", route.Method, route.Pattern, code)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// The access operations declare InvalidRequestException, not a parameter code.
|
|
||||||
func TestEKSInvalidRequestCodeIsRegistered(t *testing.T) {
|
|
||||||
assert.Equal(t, "InvalidRequestException", awserrors.ErrorEKSInvalidRequest)
|
|
||||||
assert.Equal(t, http.StatusBadRequest, awserrors.HTTPStatusForError(awserrors.Errorf(awserrors.ErrorEKSInvalidRequest, "x")))
|
|
||||||
assert.Equal(t, "BadRequestException", awserrors.ErrorEKSBadRequest)
|
|
||||||
assert.Equal(t, http.StatusBadRequest, awserrors.HTTPStatusForError(awserrors.Errorf(awserrors.ErrorEKSBadRequest, "x")))
|
|
||||||
}
|
|
||||||
@@ -131,7 +131,7 @@ var routes = []route{
|
|||||||
// Addons
|
// Addons
|
||||||
{Method: "GET", Pattern: "/addons/supported-versions", Action: "DescribeAddonVersions",
|
{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) {
|
Handler: func(ctx context.Context, nc *nats.Conn, acct, callerARN string, p []string, b []byte) (any, error) {
|
||||||
return DescribeAddonVersions(ctx, nc, acct, b)
|
return DescribeAddonVersions(ctx, nc, acct)
|
||||||
}},
|
}},
|
||||||
{Method: "GET", Pattern: "/clusters/{clusterName}/addons", Action: "ListAddons",
|
{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) {
|
Handler: func(ctx context.Context, nc *nats.Conn, acct, callerARN string, p []string, b []byte) (any, error) {
|
||||||
|
|||||||
@@ -176,7 +176,7 @@ func TestGatewayWrappers_Addons(t *testing.T) {
|
|||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
assert.NotNil(t, out1)
|
assert.NotNil(t, out1)
|
||||||
|
|
||||||
out2, err := DescribeAddonVersions(context.Background(), nc, acct, nil)
|
out2, err := DescribeAddonVersions(context.Background(), nc, acct)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
assert.NotNil(t, out2)
|
assert.NotNil(t, out2)
|
||||||
|
|
||||||
|
|||||||
@@ -1,171 +0,0 @@
|
|||||||
package cluster
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"github.com/aws/aws-sdk-go/aws"
|
|
||||||
"github.com/aws/aws-sdk-go/service/eks"
|
|
||||||
"github.com/mulgadc/spinifex/spinifex/domains/eks/addon"
|
|
||||||
"github.com/stretchr/testify/assert"
|
|
||||||
"github.com/stretchr/testify/require"
|
|
||||||
)
|
|
||||||
|
|
||||||
func describeVersions(t *testing.T, svc *EKSServiceImpl, in *eks.DescribeAddonVersionsInput) *eks.DescribeAddonVersionsOutput {
|
|
||||||
t.Helper()
|
|
||||||
out, err := svc.DescribeAddonVersions(context.Background(), in, testAccountID)
|
|
||||||
require.NoError(t, err)
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
// The terraform aws_eks_addon_version data source asks for one add-on at the
|
|
||||||
// cluster's Kubernetes version, then takes the version whose compatibility for
|
|
||||||
// that cluster version is the default (or, with most_recent, the first one).
|
|
||||||
func TestDescribeAddonVersions_TerraformProviderCall(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
svc := setupTestService(t)
|
|
||||||
|
|
||||||
out := describeVersions(t, svc, &eks.DescribeAddonVersionsInput{
|
|
||||||
AddonName: aws.String(csiAddon),
|
|
||||||
KubernetesVersion: aws.String(defaultK8sVersion),
|
|
||||||
})
|
|
||||||
require.Len(t, out.Addons, 1)
|
|
||||||
info := out.Addons[0]
|
|
||||||
assert.Equal(t, csiAddon, aws.StringValue(info.AddonName))
|
|
||||||
assert.Nil(t, out.NextToken)
|
|
||||||
|
|
||||||
spec, ok := addon.Lookup(csiAddon)
|
|
||||||
require.True(t, ok)
|
|
||||||
require.Equal(t, spec.AWSVersions(), versionStrings(info), "newest first, AWS form")
|
|
||||||
|
|
||||||
defaults := 0
|
|
||||||
for _, v := range info.AddonVersions {
|
|
||||||
require.Len(t, v.Compatibilities, 1, "one entry, for the requested cluster version")
|
|
||||||
c := v.Compatibilities[0]
|
|
||||||
assert.Equal(t, defaultK8sVersion, aws.StringValue(c.ClusterVersion))
|
|
||||||
assert.Equal(t, []string{"*"}, aws.StringValueSlice(c.PlatformVersions))
|
|
||||||
if aws.BoolValue(c.DefaultVersion) {
|
|
||||||
defaults++
|
|
||||||
assert.Equal(t, spec.AWSVersion(spec.DefaultVersion), aws.StringValue(v.AddonVersion))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
assert.Equal(t, 1, defaults, "exactly one defaultVersion true")
|
|
||||||
}
|
|
||||||
|
|
||||||
// AWS observed (EKS-01): compatibilities holds only the requested version. The
|
|
||||||
// response for a version no add-on supports is not captured; this pins the
|
|
||||||
// shape #61 chose, an empty list with no error.
|
|
||||||
func TestDescribeAddonVersions_UnsupportedKubernetesVersionIsEmpty(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
svc := setupTestService(t)
|
|
||||||
|
|
||||||
out := describeVersions(t, svc, &eks.DescribeAddonVersionsInput{
|
|
||||||
AddonName: aws.String(csiAddon),
|
|
||||||
KubernetesVersion: aws.String("1.29"),
|
|
||||||
})
|
|
||||||
assert.Empty(t, out.Addons)
|
|
||||||
assert.Nil(t, out.NextToken)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestDescribeAddonVersions_FiltersByNameAndHidesFixturesUnlessNamed(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
svc := setupTestService(t)
|
|
||||||
|
|
||||||
for _, name := range []string{csiAddon, "argocd", albController} {
|
|
||||||
out := describeVersions(t, svc, &eks.DescribeAddonVersionsInput{AddonName: aws.String(name)})
|
|
||||||
require.Len(t, out.Addons, 1, name)
|
|
||||||
assert.Equal(t, name, aws.StringValue(out.Addons[0].AddonName))
|
|
||||||
}
|
|
||||||
assert.Empty(t, describeVersions(t, svc, &eks.DescribeAddonVersionsInput{AddonName: aws.String("vpc-cni")}).Addons)
|
|
||||||
}
|
|
||||||
|
|
||||||
// maxResults counts add-ons, not versions; a page boundary never splits an
|
|
||||||
// add-on's version list, and the last page carries no token.
|
|
||||||
func TestDescribeAddonVersions_PagesByAddon(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
svc := setupTestService(t)
|
|
||||||
|
|
||||||
all := describeVersions(t, svc, &eks.DescribeAddonVersionsInput{})
|
|
||||||
require.Greater(t, len(all.Addons), 1)
|
|
||||||
assert.Nil(t, all.NextToken)
|
|
||||||
|
|
||||||
var names []string
|
|
||||||
in := &eks.DescribeAddonVersionsInput{MaxResults: aws.Int64(1)}
|
|
||||||
for range len(all.Addons) {
|
|
||||||
out := describeVersions(t, svc, in)
|
|
||||||
require.Len(t, out.Addons, 1)
|
|
||||||
names = append(names, aws.StringValue(out.Addons[0].AddonName))
|
|
||||||
assert.Equal(t, versionStrings(all.Addons[len(names)-1]), versionStrings(out.Addons[0]))
|
|
||||||
in.NextToken = out.NextToken
|
|
||||||
}
|
|
||||||
assert.Nil(t, in.NextToken, "last page carries no token")
|
|
||||||
var want []string
|
|
||||||
for _, a := range all.Addons {
|
|
||||||
want = append(want, aws.StringValue(a.AddonName))
|
|
||||||
}
|
|
||||||
assert.Equal(t, want, names)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Every add-on carries the descriptive fields the data source and console read.
|
|
||||||
func TestDescribeAddonVersions_CarriesAddonAndVersionMetadata(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
svc := setupTestService(t)
|
|
||||||
|
|
||||||
for _, a := range describeVersions(t, svc, &eks.DescribeAddonVersionsInput{}).Addons {
|
|
||||||
name := aws.StringValue(a.AddonName)
|
|
||||||
assert.NotEmpty(t, aws.StringValue(a.Type), name)
|
|
||||||
assert.NotEmpty(t, aws.StringValue(a.Owner), name)
|
|
||||||
assert.NotEmpty(t, aws.StringValue(a.Publisher), name)
|
|
||||||
assert.Nil(t, a.MarketplaceInformation, name)
|
|
||||||
for _, v := range a.AddonVersions {
|
|
||||||
assert.NotEmpty(t, v.Architecture, name)
|
|
||||||
assert.NotNil(t, v.RequiresConfiguration, name)
|
|
||||||
assert.NotNil(t, v.RequiresIamPermissions, name)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// types, publishers and owners are model filters; each keeps add-ons whose
|
|
||||||
// field matches one of the given values.
|
|
||||||
func TestDescribeAddonVersions_FiltersByTypePublisherOwner(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
svc := setupTestService(t)
|
|
||||||
|
|
||||||
first := describeVersions(t, svc, &eks.DescribeAddonVersionsInput{}).Addons[0]
|
|
||||||
cases := []struct {
|
|
||||||
name string
|
|
||||||
match func(*eks.DescribeAddonVersionsInput, []*string)
|
|
||||||
value *string
|
|
||||||
}{
|
|
||||||
{"types", func(in *eks.DescribeAddonVersionsInput, v []*string) { in.Types = v }, first.Type},
|
|
||||||
{"publishers", func(in *eks.DescribeAddonVersionsInput, v []*string) { in.Publishers = v }, first.Publisher},
|
|
||||||
{"owners", func(in *eks.DescribeAddonVersionsInput, v []*string) { in.Owners = v }, first.Owner},
|
|
||||||
}
|
|
||||||
for _, c := range cases {
|
|
||||||
hit := &eks.DescribeAddonVersionsInput{}
|
|
||||||
c.match(hit, aws.StringSlice([]string{"nobody", aws.StringValue(c.value)}))
|
|
||||||
out := describeVersions(t, svc, hit)
|
|
||||||
require.NotEmpty(t, out.Addons, c.name)
|
|
||||||
assert.Contains(t, addonNames(out), aws.StringValue(first.AddonName), c.name)
|
|
||||||
|
|
||||||
miss := &eks.DescribeAddonVersionsInput{}
|
|
||||||
c.match(miss, aws.StringSlice([]string{"nobody"}))
|
|
||||||
assert.Empty(t, describeVersions(t, svc, miss).Addons, c.name)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func versionStrings(a *eks.AddonInfo) []string {
|
|
||||||
out := make([]string, 0, len(a.AddonVersions))
|
|
||||||
for _, v := range a.AddonVersions {
|
|
||||||
out = append(out, aws.StringValue(v.AddonVersion))
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
func addonNames(o *eks.DescribeAddonVersionsOutput) []string {
|
|
||||||
out := make([]string, 0, len(o.Addons))
|
|
||||||
for _, a := range o.Addons {
|
|
||||||
out = append(out, aws.StringValue(a.AddonName))
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
@@ -1,220 +0,0 @@
|
|||||||
package cluster
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bytes"
|
|
||||||
"context"
|
|
||||||
"log/slog"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"github.com/aws/aws-sdk-go/aws"
|
|
||||||
"github.com/aws/aws-sdk-go/service/eks"
|
|
||||||
"github.com/mulgadc/spinifex/spinifex/domains/eks/addon"
|
|
||||||
awserrors "github.com/mulgadc/spinifex/spinifex/foundation/aws/errors"
|
|
||||||
"github.com/stretchr/testify/assert"
|
|
||||||
"github.com/stretchr/testify/require"
|
|
||||||
)
|
|
||||||
|
|
||||||
const (
|
|
||||||
csiAddon = "aws-ebs-csi-driver"
|
|
||||||
csiBare = "1.40.1"
|
|
||||||
csiAWSVersion = "v1.40.1-eksbuild.1"
|
|
||||||
csiWrongBuild = "v1.40.1-eksbuild.2"
|
|
||||||
versionParam = "addonVersion"
|
|
||||||
notBeforeAWSOK = "an AWS-format version must be accepted"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestDescribeAddonVersions_ReturnsAWSFormNewestFirst(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
svc := setupTestService(t)
|
|
||||||
|
|
||||||
out, err := svc.DescribeAddonVersions(context.Background(), &eks.DescribeAddonVersionsInput{}, testAccountID)
|
|
||||||
require.NoError(t, err)
|
|
||||||
require.NotEmpty(t, out.Addons)
|
|
||||||
for _, a := range out.Addons {
|
|
||||||
spec, ok := addon.Lookup(aws.StringValue(a.AddonName))
|
|
||||||
require.True(t, ok)
|
|
||||||
require.Len(t, a.AddonVersions, len(spec.Versions))
|
|
||||||
for i, v := range a.AddonVersions {
|
|
||||||
assert.Equal(t, "v"+spec.Versions[i]+"-eksbuild.1", aws.StringValue(v.AddonVersion),
|
|
||||||
"%s version %d", spec.Name, i)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestCreateAddon_AcceptsBothFormsAndStoresTheBareForm(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
for name, input := range map[string]string{"aws form": csiAWSVersion, "bare form": csiBare} {
|
|
||||||
t.Run(name, func(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
svc, fake := setupAddonService(t)
|
|
||||||
|
|
||||||
out, err := svc.CreateAddon(context.Background(), &eks.CreateAddonInput{
|
|
||||||
ClusterName: aws.String("c1"), AddonName: aws.String(csiAddon),
|
|
||||||
AddonVersion: aws.String(input),
|
|
||||||
}, testAccountID)
|
|
||||||
require.NoError(t, err, notBeforeAWSOK)
|
|
||||||
assert.Equal(t, csiAWSVersion, aws.StringValue(out.Addon.AddonVersion), "response is in AWS form")
|
|
||||||
|
|
||||||
rec, err := addon.Get(t.Context(), acctKVForTest(t, svc), "c1", csiAddon)
|
|
||||||
require.NoError(t, err)
|
|
||||||
assert.Equal(t, csiBare, rec.AddonVersion, "the record keeps the upstream form")
|
|
||||||
require.Len(t, fake.installs, 1)
|
|
||||||
assert.Equal(t, csiBare, fake.installs[0].AddonVersion, "the bundle lookup sees the upstream form")
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestCreateAddon_DefaultVersionResponseIsAWSForm(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
svc, _ := setupAddonService(t)
|
|
||||||
out, err := svc.CreateAddon(context.Background(), &eks.CreateAddonInput{
|
|
||||||
ClusterName: aws.String("c1"), AddonName: aws.String(csiAddon),
|
|
||||||
}, testAccountID)
|
|
||||||
require.NoError(t, err)
|
|
||||||
assert.Equal(t, csiAWSVersion, aws.StringValue(out.Addon.AddonVersion))
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestUpdateAddon_AcceptsBothFormsAndStoresTheBareForm(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
for name, input := range map[string]string{"aws form": csiAWSVersion, "bare form": csiBare} {
|
|
||||||
t.Run(name, func(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
svc, fake := setupAddonService(t)
|
|
||||||
_, err := svc.CreateAddon(context.Background(), &eks.CreateAddonInput{
|
|
||||||
ClusterName: aws.String("c1"), AddonName: aws.String(csiAddon),
|
|
||||||
}, testAccountID)
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
_, err = svc.UpdateAddon(context.Background(), &eks.UpdateAddonInput{
|
|
||||||
ClusterName: aws.String("c1"), AddonName: aws.String(csiAddon),
|
|
||||||
AddonVersion: aws.String(input),
|
|
||||||
}, testAccountID)
|
|
||||||
require.NoError(t, err, notBeforeAWSOK)
|
|
||||||
|
|
||||||
rec, err := addon.Get(t.Context(), acctKVForTest(t, svc), "c1", csiAddon)
|
|
||||||
require.NoError(t, err)
|
|
||||||
assert.Equal(t, csiBare, rec.AddonVersion)
|
|
||||||
require.Len(t, fake.installs, 2)
|
|
||||||
assert.Equal(t, csiBare, fake.installs[1].AddonVersion)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestDescribeAddon_ReturnsAWSFormForAnyStoredForm(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
for name, stored := range map[string]string{
|
|
||||||
"record stored bare before the change": csiBare,
|
|
||||||
"record already in AWS form": csiAWSVersion,
|
|
||||||
} {
|
|
||||||
t.Run(name, func(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
svc, _ := setupAddonService(t)
|
|
||||||
_, err := svc.addons().Create(context.Background(), acctKVForTest(t, svc), testAccountID, addon.Desired{
|
|
||||||
Cluster: "c1", Name: csiAddon, Version: stored,
|
|
||||||
})
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
desc, err := svc.DescribeAddon(context.Background(), &eks.DescribeAddonInput{
|
|
||||||
ClusterName: aws.String("c1"), AddonName: aws.String(csiAddon),
|
|
||||||
}, testAccountID)
|
|
||||||
require.NoError(t, err)
|
|
||||||
assert.Equal(t, csiAWSVersion, aws.StringValue(desc.Addon.AddonVersion), "no double wrap")
|
|
||||||
|
|
||||||
list, err := svc.ListAddons(context.Background(), &eks.ListAddonsInput{ClusterName: aws.String("c1")}, testAccountID)
|
|
||||||
require.NoError(t, err)
|
|
||||||
assert.Equal(t, []string{csiAddon}, aws.StringValueSlice(list.Addons))
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestUpdateAddon_ResponseAndRecordAfterBareRecord(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
svc, _ := setupAddonService(t)
|
|
||||||
_, err := svc.addons().Create(context.Background(), acctKVForTest(t, svc), testAccountID, addon.Desired{
|
|
||||||
Cluster: "c1", Name: csiAddon, Version: csiBare,
|
|
||||||
})
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
_, err = svc.UpdateAddon(context.Background(), &eks.UpdateAddonInput{
|
|
||||||
ClusterName: aws.String("c1"), AddonName: aws.String(csiAddon),
|
|
||||||
ConfigurationValues: aws.String(`{"a":1}`),
|
|
||||||
}, testAccountID)
|
|
||||||
require.NoError(t, err)
|
|
||||||
desc, err := svc.DescribeAddon(context.Background(), &eks.DescribeAddonInput{
|
|
||||||
ClusterName: aws.String("c1"), AddonName: aws.String(csiAddon),
|
|
||||||
}, testAccountID)
|
|
||||||
require.NoError(t, err)
|
|
||||||
assert.Equal(t, csiAWSVersion, aws.StringValue(desc.Addon.AddonVersion))
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestAddonVersionRefusal_NamesTheParameterAndListsSupportedValues(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
svc, _ := setupAddonService(t)
|
|
||||||
_, err := svc.CreateAddon(context.Background(), &eks.CreateAddonInput{
|
|
||||||
ClusterName: aws.String("c1"), AddonName: aws.String("argocd"),
|
|
||||||
}, testAccountID)
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
refusals := map[string]func() error{
|
|
||||||
"create": func() error {
|
|
||||||
_, e := svc.CreateAddon(context.Background(), &eks.CreateAddonInput{
|
|
||||||
ClusterName: aws.String("c1"), AddonName: aws.String(csiAddon),
|
|
||||||
AddonVersion: aws.String(csiWrongBuild),
|
|
||||||
}, testAccountID)
|
|
||||||
return e
|
|
||||||
},
|
|
||||||
"update": func() error {
|
|
||||||
_, e := svc.UpdateAddon(context.Background(), &eks.UpdateAddonInput{
|
|
||||||
ClusterName: aws.String("c1"), AddonName: aws.String("argocd"),
|
|
||||||
AddonVersion: aws.String("v3.0.23-eksbuild.2"),
|
|
||||||
}, testAccountID)
|
|
||||||
return e
|
|
||||||
},
|
|
||||||
}
|
|
||||||
want := map[string]string{"create": csiAWSVersion, "update": "v3.0.23-eksbuild.1"}
|
|
||||||
for name, refuse := range refusals {
|
|
||||||
t.Run(name, func(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
err := refuse()
|
|
||||||
require.Error(t, err)
|
|
||||||
code, message, ok := awserrors.ResolveErrorDetail(err)
|
|
||||||
require.True(t, ok)
|
|
||||||
assert.Equal(t, awserrors.ErrorInvalidParameterValue, code, "this change leaves the error code alone")
|
|
||||||
assert.Contains(t, message, versionParam)
|
|
||||||
assert.Contains(t, message, want[name], "the supported AWS-form value is listed")
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestAddonBareVersionInput_LogsItsUseAtInfo(t *testing.T) {
|
|
||||||
var buf bytes.Buffer
|
|
||||||
restore := slog.Default()
|
|
||||||
slog.SetDefault(slog.New(slog.NewTextHandler(&buf, &slog.HandlerOptions{Level: slog.LevelInfo})))
|
|
||||||
t.Cleanup(func() { slog.SetDefault(restore) })
|
|
||||||
|
|
||||||
svc, _ := setupAddonService(t)
|
|
||||||
_, err := svc.CreateAddon(context.Background(), &eks.CreateAddonInput{
|
|
||||||
ClusterName: aws.String("c1"), AddonName: aws.String(csiAddon),
|
|
||||||
}, testAccountID)
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
buf.Reset()
|
|
||||||
_, err = svc.UpdateAddon(context.Background(), &eks.UpdateAddonInput{
|
|
||||||
ClusterName: aws.String("c1"), AddonName: aws.String(csiAddon),
|
|
||||||
AddonVersion: aws.String(csiBare),
|
|
||||||
}, testAccountID)
|
|
||||||
require.NoError(t, err)
|
|
||||||
logged := buf.String()
|
|
||||||
assert.Contains(t, logged, "level=INFO")
|
|
||||||
assert.Contains(t, logged, "bare")
|
|
||||||
assert.Contains(t, logged, csiAddon)
|
|
||||||
assert.Contains(t, logged, "c1")
|
|
||||||
|
|
||||||
buf.Reset()
|
|
||||||
_, err = svc.UpdateAddon(context.Background(), &eks.UpdateAddonInput{
|
|
||||||
ClusterName: aws.String("c1"), AddonName: aws.String(csiAddon),
|
|
||||||
AddonVersion: aws.String(csiAWSVersion),
|
|
||||||
}, testAccountID)
|
|
||||||
require.NoError(t, err, notBeforeAWSOK)
|
|
||||||
assert.NotContains(t, buf.String(), "bare", "the AWS form is the intended form and logs nothing")
|
|
||||||
}
|
|
||||||
@@ -2,12 +2,8 @@ package cluster
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"encoding/base64"
|
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log/slog"
|
|
||||||
"slices"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"github.com/aws/aws-sdk-go/aws"
|
"github.com/aws/aws-sdk-go/aws"
|
||||||
"github.com/aws/aws-sdk-go/service/eks"
|
"github.com/aws/aws-sdk-go/service/eks"
|
||||||
@@ -39,20 +35,15 @@ func (s *EKSServiceImpl) ListAddons(ctx context.Context, input *eks.ListAddonsIn
|
|||||||
return &eks.ListAddonsOutput{Addons: aws.StringSlice(names)}, nil
|
return &eks.ListAddonsOutput{Addons: aws.StringSlice(names)}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// DescribeAddonVersions returns the add-on catalogue filtered by name, owner,
|
// DescribeAddonVersions returns the static add-on catalog, optionally filtered by name.
|
||||||
// publisher, type and the Kubernetes version its versions support, one page
|
func (s *EKSServiceImpl) DescribeAddonVersions(ctx context.Context, input *eks.DescribeAddonVersionsInput, _ string) (*eks.DescribeAddonVersionsOutput, error) {
|
||||||
// of add-ons at a time.
|
filter := ""
|
||||||
func (s *EKSServiceImpl) DescribeAddonVersions(_ context.Context, input *eks.DescribeAddonVersionsInput, _ string) (*eks.DescribeAddonVersionsOutput, error) {
|
if input != nil {
|
||||||
if input == nil {
|
filter = aws.StringValue(input.AddonName)
|
||||||
input = &eks.DescribeAddonVersionsInput{}
|
|
||||||
}
|
}
|
||||||
// Every catalogue version supports only the K3s line the node image ships.
|
specs := addon.Specs()
|
||||||
if v := aws.StringValue(input.KubernetesVersion); v != "" && v != defaultK8sVersion {
|
out := make([]*eks.AddonInfo, 0, len(specs))
|
||||||
return &eks.DescribeAddonVersionsOutput{Addons: []*eks.AddonInfo{}}, nil
|
for _, spec := range specs {
|
||||||
}
|
|
||||||
filter := aws.StringValue(input.AddonName)
|
|
||||||
var matched []addon.Spec
|
|
||||||
for _, spec := range addon.Specs() {
|
|
||||||
if filter != "" && spec.Name != filter {
|
if filter != "" && spec.Name != filter {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
@@ -61,51 +52,9 @@ func (s *EKSServiceImpl) DescribeAddonVersions(_ context.Context, input *eks.Des
|
|||||||
if spec.Hidden && filter == "" {
|
if spec.Hidden && filter == "" {
|
||||||
continue
|
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))
|
out = append(out, addonSpecToAWS(spec))
|
||||||
}
|
}
|
||||||
return &eks.DescribeAddonVersionsOutput{Addons: out, NextToken: next}, nil
|
return &eks.DescribeAddonVersionsOutput{Addons: out}, 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
|
// validateAddonServiceAccountRoleArn rejects a non-empty value that is not
|
||||||
@@ -140,12 +89,11 @@ func (s *EKSServiceImpl) CreateAddon(ctx context.Context, input *eks.CreateAddon
|
|||||||
if !ok {
|
if !ok {
|
||||||
return nil, errors.New(awserrors.ErrorInvalidParameterValue)
|
return nil, errors.New(awserrors.ErrorInvalidParameterValue)
|
||||||
}
|
}
|
||||||
version := spec.DefaultVersion
|
version := aws.StringValue(input.AddonVersion)
|
||||||
if requested := aws.StringValue(input.AddonVersion); requested != "" {
|
if version == "" {
|
||||||
var err error
|
version = spec.DefaultVersion
|
||||||
if version, err = resolveAddonVersion(ctx, spec, requested, cluster); err != nil {
|
} else if !spec.SupportsVersion(version) {
|
||||||
return nil, err
|
return nil, errors.New(awserrors.ErrorInvalidParameterValue)
|
||||||
}
|
|
||||||
}
|
}
|
||||||
acctKV, err := s.acctKVForNewDependant(ctx, accountID, cluster)
|
acctKV, err := s.acctKVForNewDependant(ctx, accountID, cluster)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -249,13 +197,9 @@ func (s *EKSServiceImpl) UpdateAddon(ctx context.Context, input *eks.UpdateAddon
|
|||||||
version := aws.StringValue(input.AddonVersion)
|
version := aws.StringValue(input.AddonVersion)
|
||||||
if version != "" {
|
if version != "" {
|
||||||
spec, ok := addon.Lookup(addonName)
|
spec, ok := addon.Lookup(addonName)
|
||||||
if !ok {
|
if !ok || !spec.SupportsVersion(version) {
|
||||||
return nil, errors.New(awserrors.ErrorInvalidParameterValue)
|
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{
|
rec, err := s.addons().Update(ctx, acctKV, accountID, cluster, addonName, addon.Change{
|
||||||
Version: version,
|
Version: version,
|
||||||
@@ -302,7 +246,7 @@ func addonRecordToAWS(cluster string, rec *addon.Record) *eks.Addon {
|
|||||||
out := &eks.Addon{
|
out := &eks.Addon{
|
||||||
AddonArn: aws.String(rec.Arn),
|
AddonArn: aws.String(rec.Arn),
|
||||||
AddonName: aws.String(rec.AddonName),
|
AddonName: aws.String(rec.AddonName),
|
||||||
AddonVersion: aws.String(awsAddonVersion(rec)),
|
AddonVersion: aws.String(rec.AddonVersion),
|
||||||
ClusterName: aws.String(cluster),
|
ClusterName: aws.String(cluster),
|
||||||
Status: aws.String(string(rec.Status)),
|
Status: aws.String(string(rec.Status)),
|
||||||
CreatedAt: aws.Time(rec.CreatedAt),
|
CreatedAt: aws.Time(rec.CreatedAt),
|
||||||
@@ -325,36 +269,17 @@ func addonRecordToAWS(cluster string, rec *addon.Record) *eks.Addon {
|
|||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
// The owner and publisher Spinifex reports for every catalogue add-on, as AWS
|
// addonSpecToAWS converts a catalog spec to the SDK AddonInfo shape.
|
||||||
// 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 {
|
func addonSpecToAWS(spec addon.Spec) *eks.AddonInfo {
|
||||||
versions := make([]*eks.AddonVersionInfo, 0, len(spec.Versions))
|
versions := make([]*eks.AddonVersionInfo, 0, len(spec.Versions))
|
||||||
for _, v := range spec.AWSVersions() {
|
for _, v := range spec.Versions {
|
||||||
versions = append(versions, &eks.AddonVersionInfo{
|
versions = append(versions, &eks.AddonVersionInfo{
|
||||||
AddonVersion: aws.String(v),
|
AddonVersion: aws.String(v),
|
||||||
Architecture: aws.StringSlice([]string{"amd64"}),
|
|
||||||
RequiresConfiguration: aws.Bool(false),
|
|
||||||
RequiresIamPermissions: aws.Bool(spec.RequiresIRSA),
|
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{
|
return &eks.AddonInfo{
|
||||||
AddonName: aws.String(spec.Name),
|
AddonName: aws.String(spec.Name),
|
||||||
Type: aws.String(spec.Type),
|
|
||||||
Owner: aws.String(addonOwner),
|
|
||||||
Publisher: aws.String(addonPublisher),
|
|
||||||
AddonVersions: versions,
|
AddonVersions: versions,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -383,30 +308,3 @@ func (s *EKSServiceImpl) addonBucket(ctx context.Context, accountID string) (jet
|
|||||||
}
|
}
|
||||||
return GetOrCreateAccountBucket(ctx, js, accountID)
|
return GetOrCreateAccountBucket(ctx, js, accountID)
|
||||||
}
|
}
|
||||||
|
|
||||||
// resolveAddonVersion maps a client version, in AWS form or bare upstream form,
|
|
||||||
// to the upstream form the record and the bundle lookup use. The bare form is
|
|
||||||
// more lenient than AWS, so each use is logged.
|
|
||||||
func resolveAddonVersion(ctx context.Context, spec addon.Spec, requested, cluster string) (string, error) {
|
|
||||||
upstream, ok := spec.ParseVersion(requested)
|
|
||||||
if !ok {
|
|
||||||
return "", awserrors.Errorf(awserrors.ErrorInvalidParameterValue,
|
|
||||||
"addonVersion %q is not supported for add-on %s; supported versions: %s",
|
|
||||||
requested, spec.Name, strings.Join(spec.AWSVersions(), ", "))
|
|
||||||
}
|
|
||||||
if requested == upstream {
|
|
||||||
slog.InfoContext(ctx, "EKS add-on version given in bare upstream form",
|
|
||||||
"addon", spec.Name, "cluster", cluster, "version", requested)
|
|
||||||
}
|
|
||||||
return upstream, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// awsAddonVersion returns a stored record's version in AWS form; an add-on the
|
|
||||||
// catalogue no longer lists keeps its stored string.
|
|
||||||
func awsAddonVersion(rec *addon.Record) string {
|
|
||||||
spec, ok := addon.Lookup(rec.AddonName)
|
|
||||||
if !ok {
|
|
||||||
return rec.AddonVersion
|
|
||||||
}
|
|
||||||
return spec.AWSVersion(rec.AddonVersion)
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -98,9 +98,7 @@ func TestCreateAddon_UnknownVersionRejected(t *testing.T) {
|
|||||||
ClusterName: aws.String("c1"), AddonName: aws.String(albController),
|
ClusterName: aws.String("c1"), AddonName: aws.String(albController),
|
||||||
AddonVersion: aws.String("9.9.9-nope"),
|
AddonVersion: aws.String("9.9.9-nope"),
|
||||||
}, testAccountID)
|
}, testAccountID)
|
||||||
code, ok := awserrors.ResolveErrorCode(err)
|
require.EqualError(t, err, awserrors.ErrorInvalidParameterValue)
|
||||||
require.True(t, ok)
|
|
||||||
assert.Equal(t, awserrors.ErrorInvalidParameterValue, code)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestCreateAddon_CreatesStagingAndDefaultsVersion(t *testing.T) {
|
func TestCreateAddon_CreatesStagingAndDefaultsVersion(t *testing.T) {
|
||||||
@@ -115,7 +113,7 @@ func TestCreateAddon_CreatesStagingAndDefaultsVersion(t *testing.T) {
|
|||||||
require.NotNil(t, out.Addon)
|
require.NotNil(t, out.Addon)
|
||||||
// Version defaults to catalog default; status starts CREATING (honest until
|
// Version defaults to catalog default; status starts CREATING (honest until
|
||||||
// VM-side delivery confirms ACTIVE).
|
// VM-side delivery confirms ACTIVE).
|
||||||
assert.Equal(t, spec.AWSVersion(spec.DefaultVersion), aws.StringValue(out.Addon.AddonVersion))
|
assert.Equal(t, spec.DefaultVersion, aws.StringValue(out.Addon.AddonVersion))
|
||||||
assert.Equal(t, string(addon.StatusCreating), aws.StringValue(out.Addon.Status))
|
assert.Equal(t, string(addon.StatusCreating), aws.StringValue(out.Addon.Status))
|
||||||
assert.Contains(t, aws.StringValue(out.Addon.AddonArn), ":addon/c1/"+albController)
|
assert.Contains(t, aws.StringValue(out.Addon.AddonArn), ":addon/c1/"+albController)
|
||||||
|
|
||||||
@@ -216,7 +214,7 @@ func TestUpdateAddon_ChangesVersionAndReinstalls(t *testing.T) {
|
|||||||
}, testAccountID)
|
}, testAccountID)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
assert.Equal(t, `{"replicaCount":2}`, aws.StringValue(desc.Addon.ConfigurationValues))
|
assert.Equal(t, `{"replicaCount":2}`, aws.StringValue(desc.Addon.ConfigurationValues))
|
||||||
assert.Equal(t, spec.AWSVersion(spec.DefaultVersion), aws.StringValue(desc.Addon.AddonVersion))
|
assert.Equal(t, spec.DefaultVersion, aws.StringValue(desc.Addon.AddonVersion))
|
||||||
assert.Equal(t, string(addon.StatusUpdating), aws.StringValue(desc.Addon.Status))
|
assert.Equal(t, string(addon.StatusUpdating), aws.StringValue(desc.Addon.Status))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -232,9 +230,7 @@ func TestUpdateAddon_UnknownVersionRejected(t *testing.T) {
|
|||||||
ClusterName: aws.String("c1"), AddonName: aws.String("argocd"),
|
ClusterName: aws.String("c1"), AddonName: aws.String("argocd"),
|
||||||
AddonVersion: aws.String("9.9.9-nope"),
|
AddonVersion: aws.String("9.9.9-nope"),
|
||||||
}, testAccountID)
|
}, testAccountID)
|
||||||
code, ok := awserrors.ResolveErrorCode(err)
|
require.EqualError(t, err, awserrors.ErrorInvalidParameterValue)
|
||||||
require.True(t, ok)
|
|
||||||
assert.Equal(t, awserrors.ErrorInvalidParameterValue, code)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestUpdateAddon_MissingIsNotFound(t *testing.T) {
|
func TestUpdateAddon_MissingIsNotFound(t *testing.T) {
|
||||||
|
|||||||
@@ -886,7 +886,7 @@ func (s *EKSServiceImpl) ensureNodeInstanceProfile(accountID, nodeRoleARN string
|
|||||||
return s.attachRoleToProfile(accountID, profileName, roleName,
|
return s.attachRoleToProfile(accountID, profileName, roleName,
|
||||||
aws.StringValue(out.InstanceProfile.Arn), len(out.InstanceProfile.Roles) > 0)
|
aws.StringValue(out.InstanceProfile.Arn), len(out.InstanceProfile.Roles) > 0)
|
||||||
}
|
}
|
||||||
if code, _ := awserrors.ResolveErrorCode(err); code != awserrors.ErrorIAMNoSuchEntity {
|
if err.Error() != awserrors.ErrorIAMNoSuchEntity {
|
||||||
return "", fmt.Errorf("get instance profile %q: %w", profileName, err)
|
return "", fmt.Errorf("get instance profile %q: %w", profileName, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -895,7 +895,7 @@ func (s *EKSServiceImpl) ensureNodeInstanceProfile(accountID, nodeRoleARN string
|
|||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
// A racing worker launch may have created it first; re-read and converge.
|
// A racing worker launch may have created it first; re-read and converge.
|
||||||
if code, _ := awserrors.ResolveErrorCode(err); code != awserrors.ErrorIAMEntityAlreadyExists {
|
if err.Error() != awserrors.ErrorIAMEntityAlreadyExists {
|
||||||
return "", fmt.Errorf("create instance profile %q: %w", profileName, err)
|
return "", fmt.Errorf("create instance profile %q: %w", profileName, err)
|
||||||
}
|
}
|
||||||
got, gerr := s.iamEnsurer().GetInstanceProfile(accountID, &iam.GetInstanceProfileInput{
|
got, gerr := s.iamEnsurer().GetInstanceProfile(accountID, &iam.GetInstanceProfileInput{
|
||||||
@@ -922,7 +922,7 @@ func (s *EKSServiceImpl) attachRoleToProfile(accountID, profileName, roleName, p
|
|||||||
InstanceProfileName: aws.String(profileName),
|
InstanceProfileName: aws.String(profileName),
|
||||||
RoleName: aws.String(roleName),
|
RoleName: aws.String(roleName),
|
||||||
})
|
})
|
||||||
if code, _ := awserrors.ResolveErrorCode(err); err != nil && code != awserrors.ErrorIAMLimitExceeded {
|
if err != nil && err.Error() != awserrors.ErrorIAMLimitExceeded {
|
||||||
return "", fmt.Errorf("add role %q to instance profile %q: %w", roleName, profileName, err)
|
return "", fmt.Errorf("add role %q to instance profile %q: %w", roleName, profileName, err)
|
||||||
}
|
}
|
||||||
return profileARN, nil
|
return profileARN, nil
|
||||||
|
|||||||
@@ -46,7 +46,7 @@ backend {{.Name}}
|
|||||||
{{- if .Redirect}}
|
{{- if .Redirect}}
|
||||||
http-request redirect {{if .Redirect.Scheme}}scheme {{.Redirect.Scheme}}{{else}}location {{.Redirect.Location}}{{end}} code {{.Redirect.Code}}
|
http-request redirect {{if .Redirect.Scheme}}scheme {{.Redirect.Scheme}}{{else}}location {{.Redirect.Location}}{{end}} code {{.Redirect.Code}}
|
||||||
{{- else if .FixedResponse}}
|
{{- else if .FixedResponse}}
|
||||||
http-request return status {{.FixedResponse.StatusCode}}{{if .FixedResponse.ContentType}} content-type {{squote .FixedResponse.ContentType}}{{end}}{{if .FixedResponse.Body}} string {{squote .FixedResponse.Body}}{{end}}
|
http-request return status {{.FixedResponse.StatusCode}}{{if .FixedResponse.ContentType}} content-type "{{.FixedResponse.ContentType}}"{{end}}{{if .FixedResponse.Body}} string "{{.FixedResponse.Body}}"{{end}}
|
||||||
{{- else}}
|
{{- else}}
|
||||||
balance roundrobin{{if .HealthCheckPath}}
|
balance roundrobin{{if .HealthCheckPath}}
|
||||||
option httpchk
|
option httpchk
|
||||||
@@ -65,14 +65,7 @@ backend {{.Name}}
|
|||||||
{{end}}
|
{{end}}
|
||||||
`
|
`
|
||||||
|
|
||||||
var haproxyHTTPTmpl = template.Must(template.New("haproxy").Funcs(template.FuncMap{"squote": haproxySingleQuote}).Parse(haproxyConfigTemplate))
|
var haproxyHTTPTmpl = template.Must(template.New("haproxy").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.
|
// HAProxyConfig holds the full configuration for rendering the HAProxy template.
|
||||||
type HAProxyConfig struct {
|
type HAProxyConfig struct {
|
||||||
@@ -573,10 +566,17 @@ func validHTTPStatusCode(s string) bool {
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
// validFixedResponseBody rejects bytes that would end the single-line HAProxy
|
// validFixedResponseBody rejects bytes that would terminate or inject into the HAProxy directive.
|
||||||
// directive the single-quoted body is rendered into.
|
|
||||||
func validFixedResponseBody(s string) bool {
|
func validFixedResponseBody(s string) bool {
|
||||||
return s != "" && !strings.ContainsAny(s, "\n\r\x00")
|
if s == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for _, r := range s {
|
||||||
|
if r == '\n' || r == '\r' || r == '"' || r == '\\' || r == '#' {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
// buildRedirect translates a RedirectAction into an HAProxy redirect directive.
|
// buildRedirect translates a RedirectAction into an HAProxy redirect directive.
|
||||||
|
|||||||
@@ -279,7 +279,7 @@ func TestGenerateHAProxyConfig_FixedResponseDefault(t *testing.T) {
|
|||||||
// Synthetic default backend named off the listener ARN, returning the fixed response.
|
// 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, "default_backend bkdefault_lst-616760baf6a3031c3")
|
||||||
assert.Contains(t, config, "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.
|
// Host-header rule still routes to the app backend.
|
||||||
assert.Contains(t, config, "use_backend bk_tg-app1 if")
|
assert.Contains(t, config, "use_backend bk_tg-app1 if")
|
||||||
assert.Contains(t, config, "10.42.0.9:8080")
|
assert.Contains(t, config, "10.42.0.9:8080")
|
||||||
@@ -309,59 +309,7 @@ func TestGenerateHAProxyConfig_FixedResponseRejectsUnsafeBody(t *testing.T) {
|
|||||||
|
|
||||||
assert.Contains(t, config, "http-request return status 200")
|
assert.Contains(t, config, "http-request return status 200")
|
||||||
assert.NotContains(t, config, "always_true")
|
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
|
// TestGenerateHAProxyConfig_RedirectSchemeDefault renders the canonical
|
||||||
|
|||||||
@@ -17,8 +17,6 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
"unicode"
|
|
||||||
"unicode/utf8"
|
|
||||||
|
|
||||||
"github.com/aws/aws-sdk-go/aws"
|
"github.com/aws/aws-sdk-go/aws"
|
||||||
"github.com/aws/aws-sdk-go/service/ec2"
|
"github.com/aws/aws-sdk-go/service/ec2"
|
||||||
@@ -2703,9 +2701,8 @@ func listenerActionFromSDK(a *elbv2.Action) ListenerAction {
|
|||||||
return action
|
return action
|
||||||
}
|
}
|
||||||
|
|
||||||
// validateListenerAction enforces the per-type action contract. Redirect and
|
// validateListenerAction enforces the per-type action contract. Redirect fields
|
||||||
// fixed-response fields are validated here so invalid input fails at the API,
|
// are validated here so invalid input fails at the API, not at render time.
|
||||||
// not at render time.
|
|
||||||
func validateListenerAction(a ListenerAction) error {
|
func validateListenerAction(a ListenerAction) error {
|
||||||
if a.Type == ActionTypeRedirect {
|
if a.Type == ActionTypeRedirect {
|
||||||
if a.Redirect == nil {
|
if a.Redirect == nil {
|
||||||
@@ -2713,39 +2710,6 @@ func validateListenerAction(a ListenerAction) error {
|
|||||||
}
|
}
|
||||||
return validateRedirectAction(a.Redirect)
|
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
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -591,8 +591,8 @@ func (s *ELBv2ServiceImpl) validateAndConvertRuleActions(ctx context.Context, in
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
case ActionTypeFixedResponse:
|
case ActionTypeFixedResponse:
|
||||||
if err := validateFixedResponseAction(action.FixedResponse); err != nil {
|
if action.FixedResponse == nil {
|
||||||
return nil, err
|
return nil, errors.New(awserrors.ErrorMissingParameter)
|
||||||
}
|
}
|
||||||
default:
|
default:
|
||||||
return nil, errors.New(awserrors.ErrorELBv2InvalidConfigurationRequest)
|
return nil, errors.New(awserrors.ErrorELBv2InvalidConfigurationRequest)
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ import (
|
|||||||
|
|
||||||
"github.com/aws/aws-sdk-go/aws"
|
"github.com/aws/aws-sdk-go/aws"
|
||||||
"github.com/aws/aws-sdk-go/service/elbv2"
|
"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/assert"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
)
|
)
|
||||||
@@ -661,53 +660,3 @@ func sortByPriority(rs []*RuleRecord) []*RuleRecord {
|
|||||||
}
|
}
|
||||||
return out
|
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)
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -48,7 +48,6 @@ var iamScopes = map[string]resourceScope{
|
|||||||
"CreateUser": createScope(arn.IAMUser, "UserName"),
|
"CreateUser": createScope(arn.IAMUser, "UserName"),
|
||||||
"GetUser": existingScope(arn.IAMUser, "UserName"),
|
"GetUser": existingScope(arn.IAMUser, "UserName"),
|
||||||
"DeleteUser": existingScope(arn.IAMUser, "UserName"),
|
"DeleteUser": existingScope(arn.IAMUser, "UserName"),
|
||||||
"UpdateUser": existingScope(arn.IAMUser, "UserName"),
|
|
||||||
"CreateAccessKey": existingScope(arn.IAMUser, "UserName"),
|
"CreateAccessKey": existingScope(arn.IAMUser, "UserName"),
|
||||||
"ListAccessKeys": existingScope(arn.IAMUser, "UserName"),
|
"ListAccessKeys": existingScope(arn.IAMUser, "UserName"),
|
||||||
"DeleteAccessKey": existingScope(arn.IAMUser, "UserName"),
|
"DeleteAccessKey": existingScope(arn.IAMUser, "UserName"),
|
||||||
@@ -285,7 +284,7 @@ func lookupResult(t lookupTarget, resource string, err error) (string, error) {
|
|||||||
}
|
}
|
||||||
return resource, nil
|
return resource, nil
|
||||||
}
|
}
|
||||||
if code, _ := awserrors.ResolveErrorCode(err); code == awserrors.ErrorIAMNoSuchEntity {
|
if err.Error() == awserrors.ErrorIAMNoSuchEntity {
|
||||||
// Authorization widens to "*", so a Deny scoped to the real target
|
// Authorization widens to "*", so a Deny scoped to the real target
|
||||||
// will not fire. Without this line that widening leaves no trace.
|
// will not fire. Without this line that widening leaves no trace.
|
||||||
slog.Warn("IAM authz: target not found, authorizing account-wide",
|
slog.Warn("IAM authz: target not found, authorizing account-wide",
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ func (s *resolverService) CanonicalResourceARN(_ string, kind arn.IAMResourceTyp
|
|||||||
return resource, nil
|
return resource, nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return "", awserrors.Errorf(awserrors.ErrorIAMNoSuchEntity, "The %s with name %s cannot be found.", kind, name)
|
return "", errors.New(awserrors.ErrorIAMNoSuchEntity)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *resolverService) LookupAccessKey(_ string) (*iamdomain.AccessKey, error) {
|
func (s *resolverService) LookupAccessKey(_ string) (*iamdomain.AccessKey, error) {
|
||||||
|
|||||||
@@ -96,9 +96,6 @@ var actions = map[string]action{
|
|||||||
"DeleteUser": handler(func(accountID string, input *iam.DeleteUserInput, d Deps) (any, error) {
|
"DeleteUser": handler(func(accountID string, input *iam.DeleteUserInput, d Deps) (any, error) {
|
||||||
return DeleteUser(accountID, input, d.IAM)
|
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) {
|
"CreateAccessKey": handler(func(accountID string, input *iam.CreateAccessKeyInput, d Deps) (any, error) {
|
||||||
return CreateAccessKey(accountID, input, d.IAM)
|
return CreateAccessKey(accountID, input, d.IAM)
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -26,7 +26,6 @@ type regService struct {
|
|||||||
|
|
||||||
getUserName string
|
getUserName string
|
||||||
getUserCalls int
|
getUserCalls int
|
||||||
updateUser *iam.UpdateUserInput
|
|
||||||
addRoleCalls int
|
addRoleCalls int
|
||||||
canonicalARN string
|
canonicalARN string
|
||||||
canonicalErr error
|
canonicalErr error
|
||||||
@@ -42,11 +41,6 @@ func (s *regService) GetUser(_ string, in *iam.GetUserInput) (*iam.GetUserOutput
|
|||||||
return &iam.GetUserOutput{}, nil
|
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) {
|
func (s *regService) AddRoleToInstanceProfile(_ string, _ *iam.AddRoleToInstanceProfileInput) (*iam.AddRoleToInstanceProfileOutput, error) {
|
||||||
s.addRoleCalls++
|
s.addRoleCalls++
|
||||||
return &iam.AddRoleToInstanceProfileOutput{}, nil
|
return &iam.AddRoleToInstanceProfileOutput{}, nil
|
||||||
@@ -274,23 +268,6 @@ func TestRegistration_AddRoleToInstanceProfilePassRole(t *testing.T) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestRegistration_UpdateUser(t *testing.T) {
|
|
||||||
svc := ®Service{}
|
|
||||||
w, calls, err := invoke(t, Deps{IAM: svc}, testAccountID, dispatch.Actor{}, nil, url.Values{
|
|
||||||
"Action": {"UpdateUser"}, "UserName": {"alice"}, "NewUserName": {"alicia"}, "NewPath": {"/eng/"},
|
|
||||||
})
|
|
||||||
require.NoError(t, err)
|
|
||||||
require.Len(t, calls, 1)
|
|
||||||
assert.Equal(t, []string{"arn:aws:iam::" + testAccountID + ":user/alice"}, calls[0].resources)
|
|
||||||
assert.Contains(t, w.Body.String(), "UpdateUserResponse")
|
|
||||||
require.NotNil(t, svc.updateUser)
|
|
||||||
assert.Equal(t, "alicia", *svc.updateUser.NewUserName)
|
|
||||||
assert.Equal(t, "/eng/", *svc.updateUser.NewPath)
|
|
||||||
|
|
||||||
_, _, err = invoke(t, Deps{IAM: svc}, testAccountID, dispatch.Actor{}, nil, url.Values{"Action": {"UpdateUser"}})
|
|
||||||
assert.True(t, awserrors.IsErrorCode(err, awserrors.ErrorMissingParameter), "got %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestActionNames_SortedAndInventory(t *testing.T) {
|
func TestActionNames_SortedAndInventory(t *testing.T) {
|
||||||
names := ActionNames()
|
names := ActionNames()
|
||||||
require.NotEmpty(t, names)
|
require.NotEmpty(t, names)
|
||||||
|
|||||||
@@ -78,15 +78,6 @@ func DeleteUser(accountID string, input *iam.DeleteUserInput, svc iamdomain.IAMS
|
|||||||
return svc.DeleteUser(accountID, input)
|
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
|
// PutUserPolicy implements the IAM PutUserPolicy action, adding or replacing a user inline
|
||||||
// policy. It returns MissingParameter when UserName, PolicyName or PolicyDocument is absent, then
|
// policy. It returns MissingParameter when UserName, PolicyName or PolicyDocument is absent, then
|
||||||
// calls the IAM service for accountID.
|
// calls the IAM service for accountID.
|
||||||
|
|||||||
@@ -1,186 +0,0 @@
|
|||||||
package iam
|
|
||||||
|
|
||||||
import (
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"github.com/aws/aws-sdk-go/aws"
|
|
||||||
"github.com/aws/aws-sdk-go/service/iam"
|
|
||||||
"github.com/mulgadc/spinifex/spinifex/foundation/aws/errors"
|
|
||||||
"github.com/stretchr/testify/assert"
|
|
||||||
"github.com/stretchr/testify/require"
|
|
||||||
)
|
|
||||||
|
|
||||||
// TestEntityErrorMessages pins the per-case text AWS returns for NoSuchEntity,
|
|
||||||
// EntityAlreadyExists, DeleteConflict and LimitExceeded, as recorded against
|
|
||||||
// real IAM, so a client sees which entity is missing or in conflict.
|
|
||||||
func TestEntityErrorMessages(t *testing.T) {
|
|
||||||
svc := setupTestIAMService(t)
|
|
||||||
acct := testAccountID
|
|
||||||
|
|
||||||
createTestUser(t, svc, "u1")
|
|
||||||
createTestRole(t, svc, "r1")
|
|
||||||
createTestRole(t, svc, "r2")
|
|
||||||
createTestGroup(t, svc, "g1")
|
|
||||||
createTestInstanceProfile(t, svc, "ip1")
|
|
||||||
policy := createTestPolicy(t, svc, "p1")
|
|
||||||
policyARN := aws.StringValue(policy.Arn)
|
|
||||||
missingPolicyARN := "arn:aws:iam::" + acct + ":policy/p1-nope"
|
|
||||||
_, err := svc.CreateOpenIDConnectProvider(acct, &iam.CreateOpenIDConnectProviderInput{
|
|
||||||
Url: aws.String(testIssuer), ClientIDList: aws.StringSlice([]string{"sts.amazonaws.com"}),
|
|
||||||
})
|
|
||||||
require.NoError(t, err)
|
|
||||||
missingOIDCARN := "arn:aws:iam::" + acct + ":oidc-provider/oidc.nope.example.com"
|
|
||||||
|
|
||||||
// Conflict fixtures: a user holding a key, a role with a managed policy, a
|
|
||||||
// role and profile linked to each other, and a group with a member.
|
|
||||||
createTestUser(t, svc, "u-key")
|
|
||||||
_, err = svc.CreateAccessKey(acct, &iam.CreateAccessKeyInput{UserName: aws.String("u-key")})
|
|
||||||
require.NoError(t, err)
|
|
||||||
createTestRole(t, svc, "r-pol")
|
|
||||||
_, err = svc.AttachRolePolicy(acct, &iam.AttachRolePolicyInput{RoleName: aws.String("r-pol"), PolicyArn: aws.String(policyARN)})
|
|
||||||
require.NoError(t, err)
|
|
||||||
createTestRole(t, svc, "r-ip")
|
|
||||||
createTestInstanceProfile(t, svc, "ip-r")
|
|
||||||
_, err = svc.AddRoleToInstanceProfile(acct, &iam.AddRoleToInstanceProfileInput{InstanceProfileName: aws.String("ip-r"), RoleName: aws.String("r-ip")})
|
|
||||||
require.NoError(t, err)
|
|
||||||
createTestGroup(t, svc, "g-mem")
|
|
||||||
_, err = svc.AddUserToGroup(acct, &iam.AddUserToGroupInput{GroupName: aws.String("g-mem"), UserName: aws.String("u1")})
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
cases := []struct {
|
|
||||||
name string
|
|
||||||
call func() error
|
|
||||||
wantCode string
|
|
||||||
wantMsg string
|
|
||||||
}{
|
|
||||||
{"GetUser missing", func() error {
|
|
||||||
_, err := svc.GetUser(acct, &iam.GetUserInput{UserName: aws.String("nope")})
|
|
||||||
return err
|
|
||||||
}, awserrors.ErrorIAMNoSuchEntity, "The user with name nope cannot be found."},
|
|
||||||
{"TagUser missing", func() error {
|
|
||||||
_, err := svc.TagUser(acct, &iam.TagUserInput{UserName: aws.String("nope"), Tags: []*iam.Tag{{Key: aws.String("k"), Value: aws.String("v")}}})
|
|
||||||
return err
|
|
||||||
}, awserrors.ErrorIAMNoSuchEntity, "The user with name nope cannot be found."},
|
|
||||||
{"AddUserToGroup missing user", func() error {
|
|
||||||
_, err := svc.AddUserToGroup(acct, &iam.AddUserToGroupInput{GroupName: aws.String("g1"), UserName: aws.String("nope")})
|
|
||||||
return err
|
|
||||||
}, awserrors.ErrorIAMNoSuchEntity, "The user with name nope cannot be found."},
|
|
||||||
{"GetRole missing", func() error {
|
|
||||||
_, err := svc.GetRole(acct, &iam.GetRoleInput{RoleName: aws.String("nope")})
|
|
||||||
return err
|
|
||||||
}, awserrors.ErrorIAMNoSuchEntity, "The role with name nope cannot be found."},
|
|
||||||
{"TagRole missing", func() error {
|
|
||||||
_, err := svc.TagRole(acct, &iam.TagRoleInput{RoleName: aws.String("nope"), Tags: []*iam.Tag{{Key: aws.String("k"), Value: aws.String("v")}}})
|
|
||||||
return err
|
|
||||||
}, awserrors.ErrorIAMNoSuchEntity, "The role with name nope cannot be found."},
|
|
||||||
{"DeleteGroup missing", func() error {
|
|
||||||
_, err := svc.DeleteGroup(acct, &iam.DeleteGroupInput{GroupName: aws.String("nope")})
|
|
||||||
return err
|
|
||||||
}, awserrors.ErrorIAMNoSuchEntity, "The group with name nope cannot be found."},
|
|
||||||
{"GetPolicy missing", func() error {
|
|
||||||
_, err := svc.GetPolicy(acct, &iam.GetPolicyInput{PolicyArn: aws.String(missingPolicyARN)})
|
|
||||||
return err
|
|
||||||
}, awserrors.ErrorIAMNoSuchEntity, "Policy " + missingPolicyARN + " was not found."},
|
|
||||||
{"DeletePolicy missing", func() error {
|
|
||||||
_, err := svc.DeletePolicy(acct, &iam.DeletePolicyInput{PolicyArn: aws.String(missingPolicyARN)})
|
|
||||||
return err
|
|
||||||
}, awserrors.ErrorIAMNoSuchEntity, "Policy " + missingPolicyARN + " was not found."},
|
|
||||||
{"DetachUserPolicy not attached", func() error {
|
|
||||||
_, err := svc.DetachUserPolicy(acct, &iam.DetachUserPolicyInput{UserName: aws.String("u1"), PolicyArn: aws.String(policyARN)})
|
|
||||||
return err
|
|
||||||
}, awserrors.ErrorIAMNoSuchEntity, "Policy " + policyARN + " was not found."},
|
|
||||||
{"AttachRolePolicy missing policy", func() error {
|
|
||||||
_, err := svc.AttachRolePolicy(acct, &iam.AttachRolePolicyInput{RoleName: aws.String("r1"), PolicyArn: aws.String(missingPolicyARN)})
|
|
||||||
return err
|
|
||||||
}, awserrors.ErrorIAMNoSuchEntity, "Policy " + missingPolicyARN + " does not exist or is not attachable."},
|
|
||||||
{"GetInstanceProfile missing", func() error {
|
|
||||||
_, err := svc.GetInstanceProfile(acct, &iam.GetInstanceProfileInput{InstanceProfileName: aws.String("nope")})
|
|
||||||
return err
|
|
||||||
}, awserrors.ErrorIAMNoSuchEntity, "Instance Profile nope cannot be found."},
|
|
||||||
{"RemoveRoleFromInstanceProfile not in profile", func() error {
|
|
||||||
_, err := svc.RemoveRoleFromInstanceProfile(acct, &iam.RemoveRoleFromInstanceProfileInput{InstanceProfileName: aws.String("ip1"), RoleName: aws.String("r2")})
|
|
||||||
return err
|
|
||||||
}, awserrors.ErrorIAMNoSuchEntity, "Role r2 in Instance Profile ip1 cannot be found."},
|
|
||||||
{"GetOpenIDConnectProvider missing", func() error {
|
|
||||||
_, err := svc.GetOpenIDConnectProvider(acct, &iam.GetOpenIDConnectProviderInput{OpenIDConnectProviderArn: aws.String(missingOIDCARN)})
|
|
||||||
return err
|
|
||||||
}, awserrors.ErrorIAMNoSuchEntity, "OpenIDConnect Provider not found for arn " + missingOIDCARN},
|
|
||||||
{"GetUserPolicy missing", func() error {
|
|
||||||
_, err := svc.GetUserPolicy(acct, &iam.GetUserPolicyInput{UserName: aws.String("u1"), PolicyName: aws.String("inl")})
|
|
||||||
return err
|
|
||||||
}, awserrors.ErrorIAMNoSuchEntity, "The user policy with name inl cannot be found."},
|
|
||||||
{"DeleteRolePolicy missing", func() error {
|
|
||||||
_, err := svc.DeleteRolePolicy(acct, &iam.DeleteRolePolicyInput{RoleName: aws.String("r1"), PolicyName: aws.String("rinl")})
|
|
||||||
return err
|
|
||||||
}, awserrors.ErrorIAMNoSuchEntity, "The role policy with name rinl cannot be found."},
|
|
||||||
{"DeleteAccessKey missing", func() error {
|
|
||||||
_, err := svc.DeleteAccessKey(acct, &iam.DeleteAccessKeyInput{UserName: aws.String("u1"), AccessKeyId: aws.String("AKIANOPE")})
|
|
||||||
return err
|
|
||||||
}, awserrors.ErrorIAMNoSuchEntity, "The Access Key with id AKIANOPE cannot be found."},
|
|
||||||
|
|
||||||
{"CreateUser duplicate", func() error {
|
|
||||||
_, err := svc.CreateUser(acct, &iam.CreateUserInput{UserName: aws.String("u1")})
|
|
||||||
return err
|
|
||||||
}, awserrors.ErrorIAMEntityAlreadyExists, "User with name u1 already exists."},
|
|
||||||
{"CreateRole duplicate", func() error {
|
|
||||||
_, err := svc.CreateRole(acct, &iam.CreateRoleInput{RoleName: aws.String("r1"), AssumeRolePolicyDocument: aws.String(validTrustPolicy())})
|
|
||||||
return err
|
|
||||||
}, awserrors.ErrorIAMEntityAlreadyExists, "Role with name r1 already exists."},
|
|
||||||
{"CreateGroup duplicate", func() error {
|
|
||||||
_, err := svc.CreateGroup(acct, &iam.CreateGroupInput{GroupName: aws.String("g1")})
|
|
||||||
return err
|
|
||||||
}, awserrors.ErrorIAMEntityAlreadyExists, "Group with name g1 already exists."},
|
|
||||||
{"CreatePolicy duplicate", func() error {
|
|
||||||
_, err := svc.CreatePolicy(acct, &iam.CreatePolicyInput{PolicyName: aws.String("p1"), PolicyDocument: aws.String(validPolicyDocument())})
|
|
||||||
return err
|
|
||||||
}, awserrors.ErrorIAMEntityAlreadyExists, "A policy called p1 already exists. Duplicate names are not allowed."},
|
|
||||||
{"CreateInstanceProfile duplicate", func() error {
|
|
||||||
_, err := svc.CreateInstanceProfile(acct, &iam.CreateInstanceProfileInput{InstanceProfileName: aws.String("ip1")})
|
|
||||||
return err
|
|
||||||
}, awserrors.ErrorIAMEntityAlreadyExists, "Instance Profile ip1 already exists."},
|
|
||||||
{"CreateOpenIDConnectProvider duplicate", func() error {
|
|
||||||
_, err := svc.CreateOpenIDConnectProvider(acct, &iam.CreateOpenIDConnectProviderInput{
|
|
||||||
Url: aws.String(testIssuer), ClientIDList: aws.StringSlice([]string{"sts.amazonaws.com"}),
|
|
||||||
})
|
|
||||||
return err
|
|
||||||
}, awserrors.ErrorIAMEntityAlreadyExists, "Provider with url " + testIssuer + " already exists."},
|
|
||||||
|
|
||||||
{"DeleteUser with access key", func() error {
|
|
||||||
_, err := svc.DeleteUser(acct, &iam.DeleteUserInput{UserName: aws.String("u-key")})
|
|
||||||
return err
|
|
||||||
}, awserrors.ErrorIAMDeleteConflict, "Cannot delete entity, must delete access keys first."},
|
|
||||||
{"DeleteRole with attached policy", func() error {
|
|
||||||
_, err := svc.DeleteRole(acct, &iam.DeleteRoleInput{RoleName: aws.String("r-pol")})
|
|
||||||
return err
|
|
||||||
}, awserrors.ErrorIAMDeleteConflict, "Cannot delete entity, must detach all policies first."},
|
|
||||||
{"DeleteRole in instance profile", func() error {
|
|
||||||
_, err := svc.DeleteRole(acct, &iam.DeleteRoleInput{RoleName: aws.String("r-ip")})
|
|
||||||
return err
|
|
||||||
}, awserrors.ErrorIAMDeleteConflict, "Cannot delete entity, must remove roles from instance profile first."},
|
|
||||||
{"DeleteInstanceProfile with role", func() error {
|
|
||||||
_, err := svc.DeleteInstanceProfile(acct, &iam.DeleteInstanceProfileInput{InstanceProfileName: aws.String("ip-r")})
|
|
||||||
return err
|
|
||||||
}, awserrors.ErrorIAMDeleteConflict, "Cannot delete entity, must remove roles from instance profile first."},
|
|
||||||
{"DeleteGroup with member", func() error {
|
|
||||||
_, err := svc.DeleteGroup(acct, &iam.DeleteGroupInput{GroupName: aws.String("g-mem")})
|
|
||||||
return err
|
|
||||||
}, awserrors.ErrorIAMDeleteConflict, "Cannot delete entity, must remove users from group first."},
|
|
||||||
{"DeletePolicy attached", func() error {
|
|
||||||
_, err := svc.DeletePolicy(acct, &iam.DeletePolicyInput{PolicyArn: aws.String(policyARN)})
|
|
||||||
return err
|
|
||||||
}, awserrors.ErrorIAMDeleteConflict, "Cannot delete a policy attached to entities."},
|
|
||||||
|
|
||||||
{"AddRoleToInstanceProfile second role", func() error {
|
|
||||||
_, err := svc.AddRoleToInstanceProfile(acct, &iam.AddRoleToInstanceProfileInput{InstanceProfileName: aws.String("ip-r"), RoleName: aws.String("r2")})
|
|
||||||
return err
|
|
||||||
}, awserrors.ErrorIAMLimitExceeded, "Cannot exceed quota for InstanceSessionsPerInstanceProfile: 1"},
|
|
||||||
}
|
|
||||||
for _, tc := range cases {
|
|
||||||
t.Run(tc.name, func(t *testing.T) {
|
|
||||||
code, msg, ok := awserrors.ResolveErrorDetail(tc.call())
|
|
||||||
require.True(t, ok, "error carries no AWS code")
|
|
||||||
assert.Equal(t, tc.wantCode, code)
|
|
||||||
assert.Equal(t, tc.wantMsg, msg)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -65,7 +65,7 @@ func (s *IAMServiceImpl) CreateGroup(accountID string, input *iam.CreateGroupInp
|
|||||||
kvKey := accountID + "." + groupName
|
kvKey := accountID + "." + groupName
|
||||||
if _, err := s.groupsBucket.Create(ctx, kvKey, data); err != nil {
|
if _, err := s.groupsBucket.Create(ctx, kvKey, data); err != nil {
|
||||||
if errors.Is(err, jetstream.ErrKeyExists) {
|
if errors.Is(err, jetstream.ErrKeyExists) {
|
||||||
return nil, awserrors.Errorf(awserrors.ErrorIAMEntityAlreadyExists, "Group with name %s already exists.", groupName)
|
return nil, errors.New(awserrors.ErrorIAMEntityAlreadyExists)
|
||||||
}
|
}
|
||||||
return nil, fmt.Errorf("store group: %w", err)
|
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 {
|
if len(group.AttachedPolicies) > 0 {
|
||||||
return nil, errDetachPoliciesFirst
|
return nil, errors.New(awserrors.ErrorIAMDeleteConflict)
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(group.InlinePolicies) > 0 {
|
if len(group.InlinePolicies) > 0 {
|
||||||
return nil, errDeletePoliciesFirst
|
return nil, errors.New(awserrors.ErrorIAMDeleteConflict)
|
||||||
}
|
}
|
||||||
|
|
||||||
members, err := s.findGroupMembers(ctx, accountID, groupName)
|
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)
|
return nil, fmt.Errorf("check group members: %w", err)
|
||||||
}
|
}
|
||||||
if len(members) > 0 {
|
if len(members) > 0 {
|
||||||
return nil, awserrors.Errorf(awserrors.ErrorIAMDeleteConflict, "Cannot delete entity, must remove users from group first.")
|
return nil, errors.New(awserrors.ErrorIAMDeleteConflict)
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := s.groupsBucket.Delete(ctx, accountID+"."+groupName); err != nil {
|
if err := s.groupsBucket.Delete(ctx, accountID+"."+groupName); err != nil {
|
||||||
@@ -234,28 +234,19 @@ func (s *IAMServiceImpl) RemoveUserFromGroup(accountID string, input *iam.Remove
|
|||||||
groupName := *input.GroupName
|
groupName := *input.GroupName
|
||||||
userName := *input.UserName
|
userName := *input.UserName
|
||||||
|
|
||||||
// Remove the membership reference without fetching the group, so a dangling
|
// Operate purely on the membership reference so a dangling pointer to an
|
||||||
// pointer to an already-deleted group is still cleanable.
|
// already-deleted group is still cleanable; never fetch the group here.
|
||||||
removed := false
|
|
||||||
err := s.updateUserCAS(ctx, accountID, userName, func(u *User) (bool, error) {
|
err := s.updateUserCAS(ctx, accountID, userName, func(u *User) (bool, error) {
|
||||||
idx := slices.Index(u.Groups, groupName)
|
idx := slices.Index(u.Groups, groupName)
|
||||||
if idx < 0 {
|
if idx < 0 {
|
||||||
return false, nil
|
return false, errors.New(awserrors.ErrorIAMNoSuchEntity)
|
||||||
}
|
}
|
||||||
u.Groups = slices.Delete(u.Groups, idx, idx+1)
|
u.Groups = slices.Delete(u.Groups, idx, idx+1)
|
||||||
removed = true
|
|
||||||
return true, nil
|
return true, nil
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
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)
|
slog.Info("IAM user removed from group", "accountID", accountID, "userName", userName, "groupName", groupName)
|
||||||
return &iam.RemoveUserFromGroupOutput{}, nil
|
return &iam.RemoveUserFromGroupOutput{}, nil
|
||||||
@@ -274,7 +265,7 @@ func (s *IAMServiceImpl) ListGroupsForUser(accountID string, input *iam.ListGrou
|
|||||||
for _, name := range user.Groups {
|
for _, name := range user.Groups {
|
||||||
group, err := s.getGroup(ctx, accountID, name)
|
group, err := s.getGroup(ctx, accountID, name)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if code, _ := awserrors.ResolveErrorCode(err); code == awserrors.ErrorIAMNoSuchEntity {
|
if err.Error() == awserrors.ErrorIAMNoSuchEntity {
|
||||||
// A dangling pointer to a vanished group is inert; skip it.
|
// A dangling pointer to a vanished group is inert; skip it.
|
||||||
// Mirrors GetUserPolicies — transient/corrupt errors fail closed.
|
// Mirrors GetUserPolicies — transient/corrupt errors fail closed.
|
||||||
slog.Warn("ListGroupsForUser: member references missing group; skipping",
|
slog.Warn("ListGroupsForUser: member references missing group; skipping",
|
||||||
@@ -303,7 +294,7 @@ func (s *IAMServiceImpl) AttachGroupPolicy(accountID string, input *iam.AttachGr
|
|||||||
|
|
||||||
// AWS-managed ARNs are stored opaquely (like roles); customer-managed ARNs must exist.
|
// AWS-managed ARNs are stored opaquely (like roles); customer-managed ARNs must exist.
|
||||||
if !iamarn.IsAWSManagedPolicyARN(policyARN) {
|
if !iamarn.IsAWSManagedPolicyARN(policyARN) {
|
||||||
if err := s.attachablePolicy(ctx, accountID, policyARN); err != nil {
|
if _, err := s.getPolicyByARN(ctx, accountID, policyARN); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -331,7 +322,7 @@ func (s *IAMServiceImpl) DetachGroupPolicy(accountID string, input *iam.DetachGr
|
|||||||
err := s.updateGroupCAS(ctx, accountID, groupName, func(g *Group) (bool, error) {
|
err := s.updateGroupCAS(ctx, accountID, groupName, func(g *Group) (bool, error) {
|
||||||
idx := slices.Index(g.AttachedPolicies, policyARN)
|
idx := slices.Index(g.AttachedPolicies, policyARN)
|
||||||
if idx < 0 {
|
if idx < 0 {
|
||||||
return false, policyNotFound(policyARN)
|
return false, errors.New(awserrors.ErrorIAMNoSuchEntity)
|
||||||
}
|
}
|
||||||
g.AttachedPolicies = slices.Delete(g.AttachedPolicies, idx, idx+1)
|
g.AttachedPolicies = slices.Delete(g.AttachedPolicies, idx, idx+1)
|
||||||
return true, nil
|
return true, nil
|
||||||
@@ -417,7 +408,7 @@ func (s *IAMServiceImpl) GetGroupPolicy(accountID string, input *iam.GetGroupPol
|
|||||||
|
|
||||||
doc, ok := group.InlinePolicies[policyName]
|
doc, ok := group.InlinePolicies[policyName]
|
||||||
if !ok {
|
if !ok {
|
||||||
return nil, noSuchEntity("group policy", policyName)
|
return nil, errors.New(awserrors.ErrorIAMNoSuchEntity)
|
||||||
}
|
}
|
||||||
|
|
||||||
return &iam.GetGroupPolicyOutput{
|
return &iam.GetGroupPolicyOutput{
|
||||||
@@ -436,7 +427,7 @@ func (s *IAMServiceImpl) DeleteGroupPolicy(accountID string, input *iam.DeleteGr
|
|||||||
|
|
||||||
err := s.updateGroupCAS(ctx, accountID, groupName, func(g *Group) (bool, error) {
|
err := s.updateGroupCAS(ctx, accountID, groupName, func(g *Group) (bool, error) {
|
||||||
if _, ok := g.InlinePolicies[policyName]; !ok {
|
if _, ok := g.InlinePolicies[policyName]; !ok {
|
||||||
return false, noSuchEntity("group policy", policyName)
|
return false, errors.New(awserrors.ErrorIAMNoSuchEntity)
|
||||||
}
|
}
|
||||||
delete(g.InlinePolicies, policyName)
|
delete(g.InlinePolicies, policyName)
|
||||||
return true, nil
|
return true, nil
|
||||||
@@ -479,7 +470,7 @@ func (s *IAMServiceImpl) getGroup(ctx context.Context, accountID, groupName stri
|
|||||||
entry, err := s.groupsBucket.Get(ctx, accountID+"."+groupName)
|
entry, err := s.groupsBucket.Get(ctx, accountID+"."+groupName)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, jetstream.ErrKeyNotFound) {
|
if errors.Is(err, jetstream.ErrKeyNotFound) {
|
||||||
return nil, noSuchEntity("group", groupName)
|
return nil, errors.New(awserrors.ErrorIAMNoSuchEntity)
|
||||||
}
|
}
|
||||||
return nil, fmt.Errorf("get group: %w", err)
|
return nil, fmt.Errorf("get group: %w", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -409,18 +409,6 @@ func TestRemoveUserFromGroup_NotMember(t *testing.T) {
|
|||||||
GroupName: aws.String("team"),
|
GroupName: aws.String("team"),
|
||||||
UserName: aws.String("erin"),
|
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.Error(t, err)
|
||||||
assert.Contains(t, err.Error(), awserrors.ErrorIAMNoSuchEntity)
|
assert.Contains(t, err.Error(), awserrors.ErrorIAMNoSuchEntity)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -66,7 +66,7 @@ func (s *IAMServiceImpl) CreateInstanceProfile(accountID string, input *iam.Crea
|
|||||||
|
|
||||||
if _, err := s.instanceProfilesBucket.Create(ctx, accountID+"."+profileName, data); err != nil {
|
if _, err := s.instanceProfilesBucket.Create(ctx, accountID+"."+profileName, data); err != nil {
|
||||||
if errors.Is(err, jetstream.ErrKeyExists) {
|
if errors.Is(err, jetstream.ErrKeyExists) {
|
||||||
return nil, awserrors.Errorf(awserrors.ErrorIAMEntityAlreadyExists, "Instance Profile %s already exists.", profileName)
|
return nil, errors.New(awserrors.ErrorIAMEntityAlreadyExists)
|
||||||
}
|
}
|
||||||
return nil, fmt.Errorf("store instance profile: %w", err)
|
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),
|
_, err := kvutil.DeleteIf(ctx, s.instanceProfilesBucket, accountID+"."+profileName, s.instanceProfileCASConfig(accountID, profileName),
|
||||||
func(p *InstanceProfile) error {
|
func(p *InstanceProfile) error {
|
||||||
if p.RoleName != "" {
|
if p.RoleName != "" {
|
||||||
return awserrors.Errorf(awserrors.ErrorIAMDeleteConflict, "Cannot delete entity, must remove roles from instance profile first.")
|
return errors.New(awserrors.ErrorIAMDeleteConflict)
|
||||||
}
|
}
|
||||||
return nil
|
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) {
|
err := s.updateInstanceProfileCAS(ctx, accountID, profileName, func(p *InstanceProfile) (bool, error) {
|
||||||
if p.RoleName != "" {
|
if p.RoleName != "" {
|
||||||
return false, awserrors.Errorf(awserrors.ErrorIAMLimitExceeded, "Cannot exceed quota for InstanceSessionsPerInstanceProfile: 1")
|
return false, errors.New(awserrors.ErrorIAMLimitExceeded)
|
||||||
}
|
}
|
||||||
p.RoleName = roleName
|
p.RoleName = roleName
|
||||||
return true, nil
|
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) {
|
err := s.updateInstanceProfileCAS(ctx, accountID, profileName, func(p *InstanceProfile) (bool, error) {
|
||||||
if p.RoleName == "" || p.RoleName != roleName {
|
if p.RoleName == "" || p.RoleName != roleName {
|
||||||
return false, awserrors.Errorf(awserrors.ErrorIAMNoSuchEntity, "Role %s in Instance Profile %s cannot be found.", roleName, profileName)
|
return false, errors.New(awserrors.ErrorIAMNoSuchEntity)
|
||||||
}
|
}
|
||||||
p.RoleName = ""
|
p.RoleName = ""
|
||||||
return true, nil
|
return true, nil
|
||||||
@@ -357,16 +357,11 @@ func (s *IAMServiceImpl) ListInstanceProfileTags(accountID string, input *iam.Li
|
|||||||
}, nil
|
}, 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) {
|
func (s *IAMServiceImpl) getInstanceProfile(ctx context.Context, accountID, profileName string) (*InstanceProfile, error) {
|
||||||
entry, err := s.instanceProfilesBucket.Get(ctx, accountID+"."+profileName)
|
entry, err := s.instanceProfilesBucket.Get(ctx, accountID+"."+profileName)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, jetstream.ErrKeyNotFound) {
|
if errors.Is(err, jetstream.ErrKeyNotFound) {
|
||||||
return nil, instanceProfileNotFound(profileName)
|
return nil, errors.New(awserrors.ErrorIAMNoSuchEntity)
|
||||||
}
|
}
|
||||||
return nil, fmt.Errorf("get instance profile: %w", err)
|
return nil, fmt.Errorf("get instance profile: %w", err)
|
||||||
}
|
}
|
||||||
@@ -390,7 +385,7 @@ func (s *IAMServiceImpl) updateInstanceProfileCAS(ctx context.Context, accountID
|
|||||||
func (s *IAMServiceImpl) instanceProfileCASConfig(accountID, profileName string) kvutil.CASConfig {
|
func (s *IAMServiceImpl) instanceProfileCASConfig(accountID, profileName string) kvutil.CASConfig {
|
||||||
return kvutil.CASConfig{
|
return kvutil.CASConfig{
|
||||||
Attempts: instanceProfileCASMaxRetries,
|
Attempts: instanceProfileCASMaxRetries,
|
||||||
NotFound: instanceProfileNotFound(profileName),
|
NotFound: errors.New(awserrors.ErrorIAMNoSuchEntity),
|
||||||
Exhausted: func(string, int) error {
|
Exhausted: func(string, int) error {
|
||||||
slog.Warn("IAM instance profile CAS exhausted retries",
|
slog.Warn("IAM instance profile CAS exhausted retries",
|
||||||
"accountID", accountID, "instanceProfileName", profileName, "attempts", instanceProfileCASMaxRetries)
|
"accountID", accountID, "instanceProfileName", profileName, "attempts", instanceProfileCASMaxRetries)
|
||||||
|
|||||||
@@ -778,7 +778,7 @@ func TestResolveInstanceProfile_NonCanonicalARNRejected(t *testing.T) {
|
|||||||
_, err := svc.ResolveInstanceProfile(testAccountID, arn)
|
_, err := svc.ResolveInstanceProfile(testAccountID, arn)
|
||||||
require.Error(t, err, arn)
|
require.Error(t, err, arn)
|
||||||
// The EC2 gateway maps this to InvalidIamInstanceProfile.NotFound by exact match.
|
// The EC2 gateway maps this to InvalidIamInstanceProfile.NotFound by exact match.
|
||||||
assert.Equal(t, awserrors.ErrorIAMNoSuchEntity, awserrors.ValidErrorCodeFromError(err), arn)
|
assert.Equal(t, awserrors.ErrorIAMNoSuchEntity, err.Error(), arn)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -90,7 +90,7 @@ func (e *SystemInstanceRoleEnsurer) GetRole(_ string, in *iam.GetRoleInput) (*ia
|
|||||||
|
|
||||||
r, ok := e.Roles[aws.StringValue(in.RoleName)]
|
r, ok := e.Roles[aws.StringValue(in.RoleName)]
|
||||||
if !ok {
|
if !ok {
|
||||||
return nil, awserrors.Errorf(awserrors.ErrorIAMNoSuchEntity, "The role with name %s cannot be found.", aws.StringValue(in.RoleName))
|
return nil, errors.New(awserrors.ErrorIAMNoSuchEntity)
|
||||||
}
|
}
|
||||||
return &iam.GetRoleOutput{Role: copyRole(r)}, nil
|
return &iam.GetRoleOutput{Role: copyRole(r)}, nil
|
||||||
}
|
}
|
||||||
@@ -104,7 +104,7 @@ func (e *SystemInstanceRoleEnsurer) CreateRole(accountID string, in *iam.CreateR
|
|||||||
e.LastRoleAcct = accountID
|
e.LastRoleAcct = accountID
|
||||||
name := aws.StringValue(in.RoleName)
|
name := aws.StringValue(in.RoleName)
|
||||||
if _, ok := e.Roles[name]; ok {
|
if _, ok := e.Roles[name]; ok {
|
||||||
return nil, awserrors.Errorf(awserrors.ErrorIAMEntityAlreadyExists, "Role with name %s already exists.", name)
|
return nil, errors.New(awserrors.ErrorIAMEntityAlreadyExists)
|
||||||
}
|
}
|
||||||
r := &iam.Role{
|
r := &iam.Role{
|
||||||
RoleName: aws.String(name),
|
RoleName: aws.String(name),
|
||||||
@@ -132,7 +132,7 @@ func (e *SystemInstanceRoleEnsurer) GetInstanceProfile(_ string, in *iam.GetInst
|
|||||||
|
|
||||||
p, ok := e.Profiles[aws.StringValue(in.InstanceProfileName)]
|
p, ok := e.Profiles[aws.StringValue(in.InstanceProfileName)]
|
||||||
if !ok {
|
if !ok {
|
||||||
return nil, awserrors.Errorf(awserrors.ErrorIAMNoSuchEntity, "Instance Profile %s cannot be found.", aws.StringValue(in.InstanceProfileName))
|
return nil, errors.New(awserrors.ErrorIAMNoSuchEntity)
|
||||||
}
|
}
|
||||||
return &iam.GetInstanceProfileOutput{InstanceProfile: copyProfile(p)}, nil
|
return &iam.GetInstanceProfileOutput{InstanceProfile: copyProfile(p)}, nil
|
||||||
}
|
}
|
||||||
@@ -148,7 +148,7 @@ func (e *SystemInstanceRoleEnsurer) CreateInstanceProfile(accountID string, in *
|
|||||||
}
|
}
|
||||||
name := aws.StringValue(in.InstanceProfileName)
|
name := aws.StringValue(in.InstanceProfileName)
|
||||||
if _, ok := e.Profiles[name]; ok {
|
if _, ok := e.Profiles[name]; ok {
|
||||||
return nil, awserrors.Errorf(awserrors.ErrorIAMEntityAlreadyExists, "Instance Profile %s already exists.", name)
|
return nil, errors.New(awserrors.ErrorIAMEntityAlreadyExists)
|
||||||
}
|
}
|
||||||
arn := "arn:aws:iam::" + accountID + ":instance-profile/" + name
|
arn := "arn:aws:iam::" + accountID + ":instance-profile/" + name
|
||||||
if e.EmptyInstanceProfileARN {
|
if e.EmptyInstanceProfileARN {
|
||||||
@@ -176,7 +176,7 @@ func (e *SystemInstanceRoleEnsurer) AddRoleToInstanceProfile(_ string, in *iam.A
|
|||||||
return nil, errors.New(awserrors.ErrorIAMNoSuchEntity)
|
return nil, errors.New(awserrors.ErrorIAMNoSuchEntity)
|
||||||
}
|
}
|
||||||
if len(p.Roles) > 0 {
|
if len(p.Roles) > 0 {
|
||||||
return nil, awserrors.Errorf(awserrors.ErrorIAMLimitExceeded, "Cannot exceed quota for InstanceSessionsPerInstanceProfile: 1")
|
return nil, errors.New(awserrors.ErrorIAMLimitExceeded)
|
||||||
}
|
}
|
||||||
p.Roles = []*iam.Role{{RoleName: in.RoleName}}
|
p.Roles = []*iam.Role{{RoleName: in.RoleName}}
|
||||||
return &iam.AddRoleToInstanceProfileOutput{}, nil
|
return &iam.AddRoleToInstanceProfileOutput{}, nil
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import (
|
|||||||
func TestGetRole_MissingReturnsNoSuchEntity(t *testing.T) {
|
func TestGetRole_MissingReturnsNoSuchEntity(t *testing.T) {
|
||||||
e := mock.New()
|
e := mock.New()
|
||||||
_, err := e.GetRole("acct", &iam.GetRoleInput{RoleName: aws.String("r1")})
|
_, err := e.GetRole("acct", &iam.GetRoleInput{RoleName: aws.String("r1")})
|
||||||
if err == nil || awserrors.ValidErrorCodeFromError(err) != awserrors.ErrorIAMNoSuchEntity {
|
if err == nil || err.Error() != awserrors.ErrorIAMNoSuchEntity {
|
||||||
t.Fatalf("err = %v, want ErrorIAMNoSuchEntity", err)
|
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 {
|
if _, err := e.CreateRole("acct", in); err != nil {
|
||||||
t.Fatalf("first CreateRole: %v", err)
|
t.Fatalf("first CreateRole: %v", err)
|
||||||
}
|
}
|
||||||
if _, err := e.CreateRole("acct", in); err == nil || awserrors.ValidErrorCodeFromError(err) != awserrors.ErrorIAMEntityAlreadyExists {
|
if _, err := e.CreateRole("acct", in); err == nil || err.Error() != awserrors.ErrorIAMEntityAlreadyExists {
|
||||||
t.Fatalf("second CreateRole err = %v, want EntityAlreadyExists", err)
|
t.Fatalf("second CreateRole err = %v, want EntityAlreadyExists", err)
|
||||||
}
|
}
|
||||||
if e.CreateRoleCalls != 2 {
|
if e.CreateRoleCalls != 2 {
|
||||||
@@ -48,7 +48,7 @@ func TestAddRoleToInstanceProfile_SecondAttachIsLimitExceeded(t *testing.T) {
|
|||||||
if _, err := e.AddRoleToInstanceProfile("acct", in); err != nil {
|
if _, err := e.AddRoleToInstanceProfile("acct", in); err != nil {
|
||||||
t.Fatalf("first AddRoleToInstanceProfile: %v", err)
|
t.Fatalf("first AddRoleToInstanceProfile: %v", err)
|
||||||
}
|
}
|
||||||
if _, err := e.AddRoleToInstanceProfile("acct", in); err == nil || awserrors.ValidErrorCodeFromError(err) != awserrors.ErrorIAMLimitExceeded {
|
if _, err := e.AddRoleToInstanceProfile("acct", in); err == nil || err.Error() != awserrors.ErrorIAMLimitExceeded {
|
||||||
t.Fatalf("second AddRoleToInstanceProfile err = %v, want LimitExceeded", err)
|
t.Fatalf("second AddRoleToInstanceProfile err = %v, want LimitExceeded", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -178,7 +178,7 @@ func (s *IAMServiceImpl) CreateOpenIDConnectProvider(accountID string, input *ia
|
|||||||
|
|
||||||
if _, err := kv.Create(ctx, OIDCProviderKey(issuer), data); err != nil {
|
if _, err := kv.Create(ctx, OIDCProviderKey(issuer), data); err != nil {
|
||||||
if errors.Is(err, jetstream.ErrKeyExists) {
|
if errors.Is(err, jetstream.ErrKeyExists) {
|
||||||
return nil, awserrors.Errorf(awserrors.ErrorIAMEntityAlreadyExists, "Provider with url %s already exists.", issuer)
|
return nil, errors.New(awserrors.ErrorIAMEntityAlreadyExists)
|
||||||
}
|
}
|
||||||
return nil, fmt.Errorf("store OIDC provider: %w", err)
|
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))
|
kv, err := s.js.KeyValue(ctx, IAMAccountBucketName(accountID))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, jetstream.ErrBucketNotFound) {
|
if errors.Is(err, jetstream.ErrBucketNotFound) {
|
||||||
return nil, oidcProviderNotFound(arn)
|
return nil, errors.New(awserrors.ErrorIAMNoSuchEntity)
|
||||||
}
|
}
|
||||||
return nil, fmt.Errorf("open IAM account bucket: %w", err)
|
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)
|
key := OIDCProviderKey(issuer)
|
||||||
if _, err := kv.Get(ctx, key); err != nil {
|
if _, err := kv.Get(ctx, key); err != nil {
|
||||||
if errors.Is(err, jetstream.ErrKeyNotFound) {
|
if errors.Is(err, jetstream.ErrKeyNotFound) {
|
||||||
return nil, oidcProviderNotFound(arn)
|
return nil, errors.New(awserrors.ErrorIAMNoSuchEntity)
|
||||||
}
|
}
|
||||||
return nil, fmt.Errorf("get OIDC provider: %w", err)
|
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))
|
kv, err := s.js.KeyValue(ctx, IAMAccountBucketName(accountID))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, jetstream.ErrBucketNotFound) {
|
if errors.Is(err, jetstream.ErrBucketNotFound) {
|
||||||
return oidcProviderNotFound(arn)
|
return errors.New(awserrors.ErrorIAMNoSuchEntity)
|
||||||
}
|
}
|
||||||
return fmt.Errorf("open IAM account bucket: %w", err)
|
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))
|
kv, err := s.js.KeyValue(ctx, IAMAccountBucketName(accountID))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, jetstream.ErrBucketNotFound) {
|
if errors.Is(err, jetstream.ErrBucketNotFound) {
|
||||||
return nil, oidcProviderNotFound(arn)
|
return nil, errors.New(awserrors.ErrorIAMNoSuchEntity)
|
||||||
}
|
}
|
||||||
return nil, fmt.Errorf("open IAM account bucket: %w", err)
|
return nil, fmt.Errorf("open IAM account bucket: %w", err)
|
||||||
}
|
}
|
||||||
entry, err := kv.Get(ctx, OIDCProviderKey(issuer))
|
entry, err := kv.Get(ctx, OIDCProviderKey(issuer))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, jetstream.ErrKeyNotFound) {
|
if errors.Is(err, jetstream.ErrKeyNotFound) {
|
||||||
return nil, oidcProviderNotFound(arn)
|
return nil, errors.New(awserrors.ErrorIAMNoSuchEntity)
|
||||||
}
|
}
|
||||||
return nil, fmt.Errorf("get OIDC provider: %w", err)
|
return nil, fmt.Errorf("get OIDC provider: %w", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -50,7 +50,7 @@ func TestCreateOpenIDConnectProvider_Idempotent(t *testing.T) {
|
|||||||
t.Fatalf("first create: %v", err)
|
t.Fatalf("first create: %v", err)
|
||||||
}
|
}
|
||||||
_, err := svc.CreateOpenIDConnectProvider(acct, in)
|
_, err := svc.CreateOpenIDConnectProvider(acct, in)
|
||||||
if err == nil || awserrors.ValidErrorCodeFromError(err) != awserrors.ErrorIAMEntityAlreadyExists {
|
if err == nil || err.Error() != awserrors.ErrorIAMEntityAlreadyExists {
|
||||||
t.Fatalf("duplicate create err = %v, want EntityAlreadyExists", err)
|
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{
|
_, err := svc.GetOpenIDConnectProvider("000000000001", &iam.GetOpenIDConnectProviderInput{
|
||||||
OpenIDConnectProviderArn: aws.String(OIDCProviderARN("000000000001", "10.0.0.1:9999/oidc/eks/x/y/z")),
|
OpenIDConnectProviderArn: aws.String(OIDCProviderARN("000000000001", "10.0.0.1:9999/oidc/eks/x/y/z")),
|
||||||
})
|
})
|
||||||
if err == nil || awserrors.ValidErrorCodeFromError(err) != awserrors.ErrorIAMNoSuchEntity {
|
if err == nil || err.Error() != awserrors.ErrorIAMNoSuchEntity {
|
||||||
t.Fatalf("err = %v, want NoSuchEntity", err)
|
t.Fatalf("err = %v, want NoSuchEntity", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -195,7 +195,7 @@ func TestDeleteOpenIDConnectProvider(t *testing.T) {
|
|||||||
_, err = svc.DeleteOpenIDConnectProvider(acct, &iam.DeleteOpenIDConnectProviderInput{
|
_, err = svc.DeleteOpenIDConnectProvider(acct, &iam.DeleteOpenIDConnectProviderInput{
|
||||||
OpenIDConnectProviderArn: created.OpenIDConnectProviderArn,
|
OpenIDConnectProviderArn: created.OpenIDConnectProviderArn,
|
||||||
})
|
})
|
||||||
if err == nil || awserrors.ValidErrorCodeFromError(err) != awserrors.ErrorIAMNoSuchEntity {
|
if err == nil || err.Error() != awserrors.ErrorIAMNoSuchEntity {
|
||||||
t.Fatalf("second delete err = %v, want NoSuchEntity", err)
|
t.Fatalf("second delete err = %v, want NoSuchEntity", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -111,11 +111,11 @@ func policyCASTarget(accountID, policyARN string) (string, kvutil.CASConfig, err
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
slog.Debug("policyCASTarget: unparseable policy ARN",
|
slog.Debug("policyCASTarget: unparseable policy ARN",
|
||||||
"accountID", accountID, "policyArn", policyARN, "err", err)
|
"accountID", accountID, "policyArn", policyARN, "err", err)
|
||||||
return "", kvutil.CASConfig{}, policyNotFound(policyARN)
|
return "", kvutil.CASConfig{}, errors.New(awserrors.ErrorIAMNoSuchEntity)
|
||||||
}
|
}
|
||||||
return accountID + "." + policyName, kvutil.CASConfig{
|
return accountID + "." + policyName, kvutil.CASConfig{
|
||||||
Attempts: policyCASMaxRetries,
|
Attempts: policyCASMaxRetries,
|
||||||
NotFound: policyNotFound(policyARN),
|
NotFound: errors.New(awserrors.ErrorIAMNoSuchEntity),
|
||||||
Exhausted: func(string, int) error {
|
Exhausted: func(string, int) error {
|
||||||
slog.Error("IAM policy CAS retries exhausted under contention",
|
slog.Error("IAM policy CAS retries exhausted under contention",
|
||||||
"accountID", accountID, "policyName", policyName, "attempts", policyCASMaxRetries)
|
"accountID", accountID, "policyName", policyName, "attempts", policyCASMaxRetries)
|
||||||
|
|||||||
@@ -224,11 +224,11 @@ func TestTagUser_DoesNotRecreateConcurrentlyDeletedUser(t *testing.T) {
|
|||||||
close(gate.release)
|
close(gate.release)
|
||||||
err = <-done
|
err = <-done
|
||||||
require.Error(t, err)
|
require.Error(t, err)
|
||||||
assert.Equal(t, awserrors.ErrorIAMNoSuchEntity, awserrors.ValidErrorCodeFromError(err))
|
assert.Equal(t, awserrors.ErrorIAMNoSuchEntity, err.Error())
|
||||||
|
|
||||||
_, err = svc.getUser(t.Context(), testAccountID, *user.UserName)
|
_, err = svc.getUser(t.Context(), testAccountID, *user.UserName)
|
||||||
require.Error(t, err)
|
require.Error(t, err)
|
||||||
assert.Equal(t, awserrors.ErrorIAMNoSuchEntity, awserrors.ValidErrorCodeFromError(err), "the deleted user must stay deleted")
|
assert.Equal(t, awserrors.ErrorIAMNoSuchEntity, err.Error(), "the deleted user must stay deleted")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Every group writer that read the record before a DetachGroupPolicy and writes
|
// 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)
|
close(gate.release)
|
||||||
err = <-done
|
err = <-done
|
||||||
require.Error(t, err)
|
require.Error(t, err)
|
||||||
assert.Equal(t, awserrors.ErrorIAMNoSuchEntity, awserrors.ValidErrorCodeFromError(err))
|
assert.Equal(t, awserrors.ErrorIAMNoSuchEntity, err.Error())
|
||||||
|
|
||||||
_, err = kv.Get(t.Context(), OIDCProviderKey(testIssuer))
|
_, err = kv.Get(t.Context(), OIDCProviderKey(testIssuer))
|
||||||
assert.ErrorIs(t, err, jetstream.ErrKeyNotFound, "the deleted provider must stay deleted")
|
assert.ErrorIs(t, err, jetstream.ErrKeyNotFound, "the deleted provider must stay deleted")
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ func (r *RoleResolver) ResolveRole(accountID, roleARN string) (passrole.Role, er
|
|||||||
}
|
}
|
||||||
role, err := r.svc.getRole(context.Background(), accountID, name)
|
role, err := r.svc.getRole(context.Background(), accountID, name)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if code, _ := awserrors.ResolveErrorCode(err); code == awserrors.ErrorIAMNoSuchEntity {
|
if err.Error() == awserrors.ErrorIAMNoSuchEntity {
|
||||||
return passrole.Role{}, passrole.ErrNotFound
|
return passrole.Role{}, passrole.ErrNotFound
|
||||||
}
|
}
|
||||||
return passrole.Role{}, err
|
return passrole.Role{}, err
|
||||||
|
|||||||
@@ -106,7 +106,7 @@ func (s *IAMServiceImpl) CreateRole(accountID string, input *iam.CreateRoleInput
|
|||||||
kvKey := accountID + "." + roleName
|
kvKey := accountID + "." + roleName
|
||||||
if _, err := s.rolesBucket.Create(ctx, kvKey, data); err != nil {
|
if _, err := s.rolesBucket.Create(ctx, kvKey, data); err != nil {
|
||||||
if errors.Is(err, jetstream.ErrKeyExists) {
|
if errors.Is(err, jetstream.ErrKeyExists) {
|
||||||
return nil, awserrors.Errorf(awserrors.ErrorIAMEntityAlreadyExists, "Role with name %s already exists.", roleName)
|
return nil, errors.New(awserrors.ErrorIAMEntityAlreadyExists)
|
||||||
}
|
}
|
||||||
return nil, fmt.Errorf("store role: %w", err)
|
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 {
|
if len(role.AttachedPolicies) > 0 {
|
||||||
return nil, errDetachPoliciesFirst
|
return nil, errors.New(awserrors.ErrorIAMDeleteConflict)
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(role.InlinePolicies) > 0 {
|
if len(role.InlinePolicies) > 0 {
|
||||||
return nil, errDeletePoliciesFirst
|
return nil, errors.New(awserrors.ErrorIAMDeleteConflict)
|
||||||
}
|
}
|
||||||
|
|
||||||
profiles, err := s.findInstanceProfilesForRole(ctx, accountID, roleName)
|
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)
|
return nil, fmt.Errorf("check role instance profiles: %w", err)
|
||||||
}
|
}
|
||||||
if len(profiles) > 0 {
|
if len(profiles) > 0 {
|
||||||
return nil, awserrors.Errorf(awserrors.ErrorIAMDeleteConflict, "Cannot delete entity, must remove roles from instance profile first.")
|
return nil, errors.New(awserrors.ErrorIAMDeleteConflict)
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := s.rolesBucket.Delete(ctx, accountID+"."+roleName); err != nil {
|
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)
|
slog.Info("IAM role description updated", "accountID", accountID, "roleName", roleName)
|
||||||
return &iam.UpdateRoleDescriptionOutput{Role: describedRoleToSDK(role)}, nil
|
return &iam.UpdateRoleDescriptionOutput{Role: gotRoleToSDK(role)}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *IAMServiceImpl) UpdateAssumeRolePolicy(accountID string, input *iam.UpdateAssumeRolePolicyInput) (*iam.UpdateAssumeRolePolicyOutput, error) {
|
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
|
// so ListAttachedRolePolicies / DescribeNodegroup round-trip instead of
|
||||||
// failing NoSuchEntity. Customer-managed ARNs must still exist.
|
// failing NoSuchEntity. Customer-managed ARNs must still exist.
|
||||||
if !iamarn.IsAWSManagedPolicyARN(policyARN) {
|
if !iamarn.IsAWSManagedPolicyARN(policyARN) {
|
||||||
if err := s.attachablePolicy(ctx, accountID, policyARN); err != nil {
|
if _, err := s.getPolicyByARN(ctx, accountID, policyARN); err != nil {
|
||||||
return nil, err
|
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) {
|
err := s.updateRoleCAS(ctx, accountID, roleName, func(role *Role) (bool, error) {
|
||||||
idx := slices.Index(role.AttachedPolicies, policyARN)
|
idx := slices.Index(role.AttachedPolicies, policyARN)
|
||||||
if idx < 0 {
|
if idx < 0 {
|
||||||
return false, policyNotFound(policyARN)
|
return false, errors.New(awserrors.ErrorIAMNoSuchEntity)
|
||||||
}
|
}
|
||||||
role.AttachedPolicies = slices.Delete(role.AttachedPolicies, idx, idx+1)
|
role.AttachedPolicies = slices.Delete(role.AttachedPolicies, idx, idx+1)
|
||||||
return true, nil
|
return true, nil
|
||||||
@@ -444,7 +444,7 @@ func (s *IAMServiceImpl) GetRolePolicy(accountID string, input *iam.GetRolePolic
|
|||||||
|
|
||||||
doc, ok := role.InlinePolicies[policyName]
|
doc, ok := role.InlinePolicies[policyName]
|
||||||
if !ok {
|
if !ok {
|
||||||
return nil, noSuchEntity("role policy", policyName)
|
return nil, errors.New(awserrors.ErrorIAMNoSuchEntity)
|
||||||
}
|
}
|
||||||
|
|
||||||
return &iam.GetRolePolicyOutput{
|
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) {
|
err := s.updateRoleCAS(ctx, accountID, roleName, func(role *Role) (bool, error) {
|
||||||
if _, ok := role.InlinePolicies[policyName]; !ok {
|
if _, ok := role.InlinePolicies[policyName]; !ok {
|
||||||
return false, noSuchEntity("role policy", policyName)
|
return false, errors.New(awserrors.ErrorIAMNoSuchEntity)
|
||||||
}
|
}
|
||||||
delete(role.InlinePolicies, policyName)
|
delete(role.InlinePolicies, policyName)
|
||||||
return true, nil
|
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)
|
entry, err := s.rolesBucket.Get(ctx, accountID+"."+roleName)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, jetstream.ErrKeyNotFound) {
|
if errors.Is(err, jetstream.ErrKeyNotFound) {
|
||||||
return nil, noSuchEntity("role", roleName)
|
return nil, errors.New(awserrors.ErrorIAMNoSuchEntity)
|
||||||
}
|
}
|
||||||
return nil, fmt.Errorf("get role: %w", err)
|
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)()
|
defer s.recordLocks.lock("role/" + accountID + "." + roleName)()
|
||||||
_, err := kvutil.Update(ctx, s.rolesBucket, accountID+"."+roleName, kvutil.CASConfig{
|
_, err := kvutil.Update(ctx, s.rolesBucket, accountID+"."+roleName, kvutil.CASConfig{
|
||||||
Attempts: roleCASMaxRetries,
|
Attempts: roleCASMaxRetries,
|
||||||
NotFound: noSuchEntity("role", roleName),
|
NotFound: errors.New(awserrors.ErrorIAMNoSuchEntity),
|
||||||
Exhausted: func(string, int) error {
|
Exhausted: func(string, int) error {
|
||||||
// Only a CAS conflict reaches here, so the role is contended rather than
|
// 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.
|
// broken; say so, or the caller's InternalError has no cause anywhere.
|
||||||
@@ -698,16 +698,6 @@ func createdRoleToSDK(r *Role) *iam.Role {
|
|||||||
return out
|
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.
|
// listedRoleToSDK is ListRoles' shape, which carries no Tags or RoleLastUsed.
|
||||||
func listedRoleToSDK(r *Role) *iam.Role {
|
func listedRoleToSDK(r *Role) *iam.Role {
|
||||||
out := roleToSDK(r)
|
out := roleToSDK(r)
|
||||||
|
|||||||
@@ -561,13 +561,7 @@ func TestUpdateRole_NotFound(t *testing.T) {
|
|||||||
func TestUpdateRoleDescription(t *testing.T) {
|
func TestUpdateRoleDescription(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
svc := setupTestIAMService(t)
|
svc := setupTestIAMService(t)
|
||||||
_, err := svc.CreateRole(testAccountID, &iam.CreateRoleInput{
|
createTestRole(t, svc, "desc-role")
|
||||||
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{
|
out, err := svc.UpdateRoleDescription(testAccountID, &iam.UpdateRoleDescriptionInput{
|
||||||
RoleName: aws.String("desc-role"),
|
RoleName: aws.String("desc-role"),
|
||||||
@@ -577,15 +571,7 @@ func TestUpdateRoleDescription(t *testing.T) {
|
|||||||
got, err := svc.GetRole(testAccountID, &iam.GetRoleInput{RoleName: aws.String("desc-role")})
|
got, err := svc.GetRole(testAccountID, &iam.GetRoleInput{RoleName: aws.String("desc-role")})
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
assert.Equal(t, "new description", *got.Role.Description)
|
assert.Equal(t, "new description", *got.Role.Description)
|
||||||
assert.Equal(t, &iam.Role{
|
assert.Equal(t, got.Role, out.Role, "output carries the role in the GetRole shape")
|
||||||
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{
|
_, err = svc.UpdateRoleDescription(testAccountID, &iam.UpdateRoleDescriptionInput{
|
||||||
RoleName: aws.String("desc-role"),
|
RoleName: aws.String("desc-role"),
|
||||||
|
|||||||
@@ -15,7 +15,6 @@ type IAMService interface {
|
|||||||
GetUser(accountID string, input *iam.GetUserInput) (*iam.GetUserOutput, error)
|
GetUser(accountID string, input *iam.GetUserInput) (*iam.GetUserOutput, error)
|
||||||
ListUsers(accountID string, input *iam.ListUsersInput) (*iam.ListUsersOutput, error)
|
ListUsers(accountID string, input *iam.ListUsersInput) (*iam.ListUsersOutput, error)
|
||||||
DeleteUser(accountID string, input *iam.DeleteUserInput) (*iam.DeleteUserOutput, error)
|
DeleteUser(accountID string, input *iam.DeleteUserInput) (*iam.DeleteUserOutput, error)
|
||||||
UpdateUser(accountID string, input *iam.UpdateUserInput) (*iam.UpdateUserOutput, error)
|
|
||||||
|
|
||||||
// Access key lifecycle — account-scoped
|
// Access key lifecycle — account-scoped
|
||||||
CreateAccessKey(accountID string, input *iam.CreateAccessKeyInput) (*iam.CreateAccessKeyOutput, error)
|
CreateAccessKey(accountID string, input *iam.CreateAccessKeyInput) (*iam.CreateAccessKeyOutput, error)
|
||||||
|
|||||||
@@ -467,7 +467,7 @@ func (s *IAMServiceImpl) CreateUser(accountID string, input *iam.CreateUserInput
|
|||||||
// Atomic create — fails if key already exists (race-safe)
|
// Atomic create — fails if key already exists (race-safe)
|
||||||
if _, err := s.usersBucket.Create(ctx, kvKey, data); err != nil {
|
if _, err := s.usersBucket.Create(ctx, kvKey, data); err != nil {
|
||||||
if errors.Is(err, jetstream.ErrKeyExists) {
|
if errors.Is(err, jetstream.ErrKeyExists) {
|
||||||
return nil, awserrors.Errorf(awserrors.ErrorIAMEntityAlreadyExists, "User with name %s already exists.", userName)
|
return nil, errors.New(awserrors.ErrorIAMEntityAlreadyExists)
|
||||||
}
|
}
|
||||||
return nil, fmt.Errorf("store user: %w", err)
|
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.
|
// block the delete either; removing the user record drops it.
|
||||||
for _, keyID := range user.AccessKeys {
|
for _, keyID := range user.AccessKeys {
|
||||||
if _, err := s.accessKeysBucket.Get(ctx, keyID); err == nil {
|
if _, err := s.accessKeysBucket.Get(ctx, keyID); err == nil {
|
||||||
return nil, awserrors.Errorf(awserrors.ErrorIAMDeleteConflict, "Cannot delete entity, must delete access keys first.")
|
return nil, errors.New(awserrors.ErrorIAMDeleteConflict)
|
||||||
} else if !errors.Is(err, jetstream.ErrKeyNotFound) {
|
} else if !errors.Is(err, jetstream.ErrKeyNotFound) {
|
||||||
return nil, fmt.Errorf("get access key: %w", err)
|
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)
|
"accountID", accountID, "userName", userName, "accessKeyID", keyID)
|
||||||
}
|
}
|
||||||
if len(user.AttachedPolicies) > 0 {
|
if len(user.AttachedPolicies) > 0 {
|
||||||
return nil, errDetachPoliciesFirst
|
return nil, errors.New(awserrors.ErrorIAMDeleteConflict)
|
||||||
}
|
}
|
||||||
if len(user.Groups) > 0 {
|
if len(user.Groups) > 0 {
|
||||||
return nil, errors.New(awserrors.ErrorIAMDeleteConflict)
|
return nil, errors.New(awserrors.ErrorIAMDeleteConflict)
|
||||||
}
|
}
|
||||||
if len(user.InlinePolicies) > 0 {
|
if len(user.InlinePolicies) > 0 {
|
||||||
return nil, errDeletePoliciesFirst
|
return nil, errors.New(awserrors.ErrorIAMDeleteConflict)
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := s.usersBucket.Delete(ctx, kvKey); err != nil {
|
if err := s.usersBucket.Delete(ctx, kvKey); err != nil {
|
||||||
@@ -625,133 +625,6 @@ func (s *IAMServiceImpl) DeleteUser(accountID string, input *iam.DeleteUserInput
|
|||||||
return &iam.DeleteUserOutput{}, nil
|
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
|
// Access Key Lifecycle
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -880,7 +753,7 @@ func (s *IAMServiceImpl) DeleteAccessKey(accountID string, input *iam.DeleteAcce
|
|||||||
err := s.updateUserCAS(ctx, accountID, userName, func(u *User) (bool, error) {
|
err := s.updateUserCAS(ctx, accountID, userName, func(u *User) (bool, error) {
|
||||||
idx := slices.Index(u.AccessKeys, accessKeyID)
|
idx := slices.Index(u.AccessKeys, accessKeyID)
|
||||||
if idx < 0 {
|
if idx < 0 {
|
||||||
return false, awserrors.Errorf(awserrors.ErrorIAMNoSuchEntity, "The Access Key with id %s cannot be found.", accessKeyID)
|
return false, errors.New(awserrors.ErrorIAMNoSuchEntity)
|
||||||
}
|
}
|
||||||
u.AccessKeys = slices.Delete(u.AccessKeys, idx, idx+1)
|
u.AccessKeys = slices.Delete(u.AccessKeys, idx, idx+1)
|
||||||
return true, nil
|
return true, nil
|
||||||
@@ -1482,8 +1355,7 @@ func (s *IAMServiceImpl) CreatePolicy(accountID string, input *iam.CreatePolicyI
|
|||||||
|
|
||||||
if _, err := s.policiesBucket.Create(ctx, kvKey, data); err != nil {
|
if _, err := s.policiesBucket.Create(ctx, kvKey, data); err != nil {
|
||||||
if errors.Is(err, jetstream.ErrKeyExists) {
|
if errors.Is(err, jetstream.ErrKeyExists) {
|
||||||
return nil, awserrors.Errorf(awserrors.ErrorIAMEntityAlreadyExists,
|
return nil, errors.New(awserrors.ErrorIAMEntityAlreadyExists)
|
||||||
"A policy called %s already exists. Duplicate names are not allowed.", policyName)
|
|
||||||
}
|
}
|
||||||
return nil, fmt.Errorf("store policy: %w", err)
|
return nil, fmt.Errorf("store policy: %w", err)
|
||||||
}
|
}
|
||||||
@@ -1630,7 +1502,7 @@ func (s *IAMServiceImpl) DeletePolicy(accountID string, input *iam.DeletePolicyI
|
|||||||
// Revision-guarded, so a version created after the checks cannot be deleted unseen.
|
// 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 {
|
policy, err := kvutil.DeleteIf(ctx, s.policiesBucket, key, cfg, func(p *Policy) error {
|
||||||
if p.ARN != policyARN {
|
if p.ARN != policyARN {
|
||||||
return policyNotFound(policyARN)
|
return errors.New(awserrors.ErrorIAMNoSuchEntity)
|
||||||
}
|
}
|
||||||
if len(p.OtherVersions) > 0 {
|
if len(p.OtherVersions) > 0 {
|
||||||
return awserrors.Errorf(awserrors.ErrorIAMDeleteConflict,
|
return awserrors.Errorf(awserrors.ErrorIAMDeleteConflict,
|
||||||
@@ -1641,7 +1513,7 @@ func (s *IAMServiceImpl) DeletePolicy(accountID string, input *iam.DeletePolicyI
|
|||||||
return fmt.Errorf("check policy attachments: %w", err)
|
return fmt.Errorf("check policy attachments: %w", err)
|
||||||
}
|
}
|
||||||
if counts[p.ARN] > 0 {
|
if counts[p.ARN] > 0 {
|
||||||
return awserrors.Errorf(awserrors.ErrorIAMDeleteConflict, "Cannot delete a policy attached to entities.")
|
return errors.New(awserrors.ErrorIAMDeleteConflict)
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
})
|
})
|
||||||
@@ -1740,7 +1612,7 @@ func (s *IAMServiceImpl) AttachUserPolicy(accountID string, input *iam.AttachUse
|
|||||||
// grant document is modeled in builtinManagedPolicyDoc and resolved at
|
// grant document is modeled in builtinManagedPolicyDoc and resolved at
|
||||||
// evaluation time. Customer-managed ARNs must still exist.
|
// evaluation time. Customer-managed ARNs must still exist.
|
||||||
if !iamarn.IsAWSManagedPolicyARN(policyARN) {
|
if !iamarn.IsAWSManagedPolicyARN(policyARN) {
|
||||||
if err := s.attachablePolicy(ctx, accountID, policyARN); err != nil {
|
if _, err := s.getPolicyByARN(ctx, accountID, policyARN); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1768,7 +1640,7 @@ func (s *IAMServiceImpl) DetachUserPolicy(accountID string, input *iam.DetachUse
|
|||||||
err := s.updateUserCAS(ctx, accountID, userName, func(u *User) (bool, error) {
|
err := s.updateUserCAS(ctx, accountID, userName, func(u *User) (bool, error) {
|
||||||
idx := slices.Index(u.AttachedPolicies, policyARN)
|
idx := slices.Index(u.AttachedPolicies, policyARN)
|
||||||
if idx < 0 {
|
if idx < 0 {
|
||||||
return false, policyNotFound(policyARN)
|
return false, errors.New(awserrors.ErrorIAMNoSuchEntity)
|
||||||
}
|
}
|
||||||
u.AttachedPolicies = slices.Delete(u.AttachedPolicies, idx, idx+1)
|
u.AttachedPolicies = slices.Delete(u.AttachedPolicies, idx, idx+1)
|
||||||
return true, nil
|
return true, nil
|
||||||
@@ -1902,7 +1774,7 @@ func (s *IAMServiceImpl) GetUserPolicy(accountID string, input *iam.GetUserPolic
|
|||||||
|
|
||||||
doc, ok := user.InlinePolicies[policyName]
|
doc, ok := user.InlinePolicies[policyName]
|
||||||
if !ok {
|
if !ok {
|
||||||
return nil, noSuchEntity("user policy", policyName)
|
return nil, errors.New(awserrors.ErrorIAMNoSuchEntity)
|
||||||
}
|
}
|
||||||
|
|
||||||
return &iam.GetUserPolicyOutput{
|
return &iam.GetUserPolicyOutput{
|
||||||
@@ -1921,7 +1793,7 @@ func (s *IAMServiceImpl) DeleteUserPolicy(accountID string, input *iam.DeleteUse
|
|||||||
|
|
||||||
err := s.updateUserCAS(ctx, accountID, userName, func(u *User) (bool, error) {
|
err := s.updateUserCAS(ctx, accountID, userName, func(u *User) (bool, error) {
|
||||||
if _, ok := u.InlinePolicies[policyName]; !ok {
|
if _, ok := u.InlinePolicies[policyName]; !ok {
|
||||||
return false, noSuchEntity("user policy", policyName)
|
return false, errors.New(awserrors.ErrorIAMNoSuchEntity)
|
||||||
}
|
}
|
||||||
delete(u.InlinePolicies, policyName)
|
delete(u.InlinePolicies, policyName)
|
||||||
return true, nil
|
return true, nil
|
||||||
@@ -2112,7 +1984,7 @@ func (s *IAMServiceImpl) GetUserPolicies(accountID, userName string) ([]PolicyDo
|
|||||||
for _, groupName := range user.Groups {
|
for _, groupName := range user.Groups {
|
||||||
group, err := s.getGroup(ctx, accountID, groupName)
|
group, err := s.getGroup(ctx, accountID, groupName)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if code, _ := awserrors.ResolveErrorCode(err); code == awserrors.ErrorIAMNoSuchEntity {
|
if err.Error() == awserrors.ErrorIAMNoSuchEntity {
|
||||||
slog.Warn("GetUserPolicies: member references missing group; skipping",
|
slog.Warn("GetUserPolicies: member references missing group; skipping",
|
||||||
"accountID", accountID, "user", userName, "group", groupName)
|
"accountID", accountID, "user", userName, "group", groupName)
|
||||||
continue
|
continue
|
||||||
@@ -2153,7 +2025,7 @@ func (s *IAMServiceImpl) getPolicyByARN(ctx context.Context, accountID, policyAR
|
|||||||
entry, err := s.policiesBucket.Get(ctx, kvKey)
|
entry, err := s.policiesBucket.Get(ctx, kvKey)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, jetstream.ErrKeyNotFound) {
|
if errors.Is(err, jetstream.ErrKeyNotFound) {
|
||||||
return "", policyNotFound(policyARN)
|
return "", errors.New(awserrors.ErrorIAMNoSuchEntity)
|
||||||
}
|
}
|
||||||
return "", fmt.Errorf("get policy: %w", err)
|
return "", fmt.Errorf("get policy: %w", err)
|
||||||
}
|
}
|
||||||
@@ -2170,9 +2042,9 @@ func (s *IAMServiceImpl) getPolicyByARN(ctx context.Context, accountID, policyAR
|
|||||||
// otherwise a malformed ARN is indistinguishable from a missing policy.
|
// otherwise a malformed ARN is indistinguishable from a missing policy.
|
||||||
slog.Debug("getPolicyByARN: unparseable policy ARN",
|
slog.Debug("getPolicyByARN: unparseable policy ARN",
|
||||||
"accountID", accountID, "policyArn", policyARN, "err", err)
|
"accountID", accountID, "policyArn", policyARN, "err", err)
|
||||||
return nil, policyNotFound(policyARN)
|
return nil, errors.New(awserrors.ErrorIAMNoSuchEntity)
|
||||||
case errors.Is(err, iamarn.ErrPolicyARNMismatch):
|
case errors.Is(err, iamarn.ErrPolicyARNMismatch):
|
||||||
return nil, policyNotFound(policyARN)
|
return nil, errors.New(awserrors.ErrorIAMNoSuchEntity)
|
||||||
case err != nil:
|
case err != nil:
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -2294,45 +2166,12 @@ func (s *IAMServiceImpl) buildAttachmentCounts(ctx context.Context, accountID st
|
|||||||
return counts, nil
|
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) {
|
func (s *IAMServiceImpl) getUser(ctx context.Context, accountID, userName string) (*User, error) {
|
||||||
kvKey := accountID + "." + userName
|
kvKey := accountID + "." + userName
|
||||||
entry, err := s.usersBucket.Get(ctx, kvKey)
|
entry, err := s.usersBucket.Get(ctx, kvKey)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, jetstream.ErrKeyNotFound) {
|
if errors.Is(err, jetstream.ErrKeyNotFound) {
|
||||||
return nil, noSuchEntity("user", userName)
|
return nil, errors.New(awserrors.ErrorIAMNoSuchEntity)
|
||||||
}
|
}
|
||||||
return nil, fmt.Errorf("get user: %w", err)
|
return nil, fmt.Errorf("get user: %w", err)
|
||||||
}
|
}
|
||||||
@@ -2356,13 +2195,9 @@ func (s *IAMServiceImpl) updateUserCAS(ctx context.Context, accountID, userName
|
|||||||
// iamRecordCASConfig is the CAS policy for an IAM user, group or OIDC provider
|
// iamRecordCASConfig is the CAS policy for an IAM user, group or OIDC provider
|
||||||
// record: a missing record is NoSuchEntity and exhausted retries are logged.
|
// record: a missing record is NoSuchEntity and exhausted retries are logged.
|
||||||
func iamRecordCASConfig(kind, accountID, name string) kvutil.CASConfig {
|
func iamRecordCASConfig(kind, accountID, name string) kvutil.CASConfig {
|
||||||
notFound := noSuchEntity(kind, name)
|
|
||||||
if kind == "oidc-provider" {
|
|
||||||
notFound = oidcProviderNotFound(name)
|
|
||||||
}
|
|
||||||
return kvutil.CASConfig{
|
return kvutil.CASConfig{
|
||||||
Attempts: roleCASMaxRetries,
|
Attempts: roleCASMaxRetries,
|
||||||
NotFound: notFound,
|
NotFound: errors.New(awserrors.ErrorIAMNoSuchEntity),
|
||||||
Exhausted: func(string, int) error {
|
Exhausted: func(string, int) error {
|
||||||
slog.Error("IAM record CAS retries exhausted under contention",
|
slog.Error("IAM record CAS retries exhausted under contention",
|
||||||
"kind", kind, "accountID", accountID, "name", name, "attempts", roleCASMaxRetries)
|
"kind", kind, "accountID", accountID, "name", name, "attempts", roleCASMaxRetries)
|
||||||
|
|||||||
@@ -48,7 +48,7 @@ func EnsureSystemInstanceProfile(e SystemInstanceRoleEnsurer, accountID, roleNam
|
|||||||
// there draws credentials from it, so provisioning one would be unasked-for.
|
// there draws credentials from it, so provisioning one would be unasked-for.
|
||||||
func ConvergeSystemRolePolicy(e SystemInstanceRoleEnsurer, accountID, roleName, policyName, policyDoc string) error {
|
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 := e.GetRole(accountID, &iam.GetRoleInput{RoleName: aws.String(roleName)}); err != nil {
|
||||||
if code, _ := awserrors.ResolveErrorCode(err); code == awserrors.ErrorIAMNoSuchEntity {
|
if err.Error() == awserrors.ErrorIAMNoSuchEntity {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
return fmt.Errorf("get role %q: %w", roleName, err)
|
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 {
|
func ensureSystemRole(e SystemInstanceRoleEnsurer, accountID, roleName string) error {
|
||||||
if _, err := e.GetRole(accountID, &iam.GetRoleInput{RoleName: aws.String(roleName)}); err == nil {
|
if _, err := e.GetRole(accountID, &iam.GetRoleInput{RoleName: aws.String(roleName)}); err == nil {
|
||||||
return nil
|
return nil
|
||||||
} else if code, _ := awserrors.ResolveErrorCode(err); code != awserrors.ErrorIAMNoSuchEntity {
|
} else if err.Error() != awserrors.ErrorIAMNoSuchEntity {
|
||||||
return fmt.Errorf("get role %q: %w", roleName, err)
|
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),
|
AssumeRolePolicyDocument: aws.String(EC2InstanceTrustPolicy),
|
||||||
Description: aws.String("Spinifex-managed system instance role"),
|
Description: aws.String("Spinifex-managed system instance role"),
|
||||||
})
|
})
|
||||||
if code, _ := awserrors.ResolveErrorCode(err); err == nil || code == awserrors.ErrorIAMEntityAlreadyExists {
|
if err == nil || err.Error() == awserrors.ErrorIAMEntityAlreadyExists {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
return fmt.Errorf("create role %q: %w", roleName, err)
|
return fmt.Errorf("create role %q: %w", roleName, err)
|
||||||
@@ -94,7 +94,7 @@ func ensureSystemInstanceProfile(e SystemInstanceRoleEnsurer, accountID, roleNam
|
|||||||
return attachSystemRole(e, accountID, roleName,
|
return attachSystemRole(e, accountID, roleName,
|
||||||
aws.StringValue(out.InstanceProfile.Arn), len(out.InstanceProfile.Roles) > 0)
|
aws.StringValue(out.InstanceProfile.Arn), len(out.InstanceProfile.Roles) > 0)
|
||||||
}
|
}
|
||||||
if code, _ := awserrors.ResolveErrorCode(err); code != awserrors.ErrorIAMNoSuchEntity {
|
if err.Error() != awserrors.ErrorIAMNoSuchEntity {
|
||||||
return "", fmt.Errorf("get instance profile %q: %w", roleName, err)
|
return "", fmt.Errorf("get instance profile %q: %w", roleName, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -102,7 +102,7 @@ func ensureSystemInstanceProfile(e SystemInstanceRoleEnsurer, accountID, roleNam
|
|||||||
InstanceProfileName: aws.String(roleName),
|
InstanceProfileName: aws.String(roleName),
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if code, _ := awserrors.ResolveErrorCode(err); code != awserrors.ErrorIAMEntityAlreadyExists {
|
if err.Error() != awserrors.ErrorIAMEntityAlreadyExists {
|
||||||
return "", fmt.Errorf("create instance profile %q: %w", roleName, err)
|
return "", fmt.Errorf("create instance profile %q: %w", roleName, err)
|
||||||
}
|
}
|
||||||
got, gerr := e.GetInstanceProfile(accountID, &iam.GetInstanceProfileInput{
|
got, gerr := e.GetInstanceProfile(accountID, &iam.GetInstanceProfileInput{
|
||||||
@@ -128,7 +128,7 @@ func attachSystemRole(e SystemInstanceRoleEnsurer, accountID, roleName, profileA
|
|||||||
InstanceProfileName: aws.String(roleName),
|
InstanceProfileName: aws.String(roleName),
|
||||||
RoleName: aws.String(roleName),
|
RoleName: aws.String(roleName),
|
||||||
})
|
})
|
||||||
if code, _ := awserrors.ResolveErrorCode(err); err != nil && code != awserrors.ErrorIAMLimitExceeded {
|
if err != nil && err.Error() != awserrors.ErrorIAMLimitExceeded {
|
||||||
return "", fmt.Errorf("add role %q to instance profile %q: %w", roleName, roleName, err)
|
return "", fmt.Errorf("add role %q to instance profile %q: %w", roleName, roleName, err)
|
||||||
}
|
}
|
||||||
return profileARN, nil
|
return profileARN, nil
|
||||||
|
|||||||
@@ -460,7 +460,7 @@ func TestCreate_InvalidTagsRefusedAndNotStored(t *testing.T) {
|
|||||||
|
|
||||||
_, err = ops.list(svc, id)
|
_, err = ops.list(svc, id)
|
||||||
require.Error(t, err)
|
require.Error(t, err)
|
||||||
assert.Equal(t, awserrors.ErrorIAMNoSuchEntity, awserrors.ValidErrorCodeFromError(err))
|
assert.Equal(t, awserrors.ErrorIAMNoSuchEntity, err.Error())
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -577,15 +577,15 @@ func TestResourceTagging_MissingEntity(t *testing.T) {
|
|||||||
|
|
||||||
err := ops.tag(svc, ops.missingID, []*iam.Tag{sdkTag("env", "prod")})
|
err := ops.tag(svc, ops.missingID, []*iam.Tag{sdkTag("env", "prod")})
|
||||||
require.Error(t, err)
|
require.Error(t, err)
|
||||||
assert.Equal(t, awserrors.ErrorIAMNoSuchEntity, awserrors.ValidErrorCodeFromError(err))
|
assert.Equal(t, awserrors.ErrorIAMNoSuchEntity, err.Error())
|
||||||
|
|
||||||
err = ops.untag(svc, ops.missingID, []*string{aws.String("env")})
|
err = ops.untag(svc, ops.missingID, []*string{aws.String("env")})
|
||||||
require.Error(t, err)
|
require.Error(t, err)
|
||||||
assert.Equal(t, awserrors.ErrorIAMNoSuchEntity, awserrors.ValidErrorCodeFromError(err))
|
assert.Equal(t, awserrors.ErrorIAMNoSuchEntity, err.Error())
|
||||||
|
|
||||||
_, err = ops.list(svc, ops.missingID)
|
_, err = ops.list(svc, ops.missingID)
|
||||||
require.Error(t, err)
|
require.Error(t, err)
|
||||||
assert.Equal(t, awserrors.ErrorIAMNoSuchEntity, awserrors.ValidErrorCodeFromError(err))
|
assert.Equal(t, awserrors.ErrorIAMNoSuchEntity, err.Error())
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -608,7 +608,7 @@ func TestTagUser_MergedLimitExceeded(t *testing.T) {
|
|||||||
Tags: []*iam.Tag{sdkTag("overflow", "v")},
|
Tags: []*iam.Tag{sdkTag("overflow", "v")},
|
||||||
})
|
})
|
||||||
require.Error(t, err)
|
require.Error(t, err)
|
||||||
assert.Equal(t, awserrors.ErrorIAMLimitExceeded, awserrors.ValidErrorCodeFromError(err))
|
assert.Equal(t, awserrors.ErrorIAMLimitExceeded, err.Error())
|
||||||
|
|
||||||
// Upserting an existing key does not grow the set and stays allowed.
|
// Upserting an existing key does not grow the set and stays allowed.
|
||||||
_, err = svc.TagUser(testAccountID, &iam.TagUserInput{
|
_, err = svc.TagUser(testAccountID, &iam.TagUserInput{
|
||||||
|
|||||||
@@ -1,171 +0,0 @@
|
|||||||
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)
|
|
||||||
assert.Equal(t, awserrors.ErrorIAMNoSuchEntity, err.Error())
|
|
||||||
|
|
||||||
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")
|
|
||||||
assert.Equal(t, awserrors.ErrorIAMNoSuchEntity, err.Error())
|
|
||||||
ak, err := svc.LookupAccessKey(*key.AccessKey.AccessKeyId)
|
|
||||||
require.NoError(t, err)
|
|
||||||
assert.Equal(t, "franklin", ak.UserName)
|
|
||||||
}
|
|
||||||
@@ -1,86 +0,0 @@
|
|||||||
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
|
|
||||||
}
|
|
||||||
|
|
||||||
var _ credentials.Provider = (*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)
|
|
||||||
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))
|
|
||||||
}
|
|
||||||
@@ -1,94 +0,0 @@
|
|||||||
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())
|
|
||||||
}
|
|
||||||
@@ -1,74 +0,0 @@
|
|||||||
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()
|
|
||||||
}
|
|
||||||
@@ -1,178 +0,0 @@
|
|||||||
// 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)
|
|
||||||
}
|
|
||||||
@@ -1,165 +0,0 @@
|
|||||||
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))
|
|
||||||
}
|
|
||||||
@@ -1,210 +0,0 @@
|
|||||||
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")
|
|
||||||
)
|
|
||||||
|
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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}
|
|
||||||
}
|
|
||||||
creds := credentials.NewCredentials(newSessionProvider(cfg.Assume, now))
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
return newStore(cfg.Region, objectstore.NewS3ObjectStore(s3.New(sess))), 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
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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
|
|
||||||
}
|
|
||||||
@@ -1,169 +0,0 @@
|
|||||||
package codestore
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bytes"
|
|
||||||
"context"
|
|
||||||
"crypto/sha256"
|
|
||||||
"encoding/hex"
|
|
||||||
"errors"
|
|
||||||
"fmt"
|
|
||||||
"sync"
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/aws/aws-sdk-go/aws"
|
|
||||||
"github.com/aws/aws-sdk-go/service/s3"
|
|
||||||
"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"
|
|
||||||
)
|
|
||||||
|
|
||||||
var testRef = Ref{AccountID: "000000000001", FunctionID: "fn-0123", RevisionID: "rev-1"}
|
|
||||||
|
|
||||||
func copyKey(sum string) string { return "000000000001/fn-0123/rev-1/" + sum + ".zip" }
|
|
||||||
|
|
||||||
func TestStore_PutGetDelete(t *testing.T) {
|
|
||||||
mem := objectstore.NewMemoryObjectStore()
|
|
||||||
s := newStore(testRegion, mem)
|
|
||||||
code := []byte("PK\x03\x04 zip bytes")
|
|
||||||
|
|
||||||
sum, err := s.Put(t.Context(), testRef, code)
|
|
||||||
require.NoError(t, err)
|
|
||||||
want := sha256.Sum256(code)
|
|
||||||
assert.Equal(t, hex.EncodeToString(want[:]), sum)
|
|
||||||
|
|
||||||
_, err = mem.HeadObject(t.Context(), &s3.HeadObjectInput{
|
|
||||||
Bucket: aws.String("lambda-code-ap-southeast-2"), Key: aws.String(copyKey(sum))})
|
|
||||||
require.NoError(t, err, "copy is keyed by account, function, revision and digest")
|
|
||||||
|
|
||||||
got, err := s.Get(t.Context(), testRef, sum)
|
|
||||||
require.NoError(t, err)
|
|
||||||
assert.Equal(t, code, got)
|
|
||||||
|
|
||||||
require.NoError(t, s.Delete(t.Context(), testRef, sum))
|
|
||||||
_, err = s.Get(t.Context(), testRef, sum)
|
|
||||||
require.ErrorIs(t, err, ErrCodeNotFound)
|
|
||||||
require.NoError(t, s.Delete(t.Context(), testRef, sum), "deleting an absent copy succeeds")
|
|
||||||
}
|
|
||||||
|
|
||||||
// A retry or a racing write with different bytes for the same revision lands on
|
|
||||||
// its own digest key, so the recorded copy is never replaced.
|
|
||||||
func TestStore_DifferentBytesNeverReplaceRecordedCopy(t *testing.T) {
|
|
||||||
s := newStore(testRegion, objectstore.NewMemoryObjectStore())
|
|
||||||
recorded, err := s.Put(t.Context(), testRef, []byte("first"))
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
var wg sync.WaitGroup
|
|
||||||
others := make([]string, 8)
|
|
||||||
for i := range others {
|
|
||||||
wg.Go(func() {
|
|
||||||
sum, err := s.Put(t.Context(), testRef, []byte(fmt.Sprintf("other-%d", i)))
|
|
||||||
assert.NoError(t, err)
|
|
||||||
others[i] = sum
|
|
||||||
})
|
|
||||||
}
|
|
||||||
wg.Wait()
|
|
||||||
|
|
||||||
got, err := s.Get(t.Context(), testRef, recorded)
|
|
||||||
require.NoError(t, err)
|
|
||||||
assert.Equal(t, []byte("first"), got)
|
|
||||||
for i, sum := range others {
|
|
||||||
got, err := s.Get(t.Context(), testRef, sum)
|
|
||||||
require.NoError(t, err)
|
|
||||||
assert.Equal(t, []byte(fmt.Sprintf("other-%d", i)), got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// DeleteRevision clears every copy of one revision, including a failed
|
|
||||||
// attempt's, and leaves other revisions alone.
|
|
||||||
func TestStore_DeleteRevisionRemovesAllCopiesOfThatRevisionOnly(t *testing.T) {
|
|
||||||
s := newStore(testRegion, objectstore.NewMemoryObjectStore())
|
|
||||||
a, err := s.Put(t.Context(), testRef, []byte("kept attempt"))
|
|
||||||
require.NoError(t, err)
|
|
||||||
b, err := s.Put(t.Context(), testRef, []byte("failed attempt"))
|
|
||||||
require.NoError(t, err)
|
|
||||||
sibling := Ref{AccountID: testRef.AccountID, FunctionID: testRef.FunctionID, RevisionID: "rev-10"}
|
|
||||||
c, err := s.Put(t.Context(), sibling, []byte("next revision"))
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
require.NoError(t, s.DeleteRevision(t.Context(), testRef))
|
|
||||||
|
|
||||||
for _, sum := range []string{a, b} {
|
|
||||||
_, err := s.Get(t.Context(), testRef, sum)
|
|
||||||
require.ErrorIs(t, err, ErrCodeNotFound)
|
|
||||||
}
|
|
||||||
_, err = s.Get(t.Context(), sibling, c)
|
|
||||||
require.NoError(t, err, "rev-10 shares the rev-1 string prefix but not its key prefix")
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestStore_GetRefusesDigestMismatch(t *testing.T) {
|
|
||||||
mem := objectstore.NewMemoryObjectStore()
|
|
||||||
s := newStore(testRegion, mem)
|
|
||||||
sum, err := s.Put(t.Context(), testRef, []byte("original"))
|
|
||||||
require.NoError(t, err)
|
|
||||||
_, err = mem.PutObject(t.Context(), &s3.PutObjectInput{
|
|
||||||
Bucket: aws.String(BucketName(testRegion)), Key: aws.String(copyKey(sum)),
|
|
||||||
Body: bytes.NewReader([]byte("replaced"))})
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
_, err = s.Get(t.Context(), testRef, sum)
|
|
||||||
require.ErrorIs(t, err, ErrDigestMismatch)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestStore_RejectsUnsafeRefAndOversize(t *testing.T) {
|
|
||||||
s := newStore(testRegion, objectstore.NewMemoryObjectStore())
|
|
||||||
for _, ref := range []Ref{
|
|
||||||
{AccountID: "", FunctionID: "f", RevisionID: "r"},
|
|
||||||
{AccountID: "a", FunctionID: "..", RevisionID: "r"},
|
|
||||||
{AccountID: "a", FunctionID: "f/../../other", RevisionID: "r"},
|
|
||||||
} {
|
|
||||||
_, err := s.Put(t.Context(), ref, []byte("x"))
|
|
||||||
require.ErrorIs(t, err, ErrInvalidRef, "%+v", ref)
|
|
||||||
}
|
|
||||||
_, err := s.Put(t.Context(), testRef, make([]byte, MaxCodeBytes+1))
|
|
||||||
require.ErrorIs(t, err, ErrCodeTooLarge)
|
|
||||||
_, err = s.Get(t.Context(), testRef, "../../other")
|
|
||||||
require.ErrorIs(t, err, ErrInvalidRef, "a digest is never used as a raw key segment")
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestOpen_FailsWhenRoleNotAssumable(t *testing.T) {
|
|
||||||
_, err := Open(OpenConfig{Region: testRegion, Endpoint: "https://127.0.0.1:1", SigningRegion: testRegion,
|
|
||||||
Assume: func() (*sts.AssumeRoleOutput, error) { return nil, errors.New("AccessDenied") }})
|
|
||||||
require.Error(t, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestManager_InitialisesOnFirstUseWithBackoff(t *testing.T) {
|
|
||||||
attempts := 0
|
|
||||||
m := NewManager(func(context.Context) error {
|
|
||||||
attempts++
|
|
||||||
if attempts < 3 {
|
|
||||||
return ErrNotOwned
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}, func() (*Store, error) { return newStore(testRegion, objectstore.NewMemoryObjectStore()), nil })
|
|
||||||
now := time.Unix(1_000_000, 0)
|
|
||||||
m.now = func() time.Time { return now }
|
|
||||||
assert.Zero(t, attempts, "construction does no provisioning")
|
|
||||||
|
|
||||||
_, err := m.Store(t.Context())
|
|
||||||
require.ErrorIs(t, err, ErrNotReady)
|
|
||||||
require.ErrorIs(t, err, ErrNotOwned)
|
|
||||||
_, err = m.Store(t.Context())
|
|
||||||
require.ErrorIs(t, err, ErrNotReady)
|
|
||||||
assert.Equal(t, 1, attempts, "a call inside the backoff window does not retry")
|
|
||||||
|
|
||||||
now = now.Add(time.Second)
|
|
||||||
_, err = m.Store(t.Context())
|
|
||||||
require.ErrorIs(t, err, ErrNotReady)
|
|
||||||
now = now.Add(time.Second)
|
|
||||||
_, err = m.Store(t.Context())
|
|
||||||
require.ErrorIs(t, err, ErrNotReady, "backoff doubled to 2s")
|
|
||||||
now = now.Add(time.Second)
|
|
||||||
first, err := m.Store(t.Context())
|
|
||||||
require.NoError(t, err)
|
|
||||||
again, err := m.Store(t.Context())
|
|
||||||
require.NoError(t, err)
|
|
||||||
assert.Same(t, first, again)
|
|
||||||
assert.Equal(t, 3, attempts)
|
|
||||||
}
|
|
||||||
@@ -88,6 +88,7 @@ type Client interface {
|
|||||||
CreateDHCPOptions(ctx context.Context, opts *nbdb.DHCPOptions) (string, error)
|
CreateDHCPOptions(ctx context.Context, opts *nbdb.DHCPOptions) (string, error)
|
||||||
UpdateDHCPOptionsOptions(ctx context.Context, uuid string, options map[string]string) error
|
UpdateDHCPOptionsOptions(ctx context.Context, uuid string, options map[string]string) error
|
||||||
DeleteDHCPOptions(ctx context.Context, uuid string) error
|
DeleteDHCPOptions(ctx context.Context, uuid string) error
|
||||||
|
FindDHCPOptionsByCIDR(ctx context.Context, cidr string) (*nbdb.DHCPOptions, error)
|
||||||
FindDHCPOptionsByExternalID(ctx context.Context, key, value string) (*nbdb.DHCPOptions, error)
|
FindDHCPOptionsByExternalID(ctx context.Context, key, value string) (*nbdb.DHCPOptions, error)
|
||||||
ListDHCPOptions(ctx context.Context) ([]nbdb.DHCPOptions, error)
|
ListDHCPOptions(ctx context.Context) ([]nbdb.DHCPOptions, error)
|
||||||
|
|
||||||
|
|||||||
@@ -573,26 +573,31 @@ func conformDHCPOptions(t *testing.T, cli ovn.Client, ctx context.Context) {
|
|||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
require.NotEmpty(t, uuid, "create must return the row UUID callers store")
|
require.NotEmpty(t, uuid, "create must return the row UUID callers store")
|
||||||
|
|
||||||
|
byCIDR, err := cli.FindDHCPOptionsByCIDR(ctx, "10.0.1.0/24")
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, uuid, byCIDR.UUID)
|
||||||
|
assert.Equal(t, "10.0.1.1", byCIDR.Options["server_id"])
|
||||||
|
|
||||||
byExtID, err := cli.FindDHCPOptionsByExternalID(ctx, "spinifex:subnet_id", "subnet-1")
|
byExtID, err := cli.FindDHCPOptionsByExternalID(ctx, "spinifex:subnet_id", "subnet-1")
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
assert.Equal(t, uuid, byExtID.UUID)
|
assert.Equal(t, uuid, byExtID.UUID)
|
||||||
assert.Equal(t, "10.0.1.0/24", byExtID.CIDR)
|
|
||||||
assert.Equal(t, "10.0.1.1", byExtID.Options["server_id"])
|
|
||||||
|
|
||||||
require.NoError(t, cli.UpdateDHCPOptionsOptions(ctx, uuid, map[string]string{
|
require.NoError(t, cli.UpdateDHCPOptionsOptions(ctx, uuid, map[string]string{
|
||||||
"server_id": "10.0.1.1", "lease_time": "7200",
|
"server_id": "10.0.1.1", "lease_time": "7200",
|
||||||
}))
|
}))
|
||||||
byExtID, err = cli.FindDHCPOptionsByExternalID(ctx, "spinifex:subnet_id", "subnet-1")
|
byCIDR, err = cli.FindDHCPOptionsByCIDR(ctx, "10.0.1.0/24")
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
assert.Equal(t, "7200", byExtID.Options["lease_time"], "update must replace the options map")
|
assert.Equal(t, "7200", byCIDR.Options["lease_time"], "update must replace the options map")
|
||||||
|
|
||||||
all, err := cli.ListDHCPOptions(ctx)
|
all, err := cli.ListDHCPOptions(ctx)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
assert.Len(t, all, 1)
|
assert.Len(t, all, 1)
|
||||||
|
|
||||||
_, err = cli.FindDHCPOptionsByExternalID(ctx, "spinifex:subnet_id", "subnet-nope")
|
_, err = cli.FindDHCPOptionsByCIDR(ctx, "10.9.9.0/24")
|
||||||
require.Error(t, err)
|
require.Error(t, err)
|
||||||
assert.Contains(t, err.Error(), "subnet-nope")
|
assert.Contains(t, err.Error(), "10.9.9.0/24")
|
||||||
|
_, err = cli.FindDHCPOptionsByExternalID(ctx, "spinifex:subnet_id", "subnet-nope")
|
||||||
|
assert.Error(t, err)
|
||||||
|
|
||||||
require.NoError(t, cli.DeleteDHCPOptions(ctx, uuid))
|
require.NoError(t, cli.DeleteDHCPOptions(ctx, uuid))
|
||||||
all, err = cli.ListDHCPOptions(ctx)
|
all, err = cli.ListDHCPOptions(ctx)
|
||||||
|
|||||||
@@ -826,6 +826,20 @@ func (c *LiveClient) DeleteDHCPOptions(ctx context.Context, uuid string) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (c *LiveClient) FindDHCPOptionsByCIDR(ctx context.Context, cidr string) (*nbdb.DHCPOptions, error) {
|
||||||
|
var options []nbdb.DHCPOptions
|
||||||
|
err := c.client.WhereCache(func(o *nbdb.DHCPOptions) bool {
|
||||||
|
return o.CIDR == cidr
|
||||||
|
}).List(ctx, &options)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("find DHCP options by CIDR: %w", err)
|
||||||
|
}
|
||||||
|
if len(options) == 0 {
|
||||||
|
return nil, fmt.Errorf("DHCP options for CIDR %q not found", cidr)
|
||||||
|
}
|
||||||
|
return &options[0], nil
|
||||||
|
}
|
||||||
|
|
||||||
func (c *LiveClient) FindDHCPOptionsByExternalID(ctx context.Context, key, value string) (*nbdb.DHCPOptions, error) {
|
func (c *LiveClient) FindDHCPOptionsByExternalID(ctx context.Context, key, value string) (*nbdb.DHCPOptions, error) {
|
||||||
var options []nbdb.DHCPOptions
|
var options []nbdb.DHCPOptions
|
||||||
err := c.client.WhereCache(func(o *nbdb.DHCPOptions) bool {
|
err := c.client.WhereCache(func(o *nbdb.DHCPOptions) bool {
|
||||||
|
|||||||
@@ -443,6 +443,18 @@ func (m *Client) DeleteDHCPOptions(_ context.Context, uuid string) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (m *Client) FindDHCPOptionsByCIDR(_ context.Context, cidr string) (*nbdb.DHCPOptions, error) {
|
||||||
|
m.Mu.Lock()
|
||||||
|
defer m.Mu.Unlock()
|
||||||
|
for _, opts := range m.DHCPOpts {
|
||||||
|
if opts.CIDR == cidr {
|
||||||
|
result := *opts
|
||||||
|
return &result, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("DHCP options for CIDR %q not found", cidr)
|
||||||
|
}
|
||||||
|
|
||||||
func (m *Client) FindDHCPOptionsByExternalID(_ context.Context, key, value string) (*nbdb.DHCPOptions, error) {
|
func (m *Client) FindDHCPOptionsByExternalID(_ context.Context, key, value string) (*nbdb.DHCPOptions, error) {
|
||||||
m.Mu.Lock()
|
m.Mu.Lock()
|
||||||
defer m.Mu.Unlock()
|
defer m.Mu.Unlock()
|
||||||
|
|||||||
@@ -374,8 +374,7 @@ func (r *reconciler) applyPorts(ctx context.Context, intent IntentState, actual
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
existing, err := r.ovn.GetLogicalSwitchPort(ctx, portName)
|
if _, err := r.ovn.GetLogicalSwitchPort(ctx, portName); err != nil {
|
||||||
if err != nil {
|
|
||||||
addrStr := spec.MAC.String() + " " + spec.PrivateIP.String()
|
addrStr := spec.MAC.String() + " " + spec.PrivateIP.String()
|
||||||
lsp := &nbdb.LogicalSwitchPort{
|
lsp := &nbdb.LogicalSwitchPort{
|
||||||
Name: portName,
|
Name: portName,
|
||||||
@@ -399,10 +398,6 @@ func (r *reconciler) applyPorts(ctx context.Context, intent IntentState, actual
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
if !spec.SuppressDHCP {
|
|
||||||
r.convergePortDHCP(ctx, existing, spec.SubnetID, res)
|
|
||||||
}
|
|
||||||
|
|
||||||
currentPGs, err := r.ovn.ListPortGroupsForPort(ctx, portName)
|
currentPGs, err := r.ovn.ListPortGroupsForPort(ctx, portName)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.Warn("reconcile/apply: list port groups for port failed", "port", portName, "err", err)
|
slog.Warn("reconcile/apply: list port groups for port failed", "port", portName, "err", err)
|
||||||
@@ -425,26 +420,6 @@ func (r *reconciler) applyPorts(ctx context.Context, intent IntentState, actual
|
|||||||
r.pruneOrphanPorts(ctx, intent, res)
|
r.pruneOrphanPorts(ctx, intent, res)
|
||||||
}
|
}
|
||||||
|
|
||||||
// convergePortDHCP points an existing LSP's dhcpv4_options at its subnet's
|
|
||||||
// current DHCP_Options row. The reference is weak, so deleting or recreating
|
|
||||||
// the row leaves the port without OVN DHCP until it is re-linked here.
|
|
||||||
func (r *reconciler) convergePortDHCP(ctx context.Context, lsp *nbdb.LogicalSwitchPort, subnetID string, res *passResult) {
|
|
||||||
dhcpOpts, err := r.ovn.FindDHCPOptionsByExternalID(ctx, "spinifex:subnet_id", subnetID)
|
|
||||||
if err != nil || dhcpOpts == nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if lsp.DHCPv4Options != nil && *lsp.DHCPv4Options == dhcpOpts.UUID {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
lsp.DHCPv4Options = &dhcpOpts.UUID
|
|
||||||
if err := r.ovn.UpdateLogicalSwitchPort(ctx, lsp); err != nil {
|
|
||||||
slog.Warn("reconcile/apply: re-link port DHCP options failed", "port", lsp.Name, "subnet_id", subnetID, "err", err)
|
|
||||||
res.fail(classPort, lsp.Name, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
slog.Info("reconcile/apply: re-linked port DHCP options", "port", lsp.Name, "subnet_id", subnetID)
|
|
||||||
}
|
|
||||||
|
|
||||||
// pruneOrphanPorts deletes guest LSPs whose spinifex:eni_id has no matching
|
// pruneOrphanPorts deletes guest LSPs whose spinifex:eni_id has no matching
|
||||||
// intent ENI, closing the create-only gap that leaks ports across instance
|
// intent ENI, closing the create-only gap that leaks ports across instance
|
||||||
// terminate and host reinstall. DeletePort clears PG memberships then removes
|
// terminate and host reinstall. DeletePort clears PG memberships then removes
|
||||||
@@ -897,8 +872,7 @@ func (r *reconciler) forgetIdlePorts(specs []policy.EIPSpec) {
|
|||||||
// never re-asserted, so a vpcd/OVN rebuild dropped it and its guest-port hop never
|
// never re-asserted, so a vpcd/OVN rebuild dropped it and its guest-port hop never
|
||||||
// converged (inbound DNATs at the gateway but never reaches the guest; ICMP is
|
// converged (inbound DNATs at the gateway but never reaches the guest; ICMP is
|
||||||
// answered by the gateway LR, so ping works while TCP hangs). A user EIP on the same
|
// answered by the gateway LR, so ping works while TCP hangs). A user EIP on the same
|
||||||
// private IP in the same VPC wins; the auto-assigned entry is skipped to avoid a
|
// private IP wins; the auto-assigned entry is skipped to avoid a duplicate rule.
|
||||||
// duplicate rule.
|
|
||||||
func (r *reconciler) floatingIPSpecs(intent IntentState) []policy.EIPSpec {
|
func (r *reconciler) floatingIPSpecs(intent IntentState) []policy.EIPSpec {
|
||||||
specs := make([]policy.EIPSpec, 0, len(intent.EIPs)+len(intent.Ports))
|
specs := make([]policy.EIPSpec, 0, len(intent.EIPs)+len(intent.Ports))
|
||||||
for _, spec := range intent.EIPs {
|
for _, spec := range intent.EIPs {
|
||||||
@@ -909,7 +883,7 @@ func (r *reconciler) floatingIPSpecs(intent IntentState) []policy.EIPSpec {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
logicalIP := p.PrivateIP.String()
|
logicalIP := p.PrivateIP.String()
|
||||||
if _, hasEIP := intent.EIPs[eipIntentKey(p.VPCID, logicalIP)]; hasEIP {
|
if _, hasEIP := intent.EIPs[logicalIP]; hasEIP {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
specs = append(specs, policy.EIPSpec{
|
specs = append(specs, policy.EIPSpec{
|
||||||
@@ -1040,7 +1014,7 @@ func (r *reconciler) applyPublicInstanceEgress(ctx context.Context, intent Inten
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
for _, e := range intent.EIPs {
|
for _, e := range intent.EIPs {
|
||||||
r.ensureEIPEgressExemption(ctx, intent, e.VPCID, subnetIDForIP(intent.Subnets, e.VPCID, e.LogicalIP), e.LogicalIP, res)
|
r.ensureEIPEgressExemption(ctx, intent, e.VPCID, subnetIDForIP(intent.Subnets, e.LogicalIP), e.LogicalIP, res)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1068,15 +1042,14 @@ func (r *reconciler) ensureEIPEgressExemption(ctx context.Context, intent Intent
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// subnetIDForIP returns the SubnetID in vpcID whose CIDR contains ip, or "" if
|
// subnetIDForIP returns the SubnetID whose CIDR contains ip, or "" if none match.
|
||||||
// none match. VPC CIDRs overlap across tenants, so the CIDR alone is ambiguous.
|
func subnetIDForIP(subnets map[string]topology.SubnetSpec, ip string) string {
|
||||||
func subnetIDForIP(subnets map[string]topology.SubnetSpec, vpcID, ip string) string {
|
|
||||||
addr, err := netip.ParseAddr(ip)
|
addr, err := netip.ParseAddr(ip)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
for _, s := range subnets {
|
for _, s := range subnets {
|
||||||
if s.VPCID == vpcID && s.CIDR.IsValid() && s.CIDR.Contains(addr) {
|
if s.CIDR.IsValid() && s.CIDR.Contains(addr) {
|
||||||
return s.SubnetID
|
return s.SubnetID
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -135,11 +135,11 @@ func TestApplyEIPs_HealthyPortDoesNotWaitForDeadOnes(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
intent := IntentState{EIPs: map[string]policy.EIPSpec{
|
intent := IntentState{EIPs: map[string]policy.EIPSpec{
|
||||||
eipIntentKey("vpc-a", "10.0.0.1"): {VPCID: "vpc-a", ExternalIP: "192.0.2.1", LogicalIP: "10.0.0.1", PortName: live},
|
"10.0.0.1": {VPCID: "vpc-a", ExternalIP: "192.0.2.1", LogicalIP: "10.0.0.1", PortName: live},
|
||||||
}}
|
}}
|
||||||
for i, name := range dead {
|
for i, name := range dead {
|
||||||
ip := "10.0.0." + string(rune('2'+i))
|
ip := "10.0.0." + string(rune('2'+i))
|
||||||
intent.EIPs[eipIntentKey("vpc-a", ip)] = policy.EIPSpec{VPCID: "vpc-a", ExternalIP: "192.0.2.9", LogicalIP: ip, PortName: name}
|
intent.EIPs[ip] = policy.EIPSpec{VPCID: "vpc-a", ExternalIP: "192.0.2.9", LogicalIP: ip, PortName: name}
|
||||||
}
|
}
|
||||||
|
|
||||||
res := &passResult{}
|
res := &passResult{}
|
||||||
|
|||||||
@@ -1,242 +0,0 @@
|
|||||||
package reconcile
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"net/netip"
|
|
||||||
"slices"
|
|
||||||
"sync"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
ec2eip "github.com/mulgadc/spinifex/spinifex/domains/ec2/eip"
|
|
||||||
ec2vpc "github.com/mulgadc/spinifex/spinifex/domains/ec2/vpc"
|
|
||||||
"github.com/mulgadc/spinifex/spinifex/domains/network/external"
|
|
||||||
"github.com/mulgadc/spinifex/spinifex/domains/network/policy"
|
|
||||||
"github.com/mulgadc/spinifex/spinifex/domains/network/topology"
|
|
||||||
"github.com/nats-io/nats.go/jetstream"
|
|
||||||
)
|
|
||||||
|
|
||||||
// hostBindings stands in for the routed-mode host route and proxy-ARP table.
|
|
||||||
type hostBindings struct {
|
|
||||||
mu sync.Mutex
|
|
||||||
bound map[string]struct{}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (h *hostBindings) hooks() policy.HostEIPBinder {
|
|
||||||
return policy.HostEIPBinder{
|
|
||||||
Bind: func(eip policy.EIPSpec, _ string) error {
|
|
||||||
h.mu.Lock()
|
|
||||||
defer h.mu.Unlock()
|
|
||||||
h.bound[eip.ExternalIP] = struct{}{}
|
|
||||||
return nil
|
|
||||||
},
|
|
||||||
Unbind: func(externalIP string) error {
|
|
||||||
h.mu.Lock()
|
|
||||||
defer h.mu.Unlock()
|
|
||||||
delete(h.bound, externalIP)
|
|
||||||
return nil
|
|
||||||
},
|
|
||||||
List: func() ([]string, error) {
|
|
||||||
h.mu.Lock()
|
|
||||||
defer h.mu.Unlock()
|
|
||||||
out := make([]string, 0, len(h.bound))
|
|
||||||
for ip := range h.bound {
|
|
||||||
out = append(out, ip)
|
|
||||||
}
|
|
||||||
slices.Sort(out)
|
|
||||||
return out, nil
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// seedOverlappingTenants writes two accounts' default-style VPCs on the same
|
|
||||||
// CIDR, each with one ENI at 172.31.0.4. eni-a carries publicA on its stamp.
|
|
||||||
func seedOverlappingTenants(t *testing.T, js jetstream.JetStream, publicA string) {
|
|
||||||
t.Helper()
|
|
||||||
for _, tenant := range []struct{ acct, suffix, mac, public string }{
|
|
||||||
{"111111111111", "a", "02:00:00:00:0a:04", publicA},
|
|
||||||
{"222222222222", "b", "02:00:00:00:0b:04", ""},
|
|
||||||
} {
|
|
||||||
vpcID, subnetID, sgID, eniID := "vpc-"+tenant.suffix, "subnet-"+tenant.suffix, "sg-"+tenant.suffix, "eni-"+tenant.suffix
|
|
||||||
seedKV(t, js, ec2vpc.KVBucketVPCs, tenant.acct+"."+vpcID, ec2vpc.VPCRecord{
|
|
||||||
VpcId: vpcID, CidrBlock: "172.31.0.0/16", AZ: "us-east-1a", VNI: 100,
|
|
||||||
})
|
|
||||||
seedKV(t, js, ec2vpc.KVBucketSubnets, tenant.acct+"."+subnetID, ec2vpc.SubnetRecord{
|
|
||||||
SubnetId: subnetID, VpcId: vpcID, CidrBlock: "172.31.0.0/20",
|
|
||||||
})
|
|
||||||
seedKV(t, js, ec2vpc.KVBucketSecurityGroups, tenant.acct+"."+sgID, ec2vpc.SecurityGroupRecord{
|
|
||||||
GroupId: sgID, VpcId: vpcID,
|
|
||||||
})
|
|
||||||
seedKV(t, js, ec2vpc.KVBucketENIs, tenant.acct+"."+eniID, ec2vpc.ENIRecord{
|
|
||||||
NetworkInterfaceId: eniID, SubnetId: subnetID, VpcId: vpcID,
|
|
||||||
PrivateIpAddress: "172.31.0.4", MacAddress: tenant.mac,
|
|
||||||
SecurityGroupIds: []string{sgID}, PublicIpAddress: tenant.public,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func seedAssociatedEIP(t *testing.T, js jetstream.JetStream, acct, suffix, publicIP, mac string) {
|
|
||||||
t.Helper()
|
|
||||||
seedKV(t, js, ec2eip.KVBucketEIPs, acct+".eipalloc-"+suffix, ec2eip.EIPRecord{
|
|
||||||
AllocationId: "eipalloc-" + suffix, PublicIp: publicIP, State: "associated",
|
|
||||||
AssociationId: "eipassoc-" + suffix, ENIId: "eni-" + suffix,
|
|
||||||
PrivateIp: "172.31.0.4", MacAddress: mac, VpcId: "vpc-" + suffix,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// Two tenants' guests share a private IP on overlapping VPC CIDRs. Tenant B
|
|
||||||
// associating its own EIP must leave tenant A's public address, NAT row and
|
|
||||||
// host binding alone, whether A's address is a user EIP or auto-assigned.
|
|
||||||
func TestReconcile_OverlappingVPCsKeepEachTenantsPublicIP(t *testing.T) {
|
|
||||||
cases := []struct {
|
|
||||||
name string
|
|
||||||
publicA string
|
|
||||||
eipA bool
|
|
||||||
}{
|
|
||||||
{name: "eip beside eip", publicA: "203.0.113.10", eipA: true},
|
|
||||||
{name: "eip beside auto-assigned", publicA: "198.51.100.20"},
|
|
||||||
}
|
|
||||||
for _, tc := range cases {
|
|
||||||
t.Run(tc.name, func(t *testing.T) {
|
|
||||||
ctx := context.Background()
|
|
||||||
js := startKV(t)
|
|
||||||
seedOverlappingTenants(t, js, tc.publicA)
|
|
||||||
if tc.eipA {
|
|
||||||
seedAssociatedEIP(t, js, "111111111111", "a", tc.publicA, "02:00:00:00:0a:04")
|
|
||||||
}
|
|
||||||
|
|
||||||
r, m := newTestReconciler(t)
|
|
||||||
hosts := &hostBindings{bound: map[string]struct{}{}}
|
|
||||||
nat, err := policy.NewNATManager(m, policy.NATModeRouted, policy.WithHostEIPBinder(hosts.hooks()))
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("NewNATManager: %v", err)
|
|
||||||
}
|
|
||||||
r.nat = nat
|
|
||||||
r.reloadIntent = func(ctx context.Context) (IntentState, error) {
|
|
||||||
return LoadIntentFromKV(ctx, js, "us-east-1a")
|
|
||||||
}
|
|
||||||
|
|
||||||
pass := func() IntentState {
|
|
||||||
t.Helper()
|
|
||||||
intent, err := r.reloadIntent(ctx)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("LoadIntentFromKV: %v", err)
|
|
||||||
}
|
|
||||||
if err := r.Reconcile(ctx, intent); err != nil {
|
|
||||||
t.Fatalf("Reconcile: %v", err)
|
|
||||||
}
|
|
||||||
if err := r.ReconcileHostEIPs(ctx); err != nil {
|
|
||||||
t.Fatalf("ReconcileHostEIPs: %v", err)
|
|
||||||
}
|
|
||||||
return intent
|
|
||||||
}
|
|
||||||
assertPublic := func(stage, externalIP, port string) {
|
|
||||||
t.Helper()
|
|
||||||
row := findNATByExternal(m, "dnat_and_snat", externalIP)
|
|
||||||
if row == nil {
|
|
||||||
t.Fatalf("%s: dnat_and_snat for %s missing", stage, externalIP)
|
|
||||||
}
|
|
||||||
if got := row.ExternalIDs["spinifex:logical_port"]; got != topology.Port(port) {
|
|
||||||
t.Errorf("%s: %s row owned by %q, want %q", stage, externalIP, got, topology.Port(port))
|
|
||||||
}
|
|
||||||
if _, ok := hosts.bound[externalIP]; !ok {
|
|
||||||
t.Errorf("%s: host binding for %s missing", stage, externalIP)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pass()
|
|
||||||
assertPublic("before tenant B associates", tc.publicA, "eni-a")
|
|
||||||
|
|
||||||
seedAssociatedEIP(t, js, "222222222222", "b", "203.0.113.20", "02:00:00:00:0b:04")
|
|
||||||
for _, stage := range []string{"after tenant B associates", "on the next pass"} {
|
|
||||||
intent := pass()
|
|
||||||
// eni-a's stamp alone would keep A's row alive, so check that
|
|
||||||
// intent itself holds every tenant's association.
|
|
||||||
wantEIPs := map[string]string{eipIntentKey("vpc-b", "172.31.0.4"): "203.0.113.20"}
|
|
||||||
if tc.eipA {
|
|
||||||
wantEIPs[eipIntentKey("vpc-a", "172.31.0.4")] = tc.publicA
|
|
||||||
}
|
|
||||||
if len(intent.EIPs) != len(wantEIPs) {
|
|
||||||
t.Errorf("%s: intent holds %d EIPs, want %d: %+v", stage, len(intent.EIPs), len(wantEIPs), intent.EIPs)
|
|
||||||
}
|
|
||||||
for key, externalIP := range wantEIPs {
|
|
||||||
if got := intent.EIPs[key].ExternalIP; got != externalIP {
|
|
||||||
t.Errorf("%s: intent EIP %s = %q, want %q", stage, key, got, externalIP)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
assertPublic(stage, tc.publicA, "eni-a")
|
|
||||||
assertPublic(stage, "203.0.113.20", "eni-b")
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// The drop-gate exemption must name the EIP's own subnet, not a subnet in
|
|
||||||
// another VPC whose CIDR happens to contain the same private IP.
|
|
||||||
func TestSubnetIDForIP_ScopedToVPC(t *testing.T) {
|
|
||||||
subnets := map[string]topology.SubnetSpec{
|
|
||||||
"subnet-a": {SubnetID: "subnet-a", VPCID: "vpc-a", CIDR: netip.MustParsePrefix("172.31.0.0/20")},
|
|
||||||
"subnet-b": {SubnetID: "subnet-b", VPCID: "vpc-b", CIDR: netip.MustParsePrefix("172.31.0.0/20")},
|
|
||||||
}
|
|
||||||
for range 20 {
|
|
||||||
if got := subnetIDForIP(subnets, "vpc-a", "172.31.0.4"); got != "subnet-a" {
|
|
||||||
t.Fatalf("vpc-a lookup = %q, want subnet-a", got)
|
|
||||||
}
|
|
||||||
if got := subnetIDForIP(subnets, "vpc-b", "172.31.0.4"); got != "subnet-b" {
|
|
||||||
t.Fatalf("vpc-b lookup = %q, want subnet-b", got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if got := subnetIDForIP(subnets, "vpc-c", "172.31.0.4"); got != "" {
|
|
||||||
t.Errorf("lookup in a VPC with no subnets = %q, want empty", got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// egressCall is one EnsureEIPInstanceEgress invocation.
|
|
||||||
type egressCall struct{ vpcID, subnetID, instanceIP string }
|
|
||||||
|
|
||||||
// recordingIGW records EnsureEIPInstanceEgress calls instead of writing OVN.
|
|
||||||
type recordingIGW struct {
|
|
||||||
external.IGWManager
|
|
||||||
|
|
||||||
calls []egressCall
|
|
||||||
}
|
|
||||||
|
|
||||||
func (g *recordingIGW) EnsureEIPInstanceEgress(_ context.Context, vpcID, subnetID, instanceIP string) error {
|
|
||||||
g.calls = append(g.calls, egressCall{vpcID, subnetID, instanceIP})
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// An EIP's drop-gate exemption goes to its own VPC's subnet even when another
|
|
||||||
// tenant's gated subnet has the same CIDR.
|
|
||||||
func TestApplyPublicInstanceEgress_EIPExemptsOwnVPCSubnet(t *testing.T) {
|
|
||||||
cidr := netip.MustParsePrefix("172.31.0.0/20")
|
|
||||||
intent := IntentState{
|
|
||||||
Subnets: map[string]topology.SubnetSpec{
|
|
||||||
"subnet-a": {SubnetID: "subnet-a", VPCID: "vpc-a", CIDR: cidr},
|
|
||||||
"subnet-b": {SubnetID: "subnet-b", VPCID: "vpc-b", CIDR: cidr},
|
|
||||||
},
|
|
||||||
EIPs: map[string]policy.EIPSpec{
|
|
||||||
eipIntentKey("vpc-b", "172.31.0.4"): {VPCID: "vpc-b", ExternalIP: "203.0.113.20", LogicalIP: "172.31.0.4"},
|
|
||||||
},
|
|
||||||
DropGates: map[string]SubnetEgressIntent{},
|
|
||||||
}
|
|
||||||
for _, s := range intent.Subnets {
|
|
||||||
intent.DropGates[subnetEgressKey(s.SubnetID, netip.MustParsePrefix("0.0.0.0/0"))] = SubnetEgressIntent{
|
|
||||||
VPCID: s.VPCID, SubnetID: s.SubnetID, DestCIDR: netip.MustParsePrefix("0.0.0.0/0"),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
for range 20 {
|
|
||||||
r, _ := newTestReconciler(t)
|
|
||||||
igw := new(recordingIGW)
|
|
||||||
r.igw = igw
|
|
||||||
res := new(passResult)
|
|
||||||
r.applyPublicInstanceEgress(context.Background(), intent, ActualState{}, res)
|
|
||||||
want := []egressCall{{"vpc-b", "subnet-b", "172.31.0.4"}}
|
|
||||||
if !slices.Equal(igw.calls, want) {
|
|
||||||
t.Fatalf("EnsureEIPInstanceEgress calls = %+v, want %+v", igw.calls, want)
|
|
||||||
}
|
|
||||||
if len(res.failures) != 0 {
|
|
||||||
t.Fatalf("pass failures = %+v, want none", res.failures)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -27,7 +27,7 @@ func captureReconcileLogs(t *testing.T) *bytes.Buffer {
|
|||||||
|
|
||||||
func idleEIPIntent(idle bool) IntentState {
|
func idleEIPIntent(idle bool) IntentState {
|
||||||
intent := IntentState{EIPs: map[string]policy.EIPSpec{
|
intent := IntentState{EIPs: map[string]policy.EIPSpec{
|
||||||
eipIntentKey("vpc-a", "10.0.0.4"): {VPCID: "vpc-a", ExternalIP: "192.0.2.46", LogicalIP: "10.0.0.4", PortName: "port-eni-1"},
|
"10.0.0.4": {VPCID: "vpc-a", ExternalIP: "192.0.2.46", LogicalIP: "10.0.0.4", PortName: "port-eni-1"},
|
||||||
}}
|
}}
|
||||||
if idle {
|
if idle {
|
||||||
intent.IdlePorts = map[string]string{"port-eni-1": "i-stopped"}
|
intent.IdlePorts = map[string]string{"port-eni-1": "i-stopped"}
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user